Skip to content

Commit 646cea6

Browse files
feat(client,server-legacy): SEP-2468 server iss emission + finishAuth(URLSearchParams) overload
Claude-Session: https://claude.ai/code/session_01XBib5gRe8AMPPJhySCz3EJ
1 parent 2bffe4c commit 646cea6

21 files changed

Lines changed: 539 additions & 71 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@modelcontextprotocol/client": minor
3+
"@modelcontextprotocol/server-legacy": minor
4+
---
5+
6+
SEP-2468 follow-up: `transport.finishAuth()` gains a `URLSearchParams` overload (preferred) that extracts `code`/`iss`, validates `iss` first, and on mismatch throws a sanitized `IssuerMismatchError` (no callback `error_description` text); callers remain responsible for `state`. **Behavior change for `@modelcontextprotocol/server-legacy`:** `mcpAuthRouter` now advertises `authorization_response_iss_parameter_supported` (default `true`; `ProxyOAuthServerProvider` reports `false`) and the bundled authorize handler appends `iss` (RFC 9207) to every `res.redirect(...)` your `OAuthServerProvider.authorize()` issues to the client's `redirect_uri`. If your provider redirects another way (`res.writeHead`, a separate consent-page response, or a standalone `authorizationHandler({provider})` without `issuerUrl`), append `params.issuer` as `iss` yourself or set `authorizationResponseIssParameterSupported: false` — otherwise RFC 9207-compliant clients (including this SDK) will reject the callback.

‎docs/migration.md‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1528,16 +1528,19 @@ The inline options object on `auth()` is now the named `AuthOptions` type, expor
15281528

15291529
`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`.
15301530

1531-
**You must** pass the callback URL's query parameters to the SDK so it can read `iss` alongside `code`:
1531+
**You must** pass the callback URL's query parameters to the SDK so it can read `iss` alongside `code`. The SDK does **not** validate `state`; compare it to your stored value before calling `finishAuth`:
15321532

15331533
```typescript
1534-
const url = new URL(callbackUrl);
1535-
await transport.finishAuth(url.searchParams); // SDK reads `code` + `iss`
1534+
const params = new URL(callbackUrl).searchParams;
1535+
if (params.get('state') !== expectedState) throw new Error('state mismatch');
1536+
await transport.finishAuth(params); // SDK reads `code` + `iss`
15361537
```
15371538

15381539
`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.
15391540

1540-
**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.
1541+
**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. The `URLSearchParams` overload handles this for you; if you parse the callback yourself, suppress them.
1542+
1543+
_(`@modelcontextprotocol/server-legacy` AS implementers — **behavior change**)_ `mcpAuthRouter()` now advertises `authorization_response_iss_parameter_supported` (default `true`) and the bundled authorize handler appends `iss` to **every** redirect — success or error — that your `OAuthServerProvider.authorize()` issues to the client's `redirect_uri` **via `res.redirect(...)` on the supplied `res`**. No provider change is required when that is how you redirect. If you emit the `Location` header another way (e.g. `res.writeHead(302, { Location })`), issue the final callback redirect from a different response (e.g. after a separate consent-page POST), or wire a standalone `authorizationHandler({provider})` without `issuerUrl`, append `params.issuer` as `iss` yourself — otherwise RFC 9207-compliant clients (including this SDK's) will reject the callback with `IssuerMismatchError`. If the callback is issued by an upstream AS you proxy to, set `authorizationResponseIssParameterSupported = false` on your provider (`ProxyOAuthServerProvider` does this) so the metadata does not over-claim.
15411544

15421545
`discoverAuthorizationServerMetadata()` now rejects metadata whose `issuer` does not exactly match the URL it was fetched for (RFC 8414 §3.3). If you connect to a known-misconfigured AS, set `skipIssuerMetadataValidation: true` on `AuthOptions` (or `skipIssuerValidation: true` on the low-level helper) — **this weakens the mix-up defense and should be treated as a temporary workaround.** It suppresses only the metadata-echo check; the callback-`iss` validation always runs (and degrades to a no-op only when `iss` is absent and the AS does not advertise support).
15431546

‎examples/shared/src/authServer.ts‎

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,33 @@ export function setupAuthServer(options: SetupAuthServerOptions): void {
132132
// toNodeHandler bypasses Express methods
133133
const betterAuthHandler = toNodeHandler(auth);
134134

135+
// The issuer identifier this AS publishes in its metadata; must exactly match the
136+
// `issuer` value better-auth emits at /.well-known/oauth-authorization-server.
137+
const issuer = authServerUrl.toString().replace(/\/$/, '');
138+
const issuerOrigin = new URL(issuer).origin;
139+
140+
// RFC 9207 (SEP-2468): append `iss` to every authorization-response redirect (success
141+
// and error) that targets the client's redirect_uri. better-auth does not emit `iss`
142+
// itself yet, so intercept the 302 Location header. Internal hops (to /sign-in or back
143+
// to /api/auth/mcp/authorize) are left untouched.
144+
authApp.use('/api/auth/mcp/authorize', (_req: Request, res: ExpressResponse, next: NextFunction) => {
145+
const originalWriteHead = res.writeHead.bind(res);
146+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
147+
res.writeHead = function (statusCode: number, ...args: any[]) {
148+
const headers = args.find(a => typeof a === 'object' && a !== null) as Record<string, string> | undefined;
149+
const loc = headers?.location ?? headers?.Location ?? (res.getHeader('Location') as string | undefined);
150+
if (statusCode >= 300 && statusCode < 400 && loc && !loc.startsWith('/') && new URL(loc).origin !== issuerOrigin) {
151+
const u = new URL(loc);
152+
u.searchParams.set('iss', issuer);
153+
if (headers && 'location' in headers) headers.location = u.href;
154+
else if (headers && 'Location' in headers) headers.Location = u.href;
155+
else res.setHeader('Location', u.href);
156+
}
157+
return originalWriteHead(statusCode, ...args);
158+
} as typeof res.writeHead;
159+
next();
160+
});
161+
135162
// DEMO ONLY: simulate the user clicking "Approve" on the consent screen.
136163
// The SDK auth driver appends `prompt=consent` whenever it requests the
137164
// `offline_access` scope (per OIDC §11). With a real user, better-auth
@@ -207,7 +234,15 @@ export function setupAuthServer(options: SetupAuthServerOptions): void {
207234
// OAuth metadata endpoints using better-auth's built-in handlers
208235
// Add explicit OPTIONS handler for CORS preflight
209236
authApp.options('/.well-known/oauth-authorization-server', cors());
210-
authApp.get('/.well-known/oauth-authorization-server', cors(), toNodeHandler(oAuthDiscoveryMetadata(auth)));
237+
// Wrap better-auth's metadata to advertise RFC 9207 support (the `iss` middleware
238+
// above makes that claim true).
239+
const discoveryHandler = oAuthDiscoveryMetadata(auth);
240+
authApp.get('/.well-known/oauth-authorization-server', cors(), async (req: Request, res: ExpressResponse) => {
241+
const upstream = await discoveryHandler(new Request(new URL(req.originalUrl, issuer)));
242+
const body = (await upstream.json()) as Record<string, unknown>;
243+
body.authorization_response_iss_parameter_supported = true;
244+
res.status(upstream.status).json(body);
245+
});
211246

212247
// Body parsers for non-better-auth routes (like /sign-in)
213248
authApp.use(express.json());

‎packages/client/src/client/auth.ts‎

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -530,6 +530,69 @@ export function isStrictScopeSuperset(union: string | undefined, current: string
530530
return false;
531531
}
532532

533+
/**
534+
* Shared `finishAuth` resolver for the `(code, iss?)` and `(URLSearchParams)` overloads.
535+
*
536+
* For the `URLSearchParams` form, only `iss` and `code` are read up front. When a `code` is
537+
* present the returned values flow into {@linkcode auth}, which runs
538+
* {@linkcode validateAuthorizationResponseIssuer} against freshly-discovered metadata before
539+
* the code is redeemed — so on mismatch the thrown {@linkcode IssuerMismatchError} carries no
540+
* `error`/`error_description`/`error_uri` text from the callback (those are attacker-controlled
541+
* in a mix-up). When no `code` is present (an error-shaped callback), `iss` is validated here
542+
* against the provider's recorded discovery state — or, when the provider does not implement
543+
* `discoveryState`, against freshly-discovered metadata mirroring what {@linkcode auth} does on
544+
* the code-present path — **before** the callback's error parameters are read; only after that
545+
* passes are they surfaced as an {@linkcode OAuthError}. When no issuer baseline can be
546+
* obtained either way, a generic {@linkcode UnauthorizedError} is thrown without surfacing the
547+
* callback's `error`/`error_description`/`error_uri`.
548+
*
549+
* @internal Exported for the transport `finishAuth` overloads; not part of the public barrel.
550+
*/
551+
export async function resolveAuthorizationCallbackParams(
552+
codeOrParams: string | URLSearchParams,
553+
iss: string | undefined,
554+
provider: OAuthClientProvider,
555+
serverUrl: string | URL,
556+
opts?: { fetchFn?: FetchLike; resourceMetadataUrl?: URL }
557+
): Promise<{ authorizationCode: string; iss: string | undefined }> {
558+
if (typeof codeOrParams === 'string') {
559+
return { authorizationCode: codeOrParams, iss };
560+
}
561+
const issParam = codeOrParams.get('iss') ?? undefined;
562+
const code = codeOrParams.get('code');
563+
if (code) {
564+
return { authorizationCode: code, iss: issParam };
565+
}
566+
// No code → error response. Gate the (potentially attacker-supplied) error params on the
567+
// issuer first. Prefer the provider's recorded discovery state; when absent, mirror auth()'s
568+
// code-present path and run a fresh discovery so the iss gate has an authentic baseline.
569+
const discoveryState = await provider.discoveryState?.();
570+
let metadata = discoveryState?.authorizationServerMetadata;
571+
if (!metadata) {
572+
try {
573+
const serverInfo = await discoverOAuthServerInfo(serverUrl, opts);
574+
metadata = serverInfo.authorizationServerMetadata;
575+
} catch {
576+
metadata = undefined;
577+
}
578+
}
579+
if (!metadata) {
580+
// No authentic baseline → cannot prove the error params came from our AS. Do NOT surface
581+
// attacker-controllable `error`/`error_description`/`error_uri` here.
582+
throw new UnauthorizedError('Authorization callback failed and the issuer could not be verified');
583+
}
584+
validateAuthorizationResponseIssuer({
585+
iss: issParam,
586+
expectedIssuer: metadata.issuer,
587+
issParameterSupported: isIssParameterSupported(metadata)
588+
});
589+
const error = codeOrParams.get('error');
590+
if (error) {
591+
throw new OAuthError(error, codeOrParams.get('error_description') ?? error, codeOrParams.get('error_uri') ?? undefined);
592+
}
593+
throw new UnauthorizedError('Authorization callback contained neither `code` nor `error`');
594+
}
595+
533596
export type ClientAuthMethod = 'client_secret_basic' | 'client_secret_post' | 'none';
534597

535598
function isClientAuthMethod(method: string): method is ClientAuthMethod {

‎packages/client/src/client/sse.ts‎

Lines changed: 25 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,16 @@ import type { ErrorEvent, EventSourceInit } from 'eventsource';
1111
import { EventSource } from 'eventsource';
1212

1313
import type { AuthProvider, OAuthClientProvider } from './auth.js';
14-
import { adaptOAuthProvider, auth, extractWWWAuthenticateParams, isOAuthClientProvider, UnauthorizedError } from './auth.js';
14+
import {
15+
adaptOAuthProvider,
16+
auth,
17+
extractWWWAuthenticateParams,
18+
isOAuthClientProvider,
19+
resolveAuthorizationCallbackParams,
20+
UnauthorizedError
21+
} from './auth.js';
22+
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- referenced in JSDoc {@linkcode}
23+
import type { IssuerMismatchError } from './authErrors.js';
1524

1625
export class SseError extends Error {
1726
constructor(
@@ -229,10 +238,15 @@ export class SSEClientTransport implements Transport {
229238
/**
230239
* Call this method after the user has finished authorizing via their user agent and is redirected back to the MCP client application. This will exchange the authorization code for an access token, enabling the next connection attempt to successfully auth.
231240
*
232-
* Prefer passing the callback URL's `searchParams` directly — the SDK extracts
233-
* `code` and `iss` (and validates `iss` per RFC 9207) for you. The `(code, iss?)`
241+
* **Preferred:** pass the callback URL's `searchParams` directly. The SDK extracts `code`
242+
* and `iss`, validates `iss` against the recorded issuer (RFC 9207) **before** reading any
243+
* other parameter, and on mismatch throws an {@linkcode IssuerMismatchError} that carries
244+
* none of the callback's `error`/`error_description`/`error_uri` text. The `(code, iss?)`
234245
* positional form remains supported for back-compat.
235246
*
247+
* The SDK does **not** validate `state`; compare it to your stored value before calling
248+
* `finishAuth`.
249+
*
236250
* @param callbackParams - The `URLSearchParams` from the authorization callback URL
237251
* (e.g. `new URL(callbackUrl).searchParams`). `code` and `iss` are read from it.
238252
*/
@@ -248,22 +262,18 @@ export class SSEClientTransport implements Transport {
248262
throw new UnauthorizedError('finishAuth requires an OAuthClientProvider');
249263
}
250264

251-
let authorizationCode: string;
252-
if (codeOrParams instanceof URLSearchParams) {
253-
const code = codeOrParams.get('code');
254-
if (!code) {
255-
throw new UnauthorizedError('Authorization callback is missing the "code" parameter');
256-
}
257-
authorizationCode = code;
258-
iss = codeOrParams.get('iss') ?? undefined;
259-
} else {
260-
authorizationCode = codeOrParams;
261-
}
265+
const { authorizationCode, iss: issParam } = await resolveAuthorizationCallbackParams(
266+
codeOrParams,
267+
iss,
268+
this._oauthProvider,
269+
this._url,
270+
{ fetchFn: this._fetchWithInit, resourceMetadataUrl: this._resourceMetadataUrl }
271+
);
262272

263273
const result = await auth(this._oauthProvider, {
264274
serverUrl: this._url,
265275
authorizationCode,
266-
iss,
276+
iss: issParam,
267277
resourceMetadataUrl: this._resourceMetadataUrl,
268278
scope: this._scope,
269279
fetchFn: this._fetchWithInit

‎packages/client/src/client/streamableHttp.ts‎

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
extractWWWAuthenticateParams,
2727
isOAuthClientProvider,
2828
isStrictScopeSuperset,
29+
resolveAuthorizationCallbackParams,
2930
UnauthorizedError
3031
} from './auth.js';
3132
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- referenced via {@linkcode} in finishAuth JSDoc
@@ -816,10 +817,16 @@ export class StreamableHTTPClientTransport implements Transport {
816817
/**
817818
* Call this method after the user has finished authorizing via their user agent and is redirected back to the MCP client application. This will exchange the authorization code for an access token, enabling the next connection attempt to successfully auth.
818819
*
819-
* Prefer passing the callback URL's `searchParams` directly — the SDK extracts
820-
* `code` and `iss` (and validates `iss` per RFC 9207) for you. The `(code, iss?)`
820+
* **Preferred:** pass the callback URL's `searchParams` directly. The SDK extracts `code`
821+
* and `iss`, validates `iss` against the recorded issuer (RFC 9207) **before** reading any
822+
* other parameter, and on mismatch throws an {@linkcode IssuerMismatchError} that carries
823+
* none of the callback's `error`/`error_description`/`error_uri` text — those are
824+
* attacker-controlled in a mix-up attack and MUST NOT be displayed. The `(code, iss?)`
821825
* positional form remains supported for back-compat.
822826
*
827+
* The SDK does **not** validate `state`; compare it to your stored value before calling
828+
* `finishAuth`.
829+
*
823830
* @param callbackParams - The `URLSearchParams` from the authorization callback URL
824831
* (e.g. `new URL(callbackUrl).searchParams`). `code` and `iss` are read from it.
825832
*/
@@ -837,22 +844,18 @@ export class StreamableHTTPClientTransport implements Transport {
837844
throw new UnauthorizedError('finishAuth requires an OAuthClientProvider');
838845
}
839846

840-
let authorizationCode: string;
841-
if (codeOrParams instanceof URLSearchParams) {
842-
const code = codeOrParams.get('code');
843-
if (!code) {
844-
throw new UnauthorizedError('Authorization callback is missing the "code" parameter');
845-
}
846-
authorizationCode = code;
847-
iss = codeOrParams.get('iss') ?? undefined;
848-
} else {
849-
authorizationCode = codeOrParams;
850-
}
847+
const { authorizationCode, iss: issParam } = await resolveAuthorizationCallbackParams(
848+
codeOrParams,
849+
iss,
850+
this._oauthProvider,
851+
this._url,
852+
{ fetchFn: this._fetchWithInit, resourceMetadataUrl: this._resourceMetadataUrl }
853+
);
851854

852855
const result = await auth(this._oauthProvider, {
853856
serverUrl: this._url,
854857
authorizationCode,
855-
iss,
858+
iss: issParam,
856859
resourceMetadataUrl: this._resourceMetadataUrl,
857860
scope: this._scope,
858861
fetchFn: this._fetchWithInit

0 commit comments

Comments
 (0)