Skip to content

Commit 5dfc2be

Browse files
feat(client): SEP-2468 RFC 9207 iss + RFC 8414 §3.3 issuer-echo validation
Implements the OAuth mix-up defenses, default-ON. - IssuerMismatchError {kind:'metadata'|'authorization_response'} in authErrors.ts (extends OAuthClientFlowError, not OAuthError, so the auth() retry block does not swallow it). Re-exported from auth.ts for back-compat. - New validateAuthorizationResponseIssuer() pure helper implementing the spec's 4-row decision table with exact === comparison (no normalization). - discoverAuthorizationServerMetadata() validates issuer against the raw discovery input per RFC 8414 §3.3; throws IssuerMismatchError{kind:metadata} on mismatch unless skipIssuerValidation. One-directional trailing-slash tolerance for the SDK-synthesized legacy-fallback URL only. - authInternal/exchangeAuthorization/fetchToken validate iss before token exchange (gated on authorizationCode \!== undefined). - finishAuth(URLSearchParams) overload on StreamableHTTPClientTransport and SSEClientTransport (reads code + iss); finishAuth(code, iss?) kept for back-compat. - AuthorizationServerMetadata.authorization_response_iss_parameter_supported schema field (z.boolean().optional().catch(undefined) — tolerant of non-boolean wire values). - skipIssuerMetadataValidation suppresses AU-02 (metadata echo) only, not the AU-01 runtime iss check. - IssuerMismatchError.message JSON-encodes received/expected (log-injection guard for attacker-controllable iss). Conformance: auth/metadata-issuer-mismatch burns. The 6 auth/iss-* cells' SEP-2468 check passes — they stay listed because the referee bundles a SEP-837 application_type DCR check (burns with PR-B). auth/2025-03-26-oauth-metadata-backcompat is added to the baseline: the §3.3 issuer-echo check correctly rejects the 0.2.0-alpha.5 referee's backcompat mock (issuer carries a path component); tracked for a referee-side fix at the next pin bump. E2E: client-auth:iss:{match,mismatch-reject,supported-missing-reject, unadvertised-proceed,no-normalize,opt-out}; as-metadata-discovery:issuer- validation flips from knownFailure to pass. Existing-test fixtures aligned to RFC 8414 §3.3 (issuer values only; the tests assert other behavior). Claude-Session: https://claude.ai/code/session_01XBib5gRe8AMPPJhySCz3EJ
1 parent d1ffd2f commit 5dfc2be

22 files changed

Lines changed: 685 additions & 60 deletions

.changeset/auth-iss-validation.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@modelcontextprotocol/core": minor
3+
"@modelcontextprotocol/client": minor
4+
---
5+
6+
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.

docs/client.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -192,8 +192,8 @@ Server only implements `client_secret_basic`/`client_secret_post`, so there is n
192192
### Full OAuth with user authorization
193193

194194
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
195-
@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, call {@linkcode
196-
@modelcontextprotocol/client!client/streamableHttp.StreamableHTTPClientTransport#finishAuth | transport.finishAuth(code)}, and reconnect.
195+
@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
196+
@modelcontextprotocol/client!client/streamableHttp.StreamableHTTPClientTransport#finishAuth | transport.finishAuth(url.searchParams)} (so the SDK can validate the RFC 9207 `iss` parameter), and reconnect.
197197

198198
For a complete working OAuth flow, see [`simpleOAuthClient.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/oauth/simpleOAuthClient.ts) and
199199
[`simpleOAuthClientProvider.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/oauth/simpleOAuthClientProvider.ts).

docs/migration-SKILL.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -576,6 +576,10 @@ side: auto-fulfilment is on by default (`ClientOptions.inputRequired`, `maxRound
576576

577577
Output-schema validator compilation is now lazy (first `callTool()` against the cached `tools/list` entry) and non-throwing (an uncompilable `outputSchema` is `console.warn`-ed and validation is skipped for that tool only); `listTools()` no longer throws on an uncompilable `outputSchema`. Applies on every era — the legacy-era `listTools()` path is unchanged at the wire level only.
578578

579+
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.
580+
581+
`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.
582+
579583
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
580584
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
581585
into the same routing.

docs/migration.md

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1491,9 +1491,28 @@ The inline options object on `auth()` is now the named `AuthOptions` type, expor
14911491

14921492
`OAuthTokens` and `OAuthClientInformation` also gain an optional `issuer?: string` field. Once the SEP-2352 behavior change lands the SDK will stamp this onto credentials before calling `saveTokens` / `saveClientInformation`; provider implementations should round-trip it unchanged. The field is currently inert.
14931493

1494+
### Authorization-server mix-up defense (RFC 9207 / RFC 8414 §3.3)
1495+
1496+
**Action required for hosts handling OAuth callbacks.**
1497+
1498+
`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`.
1499+
1500+
**You must** pass the callback URL's query parameters to the SDK so it can read `iss` alongside `code`:
1501+
1502+
```typescript
1503+
const url = new URL(callbackUrl);
1504+
await transport.finishAuth(url.searchParams); // SDK reads `code` + `iss`
1505+
```
1506+
1507+
`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.
1508+
1509+
**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.
1510+
1511+
`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 `AuthOptions` (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).
1512+
14941513
### Conformance obligations for `OAuthClientProvider` implementers
14951514

1496-
<!-- Filled in as the SEP-2468/2352/2350/837/2207 behavior PRs land. -->
1515+
<!-- Filled in as the SEP-2352/2350/837/2207 behavior PRs land. -->
14971516

14981517
## Using an LLM to migrate your code
14991518

examples/oauth/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ The **authorization-code** OAuth grant — the interactive "user signs in and ap
55
- `server.ts``setupAuthServer` (the better-auth/OIDC demo Authorization Server from `@mcp-examples/shared`) on `:PORT+1`, and a `createMcpHandler` Resource Server behind `requireBearerAuth({ verifier: demoTokenVerifier })` on `:PORT/mcp`, advertising the AS via
66
`createProtectedResourceMetadataRouter` (RFC 9728). DEMO ONLY — the AS auto-signs-in a fixed user, and with `OAUTH_DEMO_AUTO_CONSENT=1` it also auto-approves the consent screen.
77
- `client.ts`**CI-runnable headless flow.** Drives the same SDK auth machinery as the browser client, but instead of `open()`ing the authorization URL it follows the 302 chain itself with `fetch(..., { redirect: 'manual' })` (the demo AS's auto-sign-in + auto-consent collapse
8-
every interactive step into a redirect), reads the `code` off the final `Location` header, calls `transport.finishAuth(code)`, reconnects, and asserts `ctx.authInfo` round-trips. This is what the harness runs.
8+
every interactive step into a redirect), reads the callback query off the final `Location` header, calls `transport.finishAuth(url.searchParams)` (so the SDK reads `code` + `iss` per RFC 9207), reconnects, and asserts `ctx.authInfo` round-trips. This is what the harness runs.
99
- `simpleOAuthClient.ts` + `simpleOAuthClientProvider.ts`**manual real-browser flow.** Full authorization-code flow against any OAuth-protected MCP server: opens the browser, runs a local callback server on `:8090`, exchanges the code, then drops into a small `list`/`call`
1010
REPL. Run this when you want to see the consent page.
1111
- `dualModeAuth.ts` — two auth patterns through the one `authProvider` option: host-managed bearer token vs a built-in `OAuthClientProvider`.

examples/oauth/client.ts

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ const CALLBACK_URL = 'http://127.0.0.1:8090/callback';
4848
* would, and the demo AS's auto-sign-in + `autoConsent` collapse every
4949
* interactive step into a 302.
5050
*/
51-
async function followAuthorizationRedirects(authorizationUrl: URL): Promise<string> {
51+
async function followAuthorizationRedirects(authorizationUrl: URL): Promise<URLSearchParams> {
5252
let next = authorizationUrl.href;
5353
// Crude cookie jar — enough for a single-origin demo AS.
5454
const jar = new Map<string, string>();
@@ -76,7 +76,7 @@ async function followAuthorizationRedirects(authorizationUrl: URL): Promise<stri
7676
const error = resolved.searchParams.get('error');
7777
if (error) throw new Error(`AS returned error on callback: ${error} ${resolved.searchParams.get('error_description') ?? ''}`);
7878
if (!code) throw new Error(`callback redirect missing ?code: ${resolved.href}`);
79-
return code;
79+
return resolved.searchParams;
8080
}
8181
next = resolved.href;
8282
}
@@ -121,14 +121,16 @@ runClient('oauth', async () => {
121121

122122
// ---- 2. Follow the authorization URL headlessly ---------------------------
123123
// (the browser-and-user stand-in; see `followAuthorizationRedirects`).
124-
const code = await followAuthorizationRedirects(capturedAuthorizationUrl!);
124+
const callbackParams = await followAuthorizationRedirects(capturedAuthorizationUrl!);
125125

126126
// ---- 3. Exchange the code for tokens --------------------------------------
127-
// In the browser flow the local callback server hands this `code` to
128-
// `transport.finishAuth`; we read it off the `Location` header instead. The
129-
// SDK now POSTs `grant_type=authorization_code` (+ PKCE `code_verifier`) to
130-
// the AS `/token` endpoint and saves the tokens on `provider`.
131-
await firstTransport.finishAuth(code);
127+
// In the browser flow the local callback server hands the redirect query to
128+
// `transport.finishAuth`; we read it off the final `Location` header instead.
129+
// The SDK reads `code` + `iss` (RFC 9207) from the params, validates `iss`
130+
// against the recorded issuer, then POSTs `grant_type=authorization_code`
131+
// (+ PKCE `code_verifier`) to the AS `/token` endpoint and saves the tokens
132+
// on `provider`.
133+
await firstTransport.finishAuth(callbackParams);
132134
const tokens = provider.tokens();
133135
check.ok(tokens?.access_token, 'token exchange should have yielded an access_token');
134136
check.equal(tokens?.token_type, 'Bearer');

examples/oauth/simpleOAuthClient.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -77,8 +77,8 @@ class InteractiveOAuthClient {
7777
/**
7878
* Starts a temporary HTTP server to receive the OAuth callback
7979
*/
80-
private async waitForOAuthCallback(): Promise<string> {
81-
return new Promise<string>((resolve, reject) => {
80+
private async waitForOAuthCallback(): Promise<URLSearchParams> {
81+
return new Promise<URLSearchParams>((resolve, reject) => {
8282
const server = createServer((req, res) => {
8383
// Ignore favicon requests
8484
if (req.url === '/favicon.ico') {
@@ -105,7 +105,8 @@ class InteractiveOAuthClient {
105105
</html>
106106
`);
107107

108-
resolve(code);
108+
// Hand back the whole query — finishAuth() reads `code` + `iss` (RFC 9207) itself.
109+
resolve(parsedUrl.searchParams);
109110
setTimeout(() => server.close(), 3000);
110111
} else if (error) {
111112
console.log(`❌ Authorization error: ${error}`);
@@ -148,10 +149,11 @@ class InteractiveOAuthClient {
148149
} catch (error) {
149150
if (error instanceof UnauthorizedError) {
150151
console.log('🔐 OAuth required - waiting for authorization...');
151-
const callbackPromise = this.waitForOAuthCallback();
152-
const authCode = await callbackPromise;
153-
await transport.finishAuth(authCode);
154-
console.log('🔐 Authorization code received:', authCode);
152+
const callbackParams = await this.waitForOAuthCallback();
153+
// Pass the whole callback query — the SDK extracts `code` and validates
154+
// `iss` against the recorded issuer (RFC 9207) before exchanging the code.
155+
await transport.finishAuth(callbackParams);
156+
console.log('🔐 Authorization code received:', callbackParams.get('code'));
155157
console.log('🔌 Reconnecting with authenticated transport...');
156158
await this.attemptConnection(oauthProvider);
157159
} else {

0 commit comments

Comments
 (0)