Skip to content

Commit c1c19ce

Browse files
committed
feat(client,core): RFC 9207 iss parameter validation on authorization responses (SEP-2468)
- Add authorization_response_iss_parameter_supported to OAuthMetadataSchema and OpenIdProviderMetadataSchema (RFC 8414 / RFC 9207) - New exported validateAuthorizationResponseIssuer() implementing the RFC 9207 Section 2.4 decision table with exact string comparison - auth() accepts optional iss, validated against the recorded AS metadata before the authorization code is sent to any token endpoint - finishAuth(code, { iss }) optional second argument on both StreamableHTTPClientTransport and SSEClientTransport - Tests covering all four decision-table rows and the error-response-mismatch case Closes #2197
1 parent c59dc3a commit c1c19ce

8 files changed

Lines changed: 402 additions & 4 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@modelcontextprotocol/core': patch
3+
'@modelcontextprotocol/client': minor
4+
---
5+
6+
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. When provided, the `iss` from the authorization response is validated against the issuer recorded in the authorization server metadata before the authorization code is sent to any token endpoint; on mismatch the response is rejected without processing any other response parameters. All additions are backwards-compatible.

packages/client/src/client/auth.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -531,6 +531,53 @@ export async function parseErrorResponse(input: Response | string): Promise<OAut
531531
}
532532
}
533533

534+
/**
535+
* Validates the `iss` parameter of an authorization response against the issuer
536+
* recorded from the authorization server's validated metadata, per RFC 9207
537+
* (OAuth 2.0 Authorization Server Issuer Identification) Section 2.4.
538+
*
539+
* Decision table:
540+
* 1. The AS metadata advertises `authorization_response_iss_parameter_supported: true`
541+
* but the response carries no `iss` → reject.
542+
* 2. The response carries an `iss` (whether or not support was advertised) → it must be
543+
* an exact, character-by-character match of the recorded issuer (no normalization) —
544+
* mismatch → reject.
545+
* 3. Support is not advertised and no `iss` is present → proceed (validation not possible).
546+
* 4. On rejection, callers MUST NOT process the rest of the authorization response
547+
* (including any `error`/`error_description` parameters).
548+
*
549+
* @param metadata - The authorization server metadata recorded before the redirect, if available
550+
* @param iss - The `iss` parameter received in the authorization response, if any
551+
* @throws Error if the response must be rejected per RFC 9207
552+
*/
553+
export function validateAuthorizationResponseIssuer(
554+
metadata: { issuer: string; authorization_response_iss_parameter_supported?: boolean } | undefined,
555+
iss: string | undefined
556+
): void {
557+
if (iss === undefined) {
558+
if (metadata?.authorization_response_iss_parameter_supported === true) {
559+
throw new Error(
560+
'Authorization server metadata advertises authorization_response_iss_parameter_supported, but the authorization response did not include an iss parameter (RFC 9207)'
561+
);
562+
}
563+
// Neither advertised nor present: validation is not possible; proceed.
564+
return;
565+
}
566+
567+
if (metadata === undefined) {
568+
throw new Error(
569+
'Authorization response included an iss parameter, but no authorization server metadata was recorded to validate it against (RFC 9207)'
570+
);
571+
}
572+
573+
// Exact string comparison — no normalization of any kind (RFC 9207 Section 2.4).
574+
if (iss !== metadata.issuer) {
575+
throw new Error(
576+
`Authorization response iss parameter does not match the expected issuer: expected ${metadata.issuer}, got ${iss} (RFC 9207). The authorization response must not be processed.`
577+
);
578+
}
579+
}
580+
534581
/**
535582
* Orchestrates the full auth flow with a server.
536583
*
@@ -542,6 +589,12 @@ export async function auth(
542589
options: {
543590
serverUrl: string | URL;
544591
authorizationCode?: string;
592+
/**
593+
* The `iss` parameter received alongside the authorization code in the
594+
* authorization response, validated per RFC 9207 against the issuer recorded
595+
* in the authorization server metadata before the code is exchanged.
596+
*/
597+
iss?: string;
545598
scope?: string;
546599
resourceMetadataUrl?: URL;
547600
fetchFn?: FetchLike;
@@ -603,12 +656,14 @@ async function authInternal(
603656
{
604657
serverUrl,
605658
authorizationCode,
659+
iss,
606660
scope,
607661
resourceMetadataUrl,
608662
fetchFn
609663
}: {
610664
serverUrl: string | URL;
611665
authorizationCode?: string;
666+
iss?: string;
612667
scope?: string;
613668
resourceMetadataUrl?: URL;
614669
fetchFn?: FetchLike;
@@ -747,6 +802,12 @@ async function authInternal(
747802

748803
// Exchange authorization code for tokens, or fetch tokens directly for non-interactive flows
749804
if (authorizationCode !== undefined || nonInteractiveFlow) {
805+
if (authorizationCode !== undefined) {
806+
// RFC 9207: validate the authorization response issuer against the recorded
807+
// AS metadata BEFORE the code is sent to any token endpoint.
808+
validateAuthorizationResponseIssuer(metadata, iss);
809+
}
810+
750811
const tokens = await fetchToken(provider, authorizationServerUrl, {
751812
metadata,
752813
resource,

packages/client/src/client/sse.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -228,15 +228,21 @@ export class SSEClientTransport implements Transport {
228228

229229
/**
230230
* 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.
231+
*
232+
* @param authorizationCode - The authorization code from the authorization response
233+
* @param options.iss - The `iss` parameter from the authorization response, if present.
234+
* Validated against the issuer recorded in the authorization server metadata per RFC 9207
235+
* before the code is exchanged.
231236
*/
232-
async finishAuth(authorizationCode: string): Promise<void> {
237+
async finishAuth(authorizationCode: string, options?: { iss?: string }): Promise<void> {
233238
if (!this._oauthProvider) {
234239
throw new UnauthorizedError('finishAuth requires an OAuthClientProvider');
235240
}
236241

237242
const result = await auth(this._oauthProvider, {
238243
serverUrl: this._url,
239244
authorizationCode,
245+
iss: options?.iss,
240246
resourceMetadataUrl: this._resourceMetadataUrl,
241247
scope: this._scope,
242248
fetchFn: this._fetchWithInit

packages/client/src/client/streamableHttp.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -489,15 +489,21 @@ export class StreamableHTTPClientTransport implements Transport {
489489

490490
/**
491491
* 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.
492+
*
493+
* @param authorizationCode - The authorization code from the authorization response
494+
* @param options.iss - The `iss` parameter from the authorization response, if present.
495+
* Validated against the issuer recorded in the authorization server metadata per RFC 9207
496+
* before the code is exchanged.
492497
*/
493-
async finishAuth(authorizationCode: string): Promise<void> {
498+
async finishAuth(authorizationCode: string, options?: { iss?: string }): Promise<void> {
494499
if (!this._oauthProvider) {
495500
throw new UnauthorizedError('finishAuth requires an OAuthClientProvider');
496501
}
497502

498503
const result = await auth(this._oauthProvider, {
499504
serverUrl: this._url,
500505
authorizationCode,
506+
iss: options?.iss,
501507
resourceMetadataUrl: this._resourceMetadataUrl,
502508
scope: this._scope,
503509
fetchFn: this._fetchWithInit

packages/client/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export {
3535
selectResourceURL,
3636
startAuthorization,
3737
UnauthorizedError,
38+
validateAuthorizationResponseIssuer,
3839
validateClientMetadataUrl
3940
} from './client/auth.js';
4041
export type {

packages/client/test/client/auth.test.ts

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
registerClient,
2020
selectClientAuthMethod,
2121
startAuthorization,
22+
validateAuthorizationResponseIssuer,
2223
validateClientMetadataUrl
2324
} from '../../src/client/auth.js';
2425
import { createPrivateKeyJwtAuth } from '../../src/client/authExtensions.js';
@@ -4131,3 +4132,223 @@ describe('OAuth Authorization', () => {
41314132
});
41324133
});
41334134
});
4135+
4136+
describe('SEP-2468: RFC 9207 authorization response iss validation', () => {
4137+
const issuer = 'https://auth.example.com';
4138+
4139+
describe('validateAuthorizationResponseIssuer', () => {
4140+
// RFC 9207 Section 2.4, row 1: advertised but absent -> reject
4141+
it('rejects when the AS advertises iss support but the response lacks iss', () => {
4142+
expect(() =>
4143+
validateAuthorizationResponseIssuer({ issuer, authorization_response_iss_parameter_supported: true }, undefined)
4144+
).toThrow(/did not include an iss parameter/);
4145+
});
4146+
4147+
// RFC 9207 Section 2.4, row 2: present (advertised) -> exact match required
4148+
it('accepts an exactly matching iss when support is advertised', () => {
4149+
expect(() =>
4150+
validateAuthorizationResponseIssuer({ issuer, authorization_response_iss_parameter_supported: true }, issuer)
4151+
).not.toThrow();
4152+
});
4153+
4154+
// RFC 9207 Section 2.4, row 2: present even without advertisement -> still compared
4155+
it('accepts an exactly matching iss even when support is not advertised', () => {
4156+
expect(() => validateAuthorizationResponseIssuer({ issuer }, issuer)).not.toThrow();
4157+
});
4158+
4159+
it('rejects a mismatched iss regardless of advertisement', () => {
4160+
expect(() => validateAuthorizationResponseIssuer({ issuer }, 'https://attacker.example.com')).toThrow(
4161+
/does not match the expected issuer/
4162+
);
4163+
expect(() =>
4164+
validateAuthorizationResponseIssuer(
4165+
{ issuer, authorization_response_iss_parameter_supported: true },
4166+
'https://attacker.example.com'
4167+
)
4168+
).toThrow(/does not match the expected issuer/);
4169+
});
4170+
4171+
it('uses exact string comparison with no normalization', () => {
4172+
// Trailing slash and case differences are equivalent URLs but MUST be rejected
4173+
expect(() => validateAuthorizationResponseIssuer({ issuer }, `${issuer}/`)).toThrow(/does not match the expected issuer/);
4174+
expect(() => validateAuthorizationResponseIssuer({ issuer }, 'https://AUTH.example.com')).toThrow(
4175+
/does not match the expected issuer/
4176+
);
4177+
});
4178+
4179+
// RFC 9207 Section 2.4, row 3: neither advertised nor present -> proceed
4180+
it('proceeds when iss support is not advertised and no iss is present', () => {
4181+
expect(() => validateAuthorizationResponseIssuer({ issuer }, undefined)).not.toThrow();
4182+
expect(() =>
4183+
validateAuthorizationResponseIssuer({ issuer, authorization_response_iss_parameter_supported: false }, undefined)
4184+
).not.toThrow();
4185+
});
4186+
4187+
it('proceeds when no metadata is recorded and no iss is present', () => {
4188+
expect(() => validateAuthorizationResponseIssuer(undefined, undefined)).not.toThrow();
4189+
});
4190+
4191+
it('rejects when an iss is present but no metadata was recorded to validate against', () => {
4192+
expect(() => validateAuthorizationResponseIssuer(undefined, issuer)).toThrow(/no authorization server metadata was recorded/);
4193+
});
4194+
});
4195+
4196+
describe('auth() with an authorization code', () => {
4197+
const resourceMetadata = {
4198+
resource: 'https://resource.example.com',
4199+
authorization_servers: [issuer]
4200+
};
4201+
4202+
const authServerMetadata: AuthorizationServerMetadata = {
4203+
issuer,
4204+
authorization_endpoint: `${issuer}/authorize`,
4205+
token_endpoint: `${issuer}/token`,
4206+
response_types_supported: ['code'],
4207+
code_challenge_methods_supported: ['S256'],
4208+
authorization_response_iss_parameter_supported: true
4209+
};
4210+
4211+
function createMockProvider(metadata: AuthorizationServerMetadata = authServerMetadata): OAuthClientProvider {
4212+
return {
4213+
get redirectUrl() {
4214+
return 'http://localhost:3000/callback';
4215+
},
4216+
get clientMetadata() {
4217+
return {
4218+
redirect_uris: ['http://localhost:3000/callback'],
4219+
client_name: 'Test Client'
4220+
};
4221+
},
4222+
clientInformation: vi.fn().mockResolvedValue({
4223+
client_id: 'test-client-id',
4224+
client_secret: 'test-client-secret'
4225+
}),
4226+
tokens: vi.fn().mockResolvedValue(undefined),
4227+
saveTokens: vi.fn(),
4228+
redirectToAuthorization: vi.fn(),
4229+
saveCodeVerifier: vi.fn(),
4230+
codeVerifier: vi.fn().mockResolvedValue('test-verifier'),
4231+
// Discovery state recorded before the redirect, including the validated issuer
4232+
discoveryState: vi.fn().mockResolvedValue({
4233+
authorizationServerUrl: issuer,
4234+
resourceMetadata,
4235+
authorizationServerMetadata: metadata
4236+
})
4237+
};
4238+
}
4239+
4240+
beforeEach(() => {
4241+
mockFetch.mockReset();
4242+
mockFetch.mockImplementation(url => {
4243+
const urlString = url.toString();
4244+
if (urlString.includes('/token')) {
4245+
return Promise.resolve({
4246+
ok: true,
4247+
status: 200,
4248+
json: async () => ({
4249+
access_token: 'access123',
4250+
token_type: 'Bearer',
4251+
expires_in: 3600
4252+
})
4253+
});
4254+
}
4255+
return Promise.reject(new Error(`Unexpected fetch: ${urlString}`));
4256+
});
4257+
});
4258+
4259+
function tokenEndpointCalls(): unknown[][] {
4260+
return mockFetch.mock.calls.filter(call => call[0].toString().includes('/token'));
4261+
}
4262+
4263+
it('exchanges the code when the response iss matches the recorded issuer', async () => {
4264+
const provider = createMockProvider();
4265+
4266+
const result = await auth(provider, {
4267+
serverUrl: 'https://resource.example.com',
4268+
authorizationCode: 'code123',
4269+
iss: issuer
4270+
});
4271+
4272+
expect(result).toBe('AUTHORIZED');
4273+
expect(tokenEndpointCalls()).toHaveLength(1);
4274+
expect(provider.saveTokens).toHaveBeenCalled();
4275+
});
4276+
4277+
it('rejects a mismatched iss before the code reaches any token endpoint', async () => {
4278+
const provider = createMockProvider();
4279+
4280+
await expect(
4281+
auth(provider, {
4282+
serverUrl: 'https://resource.example.com',
4283+
authorizationCode: 'code123',
4284+
iss: 'https://attacker.example.com'
4285+
})
4286+
).rejects.toThrow(/does not match the expected issuer/);
4287+
4288+
expect(tokenEndpointCalls()).toHaveLength(0);
4289+
expect(provider.saveTokens).not.toHaveBeenCalled();
4290+
});
4291+
4292+
it('rejects when the AS advertises iss support but no iss is provided', async () => {
4293+
const provider = createMockProvider();
4294+
4295+
await expect(
4296+
auth(provider, {
4297+
serverUrl: 'https://resource.example.com',
4298+
authorizationCode: 'code123'
4299+
})
4300+
).rejects.toThrow(/did not include an iss parameter/);
4301+
4302+
expect(tokenEndpointCalls()).toHaveLength(0);
4303+
});
4304+
4305+
it('proceeds without an iss when the AS does not advertise support', async () => {
4306+
const provider = createMockProvider({
4307+
...authServerMetadata,
4308+
authorization_response_iss_parameter_supported: undefined
4309+
});
4310+
4311+
const result = await auth(provider, {
4312+
serverUrl: 'https://resource.example.com',
4313+
authorizationCode: 'code123'
4314+
});
4315+
4316+
expect(result).toBe('AUTHORIZED');
4317+
expect(tokenEndpointCalls()).toHaveLength(1);
4318+
});
4319+
4320+
it('does not surface error content from a mismatched-issuer error response', async () => {
4321+
// RFC 9207: on issuer mismatch the client MUST NOT process the rest of the
4322+
// authorization response — including error/error_description parameters.
4323+
// Simulate a forged callback carrying both a mismatched iss and attacker-
4324+
// controlled error content alongside the code.
4325+
const forgedAuthorizationResponse = {
4326+
code: 'code123',
4327+
iss: 'https://attacker.example.com',
4328+
error: 'access_denied',
4329+
error_description: 'ATTACKER CONTROLLED MESSAGE'
4330+
};
4331+
4332+
const provider = createMockProvider();
4333+
4334+
const error = await auth(provider, {
4335+
serverUrl: 'https://resource.example.com',
4336+
authorizationCode: forgedAuthorizationResponse.code,
4337+
iss: forgedAuthorizationResponse.iss
4338+
}).then(
4339+
() => {
4340+
throw new Error('expected auth() to reject');
4341+
},
4342+
(e: unknown) => e as Error
4343+
);
4344+
4345+
// Rejected for the issuer mismatch, without echoing the forged error params
4346+
expect(error.message).toMatch(/does not match the expected issuer/);
4347+
expect(error.message).not.toContain(forgedAuthorizationResponse.error_description);
4348+
expect(error.message).not.toContain('access_denied');
4349+
4350+
// And the code was never sent to a token endpoint
4351+
expect(tokenEndpointCalls()).toHaveLength(0);
4352+
});
4353+
});
4354+
});

0 commit comments

Comments
 (0)