From 1584c28293677d60fcfa9ae77f5ea916295e4a7d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 31 Oct 2026 10:01:21 +0000 Subject: [PATCH] fix(server): omit invalid_token when no credentials were sent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RFC 6750 §3.1 says a challenge for a request with no authentication information should not include an error code. requireBearerAuth now drops error and error_description when scope or resource_metadata is already present, and the Express adapter sends that same body. A rejected token still returns invalid_token, and a challenge with neither parameter keeps the error code. Fixes #3124 Co-authored-by: Anandu Mv --- ...-error-on-missing-credentials-challenge.md | 6 ++ docs/serving/authorization.md | 4 +- examples/bearer-auth-web/client.ts | 4 +- .../middleware/express/src/auth/bearerAuth.ts | 16 ++--- .../express/test/auth/resourceServer.test.ts | 23 +++++-- .../src/server/middleware/bearerAuth.ts | 38 +++++++++--- .../server/test/server/bearerAuth.test.ts | 62 ++++++++++++++++++- test/e2e/requirements.ts | 2 +- test/e2e/scenarios/hosting-express.test.ts | 16 ++--- 9 files changed, 133 insertions(+), 38 deletions(-) create mode 100644 .changeset/omit-error-on-missing-credentials-challenge.md diff --git a/.changeset/omit-error-on-missing-credentials-challenge.md b/.changeset/omit-error-on-missing-credentials-challenge.md new file mode 100644 index 0000000000..fb818db33a --- /dev/null +++ b/.changeset/omit-error-on-missing-credentials-challenge.md @@ -0,0 +1,6 @@ +--- +'@modelcontextprotocol/server': patch +'@modelcontextprotocol/express': patch +--- + +`requireBearerAuth` no longer sends `error="invalid_token"` when a request has no `Authorization` header and the `WWW-Authenticate` challenge already includes `scope` or `resource_metadata` (RFC 6750 §3.1). The JSON body omits the error pair in that case. A presented token that fails verification is unchanged, and a challenge with neither parameter still includes `invalid_token`. diff --git a/docs/serving/authorization.md b/docs/serving/authorization.md index c9d412599d..3068732016 100644 --- a/docs/serving/authorization.md +++ b/docs/serving/authorization.md @@ -39,7 +39,7 @@ const node = toNodeHandler(createMcpHandler(buildServer)); app.all('/mcp', auth, (req, res) => void node(req, res, req.body)); ``` -A request with a missing, malformed, or expired token gets `401` with the OAuth error code `invalid_token`. A valid token missing one of `requiredScopes` gets `403` with `insufficient_scope`. Both responses carry a `WWW-Authenticate: Bearer …` challenge whose `resource_metadata` parameter is the URL you passed — that challenge is what starts a client's OAuth flow. +A malformed or expired token gets `401` with the OAuth error code `invalid_token`. A request that sends no `Authorization` header also gets `401`. When that challenge already includes `scope` or `resource_metadata`, it leaves out `error` and `error_description` (RFC 6750 §3.1) and the JSON body is `{}`. When neither parameter is configured, the challenge still includes `invalid_token`. A valid token missing one of `requiredScopes` gets `403` with `insufficient_scope`. The 401 and 403 challenges carry `resource_metadata` when you passed a URL — that parameter is what starts a client's OAuth flow. ::: info Coming from v1? The Authorization Server helpers (`mcpAuthRouter`, `ProxyOAuthServerProvider`, …) are frozen in `@modelcontextprotocol/server-legacy/auth`. Use a dedicated identity provider for new servers; this page only covers the resource-server half. @@ -174,7 +174,7 @@ The callback runs before the primitive's input schema is validated or transforme - `requireBearerAuth` from `@modelcontextprotocol/server` is the same gate for web-standard `fetch` hosts; the Express middleware adapts the same core. - `requireBearerAuth` plus a `verifyAccessToken` you write turn an Express-mounted MCP route into an OAuth resource server; the SDK never issues tokens. -- Missing, invalid, or expired tokens get `401 invalid_token`; a token missing a `requiredScopes` entry gets `403 insufficient_scope`; both carry a `WWW-Authenticate: Bearer` challenge. +- A malformed or expired token gets `401 invalid_token`. A request with no `Authorization` header gets `401`, and that challenge omits the error code when `scope` or `resource_metadata` is present. A token missing a `requiredScopes` entry gets `403 insufficient_scope`. These responses carry a `WWW-Authenticate: Bearer` challenge. - `mcpAuthMetadataRouter` publishes the RFC 9728 document that challenge points at, plus a mirror of the AS metadata. - Verified auth flows `req.auth` → `ctx.http.authInfo`; per-operation callbacks can trigger HTTP `403` scope step-up before invocation, advertising the metadata URL the gate stamped onto `AuthInfo`. - The v1 Authorization Server helpers are frozen in `@modelcontextprotocol/server-legacy/auth`. diff --git a/examples/bearer-auth-web/client.ts b/examples/bearer-auth-web/client.ts index fbcd2d2456..6f77b0e2e4 100644 --- a/examples/bearer-auth-web/client.ts +++ b/examples/bearer-auth-web/client.ts @@ -16,7 +16,9 @@ const unauth = await fetch(url, { body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'ping' }) }); check.equal(unauth.status, 401); -check.equal(extractWWWAuthenticateParams(unauth).error, 'invalid_token'); +const challenge = extractWWWAuthenticateParams(unauth); +check.equal(challenge.error, undefined); +check.equal(challenge.scope, 'mcp'); // Authenticated → 200 and the tool sees the authInfo. Bearer auth is // HTTP-layer and era-agnostic; the client honours `--legacy` via `era`. diff --git a/packages/middleware/express/src/auth/bearerAuth.ts b/packages/middleware/express/src/auth/bearerAuth.ts index d5dddee2be..0026221954 100644 --- a/packages/middleware/express/src/auth/bearerAuth.ts +++ b/packages/middleware/express/src/auth/bearerAuth.ts @@ -1,5 +1,5 @@ import type { VerifyBearerTokenOptions } from '@modelcontextprotocol/server'; -import { bearerAuthChallengeResponse, OAuthError, OAuthErrorCode, verifyBearerToken } from '@modelcontextprotocol/server'; +import { bearerAuthChallengeResponse, verifyBearerToken } from '@modelcontextprotocol/server'; import type { RequestHandler } from 'express'; /** @@ -19,9 +19,10 @@ export type BearerAuthMiddlewareOptions = VerifyBearerTokenOptions; * `req.auth`. The MCP Streamable HTTP transport reads `req.auth` and surfaces * it to handlers as `ctx.http.authInfo`. * - * On failure the middleware sends a JSON OAuth error body and a - * `WWW-Authenticate: Bearer …` challenge that includes the configured - * `resource_metadata` URL so clients can discover the Authorization Server. + * On failure the middleware sends the status, JSON body, and + * `WWW-Authenticate` challenge produced by `bearerAuthChallengeResponse`, + * including `resource_metadata` when configured so clients can discover the + * Authorization Server. */ export function requireBearerAuth(options: BearerAuthMiddlewareOptions): RequestHandler { // Destructure at creation so a plain-JS caller passing undefined or @@ -33,15 +34,14 @@ export function requireBearerAuth(options: BearerAuthMiddlewareOptions): Request req.auth = await verifyBearerToken(req.headers.authorization, resolved); next(); } catch (error) { - // The core Response supplies status and challenge; the body is - // derived directly rather than parsed back out of the Response. + // Status, challenge, and body all come from the core response so + // a challenge that omits its error code does not regain one here. const response = bearerAuthChallengeResponse(error, resolved); const challenge = response.headers.get('WWW-Authenticate'); if (challenge !== null) { res.set('WWW-Authenticate', challenge); } - const body = error instanceof OAuthError ? error : new OAuthError(OAuthErrorCode.ServerError, 'Internal Server Error'); - res.status(response.status).json(body.toResponseObject()); + res.status(response.status).json(await response.json()); } }; } diff --git a/packages/middleware/express/test/auth/resourceServer.test.ts b/packages/middleware/express/test/auth/resourceServer.test.ts index 14ff6d653b..592b1c520b 100644 --- a/packages/middleware/express/test/auth/resourceServer.test.ts +++ b/packages/middleware/express/test/auth/resourceServer.test.ts @@ -63,11 +63,26 @@ describe('requireBearerAuth middleware', () => { expect(res.status).toHaveBeenCalledWith(401); expect(res.set).toHaveBeenCalledWith( 'WWW-Authenticate', - expect.stringMatching( - /^Bearer error="invalid_token".*resource_metadata="https:\/\/api\.example\.com\/\.well-known\/oauth-protected-resource"$/ - ) + 'Bearer resource_metadata="https://api.example.com/.well-known/oauth-protected-resource"' ); - expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ error: 'invalid_token' })); + expect(res.json).toHaveBeenCalledWith({}); + expect(next).not.toHaveBeenCalled(); + }); + + it('keeps invalid_token when the Authorization header is missing and no other challenge parameter is set', async () => { + const { req, res, next } = createMockReqResNext(undefined); + const middleware = requireBearerAuth({ verifier: mockVerifier }); + await middleware(req, res, next); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.set).toHaveBeenCalledWith( + 'WWW-Authenticate', + 'Bearer error="invalid_token", error_description="Missing Authorization header"' + ); + expect(res.json).toHaveBeenCalledWith({ + error: 'invalid_token', + error_description: 'Missing Authorization header' + }); expect(next).not.toHaveBeenCalled(); }); diff --git a/packages/server/src/server/middleware/bearerAuth.ts b/packages/server/src/server/middleware/bearerAuth.ts index 1c455d11e2..94ac88f972 100644 --- a/packages/server/src/server/middleware/bearerAuth.ts +++ b/packages/server/src/server/middleware/bearerAuth.ts @@ -116,16 +116,29 @@ export function buildWwwAuthenticateHeader( errorCode: string, description: string, requiredScopes: readonly string[], - resourceMetadataUrl: string | undefined + resourceMetadataUrl: string | undefined, + omitError = false ): string { - let header = `Bearer error="${headerQuotedValue(errorCode)}", error_description="${headerQuotedValue(description)}"`; + // RFC 6750 §3.1: omit the error pair only when another auth-param remains. + const parts: string[] = []; + const hasOtherParam = requiredScopes.length > 0 || Boolean(resourceMetadataUrl); + if (!omitError || !hasOtherParam) { + parts.push(`error="${headerQuotedValue(errorCode)}"`, `error_description="${headerQuotedValue(description)}"`); + } if (requiredScopes.length > 0) { - header += `, scope="${headerQuotedValue(requiredScopes.join(' '))}"`; + parts.push(`scope="${headerQuotedValue(requiredScopes.join(' '))}"`); } if (resourceMetadataUrl) { - header += `, resource_metadata="${headerQuotedValue(resourceMetadataUrl)}"`; + parts.push(`resource_metadata="${headerQuotedValue(resourceMetadataUrl)}"`); + } + return `Bearer ${parts.join(', ')}`; +} + +/** No `Authorization` credentials were sent. Distinct from a token the server rejected. */ +class MissingAuthorizationError extends OAuthError { + constructor() { + super(OAuthErrorCode.InvalidToken, 'Missing Authorization header'); } - return header; } /** @@ -149,7 +162,7 @@ export async function verifyBearerToken( const { verifier, requiredScopes = [], expectedResource } = options; if (!authorizationHeader) { - throw new OAuthError(OAuthErrorCode.InvalidToken, 'Missing Authorization header'); + throw new MissingAuthorizationError(); } const [type, token] = authorizationHeader.split(' '); @@ -198,7 +211,10 @@ export async function verifyBearerToken( * challenge, with `resource_metadata` when configured so clients can discover * the Authorization Server), `500` for `server_error`, `400` for anything * else. A non-`OAuthError` value answers `500 server_error`. The body is the - * OAuth error JSON. + * OAuth error JSON. A `401` for a request that sent no credentials omits + * `error` and `error_description` from the challenge and the body when + * `scope` or `resource_metadata` is present (RFC 6750 §3.1); with neither, + * the challenge keeps `invalid_token`. */ export function bearerAuthChallengeResponse( error: unknown, @@ -213,8 +229,12 @@ export function bearerAuthChallengeResponse( switch (error.code) { case OAuthErrorCode.InvalidToken: { - const challenge = buildWwwAuthenticateHeader(error.code, error.message, requiredScopes, resourceMetadataUrl); - return Response.json(error.toResponseObject(), { status: 401, headers: { 'WWW-Authenticate': challenge } }); + const omitError = error instanceof MissingAuthorizationError && (requiredScopes.length > 0 || Boolean(resourceMetadataUrl)); + const challenge = buildWwwAuthenticateHeader(error.code, error.message, requiredScopes, resourceMetadataUrl, omitError); + return Response.json(omitError ? {} : error.toResponseObject(), { + status: 401, + headers: { 'WWW-Authenticate': challenge } + }); } case OAuthErrorCode.InsufficientScope: { const challenge = buildWwwAuthenticateHeader(error.code, error.message, requiredScopes, resourceMetadataUrl); diff --git a/packages/server/test/server/bearerAuth.test.ts b/packages/server/test/server/bearerAuth.test.ts index ee6e4e7a8e..a80c785d75 100644 --- a/packages/server/test/server/bearerAuth.test.ts +++ b/packages/server/test/server/bearerAuth.test.ts @@ -230,7 +230,67 @@ describe('requireBearerAuth (web-standard)', () => { expect(result).toBeInstanceOf(Response); const response = result as Response; expect(response.status).toBe(401); - expect(response.headers.get('WWW-Authenticate')).toMatch(/resource_metadata="https:\/\/api\.example\.com/); + expect(response.headers.get('WWW-Authenticate')).toBe( + 'Bearer resource_metadata="https://api.example.com/.well-known/oauth-protected-resource"' + ); + expect(await response.json()).toEqual({}); + }); + + it('omits the error code when no credentials were sent and scope or resource_metadata remains', async () => { + const metadata = 'https://mcp.example/.well-known/oauth-protected-resource/mcp'; + const request = () => new Request('https://mcp.example/mcp'); + + const withBoth = requireBearerAuth({ + verifier: verifierReturning(validAuthInfo), + requiredScopes: ['a:read'], + resourceMetadataUrl: metadata + }); + const both = (await withBoth(request())) as Response; + expect(both.status).toBe(401); + expect(both.headers.get('WWW-Authenticate')).toBe(`Bearer scope="a:read", resource_metadata="${metadata}"`); + expect(await both.json()).toEqual({}); + + const withScope = requireBearerAuth({ verifier: verifierReturning(validAuthInfo), requiredScopes: ['a:read'] }); + const scopeOnly = (await withScope(request())) as Response; + expect(scopeOnly.headers.get('WWW-Authenticate')).toBe('Bearer scope="a:read"'); + expect(await scopeOnly.json()).toEqual({}); + }); + + it('keeps invalid_token when a request with no credentials has no other challenge parameter', async () => { + const gate = requireBearerAuth({ verifier: verifierReturning(validAuthInfo) }); + const response = (await gate(new Request('https://mcp.example/mcp'))) as Response; + expect(response.status).toBe(401); + expect(response.headers.get('WWW-Authenticate')).toBe( + 'Bearer error="invalid_token", error_description="Missing Authorization header"' + ); + expect(await response.json()).toEqual({ + error: 'invalid_token', + error_description: 'Missing Authorization header' + }); + }); + + it('keeps invalid_token when a token was sent and rejected, even if other challenge parameters are set', async () => { + const metadata = 'https://mcp.example/.well-known/oauth-protected-resource/mcp'; + const gate = requireBearerAuth({ + verifier: verifierThrowing(new OAuthError(OAuthErrorCode.InvalidToken, 'Token has expired')), + requiredScopes: ['a:read'], + resourceMetadataUrl: metadata + }); + const response = (await gate(new Request('https://mcp.example/mcp', { headers: { authorization: 'Bearer expired' } }))) as Response; + expect(response.headers.get('WWW-Authenticate')).toBe( + `Bearer error="invalid_token", error_description="Token has expired", scope="a:read", resource_metadata="${metadata}"` + ); + expect(await response.json()).toEqual({ error: 'invalid_token', error_description: 'Token has expired' }); + + const malformed = requireBearerAuth({ + verifier: verifierReturning(validAuthInfo), + resourceMetadataUrl: metadata + }); + const formatResponse = (await malformed( + new Request('https://mcp.example/mcp', { headers: { authorization: 'Basic abc' } }) + )) as Response; + expect(formatResponse.headers.get('WWW-Authenticate')).toContain('error="invalid_token"'); + expect(await formatResponse.json()).toMatchObject({ error: 'invalid_token' }); }); it('resolves to a 403 Response when scopes are missing', async () => { diff --git a/test/e2e/requirements.ts b/test/e2e/requirements.ts index 519d92acfa..e9985b2aa6 100644 --- a/test/e2e/requirements.ts +++ b/test/e2e/requirements.ts @@ -1643,7 +1643,7 @@ export const REQUIREMENTS: Record = { 'hosting:auth:missing-401': { source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#error-handling', behavior: - "A request without an Authorization header is rejected with 401; the WWW-Authenticate header carries resource_metadata (one of the spec's two permitted discovery mechanisms).", + "A request without an Authorization header is rejected with 401; the WWW-Authenticate header carries resource_metadata (one of the spec's two permitted discovery mechanisms) and omits an error code, because that parameter is already present (RFC 6750 §3.1).", transports: ['streamableHttp'], note: 'These exercise the HTTP hosting/auth layer (mostly over real Express); the matrix transport arg is ignored, so they run as a single streamableHttp-labelled cell to avoid duplicate runs.' }, diff --git a/test/e2e/scenarios/hosting-express.test.ts b/test/e2e/scenarios/hosting-express.test.ts index 5d9880c6e3..00197c53ef 100644 --- a/test/e2e/scenarios/hosting-express.test.ts +++ b/test/e2e/scenarios/hosting-express.test.ts @@ -88,13 +88,9 @@ verifies('hosting:auth:missing-401', async (_args: TestArgs) => { expect(res.status).toBe(401); const wwwAuth = res.headers.get('www-authenticate'); - expect(wwwAuth).toBeTruthy(); - expect(wwwAuth).toMatch(/^Bearer\b/i); - expect(wwwAuth).toContain('error="invalid_token"'); - expect(wwwAuth).toContain(`resource_metadata="${RESOURCE_METADATA_URL}"`); + expect(wwwAuth).toBe(`Bearer resource_metadata="${RESOURCE_METADATA_URL}"`); - const body = (await res.json()) as { error?: string }; - expect(body.error).toBe('invalid_token'); + expect(await res.json()).toEqual({}); }); verifies('hosting:auth:invalid-401', async (_args: TestArgs) => { @@ -374,12 +370,8 @@ verifies('hosting:auth:query-token-ignored', async (_args: TestArgs) => { }); expect(queryRes.status).toBe(401); - const wwwAuth = queryRes.headers.get('www-authenticate'); - expect(wwwAuth).toBeTruthy(); - expect(wwwAuth).toMatch(/^Bearer\b/i); - expect(wwwAuth).toContain('error="invalid_token"'); - const queryBody = (await queryRes.json()) as { error?: string }; - expect(queryBody.error).toBe('invalid_token'); + expect(queryRes.headers.get('www-authenticate')).toBe(`Bearer resource_metadata="${RESOURCE_METADATA_URL}"`); + expect(await queryRes.json()).toEqual({}); // Control: the same token in the Authorization header authenticates, proving only the query-string placement was ignored. const headerRes = await fetch(new URL('/mcp', host.baseUrl), {