Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 2.1k
feat(client): SEP-2468 RFC 9207 iss + RFC 8414 §3.3 issuer-echo validation#2344
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Uh oh!
There was an error while loading. Please reload this page.
Changes from all commits
File filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| --- | ||
| "@modelcontextprotocol/core": minor | ||
| "@modelcontextprotocol/client": minor | ||
| --- | ||
| Implement RFC 9207 / RFC 8414 §3.3 OAuth issuer validation (SEP-2468). `discoverAuthorizationServerMetadata()` now rejects metadata whose `issuer` does not match the discovery URL (opt out via `skipIssuerValidation` / `AuthOptions.skipIssuerMetadataValidation` — security-weakening). `auth()`, `exchangeAuthorization()`, `fetchToken()`, and `transport.finishAuth(code, iss?)` now validate the authorization-callback `iss` against the recorded issuer before redeeming the code; new `IssuerMismatchError` and `validateAuthorizationResponseIssuer()` are exported. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -579,6 +579,10 @@ Output-schema validator compilation is now lazy (first `callTool()` against the | ||
| New (no v1 equivalent): `Client.connect(transport, { prior: DiscoverResult })` — zero-round-trip connect (2026-07-28+ only; throws `EraNegotiationFailed` otherwise). Probe once, persist `client.getDiscoverResult()` (`JSON.stringify`), feed to every worker. New exported type: | ||
| `ConnectOptions` (extends `RequestOptions` with `prior?: DiscoverResult`). | ||
| OAuth callback handling: pass the callback URL's `URLSearchParams` to `transport.finishAuth(url.searchParams)` (or pass `iss` alongside `authorizationCode` to `auth()` / `finishAuth(code, iss)`). The SDK now validates `iss` per RFC 9207: a mismatched `iss` throws `IssuerMismatchError` regardless of advertised support; a missing `iss` throws only when the AS advertised `authorization_response_iss_parameter_supported: true`. Do not surface `error_description` / `error_uri` from a callback that failed this check. | ||
| `discoverAuthorizationServerMetadata()` now rejects metadata whose `issuer` does not exactly match the URL it was fetched for (RFC 8414 §3.3), throwing `IssuerMismatchError`. Pass `skipIssuerMetadataValidation: true` on `AuthOptions` (or `skipIssuerValidation: true` on the helper) only as a temporary workaround for a known-misconfigured AS. | ||
felixweinberger marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| No code changes required; wire-behavior note: on a 2026-07-28 Streamable HTTP connection, aborting an in-flight client request (caller `signal` / timeout) closes that request's SSE response stream as the spec cancellation signal — `notifications/cancelled` is no longer POSTed | ||
| there. 2025-era connections and stdio at any era still send `notifications/cancelled`. Custom `Transport` implementations that open one underlying request per outbound message and honor `TransportSendOptions.requestSignal` may declare `readonly hasPerRequestStream = true` to opt | ||
| into the same routing. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1564,9 +1564,28 @@ The inline options object on `auth()` is now the named `AuthOptions` type, expor | ||
| 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. | ||
| ### Authorization-server mix-up defense (RFC 9207 / RFC 8414 §3.3) | ||
| **Action required for hosts handling OAuth callbacks.** | ||
| `transport.finishAuth()` and `auth()` now validate the `iss` parameter from the authorization callback against the issuer recorded from the authorization server's validated metadata (RFC 9207). A **mismatched** `iss` is rejected with `IssuerMismatchError` before the code is exchanged regardless of what the AS advertised; a **missing** `iss` is rejected only when the AS advertised `authorization_response_iss_parameter_supported: true`. | ||
| **You must** pass the callback URL's query parameters to the SDK so it can read `iss` alongside `code`: | ||
| ```typescript | ||
| const url = new URL(callbackUrl); | ||
| await transport.finishAuth(url.searchParams); // SDK reads `code` + `iss` | ||
| ``` | ||
| `transport.finishAuth(code, iss)` remains supported for back-compat. If you bypass `auth()` and call `exchangeAuthorization()` / `fetchToken()` directly, pass `iss` in the options bag — the same validation runs there. | ||
| **You must not** display or act on `error`, `error_description`, or `error_uri` from the callback URL when `IssuerMismatchError` is thrown — those values are attacker-controlled in a mix-up attack. | ||
| `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). | ||
felixweinberger marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
claude[bot] marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| ### Conformance obligations for `OAuthClientProvider` implementers | ||
| <!-- Filled in as the SEP-2468/2352/2350/837/2207 behavior PRs land. --> | ||
| <!-- Filled in as the SEP-2352/2350/837/2207 behavior PRs land. --> | ||
| ## Using an LLM to migrate your code | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -48,7 +48,7 @@ const CALLBACK_URL = 'http://127.0.0.1:8090/callback'; | ||
| * would, and the demo AS's auto-sign-in + `autoConsent` collapse every | ||
| * interactive step into a 302. | ||
| */ | ||
| async function followAuthorizationRedirects(authorizationUrl: URL): Promise<string> { | ||
| async function followAuthorizationRedirects(authorizationUrl: URL): Promise<URLSearchParams> { | ||
| let next = authorizationUrl.href; | ||
| // Crude cookie jar — enough for a single-origin demo AS. | ||
| const jar = new Map<string, string>(); | ||
felixweinberger marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| @@ -76,7 +76,7 @@ async function followAuthorizationRedirects(authorizationUrl: URL): Promise<stri | ||
| const error = resolved.searchParams.get('error'); | ||
| if (error) throw new Error(`AS returned error on callback: ${error} ${resolved.searchParams.get('error_description') ?? ''}`); | ||
| if (!code) throw new Error(`callback redirect missing ?code: ${resolved.href}`); | ||
| return code; | ||
| return resolved.searchParams; | ||
| } | ||
| next = resolved.href; | ||
| } | ||
| @@ -121,14 +121,16 @@ runClient('oauth', async () => { | ||
| // ---- 2. Follow the authorization URL headlessly --------------------------- | ||
| // (the browser-and-user stand-in; see `followAuthorizationRedirects`). | ||
| const code = await followAuthorizationRedirects(capturedAuthorizationUrl!); | ||
| const callbackParams = await followAuthorizationRedirects(capturedAuthorizationUrl!); | ||
| // ---- 3. Exchange the code for tokens -------------------------------------- | ||
| // In the browser flow the local callback server hands this `code` to | ||
| // `transport.finishAuth`; we read it off the `Location` header instead. The | ||
| // SDK now POSTs `grant_type=authorization_code` (+ PKCE `code_verifier`) to | ||
| // the AS `/token` endpoint and saves the tokens on `provider`. | ||
| await firstTransport.finishAuth(code); | ||
| // In the browser flow the local callback server hands the redirect query to | ||
| // `transport.finishAuth`; we read it off the final `Location` header instead. | ||
| // The SDK reads `code` + `iss` (RFC 9207) from the params, validates `iss` | ||
| // against the recorded issuer, then POSTs `grant_type=authorization_code` | ||
| // (+ PKCE `code_verifier`) to the AS `/token` endpoint and saves the tokens | ||
| // on `provider`. | ||
| await firstTransport.finishAuth(callbackParams); | ||
| const tokens = provider.tokens(); | ||
| check.ok(tokens?.access_token, 'token exchange should have yielded an access_token'); | ||
| check.equal(tokens?.token_type, 'Bearer'); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -77,8 +77,8 @@ class InteractiveOAuthClient { | ||
| /** | ||
| * Starts a temporary HTTP server to receive the OAuth callback | ||
| */ | ||
| private async waitForOAuthCallback(): Promise<string> { | ||
| return new Promise<string>((resolve, reject) => { | ||
| private async waitForOAuthCallback(): Promise<URLSearchParams> { | ||
| return new Promise<URLSearchParams>((resolve, reject) => { | ||
| const server = createServer((req, res) => { | ||
| // Ignore favicon requests | ||
| if (req.url === '/favicon.ico') { | ||
| @@ -105,7 +105,8 @@ class InteractiveOAuthClient { | ||
| </html> | ||
| `); | ||
| resolve(code); | ||
| // Hand back the whole query — finishAuth() reads `code` + `iss` (RFC 9207) itself. | ||
| resolve(parsedUrl.searchParams); | ||
| setTimeout(() => server.close(), 3000); | ||
| } else if (error) { | ||
| console.log(`❌ Authorization error: ${error}`); | ||
| @@ -148,10 +149,11 @@ class InteractiveOAuthClient { | ||
| } catch (error) { | ||
| if (error instanceof UnauthorizedError) { | ||
| console.log('🔐 OAuth required - waiting for authorization...'); | ||
| const callbackPromise = this.waitForOAuthCallback(); | ||
| const authCode = await callbackPromise; | ||
| await transport.finishAuth(authCode); | ||
| console.log('🔐 Authorization code received:', authCode); | ||
| const callbackParams = await this.waitForOAuthCallback(); | ||
| // Pass the whole callback query — the SDK extracts `code` and validates | ||
| // `iss` against the recorded issuer (RFC 9207) before exchanging the code. | ||
| await transport.finishAuth(callbackParams); | ||
| console.log('🔐 Authorization code received:', callbackParams.get('code')); | ||
| console.log('🔌 Reconnecting with authenticated transport...'); | ||
claude[bot] marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| await this.attemptConnection(oauthProvider); | ||
| } else { | ||
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.