Skip to content
Draft
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
10 changes: 10 additions & 0 deletions .changeset/sep-2468-iss-validation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@modelcontextprotocol/core': patch
'@modelcontextprotocol/client': minor
---

Add RFC 9207 `iss` parameter validation for authorization responses (SEP-2468). `OAuthMetadataSchema` and `OpenIdProviderMetadataSchema` now recognize `authorization_response_iss_parameter_supported`. The client exports a new `validateAuthorizationResponseIssuer()` helper,
`auth()` accepts an optional `iss`, and `StreamableHTTPClientTransport.finishAuth()` / `SSEClientTransport.finishAuth()` accept an optional `{ iss }` second argument. The `iss` option is tri-state: a string is validated by exact comparison against the issuer recorded in the
authorization server metadata before the authorization code is sent to any token endpoint (mismatch rejects the response without processing any other response parameters); `null` asserts the caller inspected the authorization response and it carried no `iss`, enabling the RFC
9207 fail-closed rejection when the AS advertises `authorization_response_iss_parameter_supported: true`; `undefined` (omitted) skips validation, so existing `finishAuth(code)` callers that never see the authorization response are unaffected. All additions are
backwards-compatible.
88 changes: 88 additions & 0 deletions packages/client/src/client/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -531,6 +531,71 @@ export async function parseErrorResponse(input: Response | string): Promise<OAut
}
}

/**
* Validates the `iss` parameter of an authorization response against the issuer
* recorded from the authorization server's validated metadata, per RFC 9207
* (OAuth 2.0 Authorization Server Issuer Identification) Section 2.4.
*
* The `iss` argument is tri-state, because the SDK does not see the authorization
* response itself — the caller does:
* - `string`: the `iss` parameter received in the authorization response. Validated.
* - `null`: the caller inspected the authorization response and it contained no `iss`.
* The RFC 9207 fail-closed rule applies: if the AS metadata advertises
* `authorization_response_iss_parameter_supported: true`, the response is rejected.
* - `undefined`: the caller did not supply authorization-response parameters at all
* (e.g. legacy `finishAuth(code)` callers that only plumb the code). Validation is
* skipped — the SDK cannot distinguish "the response had no iss" from "the caller
* did not look", so it does not fail closed on the caller's behalf.
*
* Decision table (for callers that did inspect the response, i.e. `iss` is a string or `null`):
* 1. The AS metadata advertises `authorization_response_iss_parameter_supported: true`
* but the response carries no `iss` (`null`) → reject.
* 2. The response carries an `iss` (whether or not support was advertised) → it must be
* an exact, character-by-character match of the recorded issuer (no normalization) —
* mismatch → reject.
* 3. Support is not advertised and no `iss` is present → proceed (validation not possible).
* 4. On rejection, callers MUST NOT process the rest of the authorization response
* (including any `error`/`error_description` parameters).
*
* @param metadata - The authorization server metadata recorded before the redirect, if available
* @param iss - The `iss` parameter from the authorization response (`null` = response had no
* `iss`; `undefined` = caller did not provide response parameters, skip validation)
* @throws Error if the response must be rejected per RFC 9207
*/
export function validateAuthorizationResponseIssuer(
metadata: { issuer: string; authorization_response_iss_parameter_supported?: boolean } | undefined,
iss: string | null | undefined
): void {
if (iss === undefined) {
// The caller did not provide authorization-response parameters; there is
// nothing to validate and no signal that an advertised iss was dropped.
return;
}

if (iss === null) {
if (metadata?.authorization_response_iss_parameter_supported === true) {
throw new Error(
'Authorization server metadata advertises authorization_response_iss_parameter_supported, but the authorization response did not include an iss parameter (RFC 9207)'
);
}
// Neither advertised nor present: validation is not possible; proceed.
return;
}

if (metadata === undefined) {
throw new Error(
'Authorization response included an iss parameter, but no authorization server metadata was recorded to validate it against (RFC 9207)'
);
}

// Exact string comparison — no normalization of any kind (RFC 9207 Section 2.4).
if (iss !== metadata.issuer) {
throw new Error(
`Authorization response iss parameter does not match the expected issuer: expected ${metadata.issuer}, got ${iss} (RFC 9207). The authorization response must not be processed.`
);
}
}

/**
* Orchestrates the full auth flow with a server.
*
Expand All @@ -542,6 +607,20 @@ export async function auth(
options: {
serverUrl: string | URL;
authorizationCode?: string;
/**
* The `iss` parameter received alongside the authorization code in the
* authorization response, validated per RFC 9207 against the issuer recorded
* in the authorization server metadata before the code is exchanged.
*
* Pass the string value when the authorization response contained an `iss`
* parameter. Pass `null` to assert that you inspected the authorization
* response and it contained no `iss` — this enables the RFC 9207 fail-closed
* rejection when the AS advertises
* `authorization_response_iss_parameter_supported: true`. Leave `undefined`
* when the response parameters were not available to you; validation is then
* skipped entirely.
*/
iss?: string | null;
scope?: string;
resourceMetadataUrl?: URL;
fetchFn?: FetchLike;
Expand Down Expand Up @@ -603,12 +682,14 @@ async function authInternal(
{
serverUrl,
authorizationCode,
iss,
scope,
resourceMetadataUrl,
fetchFn
}: {
serverUrl: string | URL;
authorizationCode?: string;
iss?: string | null;
scope?: string;
resourceMetadataUrl?: URL;
fetchFn?: FetchLike;
Expand Down Expand Up @@ -747,6 +828,13 @@ async function authInternal(

// Exchange authorization code for tokens, or fetch tokens directly for non-interactive flows
if (authorizationCode !== undefined || nonInteractiveFlow) {
if (authorizationCode !== undefined) {
// RFC 9207: validate the authorization response issuer against the recorded
// AS metadata BEFORE the code is sent to any token endpoint. Skipped when the
// caller did not provide authorization-response parameters (iss === undefined).
validateAuthorizationResponseIssuer(metadata, iss);
}

const tokens = await fetchToken(provider, authorizationServerUrl, {
metadata,
resource,
Expand Down
12 changes: 11 additions & 1 deletion packages/client/src/client/sse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,15 +228,25 @@ export class SSEClientTransport implements Transport {

/**
* 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.
*
* @param authorizationCode - The authorization code from the authorization response
* @param options.iss - The `iss` parameter from the authorization response. Pass the string
* value when present; pass `null` to assert the authorization response was inspected and
* contained no `iss` (this enables the RFC 9207 fail-closed rejection when the AS advertises
* `authorization_response_iss_parameter_supported: true`). Leave `undefined` when the response
* parameters were not available — validation is then skipped. When provided, the value is
* validated against the issuer recorded in the authorization server metadata per RFC 9207
* before the code is exchanged.
*/
async finishAuth(authorizationCode: string): Promise<void> {
async finishAuth(authorizationCode: string, options?: { iss?: string | null }): Promise<void> {
if (!this._oauthProvider) {
throw new UnauthorizedError('finishAuth requires an OAuthClientProvider');
}

const result = await auth(this._oauthProvider, {
serverUrl: this._url,
authorizationCode,
iss: options?.iss,
resourceMetadataUrl: this._resourceMetadataUrl,
scope: this._scope,
fetchFn: this._fetchWithInit
Expand Down
12 changes: 11 additions & 1 deletion packages/client/src/client/streamableHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -489,15 +489,25 @@ export class StreamableHTTPClientTransport implements Transport {

/**
* 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.
*
* @param authorizationCode - The authorization code from the authorization response
* @param options.iss - The `iss` parameter from the authorization response. Pass the string
* value when present; pass `null` to assert the authorization response was inspected and
* contained no `iss` (this enables the RFC 9207 fail-closed rejection when the AS advertises
* `authorization_response_iss_parameter_supported: true`). Leave `undefined` when the response
* parameters were not available — validation is then skipped. When provided, the value is
* validated against the issuer recorded in the authorization server metadata per RFC 9207
* before the code is exchanged.
*/
async finishAuth(authorizationCode: string): Promise<void> {
async finishAuth(authorizationCode: string, options?: { iss?: string | null }): Promise<void> {
if (!this._oauthProvider) {
throw new UnauthorizedError('finishAuth requires an OAuthClientProvider');
}

const result = await auth(this._oauthProvider, {
serverUrl: this._url,
authorizationCode,
iss: options?.iss,
resourceMetadataUrl: this._resourceMetadataUrl,
scope: this._scope,
fetchFn: this._fetchWithInit
Expand Down
1 change: 1 addition & 0 deletions packages/client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export {
selectResourceURL,
startAuthorization,
UnauthorizedError,
validateAuthorizationResponseIssuer,
validateClientMetadataUrl
} from './client/auth.js';
export type {
Expand Down
Loading
Loading