Skip to content

Commit a625f46

Browse files
committed
fix(client): gate RFC 9207 fail-closed iss rejection behind explicit caller signal
The advertised-but-missing rejection (AS metadata sets authorization_response_iss_parameter_supported: true but no iss was supplied) previously fired whenever iss was omitted from auth()/ finishAuth(). The SDK never sees the authorization response itself, so it cannot distinguish 'the response had no iss' from 'the caller did not plumb response parameters through' — and every existing finishAuth(code) caller falls in the second bucket. This broke the client-conformance auth/pre-registration scenario (the fixture AS advertises RFC 9207 support; the harness never passes iss). iss is now tri-state on validateAuthorizationResponseIssuer(), auth(), and both transports' finishAuth(): - string: exact-match validation against the recorded issuer (unchanged) - null: caller asserts it inspected the response and it had no iss -> RFC 9207 fail-closed rejection applies when support is advertised - undefined: caller had no access to response parameters -> validation is skipped entirely Conformance: client suite back to baseline-green (auth/pre-registration 15/15). Client tests: 386 passed.
1 parent c1c19ce commit a625f46

6 files changed

Lines changed: 109 additions & 26 deletions

File tree

.changeset/sep-2468-iss-validation.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,8 @@
33
'@modelcontextprotocol/client': minor
44
---
55

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.
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,
7+
`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
8+
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
9+
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
10+
backwards-compatible.

packages/client/src/client/auth.ts

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -536,9 +536,20 @@ export async function parseErrorResponse(input: Response | string): Promise<OAut
536536
* recorded from the authorization server's validated metadata, per RFC 9207
537537
* (OAuth 2.0 Authorization Server Issuer Identification) Section 2.4.
538538
*
539-
* Decision table:
539+
* The `iss` argument is tri-state, because the SDK does not see the authorization
540+
* response itself — the caller does:
541+
* - `string`: the `iss` parameter received in the authorization response. Validated.
542+
* - `null`: the caller inspected the authorization response and it contained no `iss`.
543+
* The RFC 9207 fail-closed rule applies: if the AS metadata advertises
544+
* `authorization_response_iss_parameter_supported: true`, the response is rejected.
545+
* - `undefined`: the caller did not supply authorization-response parameters at all
546+
* (e.g. legacy `finishAuth(code)` callers that only plumb the code). Validation is
547+
* skipped — the SDK cannot distinguish "the response had no iss" from "the caller
548+
* did not look", so it does not fail closed on the caller's behalf.
549+
*
550+
* Decision table (for callers that did inspect the response, i.e. `iss` is a string or `null`):
540551
* 1. The AS metadata advertises `authorization_response_iss_parameter_supported: true`
541-
* but the response carries no `iss` → reject.
552+
* but the response carries no `iss` (`null`) → reject.
542553
* 2. The response carries an `iss` (whether or not support was advertised) → it must be
543554
* an exact, character-by-character match of the recorded issuer (no normalization) —
544555
* mismatch → reject.
@@ -547,14 +558,21 @@ export async function parseErrorResponse(input: Response | string): Promise<OAut
547558
* (including any `error`/`error_description` parameters).
548559
*
549560
* @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
561+
* @param iss - The `iss` parameter from the authorization response (`null` = response had no
562+
* `iss`; `undefined` = caller did not provide response parameters, skip validation)
551563
* @throws Error if the response must be rejected per RFC 9207
552564
*/
553565
export function validateAuthorizationResponseIssuer(
554566
metadata: { issuer: string; authorization_response_iss_parameter_supported?: boolean } | undefined,
555-
iss: string | undefined
567+
iss: string | null | undefined
556568
): void {
557569
if (iss === undefined) {
570+
// The caller did not provide authorization-response parameters; there is
571+
// nothing to validate and no signal that an advertised iss was dropped.
572+
return;
573+
}
574+
575+
if (iss === null) {
558576
if (metadata?.authorization_response_iss_parameter_supported === true) {
559577
throw new Error(
560578
'Authorization server metadata advertises authorization_response_iss_parameter_supported, but the authorization response did not include an iss parameter (RFC 9207)'
@@ -593,8 +611,16 @@ export async function auth(
593611
* The `iss` parameter received alongside the authorization code in the
594612
* authorization response, validated per RFC 9207 against the issuer recorded
595613
* in the authorization server metadata before the code is exchanged.
614+
*
615+
* Pass the string value when the authorization response contained an `iss`
616+
* parameter. Pass `null` to assert that you inspected the authorization
617+
* response and it contained no `iss` — this enables the RFC 9207 fail-closed
618+
* rejection when the AS advertises
619+
* `authorization_response_iss_parameter_supported: true`. Leave `undefined`
620+
* when the response parameters were not available to you; validation is then
621+
* skipped entirely.
596622
*/
597-
iss?: string;
623+
iss?: string | null;
598624
scope?: string;
599625
resourceMetadataUrl?: URL;
600626
fetchFn?: FetchLike;
@@ -663,7 +689,7 @@ async function authInternal(
663689
}: {
664690
serverUrl: string | URL;
665691
authorizationCode?: string;
666-
iss?: string;
692+
iss?: string | null;
667693
scope?: string;
668694
resourceMetadataUrl?: URL;
669695
fetchFn?: FetchLike;
@@ -804,7 +830,8 @@ async function authInternal(
804830
if (authorizationCode !== undefined || nonInteractiveFlow) {
805831
if (authorizationCode !== undefined) {
806832
// RFC 9207: validate the authorization response issuer against the recorded
807-
// AS metadata BEFORE the code is sent to any token endpoint.
833+
// AS metadata BEFORE the code is sent to any token endpoint. Skipped when the
834+
// caller did not provide authorization-response parameters (iss === undefined).
808835
validateAuthorizationResponseIssuer(metadata, iss);
809836
}
810837

packages/client/src/client/sse.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -230,11 +230,15 @@ export class SSEClientTransport implements Transport {
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.
231231
*
232232
* @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
233+
* @param options.iss - The `iss` parameter from the authorization response. Pass the string
234+
* value when present; pass `null` to assert the authorization response was inspected and
235+
* contained no `iss` (this enables the RFC 9207 fail-closed rejection when the AS advertises
236+
* `authorization_response_iss_parameter_supported: true`). Leave `undefined` when the response
237+
* parameters were not available — validation is then skipped. When provided, the value is
238+
* validated against the issuer recorded in the authorization server metadata per RFC 9207
235239
* before the code is exchanged.
236240
*/
237-
async finishAuth(authorizationCode: string, options?: { iss?: string }): Promise<void> {
241+
async finishAuth(authorizationCode: string, options?: { iss?: string | null }): Promise<void> {
238242
if (!this._oauthProvider) {
239243
throw new UnauthorizedError('finishAuth requires an OAuthClientProvider');
240244
}

packages/client/src/client/streamableHttp.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -491,11 +491,15 @@ export class StreamableHTTPClientTransport implements Transport {
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.
492492
*
493493
* @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
494+
* @param options.iss - The `iss` parameter from the authorization response. Pass the string
495+
* value when present; pass `null` to assert the authorization response was inspected and
496+
* contained no `iss` (this enables the RFC 9207 fail-closed rejection when the AS advertises
497+
* `authorization_response_iss_parameter_supported: true`). Leave `undefined` when the response
498+
* parameters were not available — validation is then skipped. When provided, the value is
499+
* validated against the issuer recorded in the authorization server metadata per RFC 9207
496500
* before the code is exchanged.
497501
*/
498-
async finishAuth(authorizationCode: string, options?: { iss?: string }): Promise<void> {
502+
async finishAuth(authorizationCode: string, options?: { iss?: string | null }): Promise<void> {
499503
if (!this._oauthProvider) {
500504
throw new UnauthorizedError('finishAuth requires an OAuthClientProvider');
501505
}

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

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4137,13 +4137,24 @@ describe('SEP-2468: RFC 9207 authorization response iss validation', () => {
41374137
const issuer = 'https://auth.example.com';
41384138

41394139
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', () => {
4140+
// RFC 9207 Section 2.4, row 1: advertised but absent -> reject. The caller signals
4141+
// "I inspected the authorization response and it had no iss" by passing null.
4142+
it('rejects when the AS advertises iss support but the inspected response lacks iss (null)', () => {
41424143
expect(() =>
4143-
validateAuthorizationResponseIssuer({ issuer, authorization_response_iss_parameter_supported: true }, undefined)
4144+
validateAuthorizationResponseIssuer({ issuer, authorization_response_iss_parameter_supported: true }, null)
41444145
).toThrow(/did not include an iss parameter/);
41454146
});
41464147

4148+
// undefined means the caller never had access to the authorization response
4149+
// parameters, so the SDK cannot fail closed on the caller's behalf.
4150+
it('skips validation entirely when the caller provides no response parameters (undefined)', () => {
4151+
expect(() =>
4152+
validateAuthorizationResponseIssuer({ issuer, authorization_response_iss_parameter_supported: true }, undefined)
4153+
).not.toThrow();
4154+
expect(() => validateAuthorizationResponseIssuer({ issuer }, undefined)).not.toThrow();
4155+
expect(() => validateAuthorizationResponseIssuer(undefined, undefined)).not.toThrow();
4156+
});
4157+
41474158
// RFC 9207 Section 2.4, row 2: present (advertised) -> exact match required
41484159
it('accepts an exactly matching iss when support is advertised', () => {
41494160
expect(() =>
@@ -4177,15 +4188,15 @@ describe('SEP-2468: RFC 9207 authorization response iss validation', () => {
41774188
});
41784189

41794190
// 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();
4191+
it('proceeds when iss support is not advertised and the inspected response has no iss', () => {
4192+
expect(() => validateAuthorizationResponseIssuer({ issuer }, null)).not.toThrow();
41824193
expect(() =>
4183-
validateAuthorizationResponseIssuer({ issuer, authorization_response_iss_parameter_supported: false }, undefined)
4194+
validateAuthorizationResponseIssuer({ issuer, authorization_response_iss_parameter_supported: false }, null)
41844195
).not.toThrow();
41854196
});
41864197

4187-
it('proceeds when no metadata is recorded and no iss is present', () => {
4188-
expect(() => validateAuthorizationResponseIssuer(undefined, undefined)).not.toThrow();
4198+
it('proceeds when no metadata is recorded and the inspected response has no iss', () => {
4199+
expect(() => validateAuthorizationResponseIssuer(undefined, null)).not.toThrow();
41894200
});
41904201

41914202
it('rejects when an iss is present but no metadata was recorded to validate against', () => {
@@ -4289,19 +4300,34 @@ describe('SEP-2468: RFC 9207 authorization response iss validation', () => {
42894300
expect(provider.saveTokens).not.toHaveBeenCalled();
42904301
});
42914302

4292-
it('rejects when the AS advertises iss support but no iss is provided', async () => {
4303+
it('rejects when the AS advertises iss support and the caller reports a response without iss (null)', async () => {
42934304
const provider = createMockProvider();
42944305

42954306
await expect(
42964307
auth(provider, {
42974308
serverUrl: 'https://resource.example.com',
4298-
authorizationCode: 'code123'
4309+
authorizationCode: 'code123',
4310+
iss: null
42994311
})
43004312
).rejects.toThrow(/did not include an iss parameter/);
43014313

43024314
expect(tokenEndpointCalls()).toHaveLength(0);
43034315
});
43044316

4317+
it('proceeds when iss is omitted entirely, even when the AS advertises support', async () => {
4318+
// Callers that never had access to the authorization response (legacy
4319+
// finishAuth(code) plumbing) must not be failed closed on their behalf.
4320+
const provider = createMockProvider();
4321+
4322+
const result = await auth(provider, {
4323+
serverUrl: 'https://resource.example.com',
4324+
authorizationCode: 'code123'
4325+
});
4326+
4327+
expect(result).toBe('AUTHORIZED');
4328+
expect(tokenEndpointCalls()).toHaveLength(1);
4329+
});
4330+
43054331
it('proceeds without an iss when the AS does not advertise support', async () => {
43064332
const provider = createMockProvider({
43074333
...authServerMetadata,

packages/client/test/client/streamableHttp.test.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1690,18 +1690,36 @@ describe('StreamableHTTPClientTransport', () => {
16901690
expect(mockAuthProvider.saveTokens).not.toHaveBeenCalled();
16911691
});
16921692

1693-
it('rejects when the AS advertises iss support but finishAuth receives no iss', async () => {
1693+
it('rejects when the AS advertises iss support and the caller reports a response without iss (iss: null)', async () => {
16941694
const customFetch = createOAuthFetchMock();
16951695
transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), {
16961696
authProvider: mockAuthProvider,
16971697
fetch: customFetch
16981698
});
16991699

1700-
await expect(transport.finishAuth('test-auth-code')).rejects.toThrow(/did not include an iss parameter/);
1700+
await expect(transport.finishAuth('test-auth-code', { iss: null })).rejects.toThrow(/did not include an iss parameter/);
17011701

17021702
const tokenCalls = customFetch.mock.calls.filter(([url]) => url.toString().includes('/token'));
17031703
expect(tokenCalls).toHaveLength(0);
17041704
});
1705+
1706+
it('completes the exchange when finishAuth receives no iss at all, even though the AS advertises support', async () => {
1707+
// Legacy finishAuth(code) callers cannot see the authorization response;
1708+
// the SDK must not fail closed on their behalf.
1709+
const customFetch = createOAuthFetchMock();
1710+
transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), {
1711+
authProvider: mockAuthProvider,
1712+
fetch: customFetch
1713+
});
1714+
1715+
await transport.finishAuth('test-auth-code');
1716+
1717+
const tokenCalls = customFetch.mock.calls.filter(
1718+
([url, options]) => url.toString().includes('/token') && options?.method === 'POST'
1719+
);
1720+
expect(tokenCalls).toHaveLength(1);
1721+
expect(mockAuthProvider.saveTokens).toHaveBeenCalled();
1722+
});
17051723
});
17061724

17071725
describe('SSE retry field handling', () => {

0 commit comments

Comments
 (0)