From 66918a97743309effe2d70fb1b175cd3fd33c75e Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Mon, 22 Jun 2026 13:38:10 +0000 Subject: [PATCH] =?UTF-8?q?chore:=20auth=20conformance=20closeout=20?= =?UTF-8?q?=E2=80=94=20SEP-990=20fixture,=20migration.md=20+=20client=20do?= =?UTF-8?q?cs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the existing CrossAppAccessProvider into the auth/enterprise-managed-authorization conformance fixture (8/8 checks pass, zero SDK code change). Consolidates the per-PR migration.md sections into a single 2026-authorization conformance contract aligned with the issuer-stamp model, and refreshes the clientGuide auth snippets + docs/client.md. Reconciles expected-failures baselines with the published 0.2.0-alpha.5 referee. The MyOAuthProvider guide example now implements state() and discoveryState()/ saveDiscoveryState() so the documented state-check is reachable and the callback-leg AS-binding is demonstrated; the deprecated saveAuthorizationServerUrl pair is dropped. The auth_finishAuth snippet now wires the CSRF check to provider.lastState and reconnects on a fresh StreamableHTTPClientTransport (a started transport cannot be restarted). Unused Prompt/Resource/Tool type imports dropped from the synced imports region. auth/* is fully green on both legs at the tip. Two client baseline entries remain at the alpha.5 pin: json-schema-ref-no-deref (SEP-2106 territory, unrelated) and auth/2025-03-26-oauth-metadata-backcompat (referee mock still serves a §3.3-violating issuer at this pin; tracked for a referee-side fix or removal at the next conformance pin bump). Claude-Session: https://claude.ai/code/session_01XBib5gRe8AMPPJhySCz3EJ --- docs/client.md | 125 +++++++++++++++++++++- docs/migration.md | 29 ++++-- examples/guides/clientGuide.examples.ts | 127 ++++++++++++++++++++++- examples/scoped-tools/client.ts | 1 + pnpm-lock.yaml | 10 +- test/conformance/expected-failures.yaml | 21 ++-- test/conformance/package.json | 2 +- test/conformance/src/everythingClient.ts | 18 +++- 8 files changed, 294 insertions(+), 39 deletions(-) diff --git a/docs/client.md b/docs/client.md index cc41240f7..6bb775f29 100644 --- a/docs/client.md +++ b/docs/client.md @@ -13,7 +13,15 @@ A client connects to a server, discovers what it offers — tools, resources, pr The examples below use these imports. Adjust based on which features and transport you need: ```ts source="../examples/guides/clientGuide.examples.ts#imports" -import type { AuthProvider } from '@modelcontextprotocol/client'; +import type { + AuthProvider, + OAuthClientInformationContext, + OAuthClientInformationMixed, + OAuthClientMetadata, + OAuthClientProvider, + OAuthDiscoveryState, + OAuthTokens +} from '@modelcontextprotocol/client'; import { applyMiddlewares, Client, @@ -21,6 +29,7 @@ import { createMiddleware, CrossAppAccessProvider, discoverAndRequestJwtAuthGrant, + IssuerMismatchError, PrivateKeyJwtProvider, ProtocolError, SdkError, @@ -28,7 +37,8 @@ import { SSEClientTransport, StreamableHTTPClientTransport, TRACEPARENT_META_KEY, - TRACESTATE_META_KEY + TRACESTATE_META_KEY, + UnauthorizedError } from '@modelcontextprotocol/client'; import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; ``` @@ -191,9 +201,114 @@ Server only implements `client_secret_basic`/`client_secret_post`, so there is n ### Full OAuth with user authorization -For user-facing applications, implement the {@linkcode @modelcontextprotocol/client!client/auth.OAuthClientProvider | OAuthClientProvider} interface to handle the full authorization code flow (redirects, code verifiers, token storage, dynamic client registration). The {@linkcode -@modelcontextprotocol/client!client/client.Client#connect | connect()} call will throw {@linkcode @modelcontextprotocol/client!client/auth.UnauthorizedError | UnauthorizedError} when authorization is needed — catch it, complete the browser flow, pass the redirect URL's query to {@linkcode -@modelcontextprotocol/client!client/streamableHttp.StreamableHTTPClientTransport#finishAuth | transport.finishAuth(url.searchParams)} (so the SDK can validate the RFC 9207 `iss` parameter), and reconnect. +For user-facing applications, implement the {@linkcode @modelcontextprotocol/client!client/auth.OAuthClientProvider | OAuthClientProvider} interface to handle the full authorization code flow (redirects, code verifiers, token storage, dynamic client registration). Key persisted +client credentials by the `ctx.issuer` passed to `clientInformation()` / `saveClientInformation()` so credentials registered with one authorization server are never sent to another: + +```ts source="../examples/guides/clientGuide.examples.ts#auth_oauthClientProvider" +class MyOAuthProvider implements OAuthClientProvider { + // Key DCR-obtained credentials by issuer so a client_id registered with one + // authorization server is never returned for another (SEP-2352). + private creds = new Map(); + private storedTokens?: OAuthTokens; + private verifier?: string; + private discovery?: OAuthDiscoveryState; + lastState?: string; + + readonly redirectUrl = 'http://localhost:8090/callback'; + readonly clientMetadata: OAuthClientMetadata = { + client_name: 'My MCP Client', + redirect_uris: ['http://localhost:8090/callback'], + // Loopback redirect → the SDK would default this to 'native'; set + // explicitly when the heuristic is wrong for your deployment (SEP-837). + application_type: 'native' + }; + + clientInformation(ctx?: OAuthClientInformationContext) { + return ctx ? this.creds.get(ctx.issuer) : undefined; + } + saveClientInformation(info: OAuthClientInformationMixed, ctx?: OAuthClientInformationContext) { + if (ctx) this.creds.set(ctx.issuer, info); + } + tokens() { + return this.storedTokens; + } + saveTokens(tokens: OAuthTokens) { + // In production, persist to OS keychain / secure storage — never plain files. + this.storedTokens = tokens; + } + // CSRF binding for the redirect — the SDK puts this on the authorize URL; + // your callback handler compares it before calling `finishAuth`. + state() { + this.lastState = crypto.randomUUID(); + return this.lastState; + } + // Callback-leg AS-binding (SEP-2352): record what discovery resolved before + // the redirect so the SDK can verify the code is exchanged at the same AS. + saveDiscoveryState(state: OAuthDiscoveryState) { + this.discovery = state; + } + discoveryState() { + return this.discovery; + } + redirectToAuthorization(url: URL) { + onRedirect(url); + } + saveCodeVerifier(v: string) { + this.verifier = v; + } + codeVerifier() { + if (!this.verifier) throw new Error('no code verifier'); + return this.verifier; + } +} + +const provider = new MyOAuthProvider(); +const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'), { + authProvider: provider +}); +``` + +The {@linkcode @modelcontextprotocol/client!client/client.Client#connect | connect()} call throws {@linkcode @modelcontextprotocol/client!client/auth.UnauthorizedError | UnauthorizedError} when authorization is needed — catch it, complete the browser flow, hand the callback query +to {@linkcode @modelcontextprotocol/client!client/streamableHttp.StreamableHTTPClientTransport#finishAuth | transport.finishAuth()}, and reconnect. Passing the whole `URLSearchParams` lets the SDK extract `code` and validate the RFC 9207 `iss` parameter for you: + +```ts source="../examples/guides/clientGuide.examples.ts#auth_finishAuth" +const client = new Client({ name: 'my-client', version: '1.0.0' }); +const transport = new StreamableHTTPClientTransport(url, { authProvider: provider }); +try { + await client.connect(transport); + return client; +} catch (error) { + // With version negotiation, the connect-time 401 may surface wrapped as + // SdkError(EraNegotiationFailed) whose .data.cause is the UnauthorizedError. + const root = error instanceof UnauthorizedError ? error : (error as { data?: { cause?: unknown } }).data?.cause; + if (!(root instanceof UnauthorizedError)) throw error; + // The transport called redirectToAuthorization(); fall through to the browser callback. +} + +const callbackUrl = await waitForCallback(); +const params = new URL(callbackUrl).searchParams; + +// The SDK does not validate `state` — compare it to the value your provider generated. +if (params.get('state') !== provider.lastState) throw new Error('state mismatch'); + +try { + // Preferred: hand over the whole query — the SDK extracts `code` and + // `iss`, validates `iss` (RFC 9207), and never surfaces callback-derived + // `error`/`error_description` text on mismatch. + await transport.finishAuth(params); +} catch (error) { + if (error instanceof IssuerMismatchError) { + // Mix-up attack: do NOT render params.get('error_description') to the user. + throw new Error('Authorization failed: issuer mismatch'); + } + throw error; +} + +// Reconnect on a FRESH transport — a started transport cannot be restarted; +// OAuth state (tokens, verifier, discovery) lives on the provider, not the transport. +await client.connect(new StreamableHTTPClientTransport(url, { authProvider: provider })); +return client; +``` For a complete working OAuth flow, see [`simpleOAuthClient.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/oauth/simpleOAuthClient.ts) and [`simpleOAuthClientProvider.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/oauth/simpleOAuthClientProvider.ts). diff --git a/docs/migration.md b/docs/migration.md index 8b3ef0037..0f69fd6ae 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -1580,20 +1580,21 @@ client logic; key off the HTTP `404` status instead. ## Authorization (2026-07-28 spec) -The 2026-07-28 protocol revision adds client-side authorization requirements (RFC 9207 `iss` validation, RFC 8414 §3.3 issuer-echo, per-authorization-server credential isolation, scope step-up, DCR `application_type`, and refresh-token guidance). The SDK adds the public surface for these now and will implement the parts that land in SDK code (defaulting them on) as the SEP-2468/2352/2350/837/2207 behavior changes land; the parts that live in your `OAuthClientProvider` implementation, your `clientMetadata`, or your host UI are listed under [Conformance obligations for `OAuthClientProvider` implementers](#conformance-obligations-for-oauthclientprovider-implementers). +The 2026-07-28 protocol revision adds client-side authorization requirements (RFC 9207 `iss` validation, RFC 8414 §3.3 issuer-echo, per-authorization-server credential isolation, scope step-up, DCR `application_type`, and refresh-token guidance). The SDK implements the parts that land in SDK code and defaults them on; the parts that live in your `OAuthClientProvider` implementation, your `clientMetadata`, or your host UI are listed under [Conformance obligations for `OAuthClientProvider` implementers](#conformance-obligations-for-oauthclientprovider-implementers). ### `auth()` options are now `AuthOptions` -The inline options object on `auth()` is now the named `AuthOptions` type, exported from `@modelcontextprotocol/client`. Existing call sites need no change. New fields (both currently inert — the validation behavior they feed lands in the follow-up changes tracked by SEP-2468): +The inline options object on `auth()` is now the named `AuthOptions` type, exported from `@modelcontextprotocol/client`. Existing call sites need no change. New fields: -- `iss?: string` — the form-urldecoded `iss` query parameter from the authorization callback. Pass it alongside `authorizationCode`; it is forwarded to RFC 9207 issuer validation once that lands. -- `skipIssuerMetadataValidation?: boolean` — opt-out for the RFC 8414 §3.3 issuer-echo check during discovery. **Security-weakening**; use only with authorization servers known to publish a mismatched `issuer`. +- `iss?: string` — the form-urldecoded `iss` query parameter from the authorization callback. Pass it alongside `authorizationCode` so the SDK can validate it per RFC 9207 before redeeming the code. +- `skipIssuerMetadataValidation?: boolean` — opt-out of the RFC 8414 §3.3 issuer-echo check during discovery. **Security-weakening**; use only with authorization servers known to publish a mismatched `issuer`. +- `forceReauthorization?: boolean` — skip the refresh-token branch and force a fresh authorization request. Set by the transport's step-up path when the required scope strictly exceeds the current token's; hosts driving step-up themselves set it under the same condition. See [Scope step-up](#scope-step-up-on-403-insufficient_scope-sep-2350). ### `OAuthClientProvider` credential methods receive an `issuer` context -`clientInformation(ctx?)`, `saveClientInformation(info, ctx?)`, `tokens(ctx?)`, and `saveTokens(tokens, ctx?)` now receive an optional `OAuthClientInformationContext` parameter carrying `{ issuer: string }` — the authorization server's `issuer` identifier. Providers that persist credentials should key storage by this value so that credentials registered with one authorization server are never sent to another. Providers with a single credential set may ignore the parameter; existing implementations compile unchanged. The SDK does not yet pass this argument; it begins doing so when the SEP-2352 behavior change lands. +`clientInformation(ctx?)`, `saveClientInformation(info, ctx?)`, `tokens(ctx?)`, and `saveTokens(tokens, ctx?)` now receive an optional `OAuthClientInformationContext` parameter carrying `{ issuer: string }` — the authorization server's `issuer` identifier. Providers that persist credentials should key storage by this value so that credentials registered with one authorization server are never sent to another. Providers with a single credential set may ignore the parameter; existing implementations compile unchanged. -New TypeScript-only aliases `StoredOAuthTokens` and `StoredOAuthClientInformation` add an optional `issuer?: string` field on top of the wire types and are used as the parameter/return types of `tokens()` / `saveTokens()` and `clientInformation()` / `saveClientInformation()`. The `issuer` field is **not** part of the RFC 6749/7591 wire responses and is intentionally absent from `OAuthTokensSchema` / `OAuthClientInformationSchema` so an authorization server cannot populate it; once the SEP-2352 behavior change lands the SDK will stamp it onto credentials before calling `saveTokens` / `saveClientInformation`. Provider implementations should round-trip it unchanged. The field is currently inert. +New TypeScript-only aliases `StoredOAuthTokens` and `StoredOAuthClientInformation` add an optional `issuer?: string` field on top of the wire types and are used as the parameter/return types of `tokens()` / `saveTokens()` and `clientInformation()` / `saveClientInformation()`. The `issuer` field is **not** part of the RFC 6749/7591 wire responses and is intentionally absent from `OAuthTokensSchema` / `OAuthClientInformationSchema` so an authorization server cannot populate it; the SDK stamps it onto credentials before calling `saveTokens` / `saveClientInformation`. Provider implementations should round-trip it unchanged. See [Per-authorization-server credential isolation](#per-authorization-server-credential-isolation-sep-2352) for how the stamp is used. ### Authorization-server mix-up defense (RFC 9207 / RFC 8414 §3.3) @@ -1647,13 +1648,21 @@ The bundled `ClientCredentialsProvider`, `PrivateKeyJwtProvider`, `StaticPrivate ### Conformance obligations for `OAuthClientProvider` implementers - +The SDK enforces every 2026-07-28 authorization MUST that lands in SDK code. The obligations below live in **your** `OAuthClientProvider` implementation, your `clientMetadata`, your host UI, or your resource-server configuration — the SDK structurally cannot enforce them. Each links to the example that demonstrates the conformant pattern. -#### SEP-2352 — per-authorization-server credential isolation +- **SEP-2352 — round-trip the `issuer` stamp on persisted credentials.** `saveTokens()` and `saveClientInformation()` receive values with an SDK-stamped `issuer` field; persist the value verbatim and return it verbatim from `tokens()` / `clientInformation()` and the binding holds — the SDK discards a stored value whose stamp names a different authorization server. If you serialise to a custom format, persist `issuer` alongside the rest. To hold credentials for several authorization servers at once, key your storage on `ctx.issuer` and return `undefined` for an issuer you have no entry for — but when `ctx === undefined` (the transport's per-request bearer read), return the most-recently-saved token set. You **SHOULD** implement `discoveryState()` / `saveDiscoveryState()` so the callback leg can verify it is exchanging the authorization code at the same AS the redirect targeted; without them the SDK `console.warn`s once per callback (RFC 9207 `iss` validation independently protects this leg when the AS emits `iss`). See [`examples/oauth/simpleOAuthClientProvider.ts`](../examples/oauth/simpleOAuthClientProvider.ts) for the reference pattern. -**No code change required for the common case.** If your `saveTokens()` / `saveClientInformation()` persist the value passed to them verbatim and your `tokens()` / `clientInformation()` return it verbatim, the SDK-stamped `issuer` round-trips and the binding holds. +- **SEP-2352 — pass `expectedIssuer` when supplying static client credentials.** Hosts that construct `ClientCredentialsProvider`, `PrivateKeyJwtProvider`, `StaticPrivateKeyJwtProvider`, or `CrossAppAccessProvider` with a constructor-supplied `client_secret` (or pre-signed assertion) **SHOULD** pass the new `expectedIssuer` option naming the authorization server those credentials were registered with. Without it, the credential is sent to whatever authorization server the protected resource advertises on first contact; with it, a mismatch fails before the credential leaves the process. -If you serialise to a custom format, persist the `issuer` field alongside the rest of the value. If you key storage by `ctx.issuer`, return `undefined` for an issuer you have no entry for, and treat **`ctx === undefined` as "return the most-recently-saved token set"** — the transport's per-request `Authorization: Bearer` read (`adaptOAuthProvider().token()`) calls `tokens()` with no `ctx`. +- **SEP-2207 — keep refresh tokens confidential in storage.** The SDK enforces in-transit confidentiality via the [`https:` token-endpoint guard](#token-endpoint-must-use-tls-sep-2207); in-storage confidentiality is your `saveTokens()` implementation. Use platform-appropriate secure storage (OS keychain, encrypted-at-rest store) — never persist `refresh_token` to plain files, `localStorage`, or logs. + +- **SEP-2468 — extract `iss` from the callback URL and pass it to `finishAuth`.** Your callback handler must read the `iss` query parameter alongside `code` and call `transport.finishAuth(code, iss)` — or hand the whole `URLSearchParams` to the [overload](#authorization-server-mix-up-defense-rfc-9207--rfc-8414-33). The SDK validates the value but cannot extract it from a URL it never sees. When `IssuerMismatchError` is thrown, **do not** render the callback's raw `error` / `error_description` / `error_uri` in your UI — those values are attacker-controlled in a mix-up attack. See [`examples/oauth/simpleOAuthClient.ts`](../examples/oauth/simpleOAuthClient.ts) for the extraction pattern. + +- **SEP-837 — set `application_type` correctly when overriding the heuristic.** The SDK defaults `clientMetadata.application_type` from your `redirect_uris` (loopback / custom scheme → `'native'`, else `'web'`). When the heuristic is wrong for your deployment — a web app dev-served on `localhost`, a native app with an `https:` claimed redirect — set the field explicitly; the SDK never overwrites a value you set but cannot know your deployment shape. See [Dynamic Client Registration defaults](#dynamic-client-registration-application_type-and-grant_types-defaults-sep-837-sep-2207). + +- **SEP-2350 — track cross-request step-up failures yourself.** The SDK caps step-up retries **per request** (`maxStepUpRetries`). Tracking "this (resource, operation) has already failed step-up _N_ times across the session" — to back off, surface an error, or stop prompting the user — is host state the SDK has no visibility into. See the `client-auth:stepup:*` scenarios in [`test/e2e/scenarios/client-auth.test.ts`](../test/e2e/scenarios/client-auth.test.ts) for the transport-driven step-up flow. + +- **SEP-2207 (resource-server operators) — do not advertise `offline_access` from the RS.** A resource server SHOULD NOT include `offline_access` in its `WWW-Authenticate` `scope` challenge or in its protected-resource metadata `scopes_supported` — refresh-token issuance is between the client and the authorization server. This is operator configuration of whatever serves your `WWW-Authenticate` header and PRM document, not SDK code. ## Using an LLM to migrate your code diff --git a/examples/guides/clientGuide.examples.ts b/examples/guides/clientGuide.examples.ts index e16bdf7a8..102793669 100644 --- a/examples/guides/clientGuide.examples.ts +++ b/examples/guides/clientGuide.examples.ts @@ -8,7 +8,15 @@ */ //#region imports -import type { AuthProvider } from '@modelcontextprotocol/client'; +import type { + AuthProvider, + OAuthClientInformationContext, + OAuthClientInformationMixed, + OAuthClientMetadata, + OAuthClientProvider, + OAuthDiscoveryState, + OAuthTokens +} from '@modelcontextprotocol/client'; import { applyMiddlewares, Client, @@ -16,6 +24,7 @@ import { createMiddleware, CrossAppAccessProvider, discoverAndRequestJwtAuthGrant, + IssuerMismatchError, PrivateKeyJwtProvider, ProtocolError, SdkError, @@ -23,7 +32,8 @@ import { SSEClientTransport, StreamableHTTPClientTransport, TRACEPARENT_META_KEY, - TRACESTATE_META_KEY + TRACESTATE_META_KEY, + UnauthorizedError } from '@modelcontextprotocol/client'; import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; //#endregion imports @@ -189,6 +199,119 @@ async function auth_crossAppAccess(getIdToken: () => Promise) { return transport; } +/** + * Example: Minimal `OAuthClientProvider` for the authorization-code flow. + * Client credentials are stored per authorization-server `issuer` (SEP-2352). + */ +function auth_oauthClientProvider(onRedirect: (url: URL) => void) { + //#region auth_oauthClientProvider + class MyOAuthProvider implements OAuthClientProvider { + // Key DCR-obtained credentials by issuer so a client_id registered with one + // authorization server is never returned for another (SEP-2352). + private creds = new Map(); + private storedTokens?: OAuthTokens; + private verifier?: string; + private discovery?: OAuthDiscoveryState; + lastState?: string; + + readonly redirectUrl = 'http://localhost:8090/callback'; + readonly clientMetadata: OAuthClientMetadata = { + client_name: 'My MCP Client', + redirect_uris: ['http://localhost:8090/callback'], + // Loopback redirect → the SDK would default this to 'native'; set + // explicitly when the heuristic is wrong for your deployment (SEP-837). + application_type: 'native' + }; + + clientInformation(ctx?: OAuthClientInformationContext) { + return ctx ? this.creds.get(ctx.issuer) : undefined; + } + saveClientInformation(info: OAuthClientInformationMixed, ctx?: OAuthClientInformationContext) { + if (ctx) this.creds.set(ctx.issuer, info); + } + tokens() { + return this.storedTokens; + } + saveTokens(tokens: OAuthTokens) { + // In production, persist to OS keychain / secure storage — never plain files. + this.storedTokens = tokens; + } + // CSRF binding for the redirect — the SDK puts this on the authorize URL; + // your callback handler compares it before calling `finishAuth`. + state() { + this.lastState = crypto.randomUUID(); + return this.lastState; + } + // Callback-leg AS-binding (SEP-2352): record what discovery resolved before + // the redirect so the SDK can verify the code is exchanged at the same AS. + saveDiscoveryState(state: OAuthDiscoveryState) { + this.discovery = state; + } + discoveryState() { + return this.discovery; + } + redirectToAuthorization(url: URL) { + onRedirect(url); + } + saveCodeVerifier(v: string) { + this.verifier = v; + } + codeVerifier() { + if (!this.verifier) throw new Error('no code verifier'); + return this.verifier; + } + } + + const provider = new MyOAuthProvider(); + const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'), { + authProvider: provider + }); + //#endregion auth_oauthClientProvider + return { provider, transport }; +} + +/** Example: Handling the OAuth callback — extract `iss` for RFC 9207 validation. */ +async function auth_finishAuth(url: URL, provider: OAuthClientProvider & { lastState?: string }, waitForCallback: () => Promise) { + //#region auth_finishAuth + const client = new Client({ name: 'my-client', version: '1.0.0' }); + const transport = new StreamableHTTPClientTransport(url, { authProvider: provider }); + try { + await client.connect(transport); + return client; + } catch (error) { + // With version negotiation, the connect-time 401 may surface wrapped as + // SdkError(EraNegotiationFailed) whose .data.cause is the UnauthorizedError. + const root = error instanceof UnauthorizedError ? error : (error as { data?: { cause?: unknown } }).data?.cause; + if (!(root instanceof UnauthorizedError)) throw error; + // The transport called redirectToAuthorization(); fall through to the browser callback. + } + + const callbackUrl = await waitForCallback(); + const params = new URL(callbackUrl).searchParams; + + // The SDK does not validate `state` — compare it to the value your provider generated. + if (params.get('state') !== provider.lastState) throw new Error('state mismatch'); + + try { + // Preferred: hand over the whole query — the SDK extracts `code` and + // `iss`, validates `iss` (RFC 9207), and never surfaces callback-derived + // `error`/`error_description` text on mismatch. + await transport.finishAuth(params); + } catch (error) { + if (error instanceof IssuerMismatchError) { + // Mix-up attack: do NOT render params.get('error_description') to the user. + throw new Error('Authorization failed: issuer mismatch'); + } + throw error; + } + + // Reconnect on a FRESH transport — a started transport cannot be restarted; + // OAuth state (tokens, verifier, discovery) lives on the provider, not the transport. + await client.connect(new StreamableHTTPClientTransport(url, { authProvider: provider })); + return client; + //#endregion auth_finishAuth +} + // --------------------------------------------------------------------------- // Using server features // --------------------------------------------------------------------------- diff --git a/examples/scoped-tools/client.ts b/examples/scoped-tools/client.ts index 43e3d208f..328c88486 100644 --- a/examples/scoped-tools/client.ts +++ b/examples/scoped-tools/client.ts @@ -34,6 +34,7 @@ runClient('scoped-tools', async () => { const clientMetadata: OAuthClientMetadata = { client_name: 'Scoped-Tools Step-Up Client', redirect_uris: [CALLBACK_URL], + application_type: 'native', grant_types: ['authorization_code'], response_types: ['code'], token_endpoint_auth_method: 'none', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1be90aaa6..288c29503 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1581,8 +1581,8 @@ importers: specifier: workspace:^ version: link:../../packages/client '@modelcontextprotocol/conformance': - specifier: 0.2.0-alpha.5 - version: 0.2.0-alpha.5(@cfworker/json-schema@4.1.1) + specifier: 0.2.0-alpha.6 + version: 0.2.0-alpha.6(@cfworker/json-schema@4.1.1) '@modelcontextprotocol/core': specifier: workspace:^ version: link:../../packages/core @@ -2593,8 +2593,8 @@ packages: '@manypkg/get-packages@1.1.3': resolution: {integrity: sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==} - '@modelcontextprotocol/conformance@0.2.0-alpha.5': - resolution: {integrity: sha512-sYxNHKk/m7Vx0/XxKulbcmgqz7wH2tXIge9u1G1CzpluaZHTci966m5dw7wGyQKx7IR3/V+/hAAGXc8ZqBMoyw==} + '@modelcontextprotocol/conformance@0.2.0-alpha.6': + resolution: {integrity: sha512-LIP5AQiPqKWndgcTvryyrffm6UT9kwu7lKBmnHkKGQeYu3/thCeVf+jgNCrR0xNs4Fw44f48I9GH1E+BrVbdZQ==} hasBin: true '@modelcontextprotocol/sdk@1.29.0': @@ -6483,7 +6483,7 @@ snapshots: globby: 11.1.0 read-yaml-file: 1.1.0 - '@modelcontextprotocol/conformance@0.2.0-alpha.5(@cfworker/json-schema@4.1.1)': + '@modelcontextprotocol/conformance@0.2.0-alpha.6(@cfworker/json-schema@4.1.1)': dependencies: '@modelcontextprotocol/sdk': 1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6) '@octokit/rest': 22.0.1 diff --git a/test/conformance/expected-failures.yaml b/test/conformance/expected-failures.yaml index 571c63ec9..3fe3da4b9 100644 --- a/test/conformance/expected-failures.yaml +++ b/test/conformance/expected-failures.yaml @@ -2,7 +2,7 @@ # CI exits 0 if only these fail, exits 1 on unexpected failures or stale entries. # # Baseline established against the published @modelcontextprotocol/conformance -# release pinned in package.json (0.2.0-alpha.5). Newer conformance releases +# release pinned in package.json (0.2.0-alpha.6). Newer conformance releases # are adopted by deliberately bumping the package.json pin and reconciling # this file in the same change. # @@ -17,20 +17,11 @@ # corresponding scenarios start passing and MUST be removed from this list (the # runner fails on stale entries), so the baseline burns down per milestone. -client: +client: [] # --- Draft-spec scenarios (in `--suite draft`, also part of `--suite all`) --- - # SEP-2468 RFC 9207 `iss` validation is now default-ON. The referee's - # 2025-03-26 backcompat mock emits a callback `iss` with a `/oauth` path - # component that does not match its own metadata `issuer` (conformance#359 - # fixed the metadata side; the callback `iss` is a follow-up). The SDK - # correctly rejects per RFC 9207 §2.4. Scenario is `removedIn: 2025-06-18`. - - auth/2025-03-26-oauth-metadata-backcompat + # (empty: SEP-2468/2352/2350/837/2207/990 burned by the auth bundle; the + # last referee-side gap — conformance#361 callback-iss — closed at alpha.6) - # --- Pre-existing scenarios that fail on checks added after conformance 0.1.15 --- - # SEP-990 (enterprise-managed authorization extension): no fixture handler / - # client support for the token-exchange + JWT bearer flow. - - auth/enterprise-managed-authorization - -# No server entries at the 0.2.0-alpha.5 referee — the spec#2907 renumber-mismatch -# cells burned at this pin. +# No server entries — the spec#2907 renumber-mismatch cells burned at the +# alpha.5 referee. server: [] diff --git a/test/conformance/package.json b/test/conformance/package.json index 2c8f08a77..f4ba01298 100644 --- a/test/conformance/package.json +++ b/test/conformance/package.json @@ -40,7 +40,7 @@ "test:conformance:all": "pnpm run test:conformance:client:all && pnpm run test:conformance:server:all" }, "devDependencies": { - "@modelcontextprotocol/conformance": "0.2.0-alpha.5", + "@modelcontextprotocol/conformance": "0.2.0-alpha.6", "@modelcontextprotocol/client": "workspace:^", "@modelcontextprotocol/server": "workspace:^", "@modelcontextprotocol/core": "workspace:^", diff --git a/test/conformance/src/everythingClient.ts b/test/conformance/src/everythingClient.ts index f54eaf2a9..9dc7e1a61 100644 --- a/test/conformance/src/everythingClient.ts +++ b/test/conformance/src/everythingClient.ts @@ -64,6 +64,15 @@ const ClientConformanceContextSchema = z.discriminatedUnion('name', [ idp_id_token: z.string(), idp_issuer: z.string(), idp_token_endpoint: z.string() + }), + z.object({ + name: z.literal('auth/enterprise-managed-authorization'), + client_id: z.string(), + client_secret: z.string(), + idp_client_id: z.string(), + idp_id_token: z.string(), + idp_issuer: z.string(), + idp_token_endpoint: z.string() }) ]); @@ -522,10 +531,16 @@ registerScenario('auth/client-credentials-basic', runClientCredentialsBasic); * then exchanges the ID-JAG for an access token at the AS (RFC 7523 JWT bearer grant * with client_secret_basic). The provider drives discovery + the JWT bearer step; the * assertion callback handles the IdP exchange using the context-supplied ID token. + * + * The two scenarios share the same context shape and the same client behavior: + * `auth/cross-app-access-complete-flow` is the single-AS variant; + * `auth/enterprise-managed-authorization` is the SEP-990 extension scenario that + * additionally validates `requested_token_type=id-jag`, ID-JAG `typ` and + * `client_id`/`resource` claim binding at the AS. */ async function runCrossAppAccessCompleteFlow(serverUrl: string): Promise { const ctx = parseContext(); - if (ctx.name !== 'auth/cross-app-access-complete-flow') { + if (ctx.name !== 'auth/cross-app-access-complete-flow' && ctx.name !== 'auth/enterprise-managed-authorization') { throw new Error(`Expected cross-app-access context, got ${ctx.name}`); } @@ -562,6 +577,7 @@ async function runCrossAppAccessCompleteFlow(serverUrl: string): Promise { } registerScenario('auth/cross-app-access-complete-flow', runCrossAppAccessCompleteFlow); +registerScenario('auth/enterprise-managed-authorization', runCrossAppAccessCompleteFlow); // ============================================================================ // Pre-registration scenario (no dynamic client registration)