diff --git a/.changeset/sep-2350-scope-step-up.md b/.changeset/sep-2350-scope-step-up.md new file mode 100644 index 000000000..e2155b53e --- /dev/null +++ b/.changeset/sep-2350-scope-step-up.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/client': minor +--- + +SEP-2350 scope step-up: on `403 insufficient_scope`, `StreamableHTTPClientTransport` now re-authorizes with the **union** of the previously-requested and challenged scopes (`computeScopeUnion`), bypassing the refresh-token branch when the union is a strict superset of the current token's granted scope (`isStrictScopeSuperset`, `AuthOptions.forceReauthorization`). New `onInsufficientScope: 'reauthorize' | 'throw'` (default `'reauthorize'`) and `maxStepUpRetries` (default 1) on `StreamableHTTPClientTransportOptions`; `'throw'` raises the new `InsufficientScopeError`. The GET listen-stream open path now applies the same step-up handling. The previous verbatim-header retry guard is replaced by the bounded per-send counter. diff --git a/docs/migration-SKILL.md b/docs/migration-SKILL.md index 9f6ef3f9b..821923811 100644 --- a/docs/migration-SKILL.md +++ b/docs/migration-SKILL.md @@ -119,7 +119,7 @@ Three error classes now exist: | HTTP transport error (legacy era) | `StreamableHTTPError` | `SdkHttpError` with `SdkErrorCode.ClientHttp*` | | Failed to open SSE stream | `StreamableHTTPError` | `SdkHttpError` with `SdkErrorCode.ClientHttpFailedToOpenStream` | | 401 after re-auth (circuit break) | `StreamableHTTPError` | `SdkHttpError` with `SdkErrorCode.ClientHttpAuthentication` | -| 403 after upscoping | `StreamableHTTPError` | `SdkHttpError` with `SdkErrorCode.ClientHttpForbidden` | +| 403 insufficient_scope after step-up retry cap | `StreamableHTTPError` | `SdkHttpError` with `SdkErrorCode.ClientHttpForbidden` | | Unexpected content type | `StreamableHTTPError` | `SdkError` with `SdkErrorCode.ClientHttpUnexpectedContent` | | Session termination failed | `StreamableHTTPError` | `SdkHttpError` with `SdkErrorCode.ClientHttpFailedToTerminateSession` | | Response result fails schema | `ZodError` (raw) | `SdkError` with `SdkErrorCode.InvalidResult` | @@ -171,7 +171,7 @@ if (error instanceof SdkHttpError) { console.log('Status text:', error.statusText); // string | undefined switch (error.code) { case SdkErrorCode.ClientHttpAuthentication: // 401 after re-auth - case SdkErrorCode.ClientHttpForbidden: // 403 after upscoping + case SdkErrorCode.ClientHttpForbidden: // 403 insufficient_scope after step-up retry cap case SdkErrorCode.ClientHttpFailedToOpenStream: case SdkErrorCode.ClientHttpNotImplemented: break; @@ -207,19 +207,22 @@ Individual OAuth error classes replaced with single `OAuthError` class and `OAut | `MethodNotAllowedError` | `OAuthError` with `OAuthErrorCode.MethodNotAllowed` | | `TooManyRequestsError` | `OAuthError` with `OAuthErrorCode.TooManyRequests` | | `InvalidClientMetadataError` | `OAuthError` with `OAuthErrorCode.InvalidClientMetadata` | -| `InsufficientScopeError` | `OAuthError` with `OAuthErrorCode.InsufficientScope` | +| `InsufficientScopeError` | `OAuthError` with `OAuthErrorCode.InsufficientScope` ¹ | | `InvalidTargetError` | `OAuthError` with `OAuthErrorCode.InvalidTarget` | | `CustomOAuthError` | `new OAuthError(customCode, message)` | +¹ v1 server-side OAuth error only. The new transport-layer `InsufficientScopeError` exported from `@modelcontextprotocol/client` for SEP-2350 (RFC 6750 challenge from the resource server) is a DIFFERENT class, extends `OAuthClientFlowError` not `OAuthError`, and MUST NOT be rewritten by this row. + Removed: `OAUTH_ERRORS` constant. -The OAuth client flow additionally throws dedicated classes from `@modelcontextprotocol/client` (all extend `OAuthClientFlowError`, **not** `OAuthError` — `auth()`'s `OAuthError` retry path will not catch them): +The OAuth client flow additionally throws dedicated classes from `@modelcontextprotocol/client` (all extend `OAuthClientFlowError`, **not** `OAuthError` — `auth()`'s `OAuthError` retry path will not catch them). SEP-2350 adds `InsufficientScopeError` to this set; see the migration guide's [Scope step-up section](./migration.md#scope-step-up-on-403-insufficient_scope-sep-2350). | Throw site | v2 class | | -------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | | `registerClient()` rejected by AS (any RFC 7591 error incl. `invalid_client_metadata`, `invalid_redirect_uri`) | `RegistrationRejectedError` (`status`, `body`, `submittedMetadata`) | | `exchangeAuthorization()` / `refreshAuthorization()` / `fetchToken()` / `requestJwtAuthorizationGrant()` / `exchangeJwtAuthGrant()` non-https token endpoint | `InsecureTokenEndpointError` (`tokenEndpoint`) | | RFC 9207 `iss` mismatch / RFC 8414 §3.3 issuer-echo mismatch | `IssuerMismatchError` (`kind`, `expected`, `received`) | +| Transport 403 `insufficient_scope` with `onInsufficientScope: 'throw'`, or default mode without an `OAuthClientProvider` | `InsufficientScopeError` (`requiredScope`, `resourceMetadataUrl`, `errorDescription`) | Update OAuth error handling: diff --git a/docs/migration.md b/docs/migration.md index eab065176..927224048 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -787,7 +787,7 @@ The new `SdkErrorCode` enum contains string-valued codes for local SDK errors: | `SdkErrorCode.InvalidResult` | Response result failed local schema validation | | `SdkErrorCode.ClientHttpNotImplemented` | HTTP POST request failed | | `SdkErrorCode.ClientHttpAuthentication` | Server returned 401 after re-authentication | -| `SdkErrorCode.ClientHttpForbidden` | Server returned 403 after trying upscoping | +| `SdkErrorCode.ClientHttpForbidden` | Server returned 403 insufficient_scope after step-up re-authorization (retry cap reached) | | `SdkErrorCode.ClientHttpUnexpectedContent` | Unexpected content type in HTTP response | | `SdkErrorCode.ClientHttpFailedToOpenStream` | Failed to open SSE stream | | `SdkErrorCode.ClientHttpFailedToTerminateSession` | Failed to terminate session | @@ -826,7 +826,7 @@ try { console.log('Auth failed — server rejected token after re-auth'); break; case SdkErrorCode.ClientHttpForbidden: - console.log('Forbidden after upscoping attempt'); + console.log('403 insufficient_scope after step-up re-authorization (retry cap)'); break; case SdkErrorCode.ClientHttpFailedToOpenStream: console.log('Failed to open SSE stream'); @@ -874,10 +874,12 @@ The following individual error classes have been removed in favor of `OAuthError | `MethodNotAllowedError` | `new OAuthError(OAuthErrorCode.MethodNotAllowed, message)` | | `TooManyRequestsError` | `new OAuthError(OAuthErrorCode.TooManyRequests, message)` | | `InvalidClientMetadataError` | `new OAuthError(OAuthErrorCode.InvalidClientMetadata, message)` | -| `InsufficientScopeError` | `new OAuthError(OAuthErrorCode.InsufficientScope, message)` | +| `InsufficientScopeError` | `new OAuthError(OAuthErrorCode.InsufficientScope, message)` ¹ | | `InvalidTargetError` | `new OAuthError(OAuthErrorCode.InvalidTarget, message)` | | `CustomOAuthError` | `new OAuthError(customCode, message)` | +¹ Unrelated to the new transport-layer `InsufficientScopeError` introduced for SEP-2350 — that class carries an RFC 6750 `WWW-Authenticate` challenge from the resource server and does **not** extend `OAuthError`; see [Scope step-up on `403 insufficient_scope`](#scope-step-up-on-403-insufficient_scope-sep-2350). + The `OAUTH_ERRORS` constant has also been removed. If you need the v1 OAuth error classes and `mcpAuthRouter` during migration, `@modelcontextprotocol/server-legacy/auth` provides a frozen copy: @@ -1539,6 +1541,22 @@ await transport.finishAuth(url.searchParams); // SDK reads `code` + `iss` `discoverAuthorizationServerMetadata()` now rejects metadata whose `issuer` does not exactly match the URL it was fetched for (RFC 8414 §3.3). If you connect to a known-misconfigured AS, set `skipIssuerMetadataValidation: true` on `StreamableHTTPClientTransportOptions` / `SSEClientTransportOptions` (or on `AuthOptions` if you call `auth()` directly, or `skipIssuerValidation: true` on the low-level helper) — **this weakens the mix-up defense and should be treated as a temporary workaround.** It suppresses only the metadata-echo check; the callback-`iss` validation always runs (and degrades to a no-op only when `iss` is absent and the AS does not advertise support). +### Scope step-up on `403 insufficient_scope` (SEP-2350) + +`StreamableHTTPClientTransport` now accepts `onInsufficientScope: 'reauthorize' | 'throw'` (default **`'reauthorize'`**, matching the previous unconditional behavior). + +On `'reauthorize'` the transport re-authorizes with the **union** of the previously-requested scope and the challenged scope (new exported helper `computeScopeUnion`), so previously-granted permissions are not lost on step-up. When that union is a strict superset of the current token's granted scope (`isStrictScopeSuperset`), the SDK **bypasses the refresh-token branch** and forces a fresh authorization request — the refresh grant cannot widen scope (RFC 6749 §6), so refreshing would silently drop the new scope. When the token already covers the union, refresh is used as before. + +On `'throw'` the transport raises `InsufficientScopeError { requiredScope, resourceMetadataUrl, errorDescription }` and does not re-authorize. Set `'throw'` for `client_credentials` / m2m clients where re-authorization cannot widen scope, and for interactive clients that need to gate the consent prompt behind UX. + +If you pass a non-OAuth `authProvider` (or only `requestInit` headers), a `403 insufficient_scope` now throws `InsufficientScopeError` instead of the previous generic `SdkHttpError(ClientHttpNotImplemented)` ("Error POSTing to endpoint: …") — `InsufficientScopeError` extends `Error`, not `SdkError`, so existing `instanceof SdkError` catches no longer match this case. Catch `InsufficientScopeError` explicitly, or set `onInsufficientScope: 'throw'` to make the contract explicit. + +Step-up retries are now hard-capped per send (`maxStepUpRetries`, default 1) regardless of `WWW-Authenticate` header content — the previous verbatim-header equality guard is gone. The cap is per request; cross-request "(resource, operation) already failed" tracking is host state. + +`AuthOptions` gains `forceReauthorization?: boolean` for hosts driving step-up themselves. + +The GET listen-stream open path now applies the same step-up handling as the POST send path. + ### Conformance obligations for `OAuthClientProvider` implementers diff --git a/examples/README.md b/examples/README.md index d41cd73a2..e1d19c34f 100644 --- a/examples/README.md +++ b/examples/README.md @@ -44,6 +44,7 @@ Add `-- --legacy` to the client command for the 2025-era handshake. | [`bearer-auth/`](./bearer-auth/README.md) | Resource server with bearer token; `401` + `WWW-Authenticate` | http | dual | | [`oauth/`](./oauth/README.md) | OAuth `authorization_code`: in-repo AS (auto-consent) + headless redirect-following client | http | dual | | [`oauth-client-credentials/`](./oauth-client-credentials/README.md) | OAuth `client_credentials` (machine-to-machine): in-repo AS + `ClientCredentialsProvider` | http | dual | +| [`scoped-tools/`](./scoped-tools/README.md) | Per-tool scope on `createMcpHandler` — bearer-verify gate + handler-level `ctx.http?.authInfo` checks | http | modern | ## HTTP hosting variants diff --git a/examples/scoped-tools/README.md b/examples/scoped-tools/README.md new file mode 100644 index 000000000..570ec8fcc --- /dev/null +++ b/examples/scoped-tools/README.md @@ -0,0 +1,26 @@ +# scoped-tools — per-tool scope enforced in the tool handler + +Demonstrates per-tool OAuth scope enforcement on a `createMcpHandler` +deployment: the HTTP gate does **bearer-verify + 401 only**, and each tool +handler checks `ctx.http?.authInfo?.scopes` for the scope it needs. The scope +decision lives next to the code it guards — the handler is the only place that +authoritatively knows which tool is executing — instead of in middleware that +would have to re-derive the operation from the request body. + +`server.ts` runs a minimal demo Authorization Server alongside the MCP Resource +Server. `client.ts` connects with a `files:read` token, calls `list-files` +(works), then calls `write-file` → the handler returns `{ isError: true }` with +`insufficient_scope: requires files:write`. + +The transport's automatic `403 insufficient_scope` **step-up** flow (SEP-2350 — +scope union, refresh-bypass, `maxStepUpRetries`) applies when the RS responds +`403` at the HTTP layer; that path is exercised by +`test/e2e/scenarios/client-auth.test.ts`. + +```bash +pnpm --filter @mcp-examples/scoped-tools server -- --http --port 3000 +pnpm --filter @mcp-examples/scoped-tools client -- --http http://127.0.0.1:3000/mcp +``` + +> DEMO ONLY — the bundled AS auto-approves and grants whatever scope is asked +> for. Do not deploy. diff --git a/examples/scoped-tools/client.ts b/examples/scoped-tools/client.ts new file mode 100644 index 000000000..43e3d208f --- /dev/null +++ b/examples/scoped-tools/client.ts @@ -0,0 +1,80 @@ +/** + * Self-verifying per-tool scope client. + * + * Drives the same OAuth machinery as `examples/oauth/client.ts` to obtain a + * `files:read` token, then exercises the server's handler-level per-tool scope + * checks: `list-files` succeeds; `write-file` returns a tool-result + * `{ isError: true }` because the token lacks `files:write`. The transport's + * automatic `403 insufficient_scope` step-up (SEP-2350) is exercised by the + * dedicated e2e scenario (`test/e2e/scenarios/client-auth.test.ts`); this + * example demonstrates the recommended server-side pattern of enforcing scope + * inside the tool handler that needs it. + */ +import type { OAuthClientMetadata } from '@modelcontextprotocol/client'; +import { Client, StreamableHTTPClientTransport, UnauthorizedError } from '@modelcontextprotocol/client'; + +import { check, httpUrlFromArgs, negotiationFromArgs, runClient } from '../harness.js'; +import { InMemoryOAuthClientProvider } from '../oauth/simpleOAuthClientProvider.js'; + +const URL_ARG = httpUrlFromArgs('http://127.0.0.1:3000/mcp'); +const CALLBACK_URL = 'http://127.0.0.1:8091/callback'; + +/** Follow the demo AS's auto-consent 302 and return the `code`. */ +async function followAuthorize(authorizationUrl: URL): Promise { + const res = await fetch(authorizationUrl, { redirect: 'manual' }); + const location = res.headers.get('location'); + if (!location || res.status !== 302) throw new Error(`expected 302 from /authorize, got ${res.status}`); + const code = new globalThis.URL(location).searchParams.get('code'); + if (!code) throw new Error(`authorize redirect missing ?code: ${location}`); + return code; +} + +runClient('scoped-tools', async () => { + const captured: URL[] = []; + const clientMetadata: OAuthClientMetadata = { + client_name: 'Scoped-Tools Step-Up Client', + redirect_uris: [CALLBACK_URL], + grant_types: ['authorization_code'], + response_types: ['code'], + token_endpoint_auth_method: 'none', + scope: 'files:read' + }; + const provider = new InMemoryOAuthClientProvider(CALLBACK_URL, clientMetadata, url => { + captured.push(url); + }); + + // ---- 1. Initial authorization for files:read ------------------------------ + const client = new Client({ name: 'scoped-tools-client', version: '1.0.0' }, { versionNegotiation: negotiationFromArgs() }); + const t1 = new StreamableHTTPClientTransport(new globalThis.URL(URL_ARG), { authProvider: provider }); + let challenged = false; + try { + await client.connect(t1); + } catch (error) { + const root = error instanceof UnauthorizedError ? error : (error as { data?: { cause?: unknown } }).data?.cause; + if (!(root instanceof UnauthorizedError)) throw error; + challenged = true; + } + check.ok(challenged, 'first connect must 401'); + check.equal(captured.length, 1, 'authorize URL captured'); + check.match(captured[0]?.searchParams.get('scope') ?? '', /files:read/); + await t1.finishAuth(await followAuthorize(captured[0]!)); + check.equal(provider.tokens()?.scope, 'files:read'); + + // ---- 2. Reconnect with files:read; list-files works ----------------------- + const t2 = new StreamableHTTPClientTransport(new globalThis.URL(URL_ARG), { authProvider: provider }); + await client.connect(t2); + const listed = await client.callTool({ name: 'list-files', arguments: {} }); + check.match(listed.content?.[0]?.type === 'text' ? listed.content[0].text : '', /listed by .* \[files:read]/); + + // ---- 3. write-file → handler-level insufficient_scope --------------------- + // Per-tool scope is enforced inside the tool handler (ctx.http?.authInfo), + // so an under-scoped call surfaces as a tool-result `isError`, not an HTTP + // 403. The transport's automatic step-up (SEP-2350) applies only when the + // RS responds 403 at the HTTP layer. + const denied = await client.callTool({ name: 'write-file', arguments: {} }); + check.equal(denied.isError, true, 'write-file must isError under files:read-only token'); + check.match(denied.content?.[0]?.type === 'text' ? denied.content[0].text : '', /insufficient_scope: requires files:write/); + check.equal(captured.length, 1, 'no transport step-up — scope is enforced in the tool handler'); + + await client.close(); +}); diff --git a/examples/scoped-tools/package.json b/examples/scoped-tools/package.json new file mode 100644 index 000000000..7fb699d98 --- /dev/null +++ b/examples/scoped-tools/package.json @@ -0,0 +1,27 @@ +{ + "name": "@mcp-examples/scoped-tools", + "private": true, + "type": "module", + "scripts": { + "server": "tsx server.ts", + "client": "tsx client.ts" + }, + "dependencies": { + "@modelcontextprotocol/client": "workspace:*", + "@modelcontextprotocol/express": "workspace:*", + "@modelcontextprotocol/node": "workspace:*", + "@modelcontextprotocol/server": "workspace:*", + "zod": "catalog:runtimeShared" + }, + "devDependencies": { + "tsx": "catalog:devTools" + }, + "example": { + "transports": [ + "http" + ], + "era": "modern", + "path": "/mcp", + "//": "Per-tool scope enforcement on createMcpHandler: HTTP gate does bearer-verify + 401 only; each tool handler checks ctx.http?.authInfo?.scopes and returns isError on miss. Modern-only because authInfo plumbing through ServerContext is the feature under demonstration." + } +} diff --git a/examples/scoped-tools/server.ts b/examples/scoped-tools/server.ts new file mode 100644 index 000000000..d42d82266 --- /dev/null +++ b/examples/scoped-tools/server.ts @@ -0,0 +1,209 @@ +/** + * Per-tool scoped Resource Server on `createMcpHandler`, plus a minimal + * in-process Authorization Server that issues tokens carrying whatever scope + * the client requested. + * + * One process, two listeners on adjacent ports: + * - `:PORT+1` — minimal AS: PRM/AS metadata, DCR, an `/authorize` endpoint + * that immediately 302s back to `redirect_uri?code=...` (the headless + * "auto-consent"), and a `/token` endpoint that issues a Bearer token whose + * granted scope mirrors the requested scope. + * - `:PORT` — MCP RS: `createMcpHandler` behind a bearer-verify gate (401 on + * missing/invalid token). Per-tool scope is enforced **inside each tool + * handler** via `ctx.http?.authInfo?.scopes` — the handler is the only + * place that authoritatively knows which tool is executing, so the scope + * decision lives next to the code it guards. An under-scoped call returns a + * tool-result `{ isError: true }` rather than an HTTP 403. + * + * DEMO ONLY — NOT FOR PRODUCTION. The AS auto-approves and issues whatever + * scope is asked for; tokens are validated in-process against the same AS. + */ +import { randomUUID } from 'node:crypto'; +import { createServer } from 'node:http'; + +import { createMcpExpressApp } from '@modelcontextprotocol/express'; +import { toNodeHandler } from '@modelcontextprotocol/node'; +import type { AuthInfo } from '@modelcontextprotocol/server'; +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; + +const argv = process.argv.slice(2); +const portIdx = argv.indexOf('--port'); +const MCP_PORT = portIdx === -1 ? 3000 : Number(argv[portIdx + 1]); +const AS_PORT = MCP_PORT + 1; +const MCP_URL = `http://127.0.0.1:${MCP_PORT}/mcp`; +const AS_ISSUER = `http://127.0.0.1:${AS_PORT}`; + +// --------------------------------------------------------------------------- +// Minimal Authorization Server (DEMO ONLY) +// --------------------------------------------------------------------------- +/** code → requested scope (single-use). */ +const pendingCodes = new Map(); +/** access token → granted scope. */ +const issuedTokens = new Map(); +/** client_id → redirect_uris registered via DCR — `/authorize` MUST validate against this. */ +const registeredRedirectUris = new Map(); + +/** + * The demo AS only accepts loopback redirect URIs at registration time, so an + * unauthenticated DCR cannot register an external host and then have `/authorize` + * exfiltrate authorization codes to it. RFC 8252 §7.3 permits `http:` for loopback. + */ +const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '::1', '[::1]']); +function isAllowedRedirectUri(raw: unknown): raw is string { + if (typeof raw !== 'string') return false; + let parsed: URL; + try { + parsed = new URL(raw); + } catch { + return false; + } + return (parsed.protocol === 'http:' || parsed.protocol === 'https:') && LOOPBACK_HOSTS.has(parsed.hostname); +} + +const asServer = createServer((req, res) => { + const url = new URL(req.url ?? '/', AS_ISSUER); + const json = (status: number, body: unknown): void => { + res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify(body)); + }; + if (url.pathname.startsWith('/.well-known/oauth-protected-resource')) { + return json(200, { resource: MCP_URL, authorization_servers: [AS_ISSUER], scopes_supported: ['files:read', 'files:write'] }); + } + if (url.pathname === '/.well-known/oauth-authorization-server' || url.pathname === '/.well-known/openid-configuration') { + return json(200, { + issuer: AS_ISSUER, + authorization_endpoint: `${AS_ISSUER}/authorize`, + token_endpoint: `${AS_ISSUER}/token`, + registration_endpoint: `${AS_ISSUER}/register`, + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'], + grant_types_supported: ['authorization_code'], + token_endpoint_auth_methods_supported: ['none'] + }); + } + if (url.pathname === '/register' && req.method === 'POST') { + let body = ''; + req.on('data', c => (body += String(c))); + req.on('end', () => { + // RFC 7591: echo the submitted metadata plus issued credentials. + const submitted = JSON.parse(body || '{}') as { redirect_uris?: unknown }; + const submittedUris = Array.isArray(submitted.redirect_uris) ? submitted.redirect_uris : []; + if (submittedUris.length === 0 || !submittedUris.every(u => isAllowedRedirectUri(u))) { + return json(400, { + error: 'invalid_redirect_uri', + error_description: 'this demo authorization server only accepts loopback redirect URIs' + }); + } + const clientId = `demo-${randomUUID().slice(0, 8)}`; + registeredRedirectUris.set(clientId, submittedUris); + json(201, { ...submitted, client_id: clientId, token_endpoint_auth_method: 'none' }); + }); + return; + } + if (url.pathname === '/authorize') { + // DEMO ONLY: auto-consent. A real AS would show a login + consent UI here. + // The redirect_uri MUST exactly match one registered for this client_id — + // never redirect to an unregistered URI (open-redirect → authorization-code leakage). + const clientId = url.searchParams.get('client_id') ?? ''; + const redirectUri = url.searchParams.get('redirect_uri') ?? ''; + const registered = registeredRedirectUris.get(clientId); + if (!registered || !registered.includes(redirectUri)) { + return json(400, { error: 'invalid_request', error_description: 'redirect_uri not registered for client_id' }); + } + const code = randomUUID(); + pendingCodes.set(code, url.searchParams.get('scope') ?? ''); + const redirect = new URL(redirectUri); + redirect.searchParams.set('code', code); + const state = url.searchParams.get('state'); + if (state) redirect.searchParams.set('state', state); + res.writeHead(302, { location: redirect.href }).end(); + return; + } + if (url.pathname === '/token' && req.method === 'POST') { + let body = ''; + req.on('data', c => (body += String(c))); + req.on('end', () => { + const params = new URLSearchParams(body); + const code = params.get('code') ?? ''; + const scope = pendingCodes.get(code); + if (scope === undefined) return json(400, { error: 'invalid_grant' }); + pendingCodes.delete(code); + const token = randomUUID(); + issuedTokens.set(token, scope); + json(200, { access_token: token, token_type: 'Bearer', scope, expires_in: 3600 }); + }); + return; + } + json(404, { error: 'not_found' }); +}); +asServer.listen(AS_PORT, '127.0.0.1', () => console.error(`[scoped-tools] demo AS listening on ${AS_ISSUER}`)); + +// --------------------------------------------------------------------------- +// Resource Server (MCP) — bearer-verify at the gate, per-tool scope in handlers +// --------------------------------------------------------------------------- +function verifyBearer(header: string | null): AuthInfo | undefined { + if (!header?.startsWith('Bearer ')) return undefined; + const token = header.slice('Bearer '.length); + const scope = issuedTokens.get(token); + if (scope === undefined) return undefined; + return { token, clientId: 'scoped-tools-demo', scopes: scope.split(' ').filter(Boolean) }; +} + +/** + * Per-tool scope guard. The scope decision lives with the tool handler — the + * only place that authoritatively knows which tool is executing — rather than + * in HTTP middleware that would have to re-derive the operation from the + * request body. An under-scoped call returns a tool-level `isError` result. + */ +function requireScope( + authInfo: AuthInfo | undefined, + scope: string +): { isError: true; content: [{ type: 'text'; text: string }] } | undefined { + if (authInfo?.scopes.includes(scope)) return undefined; + return { isError: true, content: [{ type: 'text', text: `insufficient_scope: requires ${scope}` }] }; +} + +const handler = createMcpHandler(() => { + const server = new McpServer({ name: 'scoped-tools', version: '1.0.0' }); + server.registerTool('list-files', { description: 'Requires files:read.', inputSchema: z.object({}) }, (_args, ctx) => { + const auth = ctx.http?.authInfo; + return ( + requireScope(auth, 'files:read') ?? { + content: [{ type: 'text', text: `listed by ${auth?.clientId} [${auth?.scopes.join(' ')}]` }] + } + ); + }); + server.registerTool('write-file', { description: 'Requires files:write.', inputSchema: z.object({}) }, (_args, ctx) => { + const auth = ctx.http?.authInfo; + return ( + requireScope(auth, 'files:write') ?? { + content: [{ type: 'text', text: `written by ${auth?.clientId} [${auth?.scopes.join(' ')}]` }] + } + ); + }); + return server; +}); + +const app = createMcpExpressApp(); +// RFC 9728 PRM: the client discovers the AS from the 401 challenge → this route → AS metadata. +app.get('/.well-known/oauth-protected-resource/mcp', (_req, res) => { + res.json({ resource: MCP_URL, authorization_servers: [AS_ISSUER], scopes_supported: ['files:read', 'files:write'] }); +}); +const node = toNodeHandler(handler); +app.all('/mcp', (req, res) => { + const authInfo = verifyBearer(req.headers.authorization ?? null); + if (!authInfo) { + res.set( + 'www-authenticate', + `Bearer resource_metadata="http://127.0.0.1:${MCP_PORT}/.well-known/oauth-protected-resource/mcp", scope="files:read"` + ); + res.status(401).json({ error: 'invalid_token' }); + return; + } + // toNodeHandler reads `req.auth` and forwards it as the entry's pass-through authInfo; + // per-tool scope is enforced inside each tool handler via ctx.http?.authInfo. + req.auth = authInfo; + void node(req, res, req.body); +}); + +app.listen(MCP_PORT, '127.0.0.1', () => console.error(`[scoped-tools] MCP RS listening on ${MCP_URL}`)); diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index 09dfb8134..950075946 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -145,7 +145,7 @@ export async function handleOAuthUnauthorized( * Adapts an `OAuthClientProvider` to the minimal `AuthProvider` interface that * transports consume. Called once at transport construction — the transport stores * the adapted provider for `_commonHeaders()` and 401 handling, while keeping the - * original `OAuthClientProvider` for OAuth-specific paths (`finishAuth()`, 403 upscoping). + * original `OAuthClientProvider` for OAuth-specific paths (`finishAuth()`, 403 `insufficient_scope` step-up). */ export function adaptOAuthProvider( provider: OAuthClientProvider, @@ -486,6 +486,60 @@ export function validateAuthorizationResponseIssuer({ } } +/** + * Computes the union of one or more OAuth `scope` strings. + * + * Each argument is a space-delimited scope string per RFC 6749 §3.3, or + * `undefined`. The result is a single space-delimited string containing each + * distinct scope token exactly once, in first-seen order, or `undefined` if + * every input is empty/undefined. + * + * No hierarchical deduplication is performed: a union may contain semantically + * redundant entries (e.g., a broad scope alongside a narrower one it implies). + * Authorization servers normalize such redundancy during token issuance; the + * spec's step-up flow does not require clients to. + * + * Used by the transport's `403 insufficient_scope` step-up path to accumulate + * previously-requested scopes with newly-challenged scopes so re-authorization + * does not lose previously-granted permissions. + */ +export function computeScopeUnion(...scopes: ReadonlyArray): string | undefined { + const seen = new Set(); + for (const scope of scopes) { + if (!scope) continue; + for (const token of scope.split(/\s+/)) { + if (token) seen.add(token); + } + } + return seen.size > 0 ? [...seen].join(' ') : undefined; +} + +/** + * Whether `union` contains at least one scope token not present in `current`. + * Both arguments are space-delimited scope strings per RFC 6749 §3.3. + * + * Used to gate the step-up refresh bypass: when the union of previously-requested + * and newly-challenged scopes is a strict superset of the current token's + * granted scope, refreshing cannot widen the grant (RFC 6749 §6), so the + * transport must force a fresh authorization request instead. When the current + * token already covers the union, refresh remains valid. + * + * An undefined or empty `current` is treated as the empty set, so any non-empty + * `union` is a strict superset. Note that per RFC 6749 §3.3 an authorization + * server MAY omit the token's `scope` field when it equals the requested scope; + * this helper is conservative and treats an absent token `scope` as empty, so + * step-up always forces a fresh authorization request in that case rather than + * risking a refresh that silently drops the widened scope. + */ +export function isStrictScopeSuperset(union: string | undefined, current: string | undefined): boolean { + if (!union) return false; + const currentSet = new Set((current ?? '').split(/\s+/).filter(Boolean)); + for (const token of union.split(/\s+/)) { + if (token && !currentSet.has(token)) return true; + } + return false; +} + export type ClientAuthMethod = 'client_secret_basic' | 'client_secret_post' | 'none'; function isClientAuthMethod(method: string): method is ClientAuthMethod { @@ -750,6 +804,25 @@ export interface AuthOptions { * @default false */ skipIssuerMetadataValidation?: boolean; + /** + * When `true`, {@linkcode auth} skips the refresh-token branch even when a + * `refresh_token` is available, and proceeds directly to a fresh + * authorization request ({@linkcode startAuthorization}). + * + * Set by the transport's `403 insufficient_scope` step-up path when the + * required scope is a strict superset of the current token's granted scope: + * the refresh grant cannot widen scope (RFC 6749 §6), so refreshing would + * silently drop the new scope and the next request would 403 again. Forcing + * a fresh authorization request ensures the widened scope reaches the + * authorization server. + * + * Hosts driving step-up themselves (with `onInsufficientScope: 'throw'`) + * should set this when {@linkcode isStrictScopeSuperset} of the union over + * the current token's `scope` is `true`. + * + * @default false + */ + forceReauthorization?: boolean; } /** @@ -814,7 +887,16 @@ export function determineScope(options: { async function authInternal( provider: OAuthClientProvider, - { serverUrl, authorizationCode, iss, scope, resourceMetadataUrl, fetchFn, skipIssuerMetadataValidation }: AuthOptions + { + serverUrl, + authorizationCode, + iss, + scope, + resourceMetadataUrl, + fetchFn, + skipIssuerMetadataValidation, + forceReauthorization + }: AuthOptions ): Promise { // SEP-837 / SEP-2207: resolve spec defaults for the DCR body. determineScope() // intentionally reads the raw provider.clientMetadata instead. @@ -987,8 +1069,11 @@ async function authInternal( const tokens = await provider.tokens(); - // Handle token refresh or new authorization - if (tokens?.refresh_token) { + // Handle token refresh or new authorization. The step-up path sets + // `forceReauthorization` when the requested scope strictly exceeds the + // current token's granted scope — refreshing would not widen it (RFC 6749 + // §6), so skip straight to a fresh authorization request. + if (tokens?.refresh_token && !forceReauthorization) { try { // Attempt to refresh the token const newTokens = await refreshAuthorization(authorizationServerUrl, { @@ -1098,9 +1183,15 @@ export async function selectResourceURL( } /** - * Extract `resource_metadata`, `scope`, and `error` from `WWW-Authenticate` header. + * Extract `resource_metadata`, `scope`, `error`, and `error_description` from a + * `WWW-Authenticate` header. */ -export function extractWWWAuthenticateParams(res: Response): { resourceMetadataUrl?: URL; scope?: string; error?: string } { +export function extractWWWAuthenticateParams(res: Response): { + resourceMetadataUrl?: URL; + scope?: string; + error?: string; + errorDescription?: string; +} { const authenticateHeader = res.headers.get('WWW-Authenticate'); if (!authenticateHeader) { return {}; @@ -1124,11 +1215,13 @@ export function extractWWWAuthenticateParams(res: Response): { resourceMetadataU const scope = extractFieldFromWwwAuth(res, 'scope') || undefined; const error = extractFieldFromWwwAuth(res, 'error') || undefined; + const errorDescription = extractFieldFromWwwAuth(res, 'error_description') || undefined; return { resourceMetadataUrl, scope, - error + error, + errorDescription }; } diff --git a/packages/client/src/client/authErrors.ts b/packages/client/src/client/authErrors.ts index d83fcdc7e..41acb2cbc 100644 --- a/packages/client/src/client/authErrors.ts +++ b/packages/client/src/client/authErrors.ts @@ -113,3 +113,39 @@ export class InsecureTokenEndpointError extends OAuthClientFlowError { this.tokenEndpoint = tokenEndpoint; } } + +/** + * Thrown by the HTTP client transport when the server responds with + * `403 Forbidden` and `WWW-Authenticate: Bearer error="insufficient_scope"`, + * and either (a) the transport's `onInsufficientScope` option is `'throw'`, or + * (b) `onInsufficientScope` is the default `'reauthorize'` but the transport + * has no {@linkcode index.OAuthClientProvider | OAuthClientProvider} to drive + * step-up (e.g. a minimal `AuthProvider`, `requestInit`-only headers, or no + * `authProvider`). + * + * Carries the challenge parameters so the host can decide whether to initiate + * step-up authorization itself (e.g., behind a UX gate) or surface the error. + * + * Does **not** extend `OAuthError`: that class represents OAuth protocol errors + * from the authorization server; this is a resource-server challenge surfaced + * at the transport layer. + * + * All fields originate from the resource server's `WWW-Authenticate` header; + * treat them as untrusted input when displaying or logging (this includes + * `requiredScope`, which appears in the error message). + */ +export class InsufficientScopeError extends OAuthClientFlowError { + /** The `scope` value from the `WWW-Authenticate` challenge — the scopes the resource server says are required. */ + readonly requiredScope?: string; + /** The `resource_metadata` URL from the `WWW-Authenticate` challenge, if present. */ + readonly resourceMetadataUrl?: URL; + /** The `error_description` from the `WWW-Authenticate` challenge, if present. */ + readonly errorDescription?: string; + + constructor(init: { requiredScope?: string; resourceMetadataUrl?: URL; errorDescription?: string }) { + super(`Insufficient scope${init.requiredScope ? `: required "${init.requiredScope}"` : ''}`); + this.requiredScope = init.requiredScope; + this.resourceMetadataUrl = init.resourceMetadataUrl; + this.errorDescription = init.errorDescription; + } +} diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index c827b7570..b7760f403 100644 --- a/packages/client/src/client/streamableHttp.ts +++ b/packages/client/src/client/streamableHttp.ts @@ -19,9 +19,21 @@ import { import { EventSourceParserStream } from 'eventsource-parser/stream'; import type { AuthProvider, OAuthClientProvider } from './auth.js'; -import { adaptOAuthProvider, auth, extractWWWAuthenticateParams, isOAuthClientProvider, UnauthorizedError } from './auth.js'; +import { + adaptOAuthProvider, + auth, + computeScopeUnion, + extractWWWAuthenticateParams, + isOAuthClientProvider, + isStrictScopeSuperset, + UnauthorizedError +} from './auth.js'; // eslint-disable-next-line @typescript-eslint/no-unused-vars -- referenced via {@linkcode} in finishAuth JSDoc import type { IssuerMismatchError } from './authErrors.js'; +import { InsufficientScopeError } from './authErrors.js'; + +/** Default cap on step-up re-authorization retries within a single send/stream-open. */ +const DEFAULT_MAX_STEP_UP_RETRIES = 1; // Default reconnection options for StreamableHTTP connections const DEFAULT_STREAMABLE_HTTP_RECONNECTION_OPTIONS: StreamableHTTPReconnectionOptions = { @@ -193,6 +205,41 @@ export type StreamableHTTPClientTransportOptions = { * handshake so the reconnected transport continues sending the required header. */ protocolVersion?: string; + + /** + * How the transport reacts to a `403 Forbidden` response carrying + * `WWW-Authenticate: Bearer error="insufficient_scope"`. + * + * - `'reauthorize'` (default): the transport runs the step-up authorization + * flow — computes the union of the previously-requested scope and the + * challenged scope, calls {@linkcode index.auth | auth()} (forcing a + * fresh authorization request when the union strictly exceeds the current + * token's granted scope, since refresh cannot widen scope per RFC 6749 + * §6), and retries the request once. Retries are bounded by + * {@linkcode StreamableHTTPClientTransportOptions.maxStepUpRetries | maxStepUpRetries}. + * If no {@linkcode index.OAuthClientProvider | OAuthClientProvider} is + * configured, step-up cannot run and the transport throws + * {@linkcode index.InsufficientScopeError | InsufficientScopeError} instead. + * - `'throw'`: the transport throws {@linkcode index.InsufficientScopeError | InsufficientScopeError} + * carrying the challenge parameters and does not re-authorize. Use this + * for `client_credentials` / m2m clients where re-authorization cannot + * widen scope, or for interactive clients that want to gate the consent + * prompt behind UX. + * + * @default 'reauthorize' + */ + onInsufficientScope?: 'reauthorize' | 'throw'; + + /** + * Maximum number of step-up re-authorization attempts the transport makes + * per send (and per GET stream open) before giving up. Only consulted when + * {@linkcode StreamableHTTPClientTransportOptions.onInsufficientScope | onInsufficientScope} + * is `'reauthorize'`. Cross-request tracking ("this resource+operation + * already failed N times across the session") is host responsibility. + * + * @default 1 + */ + maxStepUpRetries?: number; }; /** @@ -268,7 +315,8 @@ export class StreamableHTTPClientTransport implements Transport { private _sessionId?: string; private _reconnectionOptions: StreamableHTTPReconnectionOptions; private _protocolVersion?: string; - private _lastUpscopingHeader?: string; // Track last upscoping header to prevent infinite upscoping. + private _onInsufficientScope: 'reauthorize' | 'throw'; + private _maxStepUpRetries: number; private _serverRetryMs?: number; // Server-provided retry delay from SSE retry field private readonly _reconnectionScheduler?: ReconnectionScheduler; private _cancelReconnection?: () => void; @@ -305,6 +353,68 @@ export class StreamableHTTPClientTransport implements Transport { this._protocolVersion = opts?.protocolVersion; this._reconnectionOptions = opts?.reconnectionOptions ?? DEFAULT_STREAMABLE_HTTP_RECONNECTION_OPTIONS; this._reconnectionScheduler = opts?.reconnectionScheduler; + this._onInsufficientScope = opts?.onInsufficientScope ?? 'reauthorize'; + this._maxStepUpRetries = Math.max(0, opts?.maxStepUpRetries ?? DEFAULT_MAX_STEP_UP_RETRIES); + } + + /** + * SEP-2350 step-up: compute the union scope, decide whether refresh must be + * bypassed, and run {@linkcode auth}. Returns the auth result so the caller + * can decide whether to retry. Shared by the POST `_send` path and the GET + * `_startOrAuthSse` path so both apply the same `'throw'` short-circuit, + * the same superset-gated refresh bypass, and the same retry cap. + */ + private async _stepUpAuthorize( + challenge: { scope?: string; resourceMetadataUrl?: URL; errorDescription?: string; statusText?: string; text?: string | null }, + stepUpRetries: number + ): Promise<'AUTHORIZED' | 'REDIRECT'> { + if (this._onInsufficientScope === 'throw') { + throw new InsufficientScopeError({ + requiredScope: challenge.scope, + resourceMetadataUrl: challenge.resourceMetadataUrl, + errorDescription: challenge.errorDescription + }); + } + if (!this._oauthProvider) { + // No OAuth provider to drive step-up; surface the typed error so the + // host can act on it. + throw new InsufficientScopeError({ + requiredScope: challenge.scope, + resourceMetadataUrl: challenge.resourceMetadataUrl, + errorDescription: challenge.errorDescription + }); + } + if (stepUpRetries >= this._maxStepUpRetries) { + throw new SdkHttpError( + SdkErrorCode.ClientHttpForbidden, + `Server returned 403 insufficient_scope after step-up re-authorization (retry limit ${this._maxStepUpRetries} reached)`, + { status: 403, statusText: challenge.statusText ?? 'Forbidden', text: challenge.text } + ); + } + + if (challenge.resourceMetadataUrl) { + this._resourceMetadataUrl = challenge.resourceMetadataUrl; + } + + // Spec step-up: union of previously-requested scope and challenged scope, + // so previously-granted permissions are not lost on re-authorization. + const tokens = await this._oauthProvider.tokens(); + const unionScope = computeScopeUnion(this._scope, tokens?.scope, challenge.scope); + this._scope = unionScope; + + // Superset-gated refresh bypass: refresh cannot widen scope (RFC 6749 §6), + // so when the union strictly exceeds what the current token was granted + // we must force a fresh authorization request. + const forceReauthorization = isStrictScopeSuperset(unionScope, tokens?.scope); + + return auth(this._oauthProvider, { + serverUrl: this._url, + resourceMetadataUrl: this._resourceMetadataUrl, + scope: unionScope, + forceReauthorization, + fetchFn: this._fetchWithInit, + skipIssuerMetadataValidation: this._skipIssuerMetadataValidation + }); } private async _commonHeaders(): Promise { @@ -385,7 +495,7 @@ export class StreamableHTTPClientTransport implements Transport { return typeof v === 'string' && isModernProtocolVersion(v); } - private async _startOrAuthSse(options: StartSSEOptions, isAuthRetry = false): Promise { + private async _startOrAuthSse(options: StartSSEOptions, isAuthRetry = false, stepUpRetries = 0): Promise { const { resumptionToken, requestSignal } = options; // Same guard as `_handleSseStream`: a resurrected listen stream (the // POST-SSE → GET reconnect path threads `requestSignal` through @@ -424,7 +534,9 @@ export class StreamableHTTPClientTransport implements Transport { if (response.headers.has('www-authenticate')) { const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response); this._resourceMetadataUrl = resourceMetadataUrl; - this._scope = scope; + // Preserve any union accumulated by `_stepUpAuthorize` so a 401 + // mid-chain does not narrow `_scope` back to the challenge value. + this._scope = computeScopeUnion(this._scope, scope); } if (this._authProvider.onUnauthorized && !isAuthRetry) { @@ -435,7 +547,7 @@ export class StreamableHTTPClientTransport implements Transport { }); await response.text?.().catch(() => {}); // Purposely _not_ awaited, so we don't call onerror twice - return this._startOrAuthSse(options, true); + return this._startOrAuthSse(options, true, stepUpRetries); } await response.text?.().catch(() => {}); if (isAuthRetry) { @@ -447,6 +559,21 @@ export class StreamableHTTPClientTransport implements Transport { throw new UnauthorizedError(); } + if (response.status === 403) { + const { resourceMetadataUrl, scope, error, errorDescription } = extractWWWAuthenticateParams(response); + if (error === 'insufficient_scope') { + const text = await response.text?.().catch(() => null); + const result = await this._stepUpAuthorize( + { scope, resourceMetadataUrl, errorDescription, statusText: response.statusText, text }, + stepUpRetries + ); + if (result !== 'AUTHORIZED') { + throw new UnauthorizedError(); + } + return this._startOrAuthSse(options, isAuthRetry, stepUpRetries + 1); + } + } + await response.text?.().catch(() => {}); // 405 indicates that the server does not offer an SSE stream at GET endpoint @@ -783,7 +910,8 @@ export class StreamableHTTPClientTransport implements Transport { headers?: Readonly>; } | undefined, - isAuthRetry: boolean + isAuthRetry: boolean, + stepUpRetries = 0 ): Promise { try { const { resumptionToken, onresumptiontoken } = options || {}; @@ -853,7 +981,9 @@ export class StreamableHTTPClientTransport implements Transport { if (response.headers.has('www-authenticate')) { const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response); this._resourceMetadataUrl = resourceMetadataUrl; - this._scope = scope; + // Preserve any union accumulated by `_stepUpAuthorize` so a 401 + // mid-chain does not narrow `_scope` back to the challenge value. + this._scope = computeScopeUnion(this._scope, scope); } if (this._authProvider.onUnauthorized && !isAuthRetry) { @@ -864,7 +994,7 @@ export class StreamableHTTPClientTransport implements Transport { }); await response.text?.().catch(() => {}); // Purposely _not_ awaited, so we don't call onerror twice - return this._send(message, options, true); + return this._send(message, options, true, stepUpRetries); } await response.text?.().catch(() => {}); if (isAuthRetry) { @@ -878,44 +1008,18 @@ export class StreamableHTTPClientTransport implements Transport { const text = await response.text?.().catch(() => null); - if (response.status === 403 && this._oauthProvider) { - const { resourceMetadataUrl, scope, error } = extractWWWAuthenticateParams(response); + if (response.status === 403) { + const { resourceMetadataUrl, scope, error, errorDescription } = extractWWWAuthenticateParams(response); if (error === 'insufficient_scope') { - const wwwAuthHeader = response.headers.get('WWW-Authenticate'); - - // Check if we've already tried upscoping with this header to prevent infinite loops. - if (this._lastUpscopingHeader === wwwAuthHeader) { - throw new SdkHttpError(SdkErrorCode.ClientHttpForbidden, 'Server returned 403 after trying upscoping', { - status: 403, - statusText: response.statusText, - text - }); - } - - if (scope) { - this._scope = scope; - } - - if (resourceMetadataUrl) { - this._resourceMetadataUrl = resourceMetadataUrl; - } - - // Mark that upscoping was tried. - this._lastUpscopingHeader = wwwAuthHeader ?? undefined; - const result = await auth(this._oauthProvider, { - serverUrl: this._url, - resourceMetadataUrl: this._resourceMetadataUrl, - scope: this._scope, - fetchFn: this._fetchWithInit, - skipIssuerMetadataValidation: this._skipIssuerMetadataValidation - }); - + const result = await this._stepUpAuthorize( + { scope, resourceMetadataUrl, errorDescription, statusText: response.statusText, text }, + stepUpRetries + ); if (result !== 'AUTHORIZED') { throw new UnauthorizedError(); } - - return this._send(message, options, isAuthRetry); + return this._send(message, options, isAuthRetry, stepUpRetries + 1); } } @@ -956,8 +1060,6 @@ export class StreamableHTTPClientTransport implements Transport { }); } - this._lastUpscopingHeader = undefined; - // If the response is 202 Accepted, there's no body to process if (response.status === 202) { await response.text?.().catch(() => {}); diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 6172b3acc..3160cb282 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -21,6 +21,7 @@ export { assertSecureTokenEndpoint, auth, buildDiscoveryUrls, + computeScopeUnion, discoverAuthorizationServerMetadata, discoverOAuthMetadata, discoverOAuthProtectedResourceMetadata, @@ -30,6 +31,7 @@ export { extractWWWAuthenticateParams, fetchToken, isHttpsUrl, + isStrictScopeSuperset, parseErrorResponse, prepareAuthorizationCodeRequest, refreshAuthorization, @@ -42,7 +44,13 @@ export { validateAuthorizationResponseIssuer, validateClientMetadataUrl } from './client/auth.js'; -export { InsecureTokenEndpointError, IssuerMismatchError, OAuthClientFlowError, RegistrationRejectedError } from './client/authErrors.js'; +export { + InsecureTokenEndpointError, + InsufficientScopeError, + IssuerMismatchError, + OAuthClientFlowError, + RegistrationRejectedError +} from './client/authErrors.js'; export type { AssertionCallback, ClientCredentialsProviderOptions, diff --git a/packages/client/test/client/auth.test.ts b/packages/client/test/client/auth.test.ts index 5f2f97577..f90f4183e 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -8,6 +8,7 @@ import { assertSecureTokenEndpoint, auth, buildDiscoveryUrls, + computeScopeUnion, determineScope, discoverAuthorizationServerMetadata, discoverOAuthMetadata, @@ -17,6 +18,7 @@ import { extractWWWAuthenticateParams, InsecureTokenEndpointError, isHttpsUrl, + isStrictScopeSuperset, IssuerMismatchError, refreshAuthorization, registerClient, @@ -140,6 +142,62 @@ describe('OAuth Authorization', () => { expect(extractWWWAuthenticateParams(mockResponse)).toEqual({ error: 'insufficient_scope', scope: 'admin' }); }); + + it('returns error_description when present', async () => { + const mockResponse = { + headers: { + get: vi.fn(name => + name === 'WWW-Authenticate' + ? `Bearer error="insufficient_scope", scope="admin", error_description="needs admin"` + : null + ) + } + } as unknown as Response; + + expect(extractWWWAuthenticateParams(mockResponse)).toEqual({ + error: 'insufficient_scope', + scope: 'admin', + errorDescription: 'needs admin' + }); + }); + }); + + describe('computeScopeUnion', () => { + it.each([ + { inputs: [undefined], expected: undefined }, + { inputs: [undefined, undefined], expected: undefined }, + { inputs: ['', ' '], expected: undefined }, + { inputs: ['read'], expected: 'read' }, + { inputs: ['read', undefined], expected: 'read' }, + { inputs: ['read write', 'write admin'], expected: 'read write admin' }, + { inputs: ['read', 'read'], expected: 'read' }, + { inputs: [' read write ', 'admin'], expected: 'read write admin' }, + { inputs: ['a b', 'c', 'b d'], expected: 'a b c d' } + ])('union of $inputs is $expected', ({ inputs, expected }) => { + expect(computeScopeUnion(...inputs)).toBe(expected); + }); + + it('does not collapse hierarchical scopes', () => { + // The spec explicitly does not require clients to deduplicate + // hierarchically; the AS normalizes redundancy. + expect(computeScopeUnion('admin', 'read')).toBe('admin read'); + }); + }); + + describe('isStrictScopeSuperset', () => { + it.each([ + { union: undefined, current: undefined, expected: false }, + { union: undefined, current: 'read', expected: false }, + { union: 'read', current: undefined, expected: true }, + { union: 'read', current: '', expected: true }, + { union: 'read', current: 'read', expected: false }, + { union: 'read write', current: 'read', expected: true }, + { union: 'read write', current: 'read write', expected: false }, + { union: 'read write', current: 'write read admin', expected: false }, + { union: 'read', current: 'read write', expected: false } + ])('isStrictScopeSuperset($union, $current) is $expected', ({ union, current, expected }) => { + expect(isStrictScopeSuperset(union, current)).toBe(expected); + }); }); describe('discoverOAuthProtectedResourceMetadata', () => { diff --git a/packages/client/test/client/streamableHttp.test.ts b/packages/client/test/client/streamableHttp.test.ts index 3c7a2787f..8c99dc130 100644 --- a/packages/client/test/client/streamableHttp.test.ts +++ b/packages/client/test/client/streamableHttp.test.ts @@ -831,11 +831,14 @@ describe('StreamableHTTPClientTransport', () => { // Verify fetch was called twice expect(fetchMock).toHaveBeenCalledTimes(2); - // Verify auth was called with the new scope + // Verify auth was called with the union scope (no prior scope → just the + // challenged scope) and forced fresh authorization (no prior token scope + // means the union is a strict superset of the empty grant). expect(authSpy).toHaveBeenCalledWith( mockAuthProvider, expect.objectContaining({ scope: 'new_scope', + forceReauthorization: true, resourceMetadataUrl: new URL('http://example.com/resource') }) ); @@ -843,7 +846,7 @@ describe('StreamableHTTPClientTransport', () => { authSpy.mockRestore(); }); - it('prevents infinite upscoping on repeated 403', async () => { + it('caps step-up retries per send (bounded counter)', async () => { const message: JSONRPCMessage = { jsonrpc: '2.0', method: 'test', @@ -868,19 +871,94 @@ describe('StreamableHTTPClientTransport', () => { const authSpy = vi.spyOn(authModule as typeof import('../../src/client/auth.js'), 'auth'); authSpy.mockResolvedValue('AUTHORIZED'); - // First send: should trigger upscoping - await expect(transport.send(message)).rejects.toThrow('Server returned 403 after trying upscoping'); + // First send: one step-up retry (default cap = 1), then fails. + await expect(transport.send(message)).rejects.toThrow(/403 insufficient_scope after step-up re-authorization/); expect(fetchMock).toHaveBeenCalledTimes(2); // Initial call + one retry after auth expect(authSpy).toHaveBeenCalledTimes(1); // Auth called once - // Second send: should fail immediately without re-calling auth + // Second send: counter is per-send-chain, not transport-wide — a fresh + // send tries step-up once again (cross-request tracking is host + // responsibility). fetchMock.mockClear(); authSpy.mockClear(); - await expect(transport.send(message)).rejects.toThrow('Server returned 403 after trying upscoping'); + await expect(transport.send(message)).rejects.toThrow(/403 insufficient_scope after step-up re-authorization/); - expect(fetchMock).toHaveBeenCalledTimes(1); // Only one fetch call - expect(authSpy).not.toHaveBeenCalled(); // Auth not called again + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(authSpy).toHaveBeenCalledTimes(1); + + authSpy.mockRestore(); + }); + + it('step-up scope is the union of transport-tracked, token-granted, and challenged scopes', async () => { + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { + authProvider: mockAuthProvider, + maxStepUpRetries: 2 + }); + mockAuthProvider.tokens.mockResolvedValue({ access_token: 't', token_type: 'Bearer', scope: 'a b' }); + + const message: JSONRPCMessage = { jsonrpc: '2.0', method: 'test', params: {}, id: 'test-id' }; + const fetchMock = globalThis.fetch as Mock; + fetchMock + .mockResolvedValueOnce({ + ok: false, + status: 403, + statusText: 'Forbidden', + headers: new Headers({ 'WWW-Authenticate': 'Bearer error="insufficient_scope", scope="b c"' }), + text: () => Promise.resolve('') + }) + .mockResolvedValueOnce({ + ok: false, + status: 403, + statusText: 'Forbidden', + headers: new Headers({ 'WWW-Authenticate': 'Bearer error="insufficient_scope", scope="d"' }), + text: () => Promise.resolve('') + }) + .mockResolvedValueOnce({ ok: true, status: 202, headers: new Headers() }); + + const authModule = await import('../../src/client/auth.js'); + const authSpy = vi.spyOn(authModule, 'auth'); + authSpy.mockResolvedValue('AUTHORIZED'); + + await transport.send(message); + + expect(authSpy).toHaveBeenCalledTimes(2); + // First step-up: union(undefined, token 'a b', challenge 'b c') = 'a b c' + expect(authSpy.mock.calls[0]![1].scope?.split(' ').sort()).toEqual(['a', 'b', 'c']); + // Second step-up: union(tracked 'a b c', token 'a b', challenge 'd') = 'a b c d' + expect(authSpy.mock.calls[1]![1].scope?.split(' ').sort()).toEqual(['a', 'b', 'c', 'd']); + + authSpy.mockRestore(); + }); + + it("throws InsufficientScopeError on 403 when onInsufficientScope is 'throw'", async () => { + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { + authProvider: mockAuthProvider, + onInsufficientScope: 'throw' + }); + const message: JSONRPCMessage = { jsonrpc: '2.0', method: 'test', params: {}, id: 'test-id' }; + + (globalThis.fetch as Mock).mockResolvedValue({ + ok: false, + status: 403, + statusText: 'Forbidden', + headers: new Headers({ + 'WWW-Authenticate': 'Bearer error="insufficient_scope", scope="files:write", error_description="needs write"' + }), + text: () => Promise.resolve('Insufficient scope') + }); + + const authModule = await import('../../src/client/auth.js'); + const authSpy = vi.spyOn(authModule, 'auth'); + const { InsufficientScopeError } = await import('../../src/client/authErrors.js'); + + const sendPromise = transport.send(message); + await expect(sendPromise).rejects.toBeInstanceOf(InsufficientScopeError); + await expect(sendPromise).rejects.toMatchObject({ + requiredScope: 'files:write', + errorDescription: 'needs write' + }); + expect(authSpy).not.toHaveBeenCalled(); authSpy.mockRestore(); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 36b23be73..6a61f06c0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -715,6 +715,28 @@ importers: specifier: catalog:devTools version: 4.21.0 + examples/scoped-tools: + dependencies: + '@modelcontextprotocol/client': + specifier: workspace:* + version: link:../../packages/client + '@modelcontextprotocol/express': + specifier: workspace:* + version: link:../../packages/middleware/express + '@modelcontextprotocol/node': + specifier: workspace:* + version: link:../../packages/middleware/node + '@modelcontextprotocol/server': + specifier: workspace:* + version: link:../../packages/server + zod: + specifier: catalog:runtimeShared + version: 4.3.6 + devDependencies: + tsx: + specifier: catalog:devTools + version: 4.21.0 + examples/server-quickstart: dependencies: '@modelcontextprotocol/server': diff --git a/test/conformance/src/helpers/withOAuthRetry.ts b/test/conformance/src/helpers/withOAuthRetry.ts index edcbf093c..df435b5c4 100644 --- a/test/conformance/src/helpers/withOAuthRetry.ts +++ b/test/conformance/src/helpers/withOAuthRetry.ts @@ -1,5 +1,11 @@ import type { FetchLike, Middleware } from '@modelcontextprotocol/client'; -import { auth, extractWWWAuthenticateParams, UnauthorizedError } from '@modelcontextprotocol/client'; +import { + auth, + computeScopeUnion, + extractWWWAuthenticateParams, + isStrictScopeSuperset, + UnauthorizedError +} from '@modelcontextprotocol/client'; import { ConformanceOAuthProvider } from './conformanceOAuthProvider.js'; @@ -9,11 +15,20 @@ export const handle401 = async ( next: FetchLike, serverUrl: string | URL ): Promise => { - const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response); + const { resourceMetadataUrl, scope: challengedScope } = extractWWWAuthenticateParams(response); + // On a 403 insufficient_scope step-up, request the union of the previously + // granted scope and the challenged scope so the existing permissions are + // preserved (SEP-2350). On the initial 401 there is no prior token, so the + // union degenerates to the challenged scope. + const previousTokens = await provider.tokens(); + const scope = response.status === 403 ? computeScopeUnion(previousTokens?.scope, challengedScope) : challengedScope; let result = await auth(provider, { serverUrl, resourceMetadataUrl, scope, + // SEP-2350: when the union strictly exceeds the current token's granted scope, + // a refresh cannot widen it (RFC 6749 §6) — bypass refresh and re-authorize. + forceReauthorization: isStrictScopeSuperset(scope, previousTokens?.scope), fetchFn: next }); diff --git a/test/e2e/requirements.ts b/test/e2e/requirements.ts index 1aa2945b5..ef5faab0c 100644 --- a/test/e2e/requirements.ts +++ b/test/e2e/requirements.ts @@ -1905,6 +1905,41 @@ export const REQUIREMENTS: Record = { transports: ['streamableHttp'], note: 'This exercises the HTTP hosting/auth layer and OAuth client; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' }, + 'client-auth:stepup:scope-union': { + source: 'https://modelcontextprotocol.io/specification/draft/basic/authorization#step-up-authorization-flow', + behavior: + 'On 403 insufficient_scope the transport re-authorizes with the union of its previously-requested scope and the challenged scope (computeScopeUnion); the union is a plain string-set dedup with no hierarchical collapse.', + transports: ['streamableHttp'], + note: 'This exercises the HTTP hosting/auth layer and OAuth client; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' + }, + 'client-auth:stepup:retry-cap': { + source: 'https://modelcontextprotocol.io/specification/draft/basic/authorization#step-up-authorization-flow', + behavior: + 'Step-up re-authorization is bounded per send by maxStepUpRetries (default 1), independent of WWW-Authenticate header content; reaching the cap throws an SdkHttpError without further auth() calls.', + transports: ['streamableHttp'], + note: 'This exercises the HTTP hosting/auth layer and OAuth client; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' + }, + 'client-auth:stepup:throw-mode': { + source: 'sdk', + behavior: + "With onInsufficientScope: 'throw', a 403 insufficient_scope throws InsufficientScopeError carrying {requiredScope, resourceMetadataUrl, errorDescription} and never calls auth().", + transports: ['streamableHttp'], + note: 'This exercises the HTTP hosting/auth layer and OAuth client; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' + }, + 'client-auth:stepup:get-stream-403': { + source: 'https://modelcontextprotocol.io/specification/draft/basic/authorization#step-up-authorization-flow', + behavior: + 'The GET listen-stream open path applies the same 403 insufficient_scope step-up handling as the POST send path (same throw-mode short-circuit, same scope union, same per-open retry cap).', + transports: ['streamableHttp'], + note: 'This exercises the HTTP hosting/auth layer and OAuth client; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' + }, + 'client-auth:stepup:refresh-bypass-on-superset': { + source: 'https://modelcontextprotocol.io/specification/draft/basic/authorization#step-up-authorization-flow', + behavior: + "On 403 insufficient_scope step-up: when the union scope is a strict superset of the current token's granted scope, auth() bypasses the refresh-token branch (forceReauthorization) and forces a fresh authorization request so the widened scope reaches the AS; when the token already covers the union, refresh is used.", + transports: ['streamableHttp'], + note: 'This exercises the HTTP hosting/auth layer and OAuth client; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' + }, 'client-auth:as-metadata-discovery:priority-order': { source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#authorization-server-metadata-discovery', behavior: @@ -2556,6 +2591,13 @@ export const REQUIREMENTS: Record = { transports: ['entryStateless', 'entryModern'], note: 'The body hosts createMcpHandler itself behind the documented bearer-gate composition; the matrix arm selects the legacy posture and client pin. authInfo is strictly pass-through — the entry never derives it from request headers — so the cell pins delivery, not verification. The OAuth client flow that obtains the token is hosting-agnostic and is covered by the client-auth family; the dedicated client-completes-OAuth-then-negotiates-2026 journey rides the auth-package redo (M13.1) so it is targeted at the surviving auth surface.' }, + 'typescript:hosting:entry:auth:insufficient-scope-403': { + source: 'https://modelcontextprotocol.io/specification/draft/basic/authorization#scope-mismatch-handling', + behavior: + 'A bearer-protected createMcpHandler deployment whose gate enforces a per-operation scope (deriving the operation from the standard Mcp-Method/Mcp-Name request headers on the modern leg) answers an under-scoped request with 403 and a WWW-Authenticate insufficient_scope challenge naming the required scope, without the entry ever being reached for that request.', + transports: ['entryStateless', 'entryModern'], + note: 'The body hosts createMcpHandler behind a per-operation scoped bearer gate; the matrix arm selects the legacy posture and client pin. On the legacy leg the gate falls back to a single required scope (no Mcp-Name header). The cell pins the documented RS-side composition that the client-auth:stepup family drives from the client side.' + }, 'typescript:transport:stdio:dual-era-serving': { source: 'sdk', diff --git a/test/e2e/scenarios/client-auth.test.ts b/test/e2e/scenarios/client-auth.test.ts index 22e9c8932..cd4ee8e9c 100644 --- a/test/e2e/scenarios/client-auth.test.ts +++ b/test/e2e/scenarios/client-auth.test.ts @@ -14,11 +14,14 @@ import { auth, Client, ClientCredentialsProvider, + computeScopeUnion, createMiddleware, discoverAuthorizationServerMetadata, discoverOAuthProtectedResourceMetadata, exchangeAuthorization, InsecureTokenEndpointError, + InsufficientScopeError, + isStrictScopeSuperset, IssuerMismatchError, OAuthError, OAuthErrorCode, @@ -431,12 +434,13 @@ verifies('client-auth:403-scope-upgrade', async (_args: TestArgs) => { await interactiveClient.close(); } - // Phase 2: when the upscoped token is still rejected with the same header, the transport stops instead of looping. + // Phase 2: when the upscoped token is still rejected, the transport stops at the per-send retry cap instead of looping. + // The token here already covers the challenged scope, so refresh (not a fresh authorization) is used. const refreshAs = createMockAuthorizationServer({ - tokenResponses: [{ access_token: 'upscoped-access-token', token_type: 'Bearer' }] + tokenResponses: [{ access_token: 'upscoped-access-token', token_type: 'Bearer', scope: UPGRADED_SCOPE }] }); const refreshProvider = new RecordingOAuthClientProvider({ - tokens: { access_token: 'narrow-scope-token', token_type: 'Bearer', refresh_token: 'narrow-refresh-token' }, + tokens: { access_token: 'narrow-scope-token', token_type: 'Bearer', refresh_token: 'narrow-refresh-token', scope: UPGRADED_SCOPE }, clientInformation: { client_id: 'pre-registered-client' } }); @@ -459,7 +463,7 @@ verifies('client-auth:403-scope-upgrade', async (_args: TestArgs) => { try { const connectPromise = refreshClient.connect(refreshTransport); await expect(connectPromise).rejects.toBeInstanceOf(SdkError); - await expect(connectPromise).rejects.toThrow(/403 after trying upscoping/); + await expect(connectPromise).rejects.toThrow(/403 insufficient_scope after step-up re-authorization/); expect(refreshAs.tokenCalls).toHaveLength(1); expect(defined(refreshAs.tokenCalls[0], 'token call').body.get('grant_type')).toBe('refresh_token'); @@ -469,6 +473,194 @@ verifies('client-auth:403-scope-upgrade', async (_args: TestArgs) => { } }); +verifies('client-auth:stepup:scope-union', async (_args: TestArgs) => { + // computeScopeUnion is a deliberate public export. + expect(computeScopeUnion('files:read openid', 'files:write')).toBe('files:read openid files:write'); + expect(computeScopeUnion('admin', 'read')).toBe('admin read'); // no hierarchical collapse + + // The transport requests the union of its previously-requested scope and the + // newly-challenged scope on step-up. + const PREVIOUS_SCOPE = 'files:read openid'; + const CHALLENGED_SCOPE = 'files:write'; + const as = createMockAuthorizationServer(); + const provider = new RecordingOAuthClientProvider({ + tokens: { access_token: 'read-token', token_type: 'Bearer', scope: PREVIOUS_SCOPE }, + clientInformation: { client_id: 'pre-registered-client' } + }); + const combinedFetch = async (url: URL | string, init?: RequestInit): Promise => { + const urlObj = typeof url === 'string' ? new URL(url) : url; + if (urlObj.origin === ISSUER || urlObj.pathname.includes('/.well-known/')) { + return as.handleRequest(new Request(url, init)); + } + return new Response(null, { + status: 403, + headers: { 'WWW-Authenticate': `Bearer error="insufficient_scope", scope="${CHALLENGED_SCOPE}"` } + }); + }; + + const client = new Client({ name: 'c', version: '0' }); + const transport = new StreamableHTTPClientTransport(new URL(MCP_URL), { authProvider: provider, fetch: combinedFetch }); + try { + await expect(client.connect(transport)).rejects.toThrow(UnauthorizedError); + expect(provider.redirectedTo).toHaveLength(1); + const redirect = defined(provider.redirectedTo[0], 'authorize URL'); + expect(redirect.searchParams.get('scope')).toBe('files:read openid files:write'); + } finally { + await client.close(); + } +}); + +verifies(['client-auth:stepup:retry-cap', 'client-auth:stepup:refresh-bypass-on-superset'], async (_args: TestArgs) => { + // Part A — superset bypass: token granted "files:read"; challenged scope adds + // "files:write". Union strictly exceeds the token's grant → auth() forces a + // fresh authorization request (no refresh-token POST observed). + { + const as = createMockAuthorizationServer(); + const provider = new RecordingOAuthClientProvider({ + tokens: { access_token: 't', token_type: 'Bearer', refresh_token: 'rt', scope: 'files:read' }, + clientInformation: { client_id: 'pre-registered-client' } + }); + const combinedFetch = async (url: URL | string, init?: RequestInit): Promise => { + const urlObj = typeof url === 'string' ? new URL(url) : url; + if (urlObj.origin === ISSUER || urlObj.pathname.includes('/.well-known/')) { + return as.handleRequest(new Request(url, init)); + } + return new Response(null, { + status: 403, + headers: { 'WWW-Authenticate': 'Bearer error="insufficient_scope", scope="files:write"' } + }); + }; + const client = new Client({ name: 'c', version: '0' }); + const transport = new StreamableHTTPClientTransport(new URL(MCP_URL), { authProvider: provider, fetch: combinedFetch }); + try { + await expect(client.connect(transport)).rejects.toThrow(UnauthorizedError); + // Refresh was bypassed: no token-endpoint POST; the fresh authorize + // request carries the union scope. + expect(as.tokenCalls).toHaveLength(0); + expect(provider.redirectedTo).toHaveLength(1); + expect(defined(provider.redirectedTo[0], 'authorize URL').searchParams.get('scope')).toBe('files:read files:write'); + expect(isStrictScopeSuperset('files:read files:write', 'files:read')).toBe(true); + } finally { + await client.close(); + } + } + + // Part B — refresh used + retry cap: token already covers the challenged + // scope (server is misconfigured / hierarchical). Union is NOT a strict + // superset → refresh is used. Server keeps 403'ing → per-send retry cap + // (default 1) stops the loop after exactly one step-up. + { + const as = createMockAuthorizationServer({ + tokenResponses: [{ access_token: 't2', token_type: 'Bearer', scope: 'files:read files:write' }] + }); + const provider = new RecordingOAuthClientProvider({ + tokens: { access_token: 't', token_type: 'Bearer', refresh_token: 'rt', scope: 'files:read files:write' }, + clientInformation: { client_id: 'pre-registered-client' } + }); + const mcpPosts: string[] = []; + const combinedFetch = async (url: URL | string, init?: RequestInit): Promise => { + const urlObj = typeof url === 'string' ? new URL(url) : url; + if (urlObj.origin === ISSUER || urlObj.pathname.includes('/.well-known/')) { + return as.handleRequest(new Request(url, init)); + } + mcpPosts.push(urlObj.pathname); + return new Response(null, { + status: 403, + headers: { 'WWW-Authenticate': 'Bearer error="insufficient_scope", scope="files:write"' } + }); + }; + const client = new Client({ name: 'c', version: '0' }); + const transport = new StreamableHTTPClientTransport(new URL(MCP_URL), { authProvider: provider, fetch: combinedFetch }); + try { + const connectPromise = client.connect(transport); + await expect(connectPromise).rejects.toBeInstanceOf(SdkError); + await expect(connectPromise).rejects.toThrow(/retry limit 1 reached/); + expect(as.tokenCalls).toHaveLength(1); + expect(defined(as.tokenCalls[0], 'token call').body.get('grant_type')).toBe('refresh_token'); + expect(provider.redirectedTo).toHaveLength(0); + expect(mcpPosts).toHaveLength(2); + expect(isStrictScopeSuperset('files:read files:write', 'files:read files:write')).toBe(false); + } finally { + await client.close(); + } + } +}); + +verifies('client-auth:stepup:throw-mode', async (_args: TestArgs) => { + const as = createMockAuthorizationServer(); + const provider = new RecordingOAuthClientProvider({ + tokens: { access_token: 't', token_type: 'Bearer' }, + clientInformation: { client_id: 'pre-registered-client' } + }); + const combinedFetch = async (url: URL | string, init?: RequestInit): Promise => { + const urlObj = typeof url === 'string' ? new URL(url) : url; + if (urlObj.origin === ISSUER || urlObj.pathname.includes('/.well-known/')) { + return as.handleRequest(new Request(url, init)); + } + return new Response(null, { + status: 403, + headers: { + 'WWW-Authenticate': `Bearer error="insufficient_scope", scope="files:write", resource_metadata="${MCP_URL}/.well-known/oauth-protected-resource", error_description="write permission required"` + } + }); + }; + + const client = new Client({ name: 'c', version: '0' }); + const transport = new StreamableHTTPClientTransport(new URL(MCP_URL), { + authProvider: provider, + fetch: combinedFetch, + onInsufficientScope: 'throw' + }); + try { + const connectPromise = client.connect(transport); + await expect(connectPromise).rejects.toBeInstanceOf(InsufficientScopeError); + await expect(connectPromise).rejects.toMatchObject({ + requiredScope: 'files:write', + errorDescription: 'write permission required', + resourceMetadataUrl: new URL(`${MCP_URL}/.well-known/oauth-protected-resource`) + }); + // No re-authorization was attempted. + expect(as.tokenCalls).toHaveLength(0); + expect(as.discoveryCalls).toHaveLength(0); + expect(provider.redirectedTo).toHaveLength(0); + } finally { + await client.close(); + } +}); + +verifies('client-auth:stepup:get-stream-403', async (_args: TestArgs) => { + // The GET listen-stream open path applies the same step-up handling. + // We assert via 'throw' mode (parity with the POST path is the same private + // helper) so the test observes the GET branch reaching the step-up gate. + const provider = new RecordingOAuthClientProvider({ + tokens: { access_token: 't', token_type: 'Bearer' }, + clientInformation: { client_id: 'pre-registered-client' } + }); + const seenMethods: string[] = []; + const combinedFetch = async (url: URL | string, init?: RequestInit): Promise => { + seenMethods.push(init?.method ?? 'GET'); + return new Response(null, { + status: 403, + headers: { 'WWW-Authenticate': 'Bearer error="insufficient_scope", scope="listen"' } + }); + }; + + const transport = new StreamableHTTPClientTransport(new URL(MCP_URL), { + authProvider: provider, + fetch: combinedFetch, + onInsufficientScope: 'throw' + }); + await transport.start(); + try { + const resumePromise = transport.resumeStream('last-event-42'); + await expect(resumePromise).rejects.toBeInstanceOf(InsufficientScopeError); + await expect(resumePromise).rejects.toMatchObject({ requiredScope: 'listen' }); + expect(seenMethods).toEqual(['GET']); + } finally { + await transport.close(); + } +}); + verifies('client-auth:as-metadata-discovery:priority-order', async (_args: TestArgs) => { const oauthMetadata: AuthorizationServerMetadata = { issuer: ISSUER, diff --git a/test/e2e/scenarios/hosting-entry-auth.test.ts b/test/e2e/scenarios/hosting-entry-auth.test.ts index e54ac57d4..a61825020 100644 --- a/test/e2e/scenarios/hosting-entry-auth.test.ts +++ b/test/e2e/scenarios/hosting-entry-auth.test.ts @@ -20,7 +20,7 @@ * `entryModern` → a 2026-07-28-pinned client through the modern-only strict * path). */ -import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { Client, InsufficientScopeError, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; import type { AuthInfo, McpRequestContext } from '@modelcontextprotocol/server'; import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; import { expect } from 'vitest'; @@ -234,3 +234,104 @@ verifies('typescript:hosting:entry:ctx-http-req-headers', async ({ transport }: // Headers inside the handler on the leg the matrix arm selected. expect(seenByTool).toEqual([{ isFetchHeaders: true, probe: PROBE_VALUE }]); }); + +verifies('typescript:hosting:entry:auth:insufficient-scope-403', async ({ transport }: TestArgs) => { + // Per-operation scope requirements derived from the body's tool name. On the + // modern leg the gate reads the SEP-2243 standard `Mcp-Method` / `Mcp-Name` + // headers (the entry's documented per-operation routing surface); the legacy + // leg has no such header so the gate falls back to one required scope. + const REQUIRED_BY_TOOL: Record = { 'write-file': 'files:write' }; + const TOKEN_SCOPES: Record = { + 'read-only-token': ['files:read'], + 'read-write-token': ['files:read', 'files:write'] + }; + + let factoryCalls = 0; + const factory = (ctx?: McpRequestContext): McpServer => { + factoryCalls++; + const server = new McpServer({ name: 'e2e-entry-scoped', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.registerTool('list-files', { inputSchema: z.object({}) }, () => ({ + content: [{ type: 'text', text: `listed by ${ctx?.authInfo?.clientId}` }] + })); + server.registerTool('write-file', { inputSchema: z.object({}) }, () => ({ + content: [{ type: 'text', text: `written by ${ctx?.authInfo?.clientId}` }] + })); + return server; + }; + + const handler = createMcpHandler(factory, { legacy: transport === 'entryStateless' ? 'stateless' : 'reject' }); + await using _ = { [Symbol.asyncDispose]: () => handler.close() }; + + const insufficientScope = (required: string): Response => + Response.json( + { error: 'insufficient_scope' }, + { + status: 403, + headers: { + 'www-authenticate': `Bearer error="insufficient_scope", scope="${required}", error_description="${required} required for this operation"` + } + } + ); + + const gatedFetch = async (url: URL | string, init?: RequestInit): Promise => { + const request = new Request(url, init); + const auth = request.headers.get('authorization'); + const token = auth?.startsWith('Bearer ') ? auth.slice('Bearer '.length) : undefined; + if (!token) return unauthorized(); + const scopes = TOKEN_SCOPES[token]; + if (scopes === undefined) return unauthorized(); + const mcpName = request.headers.get('mcp-name') ?? undefined; + const required: string = + request.headers.get('mcp-method') === 'tools/call' && mcpName ? (REQUIRED_BY_TOOL[mcpName] ?? 'files:read') : 'files:read'; + if (!scopes.includes(required)) return insufficientScope(required); + return handler.fetch(request, { authInfo: { token, clientId: 'e2e-scoped-caller', scopes } }); + }; + + // 1. With the read-only token: list-files reaches the entry; write-file + // (modern leg) is rejected at the gate with 403 + insufficient_scope and + // the entry is never reached for that request. + const before = factoryCalls; + const readClient = new Client({ name: 'scoped-client', version: '1.0.0' }); + if (transport === 'entryModern') readClient.setVersionNegotiation({ mode: { pin: MODERN } }); + await readClient.connect( + new StreamableHTTPClientTransport(new URL('http://in-process/mcp'), { + fetch: gatedFetch, + requestInit: { headers: { Authorization: 'Bearer read-only-token' } }, + onInsufficientScope: 'throw' + }) + ); + const listed = await readClient.callTool({ name: 'list-files', arguments: {} }); + expect(listed.content).toEqual([{ type: 'text', text: 'listed by e2e-scoped-caller' }]); + const reachedAfterList = factoryCalls; + expect(reachedAfterList).toBeGreaterThan(before); + + if (transport === 'entryModern') { + const writePromise = readClient.callTool({ name: 'write-file', arguments: {} }); + await expect(writePromise).rejects.toBeInstanceOf(InsufficientScopeError); + await expect(writePromise).rejects.toMatchObject({ requiredScope: 'files:write' }); + // The 403 came from the gate; no factory call ran for that POST. + expect(factoryCalls).toBe(reachedAfterList); + } else { + // Legacy leg: no Mcp-Name header → the gate's per-operation derivation + // is not available, so write-file passes the gate (single required scope + // fallback). The cell pins that the gate composes correctly with the + // legacy serving path; per-operation enforcement on legacy is host + // responsibility (e.g., by parsing the body). + const written = await readClient.callTool({ name: 'write-file', arguments: {} }); + expect(written.content).toEqual([{ type: 'text', text: 'written by e2e-scoped-caller' }]); + } + await readClient.close().catch(() => {}); + + // 2. With the read-write token: write-file reaches the entry on both legs. + const rwClient = new Client({ name: 'scoped-client', version: '1.0.0' }); + if (transport === 'entryModern') rwClient.setVersionNegotiation({ mode: { pin: MODERN } }); + await rwClient.connect( + new StreamableHTTPClientTransport(new URL('http://in-process/mcp'), { + fetch: gatedFetch, + requestInit: { headers: { Authorization: 'Bearer read-write-token' } } + }) + ); + const written = await rwClient.callTool({ name: 'write-file', arguments: {} }); + expect(written.content).toEqual([{ type: 'text', text: 'written by e2e-scoped-caller' }]); + await rwClient.close().catch(() => {}); +});