Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/auth-iss-validation.md
Original file line numberDiff line numberDiff 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.
Comment thread
claude[bot] marked this conversation as resolved.
4 changes: 2 additions & 2 deletions docs/client.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -192,8 +192,8 @@ 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, call {@linkcode
@modelcontextprotocol/client!client/streamableHttp.StreamableHTTPClientTransport#finishAuth | transport.finishAuth(code)}, and reconnect.
@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 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).
Expand Down
4 changes: 4 additions & 0 deletions docs/migration-SKILL.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Comment thread
felixweinberger marked this conversation as resolved.

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.
Expand Down
21 changes: 20 additions & 1 deletion docs/migration.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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).
Comment thread
felixweinberger marked this conversation as resolved.

Comment thread
claude[bot] marked this conversation as resolved.
### 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

Expand Down
2 changes: 1 addition & 1 deletion examples/oauth/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,7 +5,7 @@ The **authorization-code** OAuth grant — the interactive "user signs in and ap
- `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
`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.
- `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
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.
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.
- `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`
REPL. Run this when you want to see the consent page.
- `dualModeAuth.ts` — two auth patterns through the one `authProvider` option: host-managed bearer token vs a built-in `OAuthClientProvider`.
Expand Down
18 changes: 10 additions & 8 deletions examples/oauth/client.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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>();
Comment thread
felixweinberger marked this conversation as resolved.
Expand DownExpand Up@@ -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;
}
Expand DownExpand Up@@ -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');
Expand Down
16 changes: 9 additions & 7 deletions examples/oauth/simpleOAuthClient.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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') {
Expand All@@ -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}`);
Expand DownExpand Up@@ -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...');
Comment thread
claude[bot] marked this conversation as resolved.
await this.attemptConnection(oauthProvider);
} else {
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
feat(client): SEP-2468 RFC 9207 iss + RFC 8414 §3.3 issuer-echo validation by felixweinberger · Pull Request #2344 · modelcontextprotocol/typescript-sdk · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/auth-iss-validation.md
Original file line numberDiff line numberDiff 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.
Comment thread
claude[bot] marked this conversation as resolved.
4 changes: 2 additions & 2 deletions docs/client.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -192,8 +192,8 @@ 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, call {@linkcode
@modelcontextprotocol/client!client/streamableHttp.StreamableHTTPClientTransport#finishAuth | transport.finishAuth(code)}, and reconnect.
@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 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).
Expand Down
4 changes: 4 additions & 0 deletions docs/migration-SKILL.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Comment thread
felixweinberger marked this conversation as resolved.

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.
Expand Down
21 changes: 20 additions & 1 deletion docs/migration.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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).
Comment thread
felixweinberger marked this conversation as resolved.

Comment thread
claude[bot] marked this conversation as resolved.
### 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

Expand Down
2 changes: 1 addition & 1 deletion examples/oauth/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,7 +5,7 @@ The **authorization-code** OAuth grant — the interactive "user signs in and ap
- `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
`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.
- `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
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.
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.
- `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`
REPL. Run this when you want to see the consent page.
- `dualModeAuth.ts` — two auth patterns through the one `authProvider` option: host-managed bearer token vs a built-in `OAuthClientProvider`.
Expand Down
18 changes: 10 additions & 8 deletions examples/oauth/client.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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>();
Comment thread
felixweinberger marked this conversation as resolved.
Expand DownExpand Up@@ -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;
}
Expand DownExpand Up@@ -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');
Expand Down
16 changes: 9 additions & 7 deletions examples/oauth/simpleOAuthClient.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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') {
Expand All@@ -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}`);
Expand DownExpand Up@@ -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...');
Comment thread
claude[bot] marked this conversation as resolved.
await this.attemptConnection(oauthProvider);
} else {
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(client): SEP-2468 RFC 9207 iss + RFC 8414 §3.3 issuer-echo validation by felixweinberger · Pull Request #2344 · modelcontextprotocol/typescript-sdk · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/auth-iss-validation.md
Original file line numberDiff line numberDiff 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.
Comment thread
claude[bot] marked this conversation as resolved.
4 changes: 2 additions & 2 deletions docs/client.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -192,8 +192,8 @@ 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, call {@linkcode
@modelcontextprotocol/client!client/streamableHttp.StreamableHTTPClientTransport#finishAuth | transport.finishAuth(code)}, and reconnect.
@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 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).
Expand Down
4 changes: 4 additions & 0 deletions docs/migration-SKILL.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Comment thread
felixweinberger marked this conversation as resolved.

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.
Expand Down
21 changes: 20 additions & 1 deletion docs/migration.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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).
Comment thread
felixweinberger marked this conversation as resolved.

Comment thread
claude[bot] marked this conversation as resolved.
### 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

Expand Down
2 changes: 1 addition & 1 deletion examples/oauth/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,7 +5,7 @@ The **authorization-code** OAuth grant — the interactive "user signs in and ap
- `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
`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.
- `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
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.
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.
- `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`
REPL. Run this when you want to see the consent page.
- `dualModeAuth.ts` — two auth patterns through the one `authProvider` option: host-managed bearer token vs a built-in `OAuthClientProvider`.
Expand Down
18 changes: 10 additions & 8 deletions examples/oauth/client.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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>();
Comment thread
felixweinberger marked this conversation as resolved.
Expand DownExpand Up@@ -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;
}
Expand DownExpand Up@@ -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');
Expand Down
16 changes: 9 additions & 7 deletions examples/oauth/simpleOAuthClient.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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') {
Expand All@@ -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}`);
Expand DownExpand Up@@ -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...');
Comment thread
claude[bot] marked this conversation as resolved.
await this.attemptConnection(oauthProvider);
} else {
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(client): SEP-2468 RFC 9207 iss + RFC 8414 §3.3 issuer-echo validation by felixweinberger · Pull Request #2344 · modelcontextprotocol/typescript-sdk · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/auth-iss-validation.md
Original file line numberDiff line numberDiff 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.
Comment thread
claude[bot] marked this conversation as resolved.
4 changes: 2 additions & 2 deletions docs/client.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -192,8 +192,8 @@ 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, call {@linkcode
@modelcontextprotocol/client!client/streamableHttp.StreamableHTTPClientTransport#finishAuth | transport.finishAuth(code)}, and reconnect.
@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 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).
Expand Down
4 changes: 4 additions & 0 deletions docs/migration-SKILL.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Comment thread
felixweinberger marked this conversation as resolved.

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.
Expand Down
21 changes: 20 additions & 1 deletion docs/migration.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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).
Comment thread
felixweinberger marked this conversation as resolved.

Comment thread
claude[bot] marked this conversation as resolved.
### 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

Expand Down
2 changes: 1 addition & 1 deletion examples/oauth/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,7 +5,7 @@ The **authorization-code** OAuth grant — the interactive "user signs in and ap
- `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
`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.
- `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
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.
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.
- `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`
REPL. Run this when you want to see the consent page.
- `dualModeAuth.ts` — two auth patterns through the one `authProvider` option: host-managed bearer token vs a built-in `OAuthClientProvider`.
Expand Down
18 changes: 10 additions & 8 deletions examples/oauth/client.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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>();
Comment thread
felixweinberger marked this conversation as resolved.
Expand DownExpand Up@@ -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;
}
Expand DownExpand Up@@ -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');
Expand Down
16 changes: 9 additions & 7 deletions examples/oauth/simpleOAuthClient.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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') {
Expand All@@ -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}`);
Expand DownExpand Up@@ -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...');
Comment thread
claude[bot] marked this conversation as resolved.
await this.attemptConnection(oauthProvider);
} else {
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' feat(client): SEP-2468 RFC 9207 iss + RFC 8414 §3.3 issuer-echo validation by felixweinberger · Pull Request #2344 · modelcontextprotocol/typescript-sdk · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/auth-iss-validation.md
Original file line numberDiff line numberDiff 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.
Comment thread
claude[bot] marked this conversation as resolved.
4 changes: 2 additions & 2 deletions docs/client.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -192,8 +192,8 @@ 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, call {@linkcode
@modelcontextprotocol/client!client/streamableHttp.StreamableHTTPClientTransport#finishAuth | transport.finishAuth(code)}, and reconnect.
@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 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).
Expand Down
4 changes: 4 additions & 0 deletions docs/migration-SKILL.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Comment thread
felixweinberger marked this conversation as resolved.

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.
Expand Down
21 changes: 20 additions & 1 deletion docs/migration.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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).
Comment thread
felixweinberger marked this conversation as resolved.

Comment thread
claude[bot] marked this conversation as resolved.
### 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

Expand Down
2 changes: 1 addition & 1 deletion examples/oauth/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,7 +5,7 @@ The **authorization-code** OAuth grant — the interactive "user signs in and ap
- `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
`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.
- `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
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.
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.
- `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`
REPL. Run this when you want to see the consent page.
- `dualModeAuth.ts` — two auth patterns through the one `authProvider` option: host-managed bearer token vs a built-in `OAuthClientProvider`.
Expand Down
18 changes: 10 additions & 8 deletions examples/oauth/client.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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>();
Comment thread
felixweinberger marked this conversation as resolved.
Expand DownExpand Up@@ -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;
}
Expand DownExpand Up@@ -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');
Expand Down
16 changes: 9 additions & 7 deletions examples/oauth/simpleOAuthClient.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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') {
Expand All@@ -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}`);
Expand DownExpand Up@@ -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...');
Comment thread
claude[bot] marked this conversation as resolved.
await this.attemptConnection(oauthProvider);
} else {
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(client): SEP-2468 RFC 9207 iss + RFC 8414 §3.3 issuer-echo validation by felixweinberger · Pull Request #2344 · modelcontextprotocol/typescript-sdk · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/auth-iss-validation.md
Original file line numberDiff line numberDiff 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.
Comment thread
claude[bot] marked this conversation as resolved.
4 changes: 2 additions & 2 deletions docs/client.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -192,8 +192,8 @@ 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, call {@linkcode
@modelcontextprotocol/client!client/streamableHttp.StreamableHTTPClientTransport#finishAuth | transport.finishAuth(code)}, and reconnect.
@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 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).
Expand Down
4 changes: 4 additions & 0 deletions docs/migration-SKILL.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Comment thread
felixweinberger marked this conversation as resolved.

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.
Expand Down
21 changes: 20 additions & 1 deletion docs/migration.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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).
Comment thread
felixweinberger marked this conversation as resolved.

Comment thread
claude[bot] marked this conversation as resolved.
### 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

Expand Down
2 changes: 1 addition & 1 deletion examples/oauth/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,7 +5,7 @@ The **authorization-code** OAuth grant — the interactive "user signs in and ap
- `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
`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.
- `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
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.
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.
- `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`
REPL. Run this when you want to see the consent page.
- `dualModeAuth.ts` — two auth patterns through the one `authProvider` option: host-managed bearer token vs a built-in `OAuthClientProvider`.
Expand Down
18 changes: 10 additions & 8 deletions examples/oauth/client.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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>();
Comment thread
felixweinberger marked this conversation as resolved.
Expand DownExpand Up@@ -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;
}
Expand DownExpand Up@@ -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');
Expand Down
16 changes: 9 additions & 7 deletions examples/oauth/simpleOAuthClient.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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') {
Expand All@@ -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}`);
Expand DownExpand Up@@ -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...');
Comment thread
claude[bot] marked this conversation as resolved.
await this.attemptConnection(oauthProvider);
} else {
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(client): SEP-2468 RFC 9207 iss + RFC 8414 §3.3 issuer-echo validation by felixweinberger · Pull Request #2344 · modelcontextprotocol/typescript-sdk · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/auth-iss-validation.md
Original file line numberDiff line numberDiff 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.
Comment thread
claude[bot] marked this conversation as resolved.
4 changes: 2 additions & 2 deletions docs/client.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -192,8 +192,8 @@ 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, call {@linkcode
@modelcontextprotocol/client!client/streamableHttp.StreamableHTTPClientTransport#finishAuth | transport.finishAuth(code)}, and reconnect.
@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 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).
Expand Down
4 changes: 4 additions & 0 deletions docs/migration-SKILL.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Comment thread
felixweinberger marked this conversation as resolved.

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.
Expand Down
21 changes: 20 additions & 1 deletion docs/migration.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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).
Comment thread
felixweinberger marked this conversation as resolved.

Comment thread
claude[bot] marked this conversation as resolved.
### 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

Expand Down
2 changes: 1 addition & 1 deletion examples/oauth/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,7 +5,7 @@ The **authorization-code** OAuth grant — the interactive "user signs in and ap
- `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
`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.
- `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
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.
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.
- `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`
REPL. Run this when you want to see the consent page.
- `dualModeAuth.ts` — two auth patterns through the one `authProvider` option: host-managed bearer token vs a built-in `OAuthClientProvider`.
Expand Down
18 changes: 10 additions & 8 deletions examples/oauth/client.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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>();
Comment thread
felixweinberger marked this conversation as resolved.
Expand DownExpand Up@@ -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;
}
Expand DownExpand Up@@ -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');
Expand Down
16 changes: 9 additions & 7 deletions examples/oauth/simpleOAuthClient.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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') {
Expand All@@ -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}`);
Expand DownExpand Up@@ -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...');
Comment thread
claude[bot] marked this conversation as resolved.
await this.attemptConnection(oauthProvider);
} else {
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); feat(client): SEP-2468 RFC 9207 iss + RFC 8414 §3.3 issuer-echo validation by felixweinberger · Pull Request #2344 · modelcontextprotocol/typescript-sdk · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/auth-iss-validation.md
Original file line numberDiff line numberDiff 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.
Comment thread
claude[bot] marked this conversation as resolved.
4 changes: 2 additions & 2 deletions docs/client.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -192,8 +192,8 @@ 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, call {@linkcode
@modelcontextprotocol/client!client/streamableHttp.StreamableHTTPClientTransport#finishAuth | transport.finishAuth(code)}, and reconnect.
@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 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).
Expand Down
4 changes: 4 additions & 0 deletions docs/migration-SKILL.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Comment thread
felixweinberger marked this conversation as resolved.

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.
Expand Down
21 changes: 20 additions & 1 deletion docs/migration.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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).
Comment thread
felixweinberger marked this conversation as resolved.

Comment thread
claude[bot] marked this conversation as resolved.
### 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

Expand Down
2 changes: 1 addition & 1 deletion examples/oauth/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,7 +5,7 @@ The **authorization-code** OAuth grant — the interactive "user signs in and ap
- `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
`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.
- `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
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.
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.
- `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`
REPL. Run this when you want to see the consent page.
- `dualModeAuth.ts` — two auth patterns through the one `authProvider` option: host-managed bearer token vs a built-in `OAuthClientProvider`.
Expand Down
18 changes: 10 additions & 8 deletions examples/oauth/client.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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>();
Comment thread
felixweinberger marked this conversation as resolved.
Expand DownExpand Up@@ -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;
}
Expand DownExpand Up@@ -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');
Expand Down
16 changes: 9 additions & 7 deletions examples/oauth/simpleOAuthClient.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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') {
Expand All@@ -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}`);
Expand DownExpand Up@@ -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...');
Comment thread
claude[bot] marked this conversation as resolved.
await this.attemptConnection(oauthProvider);
} else {
Expand Down
Loading
Loading