Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@ Project context for AI coding agents working on this repo. For human users, star

- `src/tools/` — one file per non-extension MCP tool (descriptor + handler + registration).
- `src/tools/extensions/<id>/` — extension packs: tools gated on a specific MediaWiki extension (SMW / Bucket / Cargo / …), grouped under a per-pack module.
- `src/runtime/` — context, dispatcher, register, reconcile, logger, constants.
- `src/runtime/` — context, dispatcher, register, reconcile, logger, constants, request-scoped context, auth-shape classifier.
- `src/wikis/` — wiki registry, selection, mwn provider, discovery, error sanitiser.
- `src/transport/` — stdio and streamable HTTP entry points, SSRF/upload guards, request context, low-level HTTP helpers.
- `src/transport/` — stdio and streamable HTTP entry points, SSRF/upload guards, low-level HTTP helpers.
- `src/auth/` — OAuth for MediaWiki, in two roles (client to a wiki, and the hosted authorization-server proxy). See [src/auth/README.md](src/auth/README.md).
- `src/config/` — `config.json` loader and substitution.
- `src/services/` — section, edit, revision, response services consumed via `ToolContext`.
- `src/results/` — response shaping (truncation, format, schemas).
Expand Down
42 changes: 42 additions & 0 deletions src/auth/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# src/auth

OAuth for MediaWiki, in two roles. The split between them is the thing to hold in your head:

- **`src/auth/` (top level)** — this server acting as an OAuth **client** to a wiki: discover the wiki's authorization server, run a login flow, acquire and refresh a token, and store it.
- **`src/auth/authorizationServer/`** — this server acting as an OAuth **authorization server** itself: the hosted proxy that fronts a wiki, mints its own per-user tokens, and speaks the full authorize / consent / callback / token / registration surface to downstream MCP clients.

Which role is active depends on configuration. The proxy path turns on only when `resolveProxyConfig` returns a config (HTTP transport + public URL + signing key + a confidential upstream consumer); otherwise the server uses the client path or plain bearer pass-through. Discovery (`metadata.ts`) and the protected-resource metadata are shared: both roles need to know a wiki's authorization server.

## Client side (top level)

- `metadata.ts` — discover an upstream wiki's AS metadata (`.well-known`, pathed, or synthesized). `UpstreamAsMetadata` is the wiki's metadata — not to be confused with the proxy's own `AsMetadataDoc`.
- `mwOauth2Endpoints.ts` — build a wiki's `rest.php/oauth2/{authorize,access_token}` URLs. Single source of truth for those paths, shared with the proxy side.
- `oauthFlow.ts` — the low-level token exchange and refresh HTTP calls, plus the error classifier (`invalid_grant` / `invalid_client` / transient / malformed).
- `acquireToken.ts` — orchestrate acquiring a token for a wiki (discovery → browser login → store).
- `browserAuth.ts` — the interactive login: open the browser, catch the redirect on a loopback listener.
- `tokenRefresh.ts` — refresh a stored token when it is near expiry, with per-wiki dedup.
- `tokenStore.ts` — persist and read back acquired tokens on disk.
- `protectedResource.ts` — build the protected-resource metadata this server advertises to MCP clients.
- `pkce.ts` — PKCE verifier / challenge helpers (used by both roles).
- `paths.ts` — resolve the on-disk config-dir file paths: the client credentials file and the proxy's durable store.

## Authorization server side (`authorizationServer/`, the hosted proxy)

- `proxyConfig.ts` — resolve the proxy config from a wiki plus environment. `ProxyWikiInput` is the wiki slice it needs (distinct from the client-side `WikiSlice`).
- `router.ts` — `mountAuthorizationServer` mounts every AS route on the Express app.
- `asMetadata.ts` — build the RFC 8414 metadata document advertising this server as the authorization server (`AsMetadataDoc`).
- `register.ts` — dynamic client registration.
- `authorize.ts` — plan the `/authorize` step: validate the downstream client and redirect, then produce the upstream authorize URL.
- `consent.ts` — the consent / cancelled / error pages, plus the CSRF, transaction, and consent cookie plumbing (the consent cookie is signed and verified in `jwt.ts`).
- `callback.ts` — handle the upstream wiki's callback, exchange the code, mint a downstream client code.
- `token.ts` — the `/token` endpoint: authorization-code and refresh grants.
- `jwt.ts` — mint and verify the proxy's own access / refresh JWTs, and sign / verify the consent cookie.
- `cimd.ts` — client-id metadata documents: resolve a URL `client_id` into a client record, host-gated.
- `redirectPolicy.ts` — redirect-URI matching and the registration-time redirect-URI allowlist (the CIMD _host_ allowlist lives in `cimd.ts`).
- `proxyStore.ts` — the in-memory store (clients, transactions, codes, upstream tokens).
- `proxyStoreCrypto.ts` — encrypt and decrypt the persisted store.
- `proxyStorePersistence.ts` — mirror the store to an encrypted file with write-through.

## Shared

`pageShell.ts` renders the HTML shell used by both roles' user-facing pages — the proxy's consent / status pages and the stdio loopback login pages. Request-scoped state (`runtime/requestContext.ts`) and the auth-shape classifier (`runtime/authShape.ts`) live under `runtime/`, not here, because the transport layer uses them too.
7 changes: 5 additions & 2 deletions src/auth/authorizationServer/proxyConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,10 @@ const UPSTREAM_REFRESH_WINDOW_MS = 30 * 24 * 60 * 60 * 1000;
const DEFAULT_TOKEN_TTL_MS = 55 * 60 * 1000;
const DEFAULT_CONSENT_TTL_MS = 30 * 24 * 60 * 60 * 1000;

interface WikiSlice {
// The slice of a wiki's config that resolveProxyConfig needs. Named distinctly
// from metadata.ts's exported WikiSlice (the client-discovery slice): different
// shape, different concern, and the two never meet at an import.
interface ProxyWikiInput {
server: string;
scriptpath: string;
oauth2ClientId?: string | null;
Expand All @@ -45,7 +48,7 @@ function stripTrailingSlash(u: string): string {

export function resolveProxyConfig(
_wikiKey: string,
wiki: WikiSlice,
wiki: ProxyWikiInput,
env: NodeJS.ProcessEnv,
): ProxyConfig | null {
const clientId = wiki.oauth2ClientId?.trim();
Expand Down
23 changes: 16 additions & 7 deletions src/auth/metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@
import { logger } from '../runtime/logger.js';
import { mwOauth2AuthorizeEndpoint, mwOauth2TokenEndpoint } from './mwOauth2Endpoints.js';

export interface AsMetadata {
// The authorization-server metadata of an upstream wiki, as discovered from its
// .well-known document or synthesized from conventions when discovery fails.
// Distinct from authorizationServer/asMetadata.ts's AsMetadataDoc, which is the
// metadata this server serves about ITSELF when acting as the hosted proxy.
export interface UpstreamAsMetadata {
issuer: string;
authorization_endpoint: string;
token_endpoint: string;
Expand Down Expand Up @@ -32,13 +36,13 @@ export interface WikiSlice {

const TIMEOUT_MS = 5000;

const cache = new Map<string, Promise<AsMetadata>>();
const cache = new Map<string, Promise<UpstreamAsMetadata>>();

export function _resetMetadataCacheForTesting(): void {
cache.clear();
}

export function fetchMetadata(wikiKey: string, wiki: WikiSlice): Promise<AsMetadata> {
export function fetchMetadata(wikiKey: string, wiki: WikiSlice): Promise<UpstreamAsMetadata> {
const cached = cache.get(wikiKey);
if (cached) {
return cached;
Expand All @@ -51,7 +55,7 @@ export function fetchMetadata(wikiKey: string, wiki: WikiSlice): Promise<AsMetad
return pending;
}

async function doFetch(wikiKey: string, wiki: WikiSlice): Promise<AsMetadata> {
async function doFetch(wikiKey: string, wiki: WikiSlice): Promise<UpstreamAsMetadata> {
const started = Date.now();
const origin = `${wiki.server}/.well-known/oauth-authorization-server`;
const pathed = `${wiki.server}/.well-known/oauth-authorization-server${wiki.scriptpath}/rest.php/oauth2`;
Expand All @@ -73,7 +77,7 @@ async function doFetch(wikiKey: string, wiki: WikiSlice): Promise<AsMetadata> {
}

// Both probes failed or returned malformed docs — synthesise from conventions.
const synthesized: AsMetadata = {
const synthesized: UpstreamAsMetadata = {
issuer: wiki.server,
authorization_endpoint: mwOauth2AuthorizeEndpoint(wiki.server, wiki.scriptpath),
token_endpoint: mwOauth2TokenEndpoint(wiki.server, wiki.scriptpath),
Expand Down Expand Up @@ -115,7 +119,7 @@ async function tryFetch(url: string): Promise<unknown> {
function parseMetadata(
raw: unknown,
source: 'well-known' | 'well-known-pathed',
): AsMetadata | null {
): UpstreamAsMetadata | null {
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- post-JSON boundary; required fields validated immediately below
const obj = raw as Record<string, unknown>;
const auth = obj.authorization_endpoint;
Expand Down Expand Up @@ -147,7 +151,12 @@ function parseMetadata(
};
}

function finalize(wikiKey: string, started: number, md: AsMetadata, wiki: WikiSlice): AsMetadata {
function finalize(
wikiKey: string,
started: number,
md: UpstreamAsMetadata,
wiki: WikiSlice,
): UpstreamAsMetadata {
const issuer = md.issuer || wiki.server;
logger.info('', {
event: 'oauth_discovery',
Expand Down
4 changes: 2 additions & 2 deletions src/auth/protectedResource.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
// src/auth/protectedResource.ts
import type { AsMetadata } from './metadata.js';
import type { UpstreamAsMetadata } from './metadata.js';

const RESOURCE_DOCUMENTATION =
'https://github.com/ProfessionalWiki/MediaWiki-MCP-Server/blob/master/docs/configuration.md#oauth';

export interface ProtectedResourceInput {
wikis: Record<string, { oauth2ClientId?: string | null }>;
metadatas: readonly AsMetadata[];
metadatas: readonly UpstreamAsMetadata[];
requestHost: string | undefined;
requestProto: 'http' | 'https' | undefined;
/**
Expand Down
4 changes: 2 additions & 2 deletions src/auth/tokenRefresh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@
import { logger } from '../runtime/logger.js';
import { OAuthFlowError, refreshTokens } from './oauthFlow.js';
import { createTokenStore, type StoredToken } from './tokenStore.js';
import type { AsMetadata } from './metadata.js';
import type { UpstreamAsMetadata } from './metadata.js';

const REFRESH_THRESHOLD_MS = 60_000;

export interface RefreshContext {
clientId: string;
metadata: Pick<AsMetadata, 'token_endpoint'>;
metadata: Pick<UpstreamAsMetadata, 'token_endpoint'>;
}

const inFlight = new Map<string, Promise<string>>();
Expand Down
4 changes: 2 additions & 2 deletions src/transport/streamableHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ import { loadConfigFromFile, type WikiConfig } from '../config/loadConfig.js';
import type { MwnProvider } from '../wikis/mwnProvider.js';
import type { ActiveWiki } from '../wikis/activeWiki.js';
import type { WikiRegistry } from '../wikis/wikiRegistry.js';
import { fetchMetadata, type AsMetadata } from '../auth/metadata.js';
import { fetchMetadata, type UpstreamAsMetadata } from '../auth/metadata.js';
import { buildProtectedResource, resolvePublicBase } from '../auth/protectedResource.js';
import { resolveProxyConfig, type ProxyConfig } from '../auth/authorizationServer/proxyConfig.js';
import type { ProxyStore } from '../auth/authorizationServer/proxyStore.js';
Expand Down Expand Up @@ -142,7 +142,7 @@ export function createOAuthProtectedResourceHandler(deps: {
),
);
const metadatas = settled
.filter((r): r is PromiseFulfilledResult<AsMetadata> => r.status === 'fulfilled')
.filter((r): r is PromiseFulfilledResult<UpstreamAsMetadata> => r.status === 'fulfilled')
.map((r) => r.value);
if (metadatas.length === 0) {
const reasons = settled
Expand Down
6 changes: 3 additions & 3 deletions tests/auth/protectedResource.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,17 @@ import {
buildProtectedResource,
type ProtectedResourceInput,
} from '../../src/auth/protectedResource.js';
import type { AsMetadata } from '../../src/auth/metadata.js';
import type { UpstreamAsMetadata } from '../../src/auth/metadata.js';

const baseMetadata: AsMetadata = {
const baseMetadata: UpstreamAsMetadata = {
issuer: 'https://wiki.example.org',
authorization_endpoint: 'https://wiki.example.org/w/rest.php/oauth2/authorize',
token_endpoint: 'https://wiki.example.org/w/rest.php/oauth2/access_token',
source: 'well-known',
synthesized: false,
};

const metadataWithScopes: AsMetadata = {
const metadataWithScopes: UpstreamAsMetadata = {
...baseMetadata,
scopes_supported: ['basic', 'editpage'],
};
Expand Down
Loading