From ec7ccbe3c26d77162249c6ae2de330d27238102f Mon Sep 17 00:00:00 2001 From: alistair3149 Date: Thu, 23 Jul 2026 10:44:19 -0400 Subject: [PATCH 1/3] Signpost src/auth and disambiguate two colliding type names Add src/auth/README.md mapping the subsystem on its central distinction: the top level is this server acting as an OAuth client to a wiki, while authorizationServer/ is this server acting as an authorization server (the hosted proxy). AGENTS.md gains a src/auth entry pointing to it; its transport line drops the now-moved request context and the runtime line gains it. Rename two names that read as collisions: - metadata.ts AsMetadata -> UpstreamAsMetadata. It is an upstream wiki's AS metadata, distinct from authorizationServer/asMetadata.ts's AsMetadataDoc (the metadata this server serves about itself). A comment now states the contrast. - proxyConfig.ts's local WikiSlice -> ProxyWikiInput, so the exported WikiSlice in metadata.ts is the only type by that name. Different shape, different concern; the two never met at an import. Pure rename plus docs; no behaviour change. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 5 ++- src/auth/README.md | 42 +++++++++++++++++++++ src/auth/authorizationServer/proxyConfig.ts | 7 +++- src/auth/metadata.ts | 23 +++++++---- src/auth/protectedResource.ts | 4 +- src/auth/tokenRefresh.ts | 4 +- src/transport/streamableHttp.ts | 4 +- tests/auth/protectedResource.test.ts | 6 +-- 8 files changed, 75 insertions(+), 20 deletions(-) create mode 100644 src/auth/README.md diff --git a/AGENTS.md b/AGENTS.md index 429faf06..ac55eaaf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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//` — 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). diff --git a/src/auth/README.md b/src/auth/README.md new file mode 100644 index 00000000..fdc31027 --- /dev/null +++ b/src/auth/README.md @@ -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 credential directory. + +## 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 page and its cryptographic verification. +- `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. +- `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 host allowlist. +- `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 with the transport layer + +`pageShell.ts` renders the HTML shell for the consent and error pages. Request-scoped state (`runtime/requestContext.ts`) and the auth-shape classifier (`runtime/authShape.ts`) live under `runtime/`, not here, because the transport uses them too. diff --git a/src/auth/authorizationServer/proxyConfig.ts b/src/auth/authorizationServer/proxyConfig.ts index 6fc49edb..2851e012 100644 --- a/src/auth/authorizationServer/proxyConfig.ts +++ b/src/auth/authorizationServer/proxyConfig.ts @@ -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; @@ -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(); diff --git a/src/auth/metadata.ts b/src/auth/metadata.ts index 9788f052..a2c1934b 100644 --- a/src/auth/metadata.ts +++ b/src/auth/metadata.ts @@ -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; @@ -32,13 +36,13 @@ export interface WikiSlice { const TIMEOUT_MS = 5000; -const cache = new Map>(); +const cache = new Map>(); export function _resetMetadataCacheForTesting(): void { cache.clear(); } -export function fetchMetadata(wikiKey: string, wiki: WikiSlice): Promise { +export function fetchMetadata(wikiKey: string, wiki: WikiSlice): Promise { const cached = cache.get(wikiKey); if (cached) { return cached; @@ -51,7 +55,7 @@ export function fetchMetadata(wikiKey: string, wiki: WikiSlice): Promise { +async function doFetch(wikiKey: string, wiki: WikiSlice): Promise { 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`; @@ -73,7 +77,7 @@ async function doFetch(wikiKey: string, wiki: WikiSlice): Promise { } // 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), @@ -115,7 +119,7 @@ async function tryFetch(url: string): Promise { 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; const auth = obj.authorization_endpoint; @@ -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', diff --git a/src/auth/protectedResource.ts b/src/auth/protectedResource.ts index 624b2f9c..869d8e5d 100644 --- a/src/auth/protectedResource.ts +++ b/src/auth/protectedResource.ts @@ -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; - metadatas: readonly AsMetadata[]; + metadatas: readonly UpstreamAsMetadata[]; requestHost: string | undefined; requestProto: 'http' | 'https' | undefined; /** diff --git a/src/auth/tokenRefresh.ts b/src/auth/tokenRefresh.ts index a8f43a46..c619d1a2 100644 --- a/src/auth/tokenRefresh.ts +++ b/src/auth/tokenRefresh.ts @@ -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; + metadata: Pick; } const inFlight = new Map>(); diff --git a/src/transport/streamableHttp.ts b/src/transport/streamableHttp.ts index df4b50c1..a1b855b7 100644 --- a/src/transport/streamableHttp.ts +++ b/src/transport/streamableHttp.ts @@ -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'; @@ -142,7 +142,7 @@ export function createOAuthProtectedResourceHandler(deps: { ), ); const metadatas = settled - .filter((r): r is PromiseFulfilledResult => r.status === 'fulfilled') + .filter((r): r is PromiseFulfilledResult => r.status === 'fulfilled') .map((r) => r.value); if (metadatas.length === 0) { const reasons = settled diff --git a/tests/auth/protectedResource.test.ts b/tests/auth/protectedResource.test.ts index 879c76dc..48a3608e 100644 --- a/tests/auth/protectedResource.test.ts +++ b/tests/auth/protectedResource.test.ts @@ -4,9 +4,9 @@ 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', @@ -14,7 +14,7 @@ const baseMetadata: AsMetadata = { synthesized: false, }; -const metadataWithScopes: AsMetadata = { +const metadataWithScopes: UpstreamAsMetadata = { ...baseMetadata, scopes_supported: ['basic', 'editpage'], }; From 739153366ef1b58ad4a70c98d8fbb2fa184a2f97 Mon Sep 17 00:00:00 2001 From: alistair3149 Date: Thu, 23 Jul 2026 10:56:37 -0400 Subject: [PATCH 2/3] Correct four file descriptions in the auth README Review caught one-liners that pointed a reader at the wrong file: - consent.ts renders pages and handles cookies; the consent cookie is signed and verified in jwt.ts, so credit jwt.ts for it. - redirectPolicy.ts holds a redirect-URI allowlist, not a host allowlist; the CIMD host allowlist is in cimd.ts. - pageShell.ts also renders the stdio loopback login pages, so it is shared between both roles, not just the proxy consent flow. - paths.ts resolves two files (client credentials and the proxy store), not a single credential directory. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/auth/README.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/auth/README.md b/src/auth/README.md index fdc31027..365b86fb 100644 --- a/src/auth/README.md +++ b/src/auth/README.md @@ -18,7 +18,7 @@ Which role is active depends on configuration. The proxy path turns on only when - `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 credential directory. +- `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) @@ -27,16 +27,16 @@ Which role is active depends on configuration. The proxy path turns on only when - `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 page and its cryptographic verification. +- `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. +- `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 host allowlist. +- `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 with the transport layer +## Shared -`pageShell.ts` renders the HTML shell for the consent and error pages. Request-scoped state (`runtime/requestContext.ts`) and the auth-shape classifier (`runtime/authShape.ts`) live under `runtime/`, not here, because the transport uses them too. +`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. From 536bd32e63ac2c0463899ab9f9745ee18920e6e4 Mon Sep 17 00:00:00 2001 From: alistair3149 Date: Thu, 23 Jul 2026 10:58:43 -0400 Subject: [PATCH 3/3] Format the auth README with oxfmt oxfmt normalizes markdown emphasis to underscores; the prior commit's `*host*` tripped fmt:check. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/auth/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/auth/README.md b/src/auth/README.md index 365b86fb..4b846a47 100644 --- a/src/auth/README.md +++ b/src/auth/README.md @@ -32,7 +32,7 @@ Which role is active depends on configuration. The proxy path turns on only when - `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`). +- `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.