diff --git a/CHANGELOG.md b/CHANGELOG.md index 982f2135..0e1dcc7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) ### Breaking changes +- The HTTP transport no longer forwards a caller's `Authorization: Bearer` header to MediaWiki. Such a request is refused with `401`, because a token minted by the wiki was not issued for this server. Use [hosted OAuth sign-in](docs/deployment.md#hosted-oauth-sign-in), or set `MCP_ALLOW_BEARER_PASSTHROUGH=true` to keep the old behaviour while you migrate; it is deprecated and will be removed. The server now warns at startup when a wiki requires a signed-in user but neither hosted sign-in nor forwarding is available, since no request could then succeed. +- The server no longer advertises the wikis' own authorization servers, so a client can no longer discover where to mint a token to send here. Without hosted OAuth sign-in enabled, `/.well-known/oauth-protected-resource` now answers `404`, and `list-wikis` stops reporting each wiki's `authorizationServer`. Deployments running the hosted sign-in are unaffected. - The `Origin` header is now validated on every bind, and a request carrying an unlisted origin is refused with `403`. If you serve a browser-based client from a public bind, set `MCP_ALLOWED_ORIGINS` before upgrading. Clients that send no `Origin` header, which is most of them, are unaffected. ### Changed diff --git a/README.md b/README.md index 805068f8..42c3f966 100644 --- a/README.md +++ b/README.md @@ -166,7 +166,7 @@ For the full field reference, env-var substitution, secret sources, change tags, Tools marked 🔐 require authentication. Write tools (including extension-pack writes) are hidden from `tools/list` when the configured default wiki has `readOnly: true` — see [Deployment](#deployment). - **Browser-based OAuth (recommended).** Sign in through a browser tab the first time a tool needs auth. Set `oauth2ClientId` and `oauth2CallbackPort` per wiki — see [docs/configuration.md — OAuth (browser-based)](docs/configuration.md#oauth-browser-based). -- **Per-request bearer token (HTTP).** Each request carries `Authorization: Bearer `; the server forwards it to MediaWiki. See [docs/deployment.md — per-request bearer token](docs/deployment.md#per-request-bearer-token-http-transport). +- **Per-request bearer token (HTTP), deprecated.** Each request carries `Authorization: Bearer ` and the server forwards it to MediaWiki. Off by default, because an MCP server must not accept tokens that were not issued for it. See [docs/deployment.md — per-request bearer token](docs/deployment.md#per-request-bearer-token-http-transport-deprecated). - **Hosted OAuth proxy (HTTP).** The server fronts one MediaWiki consumer as an OAuth 2.1 Authorization Server, so an OAuth-aware client signs each user in — no manual tokens. Point it at `https:///mcp`; anonymous read still works. See [docs/deployment.md — hosted OAuth sign-in](docs/deployment.md#hosted-oauth-sign-in). - **Manual OAuth2 access token.** Paste a long-lived token into `config.json`. See [docs/configuration.md — manual OAuth2 access token](docs/configuration.md#manual-oauth2-access-token). - **Bot password.** Fallback when Extension:OAuth isn't installed. See [docs/configuration.md — bot password](docs/configuration.md#bot-password). @@ -284,7 +284,7 @@ Running the server as a remote HTTP endpoint for other users has its own configu Defaults are safe for single-user use. Before exposing the HTTP transport to others, lock down three things: -- **Trust the proxy, not the header.** The server forwards any `Authorization: Bearer` header straight to MediaWiki — authentication is the reverse proxy's job. Terminate TLS there, and don't expose the MCP port directly on an untrusted network. See [docs/deployment.md — security checklist](docs/deployment.md#security-checklist). +- **Terminate TLS at your reverse proxy.** Don't expose the MCP port directly on an untrusted network. See [docs/deployment.md — security checklist](docs/deployment.md#security-checklist). - **Pair `MCP_BIND` with `MCP_ALLOWED_HOSTS` and `MCP_ALLOWED_ORIGINS`.** The HTTP transport binds to `127.0.0.1` by default. When you open it up with `MCP_BIND=0.0.0.0`, set `MCP_ALLOWED_HOSTS` to the hostnames your proxy forwards and `MCP_ALLOWED_ORIGINS` to the browser origins allowed to call the server — these block DNS-rebinding and cross-origin attacks respectively. - **Uploads are opt-in.** `upload-file` is disabled until you list allowed directories in `uploadDirs` or `MCP_UPLOAD_DIRS`. See [docs/configuration.md — upload directories](docs/configuration.md#upload-directories). - **Internal destinations need `MCP_TRUSTED_HOSTS`.** Outbound fetches are SSRF-guarded: a destination resolving to a private or loopback address (e.g. a Docker-network alias like `mediawiki.svc`) is refused until you list its host in `MCP_TRUSTED_HOSTS`. See [docs/deployment.md — outbound SSRF guard](docs/deployment.md#outbound-ssrf-guard). diff --git a/docs/configuration.md b/docs/configuration.md index 806e77c1..1581d505 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -201,7 +201,7 @@ If your wiki doesn't have an OAuth consumer set up, omit `oauth2ClientId`. Stati #### HTTP transport behaviour -Over the HTTP transport, OAuth runs through discovery and `401` challenges instead of a local browser flow, and each request carries the bearer for its target wiki. That behaviour is in [deployment.md — per-request bearer token](deployment.md#per-request-bearer-token-http-transport). +Over the HTTP transport, OAuth runs through discovery and `401` challenges instead of a local browser flow, and each request is served with whichever identity the deployment provides. That behaviour is in [deployment.md — per-request bearer token](deployment.md#per-request-bearer-token-http-transport-deprecated). #### Hosted OAuth proxy environment variables diff --git a/docs/deployment.md b/docs/deployment.md index f50b4d2d..57e51636 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -206,8 +206,9 @@ The `GIT_SHA` build arg populates the image's `org.opencontainers.image.revision Defaults are safe for a localhost bind. Before exposing the HTTP transport to others, confirm all of these: -- **Terminate TLS at a reverse proxy; never expose the port directly.** The server trusts any `Authorization: Bearer` header it receives without origin checks, so authentication is the proxy's job. Run it behind Caddy, nginx, or Traefik, or bind it to `127.0.0.1`; never put the raw HTTP port on an untrusted network. -- **Forward the `Authorization` header intact.** Proxy configs that strip or consume it (`header_up -Authorization`, `proxy_set_header Authorization ""`, a proxy-level basic-auth handler on the MCP route) leave the server with no token, falling back to config or anonymous. On any untrusted inbound path, strip the client-supplied `Authorization` instead, so a caller cannot inject a bearer the server would trust. +- **Terminate TLS at a reverse proxy; never expose the port directly.** Run it behind Caddy, nginx, or Traefik, or bind it to `127.0.0.1`; never put the raw HTTP port on an untrusted network. +- **A caller-supplied `Authorization` header is refused, not trusted.** The server no longer forwards one to the wiki, so a caller cannot inject a bearer it would act on. Only enable `MCP_ALLOW_BEARER_PASSTHROUGH` if you have callers holding their own wiki tokens. +- **Let the `Authorization` header reach the server.** With hosted OAuth sign-in it carries a token this server issued, and with `MCP_ALLOW_BEARER_PASSTHROUGH` set it carries the caller's own; either way `header_up -Authorization`, `proxy_set_header Authorization ""` and a proxy-level basic-auth handler on the MCP route all break sign-in. - **Set `MCP_ALLOWED_HOSTS`** to the hostnames your proxy forwards (e.g. `wiki.example.org`). This engages the SDK's DNS-rebinding check; requests to `/mcp` with a non-matching `Host` get a 403. Unset on a public bind turns the check off (with a startup warning); unset on a localhost bind is safe. - **Set `MCP_ALLOWED_ORIGINS`** to the browser origins allowed to call `/mcp` (e.g. `https://app.example.org`). A present-but-unlisted `Origin` gets a 403. The match is on hostname; see [Host and Origin matching](#host-and-origin-matching). Leaving it unset on a public bind refuses every browser request, so set it if you serve one. This is a separate decision from `MCP_ALLOWED_HOSTS`, which names hosts this server answers to rather than origins allowed to script it. - **List internal destinations in `MCP_TRUSTED_HOSTS`.** Outbound fetches are SSRF-guarded, so a wiki `server` on a private or Docker-internal address (e.g. `mediawiki.svc`) is refused until you exempt it; otherwise extension tools silently disappear. See [outbound SSRF guard](#outbound-ssrf-guard). @@ -240,6 +241,7 @@ Set `MCP_TRANSPORT=http` to select this transport (the Docker image defaults to | `MCP_ALLOWED_ORIGINS` | auto on localhost | Comma-separated `Origin`-header allowlist. Unset on a public bind refuses every browser request. See [Security checklist](#security-checklist). | | `MCP_TRUSTED_HOSTS` | unset | Comma-separated **outbound** SSRF-guard exemptions for internal destinations (e.g. `mediawiki.svc`). See [Outbound SSRF guard](#outbound-ssrf-guard). | | `MCP_ALLOW_STATIC_FALLBACK` | unset | Allow HTTP startup when a wiki has static credentials, making them a shared fallback identity. See [Security checklist](#security-checklist). | +| `MCP_ALLOW_BEARER_PASSTHROUGH` | unset | Deprecated. Forward a caller's `Authorization` header to MediaWiki as that caller. Without it such a request is refused with `401`. See [Per-request bearer token](#per-request-bearer-token-http-transport-deprecated). | `MCP_MAX_REQUEST_BODY` matches nginx's `client_max_body_size 1m`. Raise it if `update-page` calls return 413 on legitimately large edits or your wiki has raised `$wgMaxArticleSize` (MediaWiki default 2 MB). Lower it for a tighter DoS guard. @@ -259,7 +261,7 @@ Set `MCP_TRANSPORT=http` to select this transport (the Docker image defaults to When enabled, the [hosted OAuth sign-in](#hosted-oauth-sign-in) setup makes this server the OAuth authorization server the MCP client talks to, through the endpoints routed in [step 4](#4-route-the-oauth-endpoints-through-your-proxy). The bearer a client sends to `/mcp` is a token the proxy minted, not a MediaWiki token. The user's MediaWiki token stays server-side, keyed to that bearer, and is refreshed server-to-server through the confidential consumer — this is what keeps users signed in past the wiki's ~1-hour access-token lifetime, and it is the state the [store file](#proxy-state-persistence) persists. -How the sign-in challenge is issued depends on the wiki. On a **public wiki**, a tokenless request is served anonymously; a write that needs authentication returns an authentication error, and an invalid or expired bearer gets a `401` + `WWW-Authenticate` challenge. A **private wiki** (`private: true`, MediaWiki's `$wgGroupPermissions['*']['read'] = false`) answers every request, including the initial connection, with that challenge, so a client prompts for sign-in at connect. The connection-time challenge requires the wiki's `oauth2ClientId`; without it, the `401` advertises an authorization server the wiki does not have, and the server logs a warning at startup. +How the sign-in challenge is issued depends on the wiki. On a **public wiki**, a tokenless request is served anonymously; a write that needs authentication returns an authentication error, and an invalid or expired bearer gets a `401` + `WWW-Authenticate` challenge. A **private wiki** (`private: true`, MediaWiki's `$wgGroupPermissions['*']['read'] = false`) answers every request, including the initial connection, with that challenge, so a client prompts for sign-in at connect. That challenge names an authorization server only when [hosted OAuth sign-in](#hosted-oauth-sign-in) is configured; otherwise it is a bare `Bearer` challenge, because there is no document for a client to fetch. A private wiki with no hosted sign-in and no forwarding cannot serve any request, and the server says so at startup. #### Three-base topology @@ -321,9 +323,11 @@ The proxy persists its sign-in state to a local file so a restart or deploy does **In Docker, mount a persistent volume at the store path.** The image declares one at `/app/data`, but you must mount a named volume or a writable host path there, or a container restart wipes it. A host-path bind mount must be writable by the container's non-root user; a named volume handles that automatically. -### Per-request bearer token (HTTP transport) +### Per-request bearer token (HTTP transport, deprecated) -For **programmatic or non-interactive clients that already hold a MediaWiki OAuth2 access token** (a script, a CI job, an automation backend), the HTTP transport also accepts the token directly, with no browser flow. Most deployments serving humans should use [Hosted OAuth sign-in](#hosted-oauth-sign-in) instead; this is the lower-level primitive it is built on. +> **Deprecated.** An MCP server must not accept tokens that were not issued for it, so forwarding a caller's MediaWiki token is off by default and will be removed. Use [Hosted OAuth sign-in](#hosted-oauth-sign-in), which gets this server its own tokens. Set `MCP_ALLOW_BEARER_PASSTHROUGH=true` to keep the old behaviour while you migrate; the server logs a warning at startup while it is set. + +For **programmatic or non-interactive clients that already hold a MediaWiki OAuth2 access token** (a script, a CI job, an automation backend), the HTTP transport can accept the token directly, with no browser flow. The server accepts a standard OAuth 2.1 `Authorization: Bearer` header on each request, as described in the [MCP authorization specification](https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization): @@ -333,13 +337,13 @@ Authorization: Bearer Use a MediaWiki OAuth2 access token obtained from `Special:OAuthConsumerRegistration/propose/oauth2` on the target wiki, with [Extension:OAuth](https://www.mediawiki.org/wiki/Extension:OAuth) installed. The server forwards it to MediaWiki as that caller's token, so writes are attributable and MediaWiki's per-user rate limits apply. A bearer is scoped to a single MediaWiki OAuth2 realm, and the server pins nothing across requests: one client can address wikis on different authorization servers by sending the right token per request. `list-wikis` reports each OAuth wiki's `authorizationServer`. -When a wiki sets `oauth2ClientId` (see [configuration.md: OAuth (browser-based)](configuration.md#oauth-browser-based)), the server also advertises OAuth discovery on this path: the protected-resource document lists every OAuth-configured wiki's authorization server, and a capable client can run the authorization-code flow against the wiki's **own** authorization server and fetch that token itself instead of you pasting one in. A bearer-less request is challenged with `401` only when no configured wiki is usable without a token; a deployment that mixes OAuth and non-OAuth wikis still serves tokenless clients on the wikis that allow anonymous access. +The server no longer advertises the wikis' own authorization servers, so a client cannot discover where to mint such a token: obtain it yourself and configure it on the caller. Only [Hosted OAuth sign-in](#hosted-oauth-sign-in) publishes a protected-resource document, naming this server. While `MCP_ALLOW_BEARER_PASSTHROUGH=true` is set, a bearer-less request is challenged with `401` when no configured wiki is usable without a token; a deployment mixing OAuth and non-OAuth wikis still serves tokenless clients on the wikis that allow anonymous access. -**Precedence:** request header → `config.json` `token` → `config.json` `username`/`password` → anonymous. The HTTP transport refuses to start with static credentials in `config.json` unless `MCP_ALLOW_STATIC_FALLBACK=true` is set; see [the Security checklist](#security-checklist) for why. +**Precedence:** request header (only while `MCP_ALLOW_BEARER_PASSTHROUGH=true`; otherwise refused with `401`) → `config.json` `token` → `config.json` `username`/`password` → anonymous. The HTTP transport refuses to start with static credentials in `config.json` unless `MCP_ALLOW_STATIC_FALLBACK=true` is set; see [the Security checklist](#security-checklist) for why. HTTP serving is per-request, following MCP protocol revision 2026-07-28: the server issues no session ids, serves 2026-07-28 clients natively, and serves earlier 2025-era clients statelessly. Each request builds an independent MediaWiki session from the token it carries, so rotation and revocation take effect on the very next request; run the transport behind TLS so bearers stay confidential in transit. -Example with Claude Code: +Example with Claude Code, which works only on a server started with `MCP_ALLOW_BEARER_PASSTHROUGH=true`: ```sh claude mcp add --transport http my-wiki https://wiki.example.org/mcp \ diff --git a/docs/operations.md b/docs/operations.md index d2710249..6a751e48 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -28,10 +28,10 @@ Fields you'll filter on: One line on server boot — a snapshot of the effective configuration that's safe to paste into a support ticket: ```json -{"ts":"...","level":"info","event":"startup","version":"0.8.0","transport":"http","host":"0.0.0.0","port":8080,"auth_shape":"bearer-passthrough","default_wiki":"example.org","wikis":["example.org"],"allow_wiki_management":false,"allowed_hosts":["wiki.example.org"],"allowed_origins":["https://wiki.example.org"],"max_request_body":"1mb","upload_dirs_configured":false} +{"ts":"...","level":"info","event":"startup","version":"0.8.0","transport":"http","host":"0.0.0.0","port":8080,"auth_shape":"anonymous","default_wiki":"example.org","wikis":["example.org"],"allow_wiki_management":false,"allowed_hosts":["wiki.example.org"],"allowed_origins":["https://wiki.example.org"],"max_request_body":"1mb","upload_dirs_configured":false} ``` -- **`auth_shape`** — `anonymous`, `static-credential`, or `bearer-passthrough`. +- **`auth_shape`** — `anonymous`, `static-credential`, `oauth-proxy` (hosted sign-in configured), or `bearer-passthrough` (only while the deprecated `MCP_ALLOW_BEARER_PASSTHROUGH` is set). - **`host`, `port`, `allowed_hosts`, `allowed_origins`** — HTTP transport only. `allowed_hosts` is omitted when not configured. `allowed_origins` is always present: an empty array means every browser request is refused, not that the check is off. - **`upload_dirs_configured`** — `true` when `uploadDirs` (config) or `MCP_UPLOAD_DIRS` (env) is set. The actual paths are not logged. - **`max_request_body`** — HTTP transport only. The resolved `MCP_MAX_REQUEST_BODY` value. diff --git a/src/auth/protectedResource.ts b/src/auth/protectedResource.ts index 18ecd981..7de637e3 100644 --- a/src/auth/protectedResource.ts +++ b/src/auth/protectedResource.ts @@ -14,7 +14,11 @@ export interface ProtectedResourceInput { * authorization server. Pass the proxy issuer(s) here to advertise self * instead of the per-wiki upstream issuers derived from `metadatas`. */ - authorizationServersOverride?: readonly string[]; + // The authorization servers this document names. Required: only the hosted + // proxy makes this server an authorization server, and it names itself. There is + // no per-wiki fallback — naming the wikis' own issuers is what steered clients + // into minting tokens this server must not accept. + authorizationServers: readonly string[]; } export interface ProtectedResourceDoc { @@ -75,10 +79,7 @@ export function buildProtectedResource( // not match expected .../mcp"). resolvePublicBase keeps its trailing slash for // building the resource_metadata URL; strip it for the identifier only. const resource = resolvePublicBase(input.requestHost, input.requestProto).replace(/\/+$/, ''); - const issuers = - input.authorizationServersOverride !== undefined - ? [...input.authorizationServersOverride] - : [...new Set(input.metadatas.map((m) => m.issuer))]; + const issuers = [...input.authorizationServers]; const scopes = [...new Set(input.metadatas.flatMap((m) => m.scopes_supported ?? []))]; const doc: ProtectedResourceDoc = { diff --git a/src/runtime/authShape.ts b/src/runtime/authShape.ts index 89438868..3f0b5f7f 100644 --- a/src/runtime/authShape.ts +++ b/src/runtime/authShape.ts @@ -16,10 +16,19 @@ export function hasStaticCredentials(wiki: WikiConfig): boolean { export type AuthShape = 'anonymous' | 'static-credential' | 'bearer-passthrough' | 'oauth-proxy'; export type Transport = 'stdio' | 'http'; +// Forwarding a client-supplied bearer to the wiki is deprecated and off unless a +// deployment opts in: MCP servers must not accept tokens that were not issued for +// them. Reading the environment here keeps the single source of truth beside the +// shape it names, since the classifier is what the banner reports. +export function bearerPassthroughEnabled(env: NodeJS.ProcessEnv = process.env): boolean { + return env.MCP_ALLOW_BEARER_PASSTHROUGH === 'true'; +} + export function classifyAuthShape( wikis: Readonly>, transport: Transport, proxyEnabled = false, + passthroughEnabled = bearerPassthroughEnabled(), ): AuthShape { const anyStatic = Object.values(wikis).some(hasStaticCredentials); if (anyStatic) { @@ -31,5 +40,8 @@ export function classifyAuthShape( // When the hosted OAuth proxy is active this server is itself the // authorization server (minting per-user tokens), not a plain // bearer-passthrough that forwards a client-supplied token verbatim. - return proxyEnabled ? 'oauth-proxy' : 'bearer-passthrough'; + if (proxyEnabled) { + return 'oauth-proxy'; + } + return passthroughEnabled ? 'bearer-passthrough' : 'anonymous'; } diff --git a/src/runtime/wikiCapability.ts b/src/runtime/wikiCapability.ts index 4b760bb3..72ca5d6a 100644 --- a/src/runtime/wikiCapability.ts +++ b/src/runtime/wikiCapability.ts @@ -3,7 +3,7 @@ import type { ToolContext } from './context.ts'; import type { ExtensionPack } from '../tools/extensions/types.ts'; import { extensionPacks } from '../tools/extensions/index.ts'; import { getRuntimeToken } from './requestContext.ts'; -import { hasStaticCredentials } from './authShape.ts'; +import { bearerPassthroughEnabled, hasStaticCredentials } from './authShape.ts'; const CORE_WRITE_TOOL_NAMES: readonly string[] = [ 'create-page', @@ -55,8 +55,8 @@ export async function checkWikiCapability( ctx: ToolContext, ): Promise { // HTTP transport: a call to an OAuth-only wiki with no usable token can only - // fail downstream with an opaque error. Reject it up front with discovery - // guidance. (On stdio the dispatcher's acquireToken gate drives OAuth, so + // fail downstream with an opaque error, so it is rejected up front with guidance + // naming whichever action can actually resolve it. (On stdio the dispatcher's acquireToken gate drives OAuth, so // this never fires there.) On HTTP a wiki with static credentials only // coexists with a running server when MCP_ALLOW_STATIC_FALLBACK is set — // the startup bearer guard (evaluateBearerGuard) blocks startup otherwise — @@ -82,12 +82,19 @@ export async function checkWikiCapability( ); } } else if (oauthOnly && !hasStatic && anonymous) { + // Two different situations, and the caller can only act on one. With + // forwarding opted into, supplying a token works and there is no + // discovery document to point at. Without it, nothing the caller sends + // is accepted, so the message names the operator's action instead of + // asking for a token the transport would refuse. return ctx.format.error( 'authentication', - `Wiki "${wikiKey}" requires OAuth authentication. ` + - "Send an Authorization: Bearer token for this wiki; see the server's " + - "/.well-known/oauth-protected-resource document for the wiki's " + - 'authorization server.', + bearerPassthroughEnabled() + ? `Wiki "${wikiKey}" requires an authenticated user. Send an Authorization: Bearer ` + + 'token obtained from that wiki for this caller.' + : `Wiki "${wikiKey}" requires an authenticated user, and this server has no way ` + + 'to obtain one: hosted OAuth sign-in is not configured. The operator needs to ' + + 'enable it (MCP_PUBLIC_URL and MCP_OAUTH_JWT_SIGNING_KEY).', ); } } diff --git a/src/server.ts b/src/server.ts index 762f3e7b..b9e6b705 100644 --- a/src/server.ts +++ b/src/server.ts @@ -20,7 +20,7 @@ const SERVER_NAME: string = 'mediawiki-mcp-server'; const SERVER_INSTRUCTIONS: string = `Tools and resources for working with one or more MediaWiki wikis. Each configured wiki appears as an \`mcp://wikis/{wikiKey}\` resource. Every tool that operates on a wiki accepts an optional \`wiki\` argument naming the wiki to act on (the wiki-management and OAuth tools do not) — pass a wiki key (or its \`mcp://wikis/{wikiKey}\` URI). Omit it to use the configured default wiki. There is no stateful "current wiki": each call targets exactly the wiki it names, and every response reports the wiki it ran against. Call \`list-wikis\` to discover the configured wikis, their keys, and which extension tools each one supports. -Writes, deletes, and uploads use the caller's \`Authorization: Bearer\` token when present, falling back to credentials configured on the targeted wiki. +Writes, deletes, and uploads act as whichever identity the deployment provides: the signed-in user when hosted OAuth sign-in is configured, otherwise the credentials configured on the targeted wiki. Do not send an \`Authorization\` header of your own unless the deployment asked you to; one is refused rather than used. Tool errors fall into seven categories: \`not_found\`, \`permission_denied\`, \`invalid_input\`, \`conflict\`, \`authentication\`, \`rate_limited\`, and \`upstream_failure\`. Reads that exceed a per-call cap return a truncation marker describing what was returned and how to fetch the rest.`; diff --git a/src/tools/list-wikis.ts b/src/tools/list-wikis.ts index 7f4ec988..baf22f98 100644 --- a/src/tools/list-wikis.ts +++ b/src/tools/list-wikis.ts @@ -2,6 +2,7 @@ import type { CallToolResult } from '@modelcontextprotocol/server'; import type { Tool } from '../runtime/tool.ts'; import type { ToolContext } from '../runtime/context.ts'; import { extensionPacks } from './extensions/index.ts'; +import { bearerPassthroughEnabled } from '../runtime/authShape.ts'; import { fetchMetadata } from '../auth/metadata.ts'; interface WikiSummary { @@ -13,7 +14,10 @@ interface WikiSummary { reachable: boolean; // Tool names from every extension pack the wiki supports; order is not significant. extensionTools: string[]; - // AS issuer for an OAuth-configured wiki; absent otherwise. + // Where a caller obtains a token for this wiki. Reported only while forwarding a + // caller-supplied token is enabled, since that is the only shape in which the + // answer is actionable: otherwise the server refuses such a token, and pointing + // at the issuer would send a client to mint one it cannot use. authorizationServer?: string; } @@ -52,7 +56,11 @@ export const listWikis: Tool> = { } } let authorizationServer: string | undefined; - if (typeof config.oauth2ClientId === 'string' && config.oauth2ClientId.trim() !== '') { + if ( + bearerPassthroughEnabled() && + typeof config.oauth2ClientId === 'string' && + config.oauth2ClientId.trim() !== '' + ) { try { const md = await fetchMetadata(key, { server: config.server, diff --git a/src/transport/mcpRoute.ts b/src/transport/mcpRoute.ts index c7e99673..8bfd11a0 100644 --- a/src/transport/mcpRoute.ts +++ b/src/transport/mcpRoute.ts @@ -1,7 +1,7 @@ import type { Request, RequestHandler, Response } from 'express'; import type { McpHttpHandler } from '@modelcontextprotocol/server'; import { toNodeHandler } from '@modelcontextprotocol/node'; -import { hasStaticCredentials } from '../runtime/authShape.ts'; +import { bearerPassthroughEnabled, hasStaticCredentials } from '../runtime/authShape.ts'; import { withRequestContext } from '../runtime/requestContext.ts'; import type { WikiConfig } from '../config/loadConfig.ts'; import type { WikiRegistry } from '../wikis/wikiRegistry.ts'; @@ -82,7 +82,11 @@ function echoedId(req: Request): { id?: string | number } { // WWW-Authenticate: Bearer ... resource_metadata=... header pointing at this // server's protected-resource document. Reused by the legacy OAuth-only // short-circuit and the proxy invalid-JWT path so both speak the same dialect. -function emit401Challenge(req: Request, res: Response): void { +// `hasMetadata` says whether this server publishes a protected-resource document, +// which is true only while the hosted proxy is enabled. Pointing a client at that +// URL when it 404s sends it to a dead end, so the parameter is omitted instead; +// RFC 6750 admits a challenge without it. +function emit401Challenge(req: Request, res: Response, hasMetadata: boolean): void { const requestProto = resolveRequestProto(req); const base = resolvePublicBase(req.headers.host ?? undefined, requestProto); // The protected-resource document is served at the ORIGIN root (RFC 9728), not @@ -93,7 +97,9 @@ function emit401Challenge(req: Request, res: Response): void { const metadataUrl = `${origin}/.well-known/oauth-protected-resource`; res.set( 'WWW-Authenticate', - `Bearer error="invalid_token", realm="MediaWiki MCP Server", resource_metadata="${metadataUrl}"`, + hasMetadata + ? `Bearer error="invalid_token", realm="MediaWiki MCP Server", resource_metadata="${metadataUrl}"` + : `Bearer error="invalid_token", realm="MediaWiki MCP Server"`, ); res.status(401).json({ jsonrpc: '2.0', @@ -157,6 +163,7 @@ export function createMcpRouteHandler( const nodeHandler = toNodeHandler(handler, { onerror: options.onerror }); return async (req, res) => { const bearer = extractBearerToken(req); + const pc = getProxyConfig?.() ?? null; // A `private` wiki disallows anonymous reads, so the deployment requires // auth for everything: challenge any tokenless request up front — including @@ -167,12 +174,10 @@ export function createMcpRouteHandler( defaultWikiKey !== undefined && wikiRegistry?.get(defaultWikiKey)?.private === true ) { - emit401Challenge(req, res); + emit401Challenge(req, res, pc !== null); return; } - const pc = getProxyConfig?.() ?? null; - // The token threaded into withRequestContext (and thus into mwn). For the // legacy path it is the raw request bearer. For the proxy path it is the // UPSTREAM wiki token resolved from the proxy JWT (or undefined for an @@ -195,22 +200,39 @@ export function createMcpRouteHandler( if (err instanceof UpstreamBearerError && err.retryable) { emit503Unavailable(req, res); } else { - emit401Challenge(req, res); + emit401Challenge(req, res, pc !== null); } return; } } else { resolvedBearer = undefined; } - } else if (!bearer && wikiRegistry) { - // Legacy (proxy disabled): a tokenless request to a set of wikis that all - // require OAuth is rejected up front with the discovery challenge. This - // path is intentionally left UNCHANGED. + } else if (bearer && !bearerPassthroughEnabled()) { + // Proxy disabled and forwarding not opted into. The bearer was issued by + // the wiki's authorization server, not for this server, so it is refused + // rather than forwarded: accepting a token minted for another audience is + // what the passthrough prohibition is about, and silently ignoring it + // would be worse — the caller would believe it is acting as itself while + // the request ran anonymously or as a configured identity. + // + // Not conditioned on whether a wiki sets `oauth2ClientId`: that describes + // how THIS server runs browser sign-in, not whether the wiki accepts + // bearers. Any wiki with Extension:OAuth does, so a forwarded token would + // authenticate as the caller there regardless of our own configuration. + // A caller whose bearer is meant for something else is what the opt-in is + // for; the server cannot tell the two apart and must not guess. + emit401Challenge(req, res, pc !== null); + return; + } else if (!bearer && wikiRegistry && bearerPassthroughEnabled()) { + // Only meaningful while forwarding is available: a tokenless request to a + // set of wikis that all require OAuth can be answered by the caller + // supplying one. Without passthrough there is nothing a client can do, so + // the condition is an operator misconfiguration rather than a 401. const all = Object.values(wikiRegistry.getAll()); const fallbackAllowed = process.env.MCP_ALLOW_STATIC_FALLBACK === 'true'; const allNeedAuth = all.length > 0 && all.every((cfg) => wikiNeedsAuth(cfg, fallbackAllowed)); if (allNeedAuth) { - emit401Challenge(req, res); + emit401Challenge(req, res, pc !== null); return; } } diff --git a/src/transport/streamableHttp.ts b/src/transport/streamableHttp.ts index ff744513..9e6707be 100644 --- a/src/transport/streamableHttp.ts +++ b/src/transport/streamableHttp.ts @@ -20,6 +20,7 @@ import { originValidation, } from '@modelcontextprotocol/express'; import { evaluateBearerGuard } from './bearerGuard.ts'; +import { bearerPassthroughEnabled, hasStaticCredentials } from '../runtime/authShape.ts'; import { LOCALHOST_HOSTS, resolveHttpConfig } from './httpConfig.ts'; import { logger } from '../runtime/logger.ts'; import { @@ -178,13 +179,23 @@ export function handleListenError( export function createOAuthProtectedResourceHandler(deps: { wikiRegistry: WikiRegistry; - // When the hosted OAuth proxy is enabled, this server is itself the - // authorization server, so the protected-resource doc must advertise the - // proxy issuer (self) rather than the per-wiki upstream issuers. - getProxyConfig?: ProxyConfigGetter; + // Required: this document exists only while the hosted proxy does, so a caller + // that omitted it would silently disable the endpoint rather than select a + // fallback. Pass `() => null` to mean "no proxy". + getProxyConfig: ProxyConfigGetter; }): RequestHandler { return async (req, res, next) => { try { + // Only the hosted proxy makes this server an authorization server. Without + // it there is nothing to advertise: naming the wikis' own issuers is what + // steered clients into minting tokens this server must not accept. Answered + // before the metadata fetches below, so an unauthenticated request no + // longer triggers one outbound fetch per OAuth wiki. + const proxyConfig = deps.getProxyConfig(); + if (!proxyConfig) { + res.status(404).end(); + return; + } const wikis = deps.wikiRegistry.getAll(); const oauthWikis = Object.entries(wikis).filter( ([, w]) => typeof w.oauth2ClientId === 'string' && w.oauth2ClientId.trim() !== '', @@ -212,13 +223,12 @@ export function createOAuthProtectedResourceHandler(deps: { return; } const requestProto = resolveRequestProto(req); - const proxyConfig = deps.getProxyConfig?.() ?? null; const doc = buildProtectedResource({ wikis, metadatas, requestHost: req.headers.host ?? undefined, requestProto, - authorizationServersOverride: proxyConfig ? [proxyConfig.issuer] : undefined, + authorizationServers: [proxyConfig.issuer], }); if (!doc) { res.status(404).end(); @@ -426,9 +436,9 @@ export function buildApp(deps: BuildAppDeps): BuiltApp { const hasAs = typeof cfg.oauth2ClientId === 'string' && cfg.oauth2ClientId.trim() !== ''; if (cfg.private === true && !hasAs) { logger.warning( - `Wiki "${key}" is marked private but has no oauth2ClientId; anonymous clients ` + - 'will be challenged with a 401 pointing at an authorization server the wiki does ' + - 'not advertise. Configure an OAuth2 consumer or unset `private`.', + `Wiki "${key}" is marked private but has no oauth2ClientId, so a client cannot be ` + + 'pointed anywhere to sign in. Configure hosted OAuth sign-in (oauth2ClientId, ' + + 'oauth2ClientSecret, MCP_PUBLIC_URL and MCP_OAUTH_JWT_SIGNING_KEY) or unset `private`.', ); } } @@ -593,7 +603,7 @@ export function startHttpServer(): void { guard.wikis.join(', ') + '.\n' + 'A request without an Authorization header would silently act as the configured identity, ' + - 'defeating per-caller bearer passthrough.\n' + + 'so writes could not be attributed to the caller that made them.\n' + 'Remove `token`, `username`, and `password` from these wikis in config.json, ' + 'or set MCP_ALLOW_STATIC_FALLBACK=true to acknowledge the shared-identity deployment shape.', ); @@ -615,6 +625,48 @@ export function startHttpServer(): void { // than the first request. Memoized, so the route handlers reuse the cached result. const eagerProxyConfig = getDefaultProxyConfig(); const proxyEnabled = eagerProxyConfig !== null; + if (bearerPassthroughEnabled()) { + logger.warning( + proxyEnabled + ? 'MCP_ALLOW_BEARER_PASSTHROUGH=true is set but has no effect: hosted OAuth sign-in ' + + 'is configured, so a bearer is read as a token this server issued and a ' + + 'caller-supplied one is refused. Unset the variable.' + : 'MCP_ALLOW_BEARER_PASSTHROUGH=true is set. A caller-supplied Authorization header ' + + 'is forwarded to MediaWiki as that caller. This is deprecated: MCP servers must ' + + 'not accept tokens that were not issued for them. Prefer the hosted OAuth ' + + 'sign-in, which issues this server its own tokens.', + ); + } + // A deployment can now be configured so that nothing it serves can authenticate: + // wikis that require OAuth, no hosted sign-in to mint a token, and no opted-in + // forwarding to carry one. Nothing a client sends can fix that, so say it here + // rather than leaving every call to fail upstream. + if (!proxyEnabled && !bearerPassthroughEnabled()) { + const staticAllowed = process.env.MCP_ALLOW_STATIC_FALLBACK === 'true'; + const stranded = Object.entries(state.wikiRegistry.getAll()) + .filter(([key, cfg]) => { + const usesOAuth = + typeof cfg.oauth2ClientId === 'string' && cfg.oauth2ClientId.trim() !== ''; + const usableStatic = hasStaticCredentials(cfg) && staticAllowed; + // A `private` DEFAULT wiki cannot be rescued by static credentials: the + // route challenges a tokenless request before any credential is + // resolved, so every request to such a deployment is answered 401. + if (cfg.private === true && key === defaultWikiKey) { + return true; + } + return (usesOAuth || cfg.private === true) && !usableStatic; + }) + .map(([key]) => key); + if (stranded.length > 0) { + logger.warning( + 'No way to authenticate to wiki(s): ' + + stranded.join(', ') + + '. They require a signed-in user, but the hosted OAuth sign-in is not configured ' + + 'and forwarding a caller-supplied token is off. Set MCP_PUBLIC_URL and ' + + 'MCP_OAUTH_JWT_SIGNING_KEY to enable hosted sign-in (see docs/deployment.md).', + ); + } + } // Single process-wide proxy store, shared by the proxy handlers // (register/authorize/callback/token). It persists its durable state (client // registrations + upstream tokens) to an encrypted local file when the proxy is diff --git a/tests/auth/protectedResource.test.ts b/tests/auth/protectedResource.test.ts index de79e9eb..a9135248 100644 --- a/tests/auth/protectedResource.test.ts +++ b/tests/auth/protectedResource.test.ts @@ -23,6 +23,7 @@ function makeInput(overrides: Partial = {}): ProtectedRe return { wikis: { mywiki: { oauth2ClientId: 'client-abc' } }, metadatas: [baseMetadata], + authorizationServers: ['https://mcp.example.org/mcp'], requestHost: 'mcp.example.org', requestProto: 'https', ...overrides, @@ -109,9 +110,11 @@ describe('buildProtectedResource', () => { expect(result?.resource).toBe('https://localhost'); }); - it('lists the AS issuer in authorization_servers', () => { + it('lists the given authorization server, not the wiki that metadata came from', () => { const result = buildProtectedResource(makeInput()); - expect(result?.authorization_servers).toEqual(['https://wiki.example.org']); + expect(result?.authorization_servers).toEqual(['https://mcp.example.org/mcp']); + // baseMetadata's issuer is the wiki's own; it supplies scopes, never issuers. + expect(result?.authorization_servers).not.toContain('https://wiki.example.org'); }); it('always includes bearer_methods_supported: ["header"]', () => { @@ -148,39 +151,22 @@ describe('buildProtectedResource', () => { expect(result).toBeDefined(); }); - it('lists every distinct issuer across multiple authorization servers', () => { - const doc = buildProtectedResource({ - wikis: { a: { oauth2ClientId: 'ca' }, b: { oauth2ClientId: 'cb' } }, - metadatas: [ - { - issuer: 'https://a.example', - authorization_endpoint: 'x', - token_endpoint: 'y', - source: 'well-known', - synthesized: false, - scopes_supported: ['read'], - }, - { - issuer: 'https://b.example', - authorization_endpoint: 'x', - token_endpoint: 'y', - source: 'well-known', - synthesized: false, - scopes_supported: ['write'], - }, - { - issuer: 'https://a.example', - authorization_endpoint: 'x', - token_endpoint: 'y', - source: 'well-known', - synthesized: false, - }, - ], - requestHost: 'mcp.example', - requestProto: 'https', - }); - expect(doc?.authorization_servers).toEqual(['https://a.example', 'https://b.example']); - expect(doc?.scopes_supported?.sort()).toEqual(['read', 'write']); + it('names only the given authorization server, whatever the wikis use', () => { + const doc = buildProtectedResource( + makeInput({ + wikis: { a: { oauth2ClientId: 'ca' }, b: { oauth2ClientId: 'cb' } }, + metadatas: [ + { ...baseMetadata, issuer: 'https://a.example' }, + { ...baseMetadata, issuer: 'https://b.example' }, + ], + }), + ); + + // The wikis' own issuers are never advertised: a client minting a token there + // and presenting it here is the shape the passthrough prohibition forbids. + expect(doc?.authorization_servers).toEqual(['https://mcp.example.org/mcp']); + expect(doc?.authorization_servers).not.toContain('https://a.example'); + expect(doc?.authorization_servers).not.toContain('https://b.example'); }); it('returns undefined when no metadata resolved', () => { @@ -188,6 +174,7 @@ describe('buildProtectedResource', () => { buildProtectedResource({ wikis: { a: { oauth2ClientId: 'ca' } }, metadatas: [], + authorizationServers: ['https://mcp.example.org/mcp'], requestHost: 'mcp.example', requestProto: 'https', }), diff --git a/tests/helpers/fakeAuthorizationServer.ts b/tests/helpers/fakeAuthorizationServer.ts index 5cd926f4..31b36184 100644 --- a/tests/helpers/fakeAuthorizationServer.ts +++ b/tests/helpers/fakeAuthorizationServer.ts @@ -32,6 +32,9 @@ export interface FakeAsHandle { // Bearers seen on the captured action-API endpoint, in arrival order. Only // populated when captureApi is set. readonly capturedApiBearers: string[]; + // How many times this server's authorization-server metadata was fetched, so a + // test can assert a code path did NOT reach out. + readonly metadataRequests: { count: number }; close(): Promise; } @@ -67,6 +70,7 @@ export async function startFakeAs(opts: FakeAsOptions = {}): Promise((resolve) => server?.close(() => resolve())); }, @@ -74,10 +78,12 @@ export async function startFakeAs(opts: FakeAsOptions = {}): Promise { + handle.metadataRequests.count += 1; res.json(body(handle)); }); } else if (opts.wellKnown === 'pathed') { app.get('/.well-known/oauth-authorization-server/w/rest.php/oauth2', (_req, res) => { + handle.metadataRequests.count += 1; res.json(body(handle)); }); } diff --git a/tests/runtime/authShape.test.ts b/tests/runtime/authShape.test.ts index 8407220a..f2de0f78 100644 --- a/tests/runtime/authShape.test.ts +++ b/tests/runtime/authShape.test.ts @@ -1,5 +1,9 @@ -import { describe, it, expect } from 'vitest'; -import { classifyAuthShape, hasStaticCredentials } from '../../src/runtime/authShape.ts'; +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { + bearerPassthroughEnabled, + classifyAuthShape, + hasStaticCredentials, +} from '../../src/runtime/authShape.ts'; import type { WikiConfig } from '../../src/config/loadConfig.ts'; function wiki(overrides: Partial = {}): WikiConfig { @@ -77,9 +81,16 @@ describe('classifyAuthShape', () => { expect(classifyAuthShape(wikis, 'http')).toBe('static-credential'); }); - it('returns bearer-passthrough on http when no static creds', () => { + it('returns anonymous on http when no static creds and passthrough is off', () => { const wikis = { a: baseWiki }; - expect(classifyAuthShape(wikis, 'http')).toBe('bearer-passthrough'); + // Forwarding a caller-supplied bearer is off unless opted into, so a plain + // HTTP deployment is not a passthrough deployment by default. + expect(classifyAuthShape(wikis, 'http', false, false)).toBe('anonymous'); + }); + + it('returns bearer-passthrough on http once forwarding is opted into', () => { + const wikis = { a: baseWiki }; + expect(classifyAuthShape(wikis, 'http', false, true)).toBe('bearer-passthrough'); }); it('returns anonymous on stdio when no static creds', () => { @@ -105,7 +116,34 @@ describe('classifyAuthShape', () => { it('is unaffected by partial credentials (username only or password only)', () => { const wikisU = { a: { ...baseWiki, username: 'u' } }; const wikisP = { a: { ...baseWiki, password: 'p' } }; - expect(classifyAuthShape(wikisU, 'http')).toBe('bearer-passthrough'); - expect(classifyAuthShape(wikisP, 'http')).toBe('bearer-passthrough'); + expect(classifyAuthShape(wikisU, 'http', false, true)).toBe('bearer-passthrough'); + expect(classifyAuthShape(wikisP, 'http', false, true)).toBe('bearer-passthrough'); + }); +}); + +describe('bearerPassthroughEnabled', () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('is off unless the variable is exactly "true"', () => { + expect(bearerPassthroughEnabled({} as NodeJS.ProcessEnv)).toBe(false); + expect( + bearerPassthroughEnabled({ MCP_ALLOW_BEARER_PASSTHROUGH: '1' } as NodeJS.ProcessEnv), + ).toBe(false); + expect( + bearerPassthroughEnabled({ MCP_ALLOW_BEARER_PASSTHROUGH: 'TRUE' } as NodeJS.ProcessEnv), + ).toBe(false); + expect( + bearerPassthroughEnabled({ MCP_ALLOW_BEARER_PASSTHROUGH: 'true' } as NodeJS.ProcessEnv), + ).toBe(true); + }); + + it('reads process.env when given no environment', () => { + // The call sites in the request path take no argument, so this default is the + // one production actually uses. + expect(bearerPassthroughEnabled()).toBe(false); + vi.stubEnv('MCP_ALLOW_BEARER_PASSTHROUGH', 'true'); + expect(bearerPassthroughEnabled()).toBe(true); }); }); diff --git a/tests/runtime/dispatch.oauth.test.ts b/tests/runtime/dispatch.oauth.test.ts index a1b534bb..0c8bbb26 100644 --- a/tests/runtime/dispatch.oauth.test.ts +++ b/tests/runtime/dispatch.oauth.test.ts @@ -162,7 +162,7 @@ describe('dispatch OAuth integration', () => { // up front by the capability guard with an authentication error; the // dispatcher never reaches the OAuth gate, so open() is never called. expect(result.isError).toBe(true); - expect(JSON.stringify(result.content)).toContain('requires OAuth'); + expect(JSON.stringify(result.content)).toContain('requires an authenticated user'); expect(vi.mocked(openMod)).not.toHaveBeenCalled(); }); diff --git a/tests/runtime/dispatch.wiki.test.ts b/tests/runtime/dispatch.wiki.test.ts index 804c5830..2e2a141b 100644 --- a/tests/runtime/dispatch.wiki.test.ts +++ b/tests/runtime/dispatch.wiki.test.ts @@ -202,6 +202,6 @@ describe('dispatch capability guard', () => { }); const result = await dispatch(getPage, ctx)({ title: 'X' } as never); expect(result.isError).toBe(true); - expect(JSON.stringify(result.content)).toContain('requires OAuth'); + expect(JSON.stringify(result.content)).toContain('requires an authenticated user'); }); }); diff --git a/tests/runtime/startup-banner.test.ts b/tests/runtime/startup-banner.test.ts index 554e1b34..8b858777 100644 --- a/tests/runtime/startup-banner.test.ts +++ b/tests/runtime/startup-banner.test.ts @@ -99,7 +99,8 @@ describe('startup banner', () => { expect(e.transport).toBe('http'); expect(e.host).toBe('0.0.0.0'); expect(e.port).toBe(8080); - expect(e.auth_shape).toBe('bearer-passthrough'); + // Plain HTTP with no static credentials and no opted-in forwarding. + expect(e.auth_shape).toBe('anonymous'); expect(e.allowed_hosts).toEqual(['wiki.example.org']); expect(e.allowed_origins).toEqual(['https://wiki.example.org']); expect(e.max_request_body).toBe('2mb'); diff --git a/tests/runtime/wikiCapability.stepup.test.ts b/tests/runtime/wikiCapability.stepup.test.ts index 5cb120ec..2863f2a3 100644 --- a/tests/runtime/wikiCapability.stepup.test.ts +++ b/tests/runtime/wikiCapability.stepup.test.ts @@ -88,6 +88,6 @@ describe('checkWikiCapability proxy step-up', () => { // read tool — proving the step-up only loosens behavior when the proxy is on. const result = await checkWikiCapability('get-page', 'w', ctx({ proxy: false })); expect(result?.isError).toBe(true); - expect(messageOf(result)).toContain('requires OAuth'); + expect(messageOf(result)).toContain('requires an authenticated user'); }); }); diff --git a/tests/runtime/wikiCapability.test.ts b/tests/runtime/wikiCapability.test.ts index 5f7722e9..45712b14 100644 --- a/tests/runtime/wikiCapability.test.ts +++ b/tests/runtime/wikiCapability.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, afterEach, vi } from 'vitest'; import { checkWikiCapability, WRITE_TOOL_NAMES } from '../../src/runtime/wikiCapability.ts'; import { fakeContext } from '../helpers/fakeContext.ts'; import { withRequestFields } from '../../src/runtime/requestContext.ts'; @@ -43,6 +43,10 @@ function ctx( } describe('checkWikiCapability', () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + it('rejects an extension tool when the wiki lacks the extension', async () => { const result = await checkWikiCapability('cargo-query', 'w', ctx(false, rwWiki)); expect(result?.isError).toBe(true); @@ -92,8 +96,23 @@ describe('checkWikiCapability', () => { expect(result?.isError).toBe(true); const raw = result?.content?.map((c) => (c as { text?: string }).text).join('') ?? ''; const message = (JSON.parse(raw) as { message: string }).message; - expect(message).toContain('requires OAuth'); + expect(message).toContain('requires an authenticated user'); expect(message).toContain('Wiki "w"'); + // The actionable half, which differs by deployment: with no way to obtain a + // token, asking the caller for one would send it at a transport that refuses + // it, so the message names the operator's action instead. + expect(message).toContain('hosted OAuth sign-in is not configured'); + expect(message).not.toContain('Send an Authorization: Bearer'); + }); + + it('asks the caller for a token instead once forwarding is opted into', async () => { + vi.stubEnv('MCP_ALLOW_BEARER_PASSTHROUGH', 'true'); + const result = await checkWikiCapability('get-page', 'w', ctx(false, oauthWiki, true, 'http')); + expect(result?.isError).toBe(true); + const raw = result?.content?.map((c) => (c as { text?: string }).text).join('') ?? ''; + const message = (JSON.parse(raw) as { message: string }).message; + expect(message).toContain('Send an Authorization: Bearer'); + expect(message).not.toContain('hosted OAuth sign-in is not configured'); }); it('allows an HTTP call to an OAuth-only wiki when a runtime bearer is present', async () => { @@ -121,7 +140,7 @@ describe('checkWikiCapability', () => { ctx(false, oauthWiki, true, 'http'), ); expect(result?.isError).toBe(true); - expect(JSON.stringify(result?.content)).toContain('requires OAuth'); + expect(JSON.stringify(result?.content)).toContain('requires an authenticated user'); expect(JSON.stringify(result?.content)).not.toContain('not installed'); }); diff --git a/tests/tools/list-wikis.test.ts b/tests/tools/list-wikis.test.ts index 93e1294c..4c6acd41 100644 --- a/tests/tools/list-wikis.test.ts +++ b/tests/tools/list-wikis.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import type { CallToolResult } from '@modelcontextprotocol/server'; import { dispatch } from '../../src/runtime/dispatcher.ts'; import { listWikis } from '../../src/tools/list-wikis.ts'; @@ -58,6 +58,10 @@ function wikisOf(result: CallToolResult): Array> { } describe('list-wikis', () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + beforeEach(() => { fetchMetadata.mockReset(); mwnSpy.mockReset(); @@ -95,7 +99,28 @@ describe('list-wikis', () => { expect(cargo.extensionTools).toEqual([]); }); + it('withholds the authorization server unless forwarding a caller token is enabled', async () => { + fetchMetadata.mockResolvedValue({ issuer: 'https://oauth.wiki' }); + const ctx = ctxWith({}, new Set(), { + 'oauth-wiki': { + sitename: 'OAuth', + server: 'https://oauth.wiki', + articlepath: '/wiki', + scriptpath: '/w', + oauth2ClientId: 'cid', + }, + }); + + const result = await dispatch(listWikis, ctx)({} as never); + + // Naming the issuer would send a client to mint a token this server refuses. + const wiki = wikisOf(result).find((w) => w.key === 'oauth-wiki')!; + expect(wiki.authorizationServer).toBeUndefined(); + expect(fetchMetadata).not.toHaveBeenCalled(); + }); + it('reports the authorization server issuer for an OAuth-configured wiki', async () => { + vi.stubEnv('MCP_ALLOW_BEARER_PASSTHROUGH', 'true'); fetchMetadata.mockResolvedValue({ issuer: 'https://oauth.wiki' }); const ctx = ctxWith({}, new Set(), { 'oauth-wiki': { @@ -147,6 +172,7 @@ describe('list-wikis', () => { }); it('omits authorizationServer for a wiki whose metadata fetch fails, without failing the call', async () => { + vi.stubEnv('MCP_ALLOW_BEARER_PASSTHROUGH', 'true'); fetchMetadata.mockImplementation((key: string) => { if (key === 'broken-wiki') { return Promise.reject(new Error('metadata unavailable')); diff --git a/tests/transport/mcpRoute.test.ts b/tests/transport/mcpRoute.test.ts index 7b4a0e95..fcd86fb2 100644 --- a/tests/transport/mcpRoute.test.ts +++ b/tests/transport/mcpRoute.test.ts @@ -1,10 +1,15 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, afterEach, vi } from 'vitest'; import express, { type Express, type Request } from 'express'; import request from 'supertest'; import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; import { createMcpRouteHandler, extractBearerToken } from '../../src/transport/mcpRoute.ts'; import { getRuntimeToken } from '../../src/runtime/requestContext.ts'; +import type { WikiRegistry } from '../../src/wikis/wikiRegistry.ts'; + +afterEach(() => { + vi.unstubAllEnvs(); +}); function req(authorization: string | undefined): Request { return { headers: { authorization } } as unknown as Request; @@ -103,6 +108,13 @@ describe('era routing on /mcp', () => { }); describe('bearer threading through the route', () => { + function oauthRegistry(): WikiRegistry { + return { + getAll: () => ({ ex: { oauth2ClientId: 'CID' } }), + get: () => ({ oauth2ClientId: 'CID' }), + } as unknown as WikiRegistry; + } + function buildCaptureApp(captured: { token?: string; seen: boolean }): Express { const app = express(); app.use(express.json()); @@ -116,11 +128,60 @@ describe('bearer threading through the route', () => { }); }, }; - app.post('/mcp', createMcpRouteHandler(fakeHandler)); + app.post('/mcp', createMcpRouteHandler(fakeHandler, { wikiRegistry: oauthRegistry() })); return app; } - it('threads the raw bearer into the request context (proxy disabled)', async () => { + it('refuses a caller-supplied bearer when forwarding is not opted into', async () => { + const captured: { token?: string; seen: boolean } = { seen: false }; + const res = await request(buildCaptureApp(captured)) + .post('/mcp') + .set('Authorization', 'Bearer raw-wiki-token') + .send({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }); + // The token was issued by the wiki's authorization server, not for this + // server. Refused rather than forwarded, and rather than ignored — ignoring + // it would run the request anonymously while the caller believed otherwise. + expect(res.status).toBe(401); + expect(captured.seen).toBe(false); + }); + + it('refuses a bearer even when no wiki configures OAuth sign-in', async () => { + const captured: { token?: string; seen: boolean } = { seen: false }; + const app = express(); + app.use(express.json()); + const fakeHandler = { + fetch: async (): Promise => { + captured.seen = true; + captured.token = getRuntimeToken(); + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }, + }; + // No oauth2ClientId anywhere — the shape of the documented public read-only + // and manual-token deployments. + const registry = { + getAll: () => ({ ex: { sitename: 'Ex' } }), + get: () => ({ sitename: 'Ex' }), + } as unknown as WikiRegistry; + app.post('/mcp', createMcpRouteHandler(fakeHandler, { wikiRegistry: registry })); + + const res = await request(app) + .post('/mcp') + .set('Authorization', 'Bearer raw-wiki-token') + .send({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }); + + // `oauth2ClientId` describes how THIS server runs browser sign-in, not whether + // the wiki accepts bearers — any wiki with Extension:OAuth does. Keying the + // refusal on it would forward the token on exactly these deployments. + expect(res.status).toBe(401); + expect(captured.seen).toBe(false); + expect(captured.token).toBeUndefined(); + }); + + it('threads the raw bearer into the request context once forwarding is opted into', async () => { + vi.stubEnv('MCP_ALLOW_BEARER_PASSTHROUGH', 'true'); const captured: { token?: string; seen: boolean } = { seen: false }; const res = await request(buildCaptureApp(captured)) .post('/mcp') diff --git a/tests/transport/streamableHttp.asMetadata.test.ts b/tests/transport/streamableHttp.asMetadata.test.ts index 7e32aa92..33aee4c4 100644 --- a/tests/transport/streamableHttp.asMetadata.test.ts +++ b/tests/transport/streamableHttp.asMetadata.test.ts @@ -117,7 +117,7 @@ describe('protected-resource authorization_servers self-advertise', () => { expect(res.body.authorization_servers).toEqual(['https://mcp.example/mcp']); }); - it('falls back to the upstream wiki issuer when the proxy is disabled', async () => { + it('advertises nothing when the proxy is disabled', async () => { fakeAs = await startFakeAs(); const wikiCfg: Partial = { sitename: 'OAuthWiki', @@ -129,7 +129,13 @@ describe('protected-resource authorization_servers self-advertise', () => { const app = buildApp(fakeRegistry({ mywiki: wikiCfg }), () => null); const res = await request(app).get('/.well-known/oauth-protected-resource'); - expect(res.status).toBe(200); - expect(res.body.authorization_servers).toEqual([fakeAs.url]); + + // Only the hosted proxy makes this server an authorization server. Naming the + // wiki's own issuer here is what steered clients into minting tokens at the + // wiki and presenting them to us, which is the shape the spec forbids. + expect(res.status).toBe(404); + // Answered before any upstream discovery, so an unauthenticated request no + // longer costs one outbound metadata fetch per OAuth wiki. + expect(fakeAs.metadataRequests.count).toBe(0); }); }); diff --git a/tests/transport/streamableHttp.oauth.test.ts b/tests/transport/streamableHttp.oauth.test.ts index 20fceef3..7b60db12 100644 --- a/tests/transport/streamableHttp.oauth.test.ts +++ b/tests/transport/streamableHttp.oauth.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, afterEach, vi } from 'vitest'; +import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest'; import express, { type Express } from 'express'; import request from 'supertest'; @@ -21,16 +21,27 @@ function fakeRegistry(wikis: Record>): WikiRegistry } as unknown as WikiRegistry; } -function buildWellKnownApp(registry: WikiRegistry): Express { +// `issuer` stands in for a whole ProxyConfig: the protected-resource document is +// the only consumer here and it reads nothing else. +function buildWellKnownApp(registry: WikiRegistry, issuer?: string): Express { const app = express(); app.use(express.json()); app.get( '/.well-known/oauth-protected-resource', - createOAuthProtectedResourceHandler({ wikiRegistry: registry }), + createOAuthProtectedResourceHandler({ + wikiRegistry: registry, + getProxyConfig: () => (issuer === undefined ? null : ({ issuer } as never)), + }), ); return app; } +const PROXY_ISSUER = 'https://mcp.example/mcp'; + +// Enough of a ProxyConfig for the route to report that a protected-resource +// document exists. The challenge only advertises resource_metadata when one does. +const WITH_PROXY = { getProxyConfig: () => ({ issuer: PROXY_ISSUER }) as never }; + function buildMcpApp( registry?: WikiRegistry, options: Omit = {}, @@ -54,7 +65,7 @@ describe('GET /.well-known/oauth-protected-resource', () => { fakeAs = undefined; }); - it('returns 200 with authorization_servers when a wiki has oauth2ClientId', async () => { + it('advertises the proxy issuer when the hosted proxy is enabled', async () => { fakeAs = await startFakeAs(); const wikiCfg: Partial = { sitename: 'OAuthWiki', @@ -64,13 +75,15 @@ describe('GET /.well-known/oauth-protected-resource', () => { oauth2ClientId: 'my-client-id', }; const registry = fakeRegistry({ mywiki: wikiCfg }); - const app = buildWellKnownApp(registry); + const app = buildWellKnownApp(registry, PROXY_ISSUER); const res = await request(app).get('/.well-known/oauth-protected-resource'); expect(res.status).toBe(200); - expect(res.body.authorization_servers).toBeDefined(); - expect(Array.isArray(res.body.authorization_servers)).toBe(true); - expect(res.body.authorization_servers[0]).toBe(fakeAs.url); + // This server is the authorization server, so it names itself. The wiki's own + // issuer is deliberately never advertised: a client minting a token there and + // presenting it here is the shape the passthrough prohibition forbids. + expect(res.body.authorization_servers).toEqual([PROXY_ISSUER]); + expect(res.body.authorization_servers).not.toContain(fakeAs.url); expect(res.body.bearer_methods_supported).toEqual(['header']); }); @@ -82,7 +95,7 @@ describe('GET /.well-known/oauth-protected-resource', () => { articlepath: '/wiki', }; const registry = fakeRegistry({ plain: wikiCfg }); - const app = buildWellKnownApp(registry); + const app = buildWellKnownApp(registry, PROXY_ISSUER); const res = await request(app).get('/.well-known/oauth-protected-resource'); expect(res.status).toBe(404); @@ -97,7 +110,7 @@ describe('GET /.well-known/oauth-protected-resource', () => { oauth2ClientId: '', }; const registry = fakeRegistry({ empty: wikiCfg }); - const app = buildWellKnownApp(registry); + const app = buildWellKnownApp(registry, PROXY_ISSUER); const res = await request(app).get('/.well-known/oauth-protected-resource'); expect(res.status).toBe(404); @@ -113,7 +126,7 @@ describe('GET /.well-known/oauth-protected-resource', () => { oauth2ClientId: 'my-client-id', }; const registry = fakeRegistry({ mywiki: wikiCfg }); - const app = buildWellKnownApp(registry); + const app = buildWellKnownApp(registry, PROXY_ISSUER); // MCP_PUBLIC_URL not set; resource is derived from host header and proto const res = await request(app) @@ -125,38 +138,27 @@ describe('GET /.well-known/oauth-protected-resource', () => { expect(res.body.resource).toBe('https://mcp.example.org'); }); - it('lists every OAuth wiki authorization server when two wikis use different servers', async () => { + it('is not served at all when the proxy is disabled', async () => { fakeAs = await startFakeAs(); - const fakeAs2 = await startFakeAs(); - try { - const wikiCfgA: Partial = { - sitename: 'WikiA', - server: fakeAs.url, - scriptpath: '/w', - articlepath: '/wiki', - oauth2ClientId: 'client-a', - }; - const wikiCfgB: Partial = { - sitename: 'WikiB', - server: fakeAs2.url, - scriptpath: '/w', - articlepath: '/wiki', - oauth2ClientId: 'client-b', - }; - const registry = fakeRegistry({ wikiA: wikiCfgA, wikiB: wikiCfgB }); - const app = buildWellKnownApp(registry); + const app = buildWellKnownApp( + fakeRegistry({ + mywiki: { + sitename: 'OAuthWiki', + server: fakeAs.url, + scriptpath: '/w', + articlepath: '/wiki', + oauth2ClientId: 'my-client-id', + }, + }), + ); - const res = await request(app).get('/.well-known/oauth-protected-resource'); - expect(res.status).toBe(200); - expect(res.body.authorization_servers).toContain(fakeAs.url); - expect(res.body.authorization_servers).toContain(fakeAs2.url); - expect(res.body.authorization_servers).toHaveLength(2); - } finally { - await fakeAs2.close(); - } + const res = await request(app).get('/.well-known/oauth-protected-resource'); + + expect(res.status).toBe(404); + expect(fakeAs.metadataRequests.count).toBe(0); }); - it('still includes a reachable wiki AS when another wiki metadata fetch rejects', async () => { + it('still serves the document when one wiki metadata fetch rejects', async () => { fakeAs = await startFakeAs(); // A second AS that explicitly advertises a non-S256 PKCE method makes // fetchMetadata reject with MetadataError; Promise.allSettled keeps it @@ -180,11 +182,14 @@ describe('GET /.well-known/oauth-protected-resource', () => { oauth2ClientId: 'client-bad', }; const registry = fakeRegistry({ reachable: reachable, rejecting: rejecting }); - const app = buildWellKnownApp(registry); + const app = buildWellKnownApp(registry, PROXY_ISSUER); const res = await request(app).get('/.well-known/oauth-protected-resource'); + // Upstream metadata is still fetched, for scopes_supported, so one wiki + // being unreachable must not take the whole document down. Neither wiki's + // issuer appears in it — this server names only itself. expect(res.status).toBe(200); - expect(res.body.authorization_servers).toContain(fakeAs.url); + expect(res.body.authorization_servers).toEqual([PROXY_ISSUER]); expect(res.body.authorization_servers).not.toContain(badAs.url); } finally { await badAs.close(); @@ -204,7 +209,7 @@ describe('GET /.well-known/oauth-protected-resource', () => { oauth2ClientId: 'client-bad', }; const registry = fakeRegistry({ rejecting: rejecting }); - const app = buildWellKnownApp(registry); + const app = buildWellKnownApp(registry, PROXY_ISSUER); const res = await request(app).get('/.well-known/oauth-protected-resource'); expect(res.status).toBe(503); @@ -216,6 +221,13 @@ describe('GET /.well-known/oauth-protected-resource', () => { }); describe('POST /mcp 401 short-circuit when every wiki requires auth', () => { + // The challenge tells a caller to supply a wiki token, which is only actionable + // while forwarding one is available. Without the opt-in there is nothing a + // client can do, so the challenge is not emitted at all. + beforeEach(() => { + vi.stubEnv('MCP_ALLOW_BEARER_PASSTHROUGH', 'true'); + }); + afterEach(() => { delete process.env.MCP_ALLOW_STATIC_FALLBACK; vi.unstubAllEnvs(); @@ -229,7 +241,7 @@ describe('POST /mcp 401 short-circuit when every wiki requires auth', () => { articlepath: '/wiki', oauth2ClientId: 'client-id-123', }; - const app = buildMcpApp(fakeRegistry({ mywiki: wikiCfg })); + const app = buildMcpApp(fakeRegistry({ mywiki: wikiCfg }), WITH_PROXY); const res = await request(app) .post('/mcp') @@ -248,6 +260,28 @@ describe('POST /mcp 401 short-circuit when every wiki requires auth', () => { expect(wwwAuth).toMatch(/\/.well-known\/oauth-protected-resource"/); }); + it('omits resource_metadata when no protected-resource document is served', async () => { + const wikiCfg: Partial = { + sitename: 'OAuthWiki', + server: 'https://wiki.example', + scriptpath: '/w', + articlepath: '/wiki', + oauth2ClientId: 'client-id-123', + }; + // No proxy, so /.well-known/oauth-protected-resource answers 404. Advertising + // it would send an RFC 9728 client to a document that does not exist. + const app = buildMcpApp(fakeRegistry({ mywiki: wikiCfg })); + + const res = await request(app) + .post('/mcp') + .send({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }); + + expect(res.status).toBe(401); + const challenge = res.headers['www-authenticate'] ?? ''; + expect(challenge).toContain('Bearer error="invalid_token"'); + expect(challenge).not.toContain('resource_metadata'); + }); + it('does NOT return 401 when the wiki has no oauth2ClientId', async () => { const wikiCfg: Partial = { sitename: 'PlainWiki', @@ -277,7 +311,7 @@ describe('POST /mcp 401 short-circuit when every wiki requires auth', () => { expect(res.status).not.toBe(401); }); - it('does NOT return 401 when bearer is present even with oauth2ClientId set', async () => { + it('does NOT return 401 when a bearer is present and forwarding is opted into', async () => { const wikiCfg: Partial = { sitename: 'OAuthWiki', server: 'https://wiki.example', @@ -366,7 +400,7 @@ describe('POST /mcp 401 short-circuit when every wiki requires auth', () => { articlepath: '/wiki', oauth2ClientId: 'client-id-123', }; - const app = buildMcpApp(fakeRegistry({ mywiki: wikiCfg })); + const app = buildMcpApp(fakeRegistry({ mywiki: wikiCfg }), WITH_PROXY); const res = await request(app) .post('/mcp') @@ -392,7 +426,7 @@ describe('POST /mcp 401 short-circuit when every wiki requires auth', () => { articlepath: '/wiki', oauth2ClientId: 'client-id-123', }; - const app = buildMcpApp(fakeRegistry({ mywiki: wikiCfg })); + const app = buildMcpApp(fakeRegistry({ mywiki: wikiCfg }), WITH_PROXY); const res = await request(app) .post('/mcp') @@ -416,7 +450,7 @@ describe('POST /mcp 401 short-circuit when every wiki requires auth', () => { articlepath: '/wiki', oauth2ClientId: 'client-id-123', }; - const app = buildMcpApp(fakeRegistry({ mywiki: wikiCfg })); + const app = buildMcpApp(fakeRegistry({ mywiki: wikiCfg }), WITH_PROXY); const res = await request(app) .post('/mcp') diff --git a/tests/transport/streamableHttp.proxyBearer.test.ts b/tests/transport/streamableHttp.proxyBearer.test.ts index 4f23de9e..d4b0920b 100644 --- a/tests/transport/streamableHttp.proxyBearer.test.ts +++ b/tests/transport/streamableHttp.proxyBearer.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, afterEach } from 'vitest'; import express, { type Express } from 'express'; import request from 'supertest'; @@ -29,6 +29,10 @@ const pc = { } as unknown as ProxyConfig; describe('resolveUpstreamBearer', () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + it('returns the upstream access token for a valid JWT', async () => { const store = new InMemoryProxyStore(); const id = store.putUpstreamToken({ accessToken: 'WA', expiresAt: Date.now() + 1e6 }); @@ -366,10 +370,38 @@ describe('POST /mcp proxy bearer rewire', () => { expect(captured.token).toBeUndefined(); }); - it('leaves the legacy 401 challenge unchanged when the proxy is disabled', async () => { + it('serves a tokenless request when the proxy is off and forwarding is not opted into', async () => { + const captured: { token?: string; seen: boolean } = { seen: false }; + // An OAuth-only wiki with no proxy and no forwarding leaves a caller nothing + // it can supply, so the old discovery challenge would send it round a loop it + // cannot complete. That state is an operator misconfiguration, not a 401. + const app = buildMcpApp(fakeRegistry({ test: oauthWiki }), () => null, undefined, captured); + + const res = await request(app).post('/mcp').set('Content-Type', 'application/json').send(body); + + expect(res.status).not.toBe(401); + expect(captured.seen).toBe(true); + expect(captured.token).toBeUndefined(); + }); + + it('refuses a caller-supplied bearer when the proxy is off and forwarding is not opted into', async () => { + const captured: { token?: string; seen: boolean } = { seen: false }; + const app = buildMcpApp(fakeRegistry({ test: oauthWiki }), () => null, undefined, captured); + + const res = await request(app) + .post('/mcp') + .set('Content-Type', 'application/json') + .set('Authorization', 'Bearer raw-wiki-token') + .send(body); + + expect(res.status).toBe(401); + expect(res.body?.error?.code).toBe(AUTHENTICATION_REQUIRED_ERROR_CODE); + expect(captured.seen).toBe(false); + }); + + it('challenges a tokenless request once forwarding is opted into', async () => { + vi.stubEnv('MCP_ALLOW_BEARER_PASSTHROUGH', 'true'); const captured: { token?: string; seen: boolean } = { seen: false }; - // Proxy disabled (getProxyConfig returns null, no store): the OAuth-only - // wiki with no bearer must still get the legacy 401 short-circuit. const app = buildMcpApp(fakeRegistry({ test: oauthWiki }), () => null, undefined, captured); const res = await request(app).post('/mcp').set('Content-Type', 'application/json').send(body); @@ -380,7 +412,8 @@ describe('POST /mcp proxy bearer rewire', () => { expect(captured.seen).toBe(false); }); - it('forwards the raw bearer unchanged when the proxy is disabled (legacy passthrough)', async () => { + it('forwards the raw bearer once forwarding is opted into', async () => { + vi.stubEnv('MCP_ALLOW_BEARER_PASSTHROUGH', 'true'); const captured: { token?: string; seen: boolean } = { seen: false }; const app = buildMcpApp(fakeRegistry({ test: oauthWiki }), () => null, undefined, captured);