Skip to content

Commit 36a0046

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

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
@@ -1531,16 +1531,19 @@ New TypeScript-only aliases `StoredOAuthTokens` and `StoredOAuthClientInformatio
15311531

15321532
`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`.
15331533

1534-
**You must** pass the callback URL's query parameters to the SDK so it can read `iss` alongside `code`:
1534+
**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`:
15351535

15361536
```typescript
1537-
const url = new URL(callbackUrl);
1538-
await transport.finishAuth(url.searchParams); // SDK reads `code` + `iss`
1537+
const params = new URL(callbackUrl).searchParams;
1538+
if (params.get('state') !== expectedState) throw new Error('state mismatch');
1539+
await transport.finishAuth(params); // SDK reads `code` + `iss`
15391540
```
15401541

15411542
`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.
15421543

1543-
**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.
1544+
**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.
1545+
1546+
_(`@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.
15441547

15451548
`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).
15461549

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
@@ -540,6 +540,69 @@ export function isStrictScopeSuperset(union: string | undefined, current: string
540540
return false;
541541
}
542542

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

545608
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(
@@ -242,10 +251,15 @@ export class SSEClientTransport implements Transport {
242251
/**
243252
* 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.
244253
*
245-
* Prefer passing the callback URL's `searchParams` directly — the SDK extracts
246-
* `code` and `iss` (and validates `iss` per RFC 9207) for you. The `(code, iss?)`
254+
* **Preferred:** pass the callback URL's `searchParams` directly. The SDK extracts `code`
255+
* and `iss`, validates `iss` against the recorded issuer (RFC 9207) **before** reading any
256+
* other parameter, and on mismatch throws an {@linkcode IssuerMismatchError} that carries
257+
* none of the callback's `error`/`error_description`/`error_uri` text. The `(code, iss?)`
247258
* positional form remains supported for back-compat.
248259
*
260+
* The SDK does **not** validate `state`; compare it to your stored value before calling
261+
* `finishAuth`.
262+
*
249263
* @param callbackParams - The `URLSearchParams` from the authorization callback URL
250264
* (e.g. `new URL(callbackUrl).searchParams`). `code` and `iss` are read from it.
251265
*/
@@ -261,22 +275,18 @@ export class SSEClientTransport implements Transport {
261275
throw new UnauthorizedError('finishAuth requires an OAuthClientProvider');
262276
}
263277

264-
let authorizationCode: string;
265-
if (codeOrParams instanceof URLSearchParams) {
266-
const code = codeOrParams.get('code');
267-
if (!code) {
268-
throw new UnauthorizedError('Authorization callback is missing the "code" parameter');
269-
}
270-
authorizationCode = code;
271-
iss = codeOrParams.get('iss') ?? undefined;
272-
} else {
273-
authorizationCode = codeOrParams;
274-
}
278+
const { authorizationCode, iss: issParam } = await resolveAuthorizationCallbackParams(
279+
codeOrParams,
280+
iss,
281+
this._oauthProvider,
282+
this._url,
283+
{ fetchFn: this._fetchWithInit, resourceMetadataUrl: this._resourceMetadataUrl }
284+
);
275285

276286
const result = await auth(this._oauthProvider, {
277287
serverUrl: this._url,
278288
authorizationCode,
279-
iss,
289+
iss: issParam,
280290
resourceMetadataUrl: this._resourceMetadataUrl,
281291
scope: this._scope,
282292
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
@@ -829,10 +830,16 @@ export class StreamableHTTPClientTransport implements Transport {
829830
/**
830831
* 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.
831832
*
832-
* Prefer passing the callback URL's `searchParams` directly — the SDK extracts
833-
* `code` and `iss` (and validates `iss` per RFC 9207) for you. The `(code, iss?)`
833+
* **Preferred:** pass the callback URL's `searchParams` directly. The SDK extracts `code`
834+
* and `iss`, validates `iss` against the recorded issuer (RFC 9207) **before** reading any
835+
* other parameter, and on mismatch throws an {@linkcode IssuerMismatchError} that carries
836+
* none of the callback's `error`/`error_description`/`error_uri` text — those are
837+
* attacker-controlled in a mix-up attack and MUST NOT be displayed. The `(code, iss?)`
834838
* positional form remains supported for back-compat.
835839
*
840+
* The SDK does **not** validate `state`; compare it to your stored value before calling
841+
* `finishAuth`.
842+
*
836843
* @param callbackParams - The `URLSearchParams` from the authorization callback URL
837844
* (e.g. `new URL(callbackUrl).searchParams`). `code` and `iss` are read from it.
838845
*/
@@ -850,22 +857,18 @@ export class StreamableHTTPClientTransport implements Transport {
850857
throw new UnauthorizedError('finishAuth requires an OAuthClientProvider');
851858
}
852859

853-
let authorizationCode: string;
854-
if (codeOrParams instanceof URLSearchParams) {
855-
const code = codeOrParams.get('code');
856-
if (!code) {
857-
throw new UnauthorizedError('Authorization callback is missing the "code" parameter');
858-
}
859-
authorizationCode = code;
860-
iss = codeOrParams.get('iss') ?? undefined;
861-
} else {
862-
authorizationCode = codeOrParams;
863-
}
860+
const { authorizationCode, iss: issParam } = await resolveAuthorizationCallbackParams(
861+
codeOrParams,
862+
iss,
863+
this._oauthProvider,
864+
this._url,
865+
{ fetchFn: this._fetchWithInit, resourceMetadataUrl: this._resourceMetadataUrl }
866+
);
864867

865868
const result = await auth(this._oauthProvider, {
866869
serverUrl: this._url,
867870
authorizationCode,
868-
iss,
871+
iss: issParam,
869872
resourceMetadataUrl: this._resourceMetadataUrl,
870873
scope: this._scope,
871874
fetchFn: this._fetchWithInit,

0 commit comments

Comments
 (0)