Skip to content
Closed
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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
14 changes: 14 additions & 0 deletions .changeset/sep-2468-iss-validation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@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
Comment thread
mattzcarey marked this conversation as resolved.
Outdated
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 RFC 9207 response validation, so existing `finishAuth(code)` callers that never see the authorization response are unaffected.

Discovery also now validates authorization-server metadata issuer values per RFC 8414 Section 3.3. Metadata discovered for a PRM-provided authorization server URL is rejected when its `issuer` does not match that URL, and the public `discoverAuthorizationServerMetadata()` helper
throws on mismatches or invalid issuer identifiers unless called with `{ validateIssuer: false }` for intentional alias discovery. Cached discovery state is also validated; stale legacy no-PRM fallback state that saved the MCP server origin before learning a distinct metadata
issuer is ignored and refreshed. For legacy servers without protected resource metadata, metadata is still discovered at the MCP server origin; when that metadata names a distinct issuer, the SDK now treats the metadata `issuer` as the authorization server URL for persisted
discovery state and fallback endpoint construction.
1 change: 1 addition & 0 deletions examples/oauth/simpleOAuthClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ class InteractiveOAuthClient {
console.log(`📥 Received callback: ${req.url}`);
const parsedUrl = new URL(req.url || '', 'http://localhost');
const code = parsedUrl.searchParams.get('code');
const iss = parsedUrl.searchParams.get('iss');
Comment thread
mattzcarey marked this conversation as resolved.
Outdated
const error = parsedUrl.searchParams.get('error');

if (code) {
Expand Down
285 changes: 243 additions & 42 deletions packages/client/src/client/auth.ts

Large diffs are not rendered by default.

24 changes: 22 additions & 2 deletions packages/client/src/client/authErrors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,29 @@ export class IssuerMismatchError extends OAuthClientFlowError {
readonly received: string | undefined;

constructor(kind: 'metadata' | 'authorization_response', expected: string | undefined, received: string | undefined) {
const where = kind === 'metadata' ? 'authorization server metadata (RFC 8414 §3.3)' : 'authorization response (RFC 9207)';
// JSON-stringify embedded values so attacker-supplied control characters cannot forge log lines.
super(`Issuer mismatch in ${where}: expected ${JSON.stringify(expected)}, received ${JSON.stringify(received)}`);
let message: string;
if (kind === 'authorization_response') {
if (received === undefined) {
message =
'Authorization server metadata advertises authorization_response_iss_parameter_supported, ' +
'but the authorization response did not include an iss parameter (RFC 9207)';
} else if (expected === undefined) {
message =
'Authorization response included an iss parameter, but no authorization server metadata was recorded ' +
'to validate it against (RFC 9207)';
} else {
message =
'Authorization response iss parameter does not match the expected issuer: ' +
`expected ${JSON.stringify(expected)}, got ${JSON.stringify(received)} (RFC 9207). ` +
'The authorization response must not be processed.';
}
} else {
message = `Issuer mismatch in authorization server metadata (RFC 8414 §3.3): expected ${JSON.stringify(
expected
)}, received ${JSON.stringify(received)}`;
}
super(message);
this.kind = kind;
this.expected = expected;
this.received = received;
Expand Down
4 changes: 2 additions & 2 deletions packages/client/src/client/crossAppAccess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,8 +205,8 @@ export async function requestJwtAuthorizationGrant(options: RequestJwtAuthGrantO
export async function discoverAndRequestJwtAuthGrant(options: DiscoverAndRequestJwtAuthGrantOptions): Promise<JwtAuthGrantResult> {
const { idpUrl, fetchFn = fetch, ...restOptions } = options;

// Discover IdP's authorization server metadata
const metadata = await discoverAuthorizationServerMetadata(String(idpUrl), { fetchFn });
// Enterprise IdP URLs are caller-configured and may be aliases for a canonical issuer.
const metadata = await discoverAuthorizationServerMetadata(String(idpUrl), { fetchFn, validateIssuer: false });
Comment thread
mattzcarey marked this conversation as resolved.

if (!metadata?.token_endpoint) {
throw new Error(`Failed to discover token endpoint for IdP: ${idpUrl}`);
Expand Down
4 changes: 2 additions & 2 deletions packages/client/src/client/sse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,8 +269,8 @@ export class SSEClientTransport implements Transport {
* @param iss - The form-urldecoded `iss` query parameter from the same callback URL, if
* present. Validated per RFC 9207 against the recorded issuer before the code is redeemed.
*/
async finishAuth(authorizationCode: string, iss?: string): Promise<void>;
async finishAuth(codeOrParams: string | URLSearchParams, iss?: string): Promise<void> {
async finishAuth(authorizationCode: string, iss?: string | null | { iss?: string | null }): Promise<void>;
async finishAuth(codeOrParams: string | URLSearchParams, iss?: string | null | { iss?: string | null }): Promise<void> {
if (!this._oauthProvider) {
throw new UnauthorizedError('finishAuth requires an OAuthClientProvider');
}
Expand Down
4 changes: 2 additions & 2 deletions packages/client/src/client/streamableHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -851,8 +851,8 @@ export class StreamableHTTPClientTransport implements Transport {
* When the authorization server advertises `authorization_response_iss_parameter_supported: true`,
* omitting this causes the exchange to be **rejected** with {@linkcode IssuerMismatchError}.
*/
async finishAuth(authorizationCode: string, iss?: string): Promise<void>;
async finishAuth(codeOrParams: string | URLSearchParams, iss?: string): Promise<void> {
async finishAuth(authorizationCode: string, iss?: string | null | { iss?: string | null }): Promise<void>;
Comment thread
mattzcarey marked this conversation as resolved.
Outdated
async finishAuth(codeOrParams: string | URLSearchParams, iss?: string | null | { iss?: string | null }): Promise<void> {
if (!this._oauthProvider) {
throw new UnauthorizedError('finishAuth requires an OAuthClientProvider');
}
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 @@ -12,6 +12,7 @@ export type {
AuthProvider,
AuthResult,
ClientAuthMethod,
DiscoverAuthorizationServerMetadataOptions,
OAuthClientInformationContext,
OAuthClientProvider,
OAuthDiscoveryState,
Comment thread
mattzcarey marked this conversation as resolved.
Expand Down
Loading
Loading