From f13dea98be6a02f2b44566fa86e334774cdfbc7e Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Tue, 9 Jun 2026 21:42:57 +0100 Subject: [PATCH 01/12] 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 --- .changeset/sep-2468-iss-validation.md | 6 + packages/client/src/client/auth.ts | 61 +++++ packages/client/src/client/sse.ts | 8 +- packages/client/src/client/streamableHttp.ts | 8 +- packages/client/src/index.ts | 1 + packages/client/test/client/auth.test.ts | 221 ++++++++++++++++++ .../client/test/client/streamableHttp.test.ts | 95 ++++++++ packages/core-internal/src/shared/auth.ts | 6 +- 8 files changed, 402 insertions(+), 4 deletions(-) create mode 100644 .changeset/sep-2468-iss-validation.md diff --git a/.changeset/sep-2468-iss-validation.md b/.changeset/sep-2468-iss-validation.md new file mode 100644 index 0000000000..649ab30d2d --- /dev/null +++ b/.changeset/sep-2468-iss-validation.md @@ -0,0 +1,6 @@ +--- +'@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. 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. diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index 9e47a38203..a1aa5726c6 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -531,6 +531,53 @@ export async function parseErrorResponse(input: Response | string): Promise { + async finishAuth(authorizationCode: string, options?: { iss?: string }): Promise { if (!this._oauthProvider) { throw new UnauthorizedError('finishAuth requires an OAuthClientProvider'); } @@ -237,6 +242,7 @@ export class SSEClientTransport implements Transport { const result = await auth(this._oauthProvider, { serverUrl: this._url, authorizationCode, + iss: options?.iss, resourceMetadataUrl: this._resourceMetadataUrl, scope: this._scope, fetchFn: this._fetchWithInit diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index 8962cf5639..45843c0a7a 100644 --- a/packages/client/src/client/streamableHttp.ts +++ b/packages/client/src/client/streamableHttp.ts @@ -489,8 +489,13 @@ 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, if present. + * Validated against the issuer recorded in the authorization server metadata per RFC 9207 + * before the code is exchanged. */ - async finishAuth(authorizationCode: string): Promise { + async finishAuth(authorizationCode: string, options?: { iss?: string }): Promise { if (!this._oauthProvider) { throw new UnauthorizedError('finishAuth requires an OAuthClientProvider'); } @@ -498,6 +503,7 @@ export class StreamableHTTPClientTransport implements Transport { const result = await auth(this._oauthProvider, { serverUrl: this._url, authorizationCode, + iss: options?.iss, resourceMetadataUrl: this._resourceMetadataUrl, scope: this._scope, fetchFn: this._fetchWithInit diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index b48d7cd0e8..8c552ed92c 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -35,6 +35,7 @@ export { selectResourceURL, startAuthorization, UnauthorizedError, + validateAuthorizationResponseIssuer, validateClientMetadataUrl } from './client/auth'; export type { diff --git a/packages/client/test/client/auth.test.ts b/packages/client/test/client/auth.test.ts index a4b22e0c44..0d96c841de 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -19,6 +19,7 @@ import { registerClient, selectClientAuthMethod, startAuthorization, + validateAuthorizationResponseIssuer, validateClientMetadataUrl } from '../../src/client/auth'; import { createPrivateKeyJwtAuth } from '../../src/client/authExtensions'; @@ -4149,3 +4150,223 @@ describe('OAuth Authorization', () => { }); }); }); + +describe('SEP-2468: RFC 9207 authorization response iss validation', () => { + const issuer = 'https://auth.example.com'; + + describe('validateAuthorizationResponseIssuer', () => { + // RFC 9207 Section 2.4, row 1: advertised but absent -> reject + it('rejects when the AS advertises iss support but the response lacks iss', () => { + expect(() => + validateAuthorizationResponseIssuer({ issuer, authorization_response_iss_parameter_supported: true }, undefined) + ).toThrow(/did not include an iss parameter/); + }); + + // RFC 9207 Section 2.4, row 2: present (advertised) -> exact match required + it('accepts an exactly matching iss when support is advertised', () => { + expect(() => + validateAuthorizationResponseIssuer({ issuer, authorization_response_iss_parameter_supported: true }, issuer) + ).not.toThrow(); + }); + + // RFC 9207 Section 2.4, row 2: present even without advertisement -> still compared + it('accepts an exactly matching iss even when support is not advertised', () => { + expect(() => validateAuthorizationResponseIssuer({ issuer }, issuer)).not.toThrow(); + }); + + it('rejects a mismatched iss regardless of advertisement', () => { + expect(() => validateAuthorizationResponseIssuer({ issuer }, 'https://attacker.example.com')).toThrow( + /does not match the expected issuer/ + ); + expect(() => + validateAuthorizationResponseIssuer( + { issuer, authorization_response_iss_parameter_supported: true }, + 'https://attacker.example.com' + ) + ).toThrow(/does not match the expected issuer/); + }); + + it('uses exact string comparison with no normalization', () => { + // Trailing slash and case differences are equivalent URLs but MUST be rejected + expect(() => validateAuthorizationResponseIssuer({ issuer }, `${issuer}/`)).toThrow(/does not match the expected issuer/); + expect(() => validateAuthorizationResponseIssuer({ issuer }, 'https://AUTH.example.com')).toThrow( + /does not match the expected issuer/ + ); + }); + + // RFC 9207 Section 2.4, row 3: neither advertised nor present -> proceed + it('proceeds when iss support is not advertised and no iss is present', () => { + expect(() => validateAuthorizationResponseIssuer({ issuer }, undefined)).not.toThrow(); + expect(() => + validateAuthorizationResponseIssuer({ issuer, authorization_response_iss_parameter_supported: false }, undefined) + ).not.toThrow(); + }); + + it('proceeds when no metadata is recorded and no iss is present', () => { + expect(() => validateAuthorizationResponseIssuer(undefined, undefined)).not.toThrow(); + }); + + it('rejects when an iss is present but no metadata was recorded to validate against', () => { + expect(() => validateAuthorizationResponseIssuer(undefined, issuer)).toThrow(/no authorization server metadata was recorded/); + }); + }); + + describe('auth() with an authorization code', () => { + const resourceMetadata = { + resource: 'https://resource.example.com', + authorization_servers: [issuer] + }; + + const authServerMetadata: AuthorizationServerMetadata = { + issuer, + authorization_endpoint: `${issuer}/authorize`, + token_endpoint: `${issuer}/token`, + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'], + authorization_response_iss_parameter_supported: true + }; + + function createMockProvider(metadata: AuthorizationServerMetadata = authServerMetadata): OAuthClientProvider { + return { + get redirectUrl() { + return 'http://localhost:3000/callback'; + }, + get clientMetadata() { + return { + redirect_uris: ['http://localhost:3000/callback'], + client_name: 'Test Client' + }; + }, + clientInformation: vi.fn().mockResolvedValue({ + client_id: 'test-client-id', + client_secret: 'test-client-secret' + }), + tokens: vi.fn().mockResolvedValue(undefined), + saveTokens: vi.fn(), + redirectToAuthorization: vi.fn(), + saveCodeVerifier: vi.fn(), + codeVerifier: vi.fn().mockResolvedValue('test-verifier'), + // Discovery state recorded before the redirect, including the validated issuer + discoveryState: vi.fn().mockResolvedValue({ + authorizationServerUrl: issuer, + resourceMetadata, + authorizationServerMetadata: metadata + }) + }; + } + + beforeEach(() => { + mockFetch.mockReset(); + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + if (urlString.includes('/token')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + access_token: 'access123', + token_type: 'Bearer', + expires_in: 3600 + }) + }); + } + return Promise.reject(new Error(`Unexpected fetch: ${urlString}`)); + }); + }); + + function tokenEndpointCalls(): unknown[][] { + return mockFetch.mock.calls.filter(call => call[0].toString().includes('/token')); + } + + it('exchanges the code when the response iss matches the recorded issuer', async () => { + const provider = createMockProvider(); + + const result = await auth(provider, { + serverUrl: 'https://resource.example.com', + authorizationCode: 'code123', + iss: issuer + }); + + expect(result).toBe('AUTHORIZED'); + expect(tokenEndpointCalls()).toHaveLength(1); + expect(provider.saveTokens).toHaveBeenCalled(); + }); + + it('rejects a mismatched iss before the code reaches any token endpoint', async () => { + const provider = createMockProvider(); + + await expect( + auth(provider, { + serverUrl: 'https://resource.example.com', + authorizationCode: 'code123', + iss: 'https://attacker.example.com' + }) + ).rejects.toThrow(/does not match the expected issuer/); + + expect(tokenEndpointCalls()).toHaveLength(0); + expect(provider.saveTokens).not.toHaveBeenCalled(); + }); + + it('rejects when the AS advertises iss support but no iss is provided', async () => { + const provider = createMockProvider(); + + await expect( + auth(provider, { + serverUrl: 'https://resource.example.com', + authorizationCode: 'code123' + }) + ).rejects.toThrow(/did not include an iss parameter/); + + expect(tokenEndpointCalls()).toHaveLength(0); + }); + + it('proceeds without an iss when the AS does not advertise support', async () => { + const provider = createMockProvider({ + ...authServerMetadata, + authorization_response_iss_parameter_supported: undefined + }); + + const result = await auth(provider, { + serverUrl: 'https://resource.example.com', + authorizationCode: 'code123' + }); + + expect(result).toBe('AUTHORIZED'); + expect(tokenEndpointCalls()).toHaveLength(1); + }); + + it('does not surface error content from a mismatched-issuer error response', async () => { + // RFC 9207: on issuer mismatch the client MUST NOT process the rest of the + // authorization response — including error/error_description parameters. + // Simulate a forged callback carrying both a mismatched iss and attacker- + // controlled error content alongside the code. + const forgedAuthorizationResponse = { + code: 'code123', + iss: 'https://attacker.example.com', + error: 'access_denied', + error_description: 'ATTACKER CONTROLLED MESSAGE' + }; + + const provider = createMockProvider(); + + const error = await auth(provider, { + serverUrl: 'https://resource.example.com', + authorizationCode: forgedAuthorizationResponse.code, + iss: forgedAuthorizationResponse.iss + }).then( + () => { + throw new Error('expected auth() to reject'); + }, + (e: unknown) => e as Error + ); + + // Rejected for the issuer mismatch, without echoing the forged error params + expect(error.message).toMatch(/does not match the expected issuer/); + expect(error.message).not.toContain(forgedAuthorizationResponse.error_description); + expect(error.message).not.toContain('access_denied'); + + // And the code was never sent to a token endpoint + expect(tokenEndpointCalls()).toHaveLength(0); + }); + }); +}); diff --git a/packages/client/test/client/streamableHttp.test.ts b/packages/client/test/client/streamableHttp.test.ts index 42709717e4..5b75484f83 100644 --- a/packages/client/test/client/streamableHttp.test.ts +++ b/packages/client/test/client/streamableHttp.test.ts @@ -1708,6 +1708,101 @@ describe('StreamableHTTPClientTransport', () => { }); }); + describe('finishAuth iss validation (SEP-2468 / RFC 9207)', () => { + const issuer = 'http://localhost:1234'; + + function createOAuthFetchMock(): Mock { + return ( + vi + .fn() + // Protected resource metadata discovery + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + authorization_servers: [issuer], + resource: 'http://localhost:1234/mcp' + }) + }) + // OAuth metadata discovery — advertises RFC 9207 iss support + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + issuer, + authorization_endpoint: 'http://localhost:1234/authorize', + token_endpoint: 'http://localhost:1234/token', + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'], + authorization_response_iss_parameter_supported: true + }) + }) + // Code exchange + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + access_token: 'new-access-token', + refresh_token: 'new-refresh-token', + token_type: 'Bearer', + expires_in: 3600 + }) + }) + ); + } + + it('plumbs a matching iss through to validation and completes the exchange', async () => { + const customFetch = createOAuthFetchMock(); + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { + authProvider: mockAuthProvider, + fetch: customFetch + }); + + await transport.finishAuth('test-auth-code', { iss: issuer }); + + // The code reached the token endpoint and tokens were saved + const tokenCalls = customFetch.mock.calls.filter( + ([url, options]) => url.toString().includes('/token') && options?.method === 'POST' + ); + expect(tokenCalls).toHaveLength(1); + expect(mockAuthProvider.saveTokens).toHaveBeenCalledWith({ + access_token: 'new-access-token', + token_type: 'Bearer', + expires_in: 3600, + refresh_token: 'new-refresh-token' + }); + }); + + it('rejects a mismatched iss before the code reaches the token endpoint', async () => { + const customFetch = createOAuthFetchMock(); + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { + authProvider: mockAuthProvider, + fetch: customFetch + }); + + await expect(transport.finishAuth('test-auth-code', { iss: 'https://attacker.example.com' })).rejects.toThrow( + /does not match the expected issuer/ + ); + + const tokenCalls = customFetch.mock.calls.filter(([url]) => url.toString().includes('/token')); + expect(tokenCalls).toHaveLength(0); + expect(mockAuthProvider.saveTokens).not.toHaveBeenCalled(); + }); + + it('rejects when the AS advertises iss support but finishAuth receives no iss', async () => { + const customFetch = createOAuthFetchMock(); + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { + authProvider: mockAuthProvider, + fetch: customFetch + }); + + await expect(transport.finishAuth('test-auth-code')).rejects.toThrow(/did not include an iss parameter/); + + const tokenCalls = customFetch.mock.calls.filter(([url]) => url.toString().includes('/token')); + expect(tokenCalls).toHaveLength(0); + }); + }); + describe('SSE retry field handling', () => { beforeEach(() => { vi.useFakeTimers(); diff --git a/packages/core-internal/src/shared/auth.ts b/packages/core-internal/src/shared/auth.ts index deee583aa1..6d621ad48b 100644 --- a/packages/core-internal/src/shared/auth.ts +++ b/packages/core-internal/src/shared/auth.ts @@ -66,7 +66,8 @@ export const OAuthMetadataSchema = z.looseObject({ introspection_endpoint_auth_methods_supported: z.array(z.string()).optional(), introspection_endpoint_auth_signing_alg_values_supported: z.array(z.string()).optional(), code_challenge_methods_supported: z.array(z.string()).optional(), - client_id_metadata_document_supported: z.boolean().optional() + client_id_metadata_document_supported: z.boolean().optional(), + authorization_response_iss_parameter_supported: z.boolean().optional() }); /** @@ -110,7 +111,8 @@ export const OpenIdProviderMetadataSchema = z.looseObject({ require_request_uri_registration: z.boolean().optional(), op_policy_uri: SafeUrlSchema.optional(), op_tos_uri: SafeUrlSchema.optional(), - client_id_metadata_document_supported: z.boolean().optional() + client_id_metadata_document_supported: z.boolean().optional(), + authorization_response_iss_parameter_supported: z.boolean().optional() }); /** From f62506a60c5a31b3d3b2486aaadbf4bb1231448d Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Tue, 9 Jun 2026 22:07:25 +0100 Subject: [PATCH 02/12] fix(client): gate RFC 9207 fail-closed iss rejection behind explicit caller signal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .changeset/sep-2468-iss-validation.md | 6 ++- packages/client/src/client/auth.ts | 41 ++++++++++++++--- packages/client/src/client/sse.ts | 10 ++-- packages/client/src/client/streamableHttp.ts | 10 ++-- packages/client/test/client/auth.test.ts | 46 +++++++++++++++---- .../client/test/client/streamableHttp.test.ts | 22 ++++++++- 6 files changed, 109 insertions(+), 26 deletions(-) diff --git a/.changeset/sep-2468-iss-validation.md b/.changeset/sep-2468-iss-validation.md index 649ab30d2d..784df78b0e 100644 --- a/.changeset/sep-2468-iss-validation.md +++ b/.changeset/sep-2468-iss-validation.md @@ -3,4 +3,8 @@ '@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. 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. +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. diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index a1aa5726c6..8d271f361e 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -536,9 +536,20 @@ export async function parseErrorResponse(input: Response | string): Promise { + async finishAuth(authorizationCode: string, options?: { iss?: string | null }): Promise { if (!this._oauthProvider) { throw new UnauthorizedError('finishAuth requires an OAuthClientProvider'); } diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index 45843c0a7a..8f8406ae13 100644 --- a/packages/client/src/client/streamableHttp.ts +++ b/packages/client/src/client/streamableHttp.ts @@ -491,11 +491,15 @@ 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, if present. - * Validated against the issuer recorded in the authorization server metadata per RFC 9207 + * @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, options?: { iss?: string }): Promise { + async finishAuth(authorizationCode: string, options?: { iss?: string | null }): Promise { if (!this._oauthProvider) { throw new UnauthorizedError('finishAuth requires an OAuthClientProvider'); } diff --git a/packages/client/test/client/auth.test.ts b/packages/client/test/client/auth.test.ts index 0d96c841de..b1ccdd3c5b 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -4155,13 +4155,24 @@ describe('SEP-2468: RFC 9207 authorization response iss validation', () => { const issuer = 'https://auth.example.com'; describe('validateAuthorizationResponseIssuer', () => { - // RFC 9207 Section 2.4, row 1: advertised but absent -> reject - it('rejects when the AS advertises iss support but the response lacks iss', () => { + // RFC 9207 Section 2.4, row 1: advertised but absent -> reject. The caller signals + // "I inspected the authorization response and it had no iss" by passing null. + it('rejects when the AS advertises iss support but the inspected response lacks iss (null)', () => { expect(() => - validateAuthorizationResponseIssuer({ issuer, authorization_response_iss_parameter_supported: true }, undefined) + validateAuthorizationResponseIssuer({ issuer, authorization_response_iss_parameter_supported: true }, null) ).toThrow(/did not include an iss parameter/); }); + // undefined means the caller never had access to the authorization response + // parameters, so the SDK cannot fail closed on the caller's behalf. + it('skips validation entirely when the caller provides no response parameters (undefined)', () => { + expect(() => + validateAuthorizationResponseIssuer({ issuer, authorization_response_iss_parameter_supported: true }, undefined) + ).not.toThrow(); + expect(() => validateAuthorizationResponseIssuer({ issuer }, undefined)).not.toThrow(); + expect(() => validateAuthorizationResponseIssuer(undefined, undefined)).not.toThrow(); + }); + // RFC 9207 Section 2.4, row 2: present (advertised) -> exact match required it('accepts an exactly matching iss when support is advertised', () => { expect(() => @@ -4195,15 +4206,15 @@ describe('SEP-2468: RFC 9207 authorization response iss validation', () => { }); // RFC 9207 Section 2.4, row 3: neither advertised nor present -> proceed - it('proceeds when iss support is not advertised and no iss is present', () => { - expect(() => validateAuthorizationResponseIssuer({ issuer }, undefined)).not.toThrow(); + it('proceeds when iss support is not advertised and the inspected response has no iss', () => { + expect(() => validateAuthorizationResponseIssuer({ issuer }, null)).not.toThrow(); expect(() => - validateAuthorizationResponseIssuer({ issuer, authorization_response_iss_parameter_supported: false }, undefined) + validateAuthorizationResponseIssuer({ issuer, authorization_response_iss_parameter_supported: false }, null) ).not.toThrow(); }); - it('proceeds when no metadata is recorded and no iss is present', () => { - expect(() => validateAuthorizationResponseIssuer(undefined, undefined)).not.toThrow(); + it('proceeds when no metadata is recorded and the inspected response has no iss', () => { + expect(() => validateAuthorizationResponseIssuer(undefined, null)).not.toThrow(); }); it('rejects when an iss is present but no metadata was recorded to validate against', () => { @@ -4307,19 +4318,34 @@ describe('SEP-2468: RFC 9207 authorization response iss validation', () => { expect(provider.saveTokens).not.toHaveBeenCalled(); }); - it('rejects when the AS advertises iss support but no iss is provided', async () => { + it('rejects when the AS advertises iss support and the caller reports a response without iss (null)', async () => { const provider = createMockProvider(); await expect( auth(provider, { serverUrl: 'https://resource.example.com', - authorizationCode: 'code123' + authorizationCode: 'code123', + iss: null }) ).rejects.toThrow(/did not include an iss parameter/); expect(tokenEndpointCalls()).toHaveLength(0); }); + it('proceeds when iss is omitted entirely, even when the AS advertises support', async () => { + // Callers that never had access to the authorization response (legacy + // finishAuth(code) plumbing) must not be failed closed on their behalf. + const provider = createMockProvider(); + + const result = await auth(provider, { + serverUrl: 'https://resource.example.com', + authorizationCode: 'code123' + }); + + expect(result).toBe('AUTHORIZED'); + expect(tokenEndpointCalls()).toHaveLength(1); + }); + it('proceeds without an iss when the AS does not advertise support', async () => { const provider = createMockProvider({ ...authServerMetadata, diff --git a/packages/client/test/client/streamableHttp.test.ts b/packages/client/test/client/streamableHttp.test.ts index 5b75484f83..539c756f14 100644 --- a/packages/client/test/client/streamableHttp.test.ts +++ b/packages/client/test/client/streamableHttp.test.ts @@ -1789,18 +1789,36 @@ describe('StreamableHTTPClientTransport', () => { expect(mockAuthProvider.saveTokens).not.toHaveBeenCalled(); }); - it('rejects when the AS advertises iss support but finishAuth receives no iss', async () => { + it('rejects when the AS advertises iss support and the caller reports a response without iss (iss: null)', async () => { const customFetch = createOAuthFetchMock(); transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { authProvider: mockAuthProvider, fetch: customFetch }); - await expect(transport.finishAuth('test-auth-code')).rejects.toThrow(/did not include an iss parameter/); + await expect(transport.finishAuth('test-auth-code', { iss: null })).rejects.toThrow(/did not include an iss parameter/); const tokenCalls = customFetch.mock.calls.filter(([url]) => url.toString().includes('/token')); expect(tokenCalls).toHaveLength(0); }); + + it('completes the exchange when finishAuth receives no iss at all, even though the AS advertises support', async () => { + // Legacy finishAuth(code) callers cannot see the authorization response; + // the SDK must not fail closed on their behalf. + const customFetch = createOAuthFetchMock(); + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { + authProvider: mockAuthProvider, + fetch: customFetch + }); + + await transport.finishAuth('test-auth-code'); + + const tokenCalls = customFetch.mock.calls.filter( + ([url, options]) => url.toString().includes('/token') && options?.method === 'POST' + ); + expect(tokenCalls).toHaveLength(1); + expect(mockAuthProvider.saveTokens).toHaveBeenCalled(); + }); }); describe('SSE retry field handling', () => { From 9c3a432b2ee067dff06a220f8e25bb0814e88d5b Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Thu, 25 Jun 2026 12:03:56 +0200 Subject: [PATCH 03/12] fix(client): document issuer callback plumbing --- docs/client.md | 2 +- examples/client/src/elicitationUrlExample.ts | 12 +++++----- examples/client/src/simpleOAuthClient.ts | 12 +++++----- packages/client/test/client/sse.test.ts | 23 +++++++++++++++++++- 4 files changed, 35 insertions(+), 14 deletions(-) diff --git a/docs/client.md b/docs/client.md index c2bb5b05b1..0e61fc5554 100644 --- a/docs/client.md +++ b/docs/client.md @@ -164,7 +164,7 @@ For a runnable example supporting both auth methods via environment variables, s ### Full OAuth with user authorization -For user-facing applications, implement the {@linkcode @modelcontextprotocol/client!client/auth.OAuthClientProvider | OAuthClientProvider} interface to handle the full authorization code flow (redirects, code verifiers, token storage, dynamic client registration). The {@linkcode @modelcontextprotocol/client!client/client.Client#connect | connect()} call will throw {@linkcode @modelcontextprotocol/client!client/auth.UnauthorizedError | UnauthorizedError} when authorization is needed — catch it, complete the browser flow, call {@linkcode @modelcontextprotocol/client!client/streamableHttp.StreamableHTTPClientTransport#finishAuth | transport.finishAuth(code)}, and reconnect. +For user-facing applications, implement the {@linkcode @modelcontextprotocol/client!client/auth.OAuthClientProvider | OAuthClientProvider} interface to handle the full authorization code flow (redirects, code verifiers, token storage, dynamic client registration). The {@linkcode @modelcontextprotocol/client!client/client.Client#connect | connect()} call will throw {@linkcode @modelcontextprotocol/client!client/auth.UnauthorizedError | UnauthorizedError} when authorization is needed — catch it, complete the browser flow, extract the callback `code` and `iss` query parameters, call {@linkcode @modelcontextprotocol/client!client/streamableHttp.StreamableHTTPClientTransport#finishAuth | transport.finishAuth(code, { iss })}, and reconnect. Pass `iss: null` when the callback was inspected and omitted `iss`; leaving it `undefined` preserves legacy behavior and skips RFC 9207 issuer validation. For a complete working OAuth flow, see [`simpleOAuthClient.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/simpleOAuthClient.ts) and [`simpleOAuthClientProvider.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/simpleOAuthClientProvider.ts). diff --git a/examples/client/src/elicitationUrlExample.ts b/examples/client/src/elicitationUrlExample.ts index f1b6db3b52..519c685063 100644 --- a/examples/client/src/elicitationUrlExample.ts +++ b/examples/client/src/elicitationUrlExample.ts @@ -440,8 +440,8 @@ async function handleURLElicitation(params: ElicitRequestURLParams): Promise { - return new Promise((resolve, reject) => { +async function waitForOAuthCallback(): Promise<{ code: string; iss: string | null }> { + return new Promise<{ code: string; iss: string | null }>((resolve, reject) => { const server = createServer((req, res) => { // Ignore favicon requests if (req.url === '/favicon.ico') { @@ -453,6 +453,7 @@ async function waitForOAuthCallback(): Promise { 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'); const error = parsedUrl.searchParams.get('error'); if (code) { @@ -469,7 +470,7 @@ async function waitForOAuthCallback(): Promise { `); - resolve(code); + resolve({ code, iss }); setTimeout(() => server.close(), 15_000); } else if (error) { console.log(`❌ Authorization error: ${error}`); @@ -519,9 +520,8 @@ async function attemptConnection(oauthProvider: InMemoryOAuthClientProvider): Pr } catch (error) { if (error instanceof UnauthorizedError) { console.log('🔐 OAuth required - waiting for authorization...'); - const callbackPromise = waitForOAuthCallback(); - const authCode = await callbackPromise; - await transport.finishAuth(authCode); + const { code: authCode, iss } = await waitForOAuthCallback(); + await transport.finishAuth(authCode, { iss }); console.log('🔐 Authorization code received:', authCode); console.log('🔌 Reconnecting with authenticated transport...'); // Recursively retry connection after OAuth completion diff --git a/examples/client/src/simpleOAuthClient.ts b/examples/client/src/simpleOAuthClient.ts index a0a24b1452..19ad1734ad 100644 --- a/examples/client/src/simpleOAuthClient.ts +++ b/examples/client/src/simpleOAuthClient.ts @@ -72,8 +72,8 @@ class InteractiveOAuthClient { /** * Starts a temporary HTTP server to receive the OAuth callback */ - private async waitForOAuthCallback(): Promise { - return new Promise((resolve, reject) => { + private async waitForOAuthCallback(): Promise<{ code: string; iss: string | null }> { + return new Promise<{ code: string; iss: string | null }>((resolve, reject) => { const server = createServer((req, res) => { // Ignore favicon requests if (req.url === '/favicon.ico') { @@ -85,6 +85,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'); const error = parsedUrl.searchParams.get('error'); if (code) { @@ -100,7 +101,7 @@ class InteractiveOAuthClient { `); - resolve(code); + resolve({ code, iss }); setTimeout(() => server.close(), 3000); } else if (error) { console.log(`❌ Authorization error: ${error}`); @@ -143,9 +144,8 @@ class InteractiveOAuthClient { } catch (error) { if (error instanceof UnauthorizedError) { console.log('🔐 OAuth required - waiting for authorization...'); - const callbackPromise = this.waitForOAuthCallback(); - const authCode = await callbackPromise; - await transport.finishAuth(authCode); + const { code: authCode, iss } = await this.waitForOAuthCallback(); + await transport.finishAuth(authCode, { iss }); console.log('🔐 Authorization code received:', authCode); console.log('🔌 Reconnecting with authenticated transport...'); await this.attemptConnection(oauthProvider); diff --git a/packages/client/test/client/sse.test.ts b/packages/client/test/client/sse.test.ts index a5e79f6c99..25341a891c 100644 --- a/packages/client/test/client/sse.test.ts +++ b/packages/client/test/client/sse.test.ts @@ -1237,7 +1237,8 @@ describe('SSEClientTransport', () => { token_endpoint: `http://127.0.0.1:${(authServer.address() as AddressInfo).port}/token`, registration_endpoint: `http://127.0.0.1:${(authServer.address() as AddressInfo).port}/register`, response_types_supported: ['code'], - code_challenge_methods_supported: ['S256'] + code_challenge_methods_supported: ['S256'], + authorization_response_iss_parameter_supported: true }) ); return; @@ -1527,6 +1528,26 @@ describe('SSEClientTransport', () => { // Global fetch should never have been called expect(globalFetchSpy).not.toHaveBeenCalled(); }); + + it('rejects a mismatched finishAuth iss before token exchange', async () => { + const authProviderWithCode = createMockAuthProvider({ + clientRegistered: true, + authorizationCode: 'test-auth-code' + }); + + transport = new SSEClientTransport(resourceBaseUrl, { + authProvider: authProviderWithCode, + fetch: customFetch + }); + + await expect(transport.finishAuth('test-auth-code', { iss: 'https://attacker.example.com' })).rejects.toThrow( + /does not match the expected issuer/ + ); + + const tokenCalls = customFetch.mock.calls.filter(([url]) => url.toString().includes('/token')); + expect(tokenCalls).toHaveLength(0); + expect(authProviderWithCode.saveTokens).not.toHaveBeenCalled(); + }); }); describe('minimal AuthProvider (non-OAuth)', () => { From 89d47ce7405dda8df15b66fbaeb133e16a131f69 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Thu, 25 Jun 2026 12:28:24 +0200 Subject: [PATCH 04/12] fix(docs): avoid typedoc brace parsing warning --- docs/client.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/client.md b/docs/client.md index 0e61fc5554..dca9b95277 100644 --- a/docs/client.md +++ b/docs/client.md @@ -164,7 +164,7 @@ For a runnable example supporting both auth methods via environment variables, s ### Full OAuth with user authorization -For user-facing applications, implement the {@linkcode @modelcontextprotocol/client!client/auth.OAuthClientProvider | OAuthClientProvider} interface to handle the full authorization code flow (redirects, code verifiers, token storage, dynamic client registration). The {@linkcode @modelcontextprotocol/client!client/client.Client#connect | connect()} call will throw {@linkcode @modelcontextprotocol/client!client/auth.UnauthorizedError | UnauthorizedError} when authorization is needed — catch it, complete the browser flow, extract the callback `code` and `iss` query parameters, call {@linkcode @modelcontextprotocol/client!client/streamableHttp.StreamableHTTPClientTransport#finishAuth | transport.finishAuth(code, { iss })}, and reconnect. Pass `iss: null` when the callback was inspected and omitted `iss`; leaving it `undefined` preserves legacy behavior and skips RFC 9207 issuer validation. +For user-facing applications, implement the {@linkcode @modelcontextprotocol/client!client/auth.OAuthClientProvider | OAuthClientProvider} interface to handle the full authorization code flow (redirects, code verifiers, token storage, dynamic client registration). The {@linkcode @modelcontextprotocol/client!client/client.Client#connect | connect()} call will throw {@linkcode @modelcontextprotocol/client!client/auth.UnauthorizedError | UnauthorizedError} when authorization is needed — catch it, complete the browser flow, extract the callback `code` and `iss` query parameters, call {@linkcode @modelcontextprotocol/client!client/streamableHttp.StreamableHTTPClientTransport#finishAuth | transport.finishAuth} with the code and `iss` option, and reconnect. Pass `iss: null` when the callback was inspected and omitted `iss`; leaving it `undefined` preserves legacy behavior and skips RFC 9207 issuer validation. For a complete working OAuth flow, see [`simpleOAuthClient.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/simpleOAuthClient.ts) and [`simpleOAuthClientProvider.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/simpleOAuthClientProvider.ts). From 76fa6a0bebd6ce14532affb9fa117d9c2e070cf2 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Thu, 25 Jun 2026 16:33:25 +0200 Subject: [PATCH 05/12] fix(client): validate discovered authorization server issuer --- packages/client/src/client/auth.ts | 34 ++++++++- packages/client/test/client/auth.test.ts | 92 +++++++++++++++++++----- packages/client/test/client/sse.test.ts | 9 +-- 3 files changed, 112 insertions(+), 23 deletions(-) diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index 8d271f361e..d5aae4ad51 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -596,6 +596,27 @@ export function validateAuthorizationResponseIssuer( } } +function normalizeDiscoveredIssuerIdentifier(issuer: string | URL): string { + const normalized = new URL(issuer).toString(); + + return normalized.endsWith('/') ? normalized.slice(0, -1) : normalized; +} + +function validateAuthorizationServerMetadataIssuer(metadata: { issuer: string } | undefined, authorizationServerUrl: string | URL): void { + if (!metadata) { + return; + } + + const expectedIssuer = normalizeDiscoveredIssuerIdentifier(authorizationServerUrl); + const actualIssuer = normalizeDiscoveredIssuerIdentifier(metadata.issuer); + + if (actualIssuer !== expectedIssuer) { + throw new Error( + `Authorization server metadata issuer does not match the expected issuer: expected ${expectedIssuer}, got ${metadata.issuer} (RFC 8414 Section 3.3)` + ); + } +} + /** * Orchestrates the full auth flow with a server. * @@ -715,6 +736,7 @@ async function authInternal( resourceMetadata = cachedState.resourceMetadata; metadata = cachedState.authorizationServerMetadata ?? (await discoverAuthorizationServerMetadata(authorizationServerUrl, { fetchFn })); + validateAuthorizationServerMetadataIssuer(metadata, authorizationServerUrl); // If resource metadata wasn't cached, try to fetch it for selectResourceURL if (!resourceMetadata) { @@ -748,6 +770,7 @@ async function authInternal( const serverInfo = await discoverOAuthServerInfo(serverUrl, { resourceMetadataUrl: effectiveResourceMetadataUrl, fetchFn }); authorizationServerUrl = serverInfo.authorizationServerUrl; metadata = serverInfo.authorizationServerMetadata; + validateAuthorizationServerMetadataIssuer(metadata, authorizationServerUrl); resourceMetadata = serverInfo.resourceMetadata; // Persist discovery state for future use @@ -1351,9 +1374,14 @@ export async function discoverAuthorizationServerMetadata( } // Parse and validate based on type - return type === 'oauth' - ? OAuthMetadataSchema.parse(await response.json()) - : OpenIdProviderDiscoveryMetadataSchema.parse(await response.json()); + const metadata = + type === 'oauth' + ? OAuthMetadataSchema.parse(await response.json()) + : OpenIdProviderDiscoveryMetadataSchema.parse(await response.json()); + + validateAuthorizationServerMetadataIssuer(metadata, authorizationServerUrl); + + return metadata; } return undefined; diff --git a/packages/client/test/client/auth.test.ts b/packages/client/test/client/auth.test.ts index b1ccdd3c5b..8462905721 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -901,6 +901,14 @@ describe('OAuth Authorization', () => { code_challenge_methods_supported: ['S256'] }; + const validOpenIdTenantMetadata = { + ...validOpenIdMetadata, + issuer: 'https://auth.example.com/tenant1', + authorization_endpoint: 'https://auth.example.com/tenant1/authorize', + token_endpoint: 'https://auth.example.com/tenant1/token', + jwks_uri: 'https://auth.example.com/tenant1/jwks' + }; + it('tries URLs in order and returns first successful metadata', async () => { // First OAuth URL (path before well-known) fails with 404 mockFetch.mockResolvedValueOnce({ @@ -912,12 +920,12 @@ describe('OAuth Authorization', () => { mockFetch.mockResolvedValueOnce({ ok: true, status: 200, - json: async () => validOpenIdMetadata + json: async () => validOpenIdTenantMetadata }); const metadata = await discoverAuthorizationServerMetadata('https://auth.example.com/tenant1'); - expect(metadata).toEqual(validOpenIdMetadata); + expect(metadata).toEqual(validOpenIdTenantMetadata); // Verify it tried the URLs in the correct order const calls = mockFetch.mock.calls; @@ -938,11 +946,45 @@ describe('OAuth Authorization', () => { json: async () => validOpenIdMetadata }); - const metadata = await discoverAuthorizationServerMetadata('https://mcp.example.com'); + const metadata = await discoverAuthorizationServerMetadata('https://auth.example.com'); expect(metadata).toEqual(validOpenIdMetadata); }); + it('rejects OAuth metadata whose issuer does not match the authorization server URL', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + ...validOAuthMetadata, + issuer: 'https://attacker.example.com' + }) + }); + + await expect(discoverAuthorizationServerMetadata('https://auth.example.com')).rejects.toThrow( + /Authorization server metadata issuer does not match the expected issuer/ + ); + }); + + it('rejects OpenID metadata whose issuer does not match the authorization server URL', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404 + }); + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + ...validOpenIdMetadata, + issuer: 'https://attacker.example.com' + }) + }); + + await expect(discoverAuthorizationServerMetadata('https://auth.example.com')).rejects.toThrow( + /Authorization server metadata issuer does not match the expected issuer/ + ); + }); + it('continues on 502 and tries next URL', async () => { // First URL (OAuth) returns 502 (reverse proxy with no route) mockFetch.mockResolvedValueOnce({ @@ -2296,10 +2338,10 @@ describe('OAuth Authorization', () => { ok: true, status: 200, json: async () => ({ - issuer: 'https://auth.example.com', - authorization_endpoint: 'https://auth.example.com/authorize', - token_endpoint: 'https://auth.example.com/token', - registration_endpoint: 'https://auth.example.com/register', + issuer: 'https://resource.example.com', + authorization_endpoint: 'https://resource.example.com/authorize', + token_endpoint: 'https://resource.example.com/token', + registration_endpoint: 'https://resource.example.com/register', response_types_supported: ['code'], code_challenge_methods_supported: ['S256'] }) @@ -2749,9 +2791,9 @@ describe('OAuth Authorization', () => { ok: true, status: 200, json: async () => ({ - issuer: 'https://auth.example.com', - authorization_endpoint: 'https://auth.example.com/authorize', - token_endpoint: 'https://auth.example.com/token', + issuer: 'https://api.example.com', + authorization_endpoint: 'https://api.example.com/authorize', + token_endpoint: 'https://api.example.com/token', response_types_supported: ['code'], code_challenge_methods_supported: ['S256'] }) @@ -2805,9 +2847,9 @@ describe('OAuth Authorization', () => { ok: true, status: 200, json: async () => ({ - issuer: 'https://auth.example.com', - authorization_endpoint: 'https://auth.example.com/authorize', - token_endpoint: 'https://auth.example.com/token', + issuer: 'https://api.example.com', + authorization_endpoint: 'https://api.example.com/authorize', + token_endpoint: 'https://api.example.com/token', response_types_supported: ['code'], code_challenge_methods_supported: ['S256'] }) @@ -2869,9 +2911,9 @@ describe('OAuth Authorization', () => { ok: true, status: 200, json: async () => ({ - issuer: 'https://auth.example.com', - authorization_endpoint: 'https://auth.example.com/authorize', - token_endpoint: 'https://auth.example.com/token', + issuer: 'https://api.example.com', + authorization_endpoint: 'https://api.example.com/authorize', + token_endpoint: 'https://api.example.com/token', response_types_supported: ['code'], code_challenge_methods_supported: ['S256'] }) @@ -4318,6 +4360,24 @@ describe('SEP-2468: RFC 9207 authorization response iss validation', () => { expect(provider.saveTokens).not.toHaveBeenCalled(); }); + it('rejects cached AS metadata with a mismatched issuer before code exchange', async () => { + const provider = createMockProvider({ + ...authServerMetadata, + issuer: 'https://attacker.example.com' + }); + + await expect( + auth(provider, { + serverUrl: 'https://resource.example.com', + authorizationCode: 'code123', + iss: 'https://attacker.example.com' + }) + ).rejects.toThrow(/Authorization server metadata issuer does not match the expected issuer/); + + expect(tokenEndpointCalls()).toHaveLength(0); + expect(provider.saveTokens).not.toHaveBeenCalled(); + }); + it('rejects when the AS advertises iss support and the caller reports a response without iss (null)', async () => { const provider = createMockProvider(); diff --git a/packages/client/test/client/sse.test.ts b/packages/client/test/client/sse.test.ts index 25341a891c..1bc9ec9b31 100644 --- a/packages/client/test/client/sse.test.ts +++ b/packages/client/test/client/sse.test.ts @@ -42,15 +42,16 @@ describe('SSEClientTransport', () => { authServer = createServer((req, res) => { if (req.url === '/.well-known/oauth-authorization-server') { + const issuer = authBaseUrl.origin; res.writeHead(200, { 'Content-Type': 'application/json' }); res.end( JSON.stringify({ - issuer: 'https://auth.example.com', - authorization_endpoint: 'https://auth.example.com/authorize', - token_endpoint: 'https://auth.example.com/token', - registration_endpoint: 'https://auth.example.com/register', + issuer, + authorization_endpoint: `${issuer}/authorize`, + token_endpoint: `${issuer}/token`, + registration_endpoint: `${issuer}/register`, response_types_supported: ['code'], code_challenge_methods_supported: ['S256'] }) From 7b343d732c4cd497478ec85d5ad2131ce722a908 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Thu, 25 Jun 2026 16:47:49 +0200 Subject: [PATCH 06/12] fix(client): preserve legacy OAuth metadata fallback --- packages/client/src/client/auth.ts | 83 ++++++++++------ packages/client/test/client/auth.test.ts | 115 +++++++++++++++++++++++ test/e2e/requirements.ts | 7 +- 3 files changed, 172 insertions(+), 33 deletions(-) diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index d5aae4ad51..e9fb1c508c 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -1314,34 +1314,22 @@ export function buildDiscoveryUrls(authorizationServerUrl: string | URL): { url: return urlsToTry; } -/** - * Discovers authorization server metadata with support for - * {@link https://datatracker.ietf.org/doc/html/rfc8414 | RFC 8414} OAuth 2.0 - * Authorization Server Metadata and - * {@link https://openid.net/specs/openid-connect-discovery-1_0.html | OpenID Connect Discovery 1.0} - * specifications. - * - * This function implements a fallback strategy for authorization server discovery: - * 1. Attempts RFC 8414 OAuth metadata discovery first - * 2. If OAuth discovery fails, falls back to OpenID Connect Discovery - * - * @param authorizationServerUrl - The authorization server URL obtained from the MCP Server's - * protected resource metadata, or the MCP server's URL if the - * metadata was not found. - * @param options - Configuration options - * @param options.fetchFn - Optional fetch function for making HTTP requests, defaults to global fetch - * @param options.protocolVersion - MCP protocol version to use, defaults to {@linkcode LATEST_PROTOCOL_VERSION} - * @returns Promise resolving to authorization server metadata, or undefined if discovery fails - */ -export async function discoverAuthorizationServerMetadata( +interface DiscoverAuthorizationServerMetadataOptions { + fetchFn?: FetchLike; + protocolVersion?: string; +} + +interface DiscoverAuthorizationServerMetadataInternalOptions extends DiscoverAuthorizationServerMetadataOptions { + validateIssuer?: boolean; +} + +async function discoverAuthorizationServerMetadataInternal( authorizationServerUrl: string | URL, { fetchFn = fetch, - protocolVersion = LATEST_PROTOCOL_VERSION - }: { - fetchFn?: FetchLike; - protocolVersion?: string; - } = {} + protocolVersion = LATEST_PROTOCOL_VERSION, + validateIssuer = true + }: DiscoverAuthorizationServerMetadataInternalOptions = {} ): Promise { const headers = { 'MCP-Protocol-Version': protocolVersion, @@ -1379,7 +1367,9 @@ export async function discoverAuthorizationServerMetadata( ? OAuthMetadataSchema.parse(await response.json()) : OpenIdProviderDiscoveryMetadataSchema.parse(await response.json()); - validateAuthorizationServerMetadataIssuer(metadata, authorizationServerUrl); + if (validateIssuer) { + validateAuthorizationServerMetadataIssuer(metadata, authorizationServerUrl); + } return metadata; } @@ -1387,6 +1377,32 @@ export async function discoverAuthorizationServerMetadata( return undefined; } +/** + * Discovers authorization server metadata with support for + * {@link https://datatracker.ietf.org/doc/html/rfc8414 | RFC 8414} OAuth 2.0 + * Authorization Server Metadata and + * {@link https://openid.net/specs/openid-connect-discovery-1_0.html | OpenID Connect Discovery 1.0} + * specifications. + * + * This function implements a fallback strategy for authorization server discovery: + * 1. Attempts RFC 8414 OAuth metadata discovery first + * 2. If OAuth discovery fails, falls back to OpenID Connect Discovery + * + * @param authorizationServerUrl - The authorization server URL obtained from the MCP Server's + * protected resource metadata, or the MCP server's URL if the + * metadata was not found. + * @param options - Configuration options + * @param options.fetchFn - Optional fetch function for making HTTP requests, defaults to global fetch + * @param options.protocolVersion - MCP protocol version to use, defaults to {@linkcode LATEST_PROTOCOL_VERSION} + * @returns Promise resolving to authorization server metadata, or undefined if discovery fails + */ +export async function discoverAuthorizationServerMetadata( + authorizationServerUrl: string | URL, + options: DiscoverAuthorizationServerMetadataOptions = {} +): Promise { + return discoverAuthorizationServerMetadataInternal(authorizationServerUrl, options); +} + /** * Result of {@linkcode discoverOAuthServerInfo}. */ @@ -1439,6 +1455,7 @@ export async function discoverOAuthServerInfo( ): Promise { let resourceMetadata: OAuthProtectedResourceMetadata | undefined; let authorizationServerUrl: string | undefined; + let authorizationServerUrlFromResourceMetadata = false; try { resourceMetadata = await discoverOAuthProtectedResourceMetadata( @@ -1448,6 +1465,7 @@ export async function discoverOAuthServerInfo( ); if (resourceMetadata.authorization_servers && resourceMetadata.authorization_servers.length > 0) { authorizationServerUrl = resourceMetadata.authorization_servers[0]; + authorizationServerUrlFromResourceMetadata = true; } } catch (error) { // Network failures (DNS, connection refused) surface as TypeError from fetch. Those are @@ -1465,7 +1483,18 @@ export async function discoverOAuthServerInfo( authorizationServerUrl = String(new URL('/', serverUrl)); } - const authorizationServerMetadata = await discoverAuthorizationServerMetadata(authorizationServerUrl, { fetchFn: opts?.fetchFn }); + const authorizationServerMetadata = await discoverAuthorizationServerMetadataInternal(authorizationServerUrl, { + fetchFn: opts?.fetchFn, + validateIssuer: authorizationServerUrlFromResourceMetadata + }); + + if (!authorizationServerUrlFromResourceMetadata && authorizationServerMetadata) { + const fallbackIssuer = normalizeDiscoveredIssuerIdentifier(authorizationServerUrl); + const metadataIssuer = normalizeDiscoveredIssuerIdentifier(authorizationServerMetadata.issuer); + if (metadataIssuer !== fallbackIssuer) { + authorizationServerUrl = authorizationServerMetadata.issuer; + } + } return { authorizationServerUrl, diff --git a/packages/client/test/client/auth.test.ts b/packages/client/test/client/auth.test.ts index 8462905721..107fa9cedc 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -1190,6 +1190,43 @@ describe('OAuth Authorization', () => { expect(result.authorizationServerMetadata).toBeDefined(); }); + it('uses legacy fallback metadata issuer when it differs from the MCP origin', async () => { + const legacyAuthMetadata = { + ...validAuthMetadata, + issuer: 'https://auth.example.com/oauth', + authorization_endpoint: 'https://auth.example.com/oauth/authorize', + token_endpoint: 'https://auth.example.com/oauth/token' + }; + + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + if (urlString.includes('/.well-known/oauth-protected-resource')) { + return Promise.resolve({ + ok: false, + status: 404 + }); + } + + if (urlString.includes('/.well-known/oauth-authorization-server')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => legacyAuthMetadata + }); + } + + return Promise.reject(new Error(`Unexpected fetch: ${urlString}`)); + }); + + const result = await discoverOAuthServerInfo('https://resource.example.com'); + + expect(result.authorizationServerUrl).toBe('https://auth.example.com/oauth'); + expect(result.resourceMetadata).toBeUndefined(); + expect(result.authorizationServerMetadata).toEqual(legacyAuthMetadata); + expect(mockFetch.mock.calls[1]![0].toString()).toBe('https://resource.example.com/.well-known/oauth-authorization-server'); + }); + it('forwards resourceMetadataUrl override to protected resource metadata discovery', async () => { const overrideUrl = new URL('https://custom.example.com/.well-known/oauth-protected-resource'); @@ -2388,6 +2425,84 @@ describe('OAuth Authorization', () => { expect(mockFetch.mock.calls[1]![0].toString()).toBe('https://resource.example.com/.well-known/oauth-authorization-server'); }); + it('saves the metadata issuer as the AS URL for legacy no-PRM fallback', async () => { + const saveDiscoveryState = vi.fn(); + const saveAuthorizationServerUrl = vi.fn(); + const provider: OAuthClientProvider = { + ...mockProvider, + clientInformation: vi.fn().mockResolvedValue(undefined), + tokens: vi.fn().mockResolvedValue(undefined), + saveClientInformation: vi.fn(), + saveCodeVerifier: vi.fn(), + redirectToAuthorization: vi.fn(), + saveDiscoveryState, + saveAuthorizationServerUrl + }; + + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + if (urlString.includes('/.well-known/oauth-protected-resource')) { + return Promise.resolve({ + ok: false, + status: 404 + }); + } + + if (urlString === 'https://resource.example.com/.well-known/oauth-authorization-server') { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + issuer: 'https://auth.example.com/oauth', + authorization_endpoint: 'https://auth.example.com/oauth/authorize', + token_endpoint: 'https://auth.example.com/oauth/token', + registration_endpoint: 'https://auth.example.com/oauth/register', + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'] + }) + }); + } + + if (urlString === 'https://auth.example.com/oauth/register') { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + client_id: 'test-client-id', + client_secret: 'test-client-secret', + client_id_issued_at: 1_612_137_600, + client_secret_expires_at: 1_612_224_000, + redirect_uris: ['http://localhost:3000/callback'], + client_name: 'Test Client' + }) + }); + } + + return Promise.reject(new Error(`Unexpected fetch call: ${urlString}`)); + }); + + const result = await auth(provider, { + serverUrl: 'https://resource.example.com' + }); + + expect(result).toBe('REDIRECT'); + expect(saveDiscoveryState).toHaveBeenCalledWith( + expect.objectContaining({ + authorizationServerUrl: 'https://auth.example.com/oauth', + resourceMetadata: undefined, + authorizationServerMetadata: expect.objectContaining({ + issuer: 'https://auth.example.com/oauth' + }) + }) + ); + expect(saveAuthorizationServerUrl).toHaveBeenCalledWith('https://auth.example.com/oauth'); + + const redirectCall = (provider.redirectToAuthorization as Mock).mock.calls[0]!; + const authUrl: URL = redirectCall[0]; + expect(authUrl.origin + authUrl.pathname).toBe('https://auth.example.com/oauth/authorize'); + }); + it('uses base URL (with root path) as authorization server when protected-resource-metadata discovery fails', async () => { // Setup: First call to protected resource metadata fails (404) // When no authorization_servers are found in protected resource metadata, diff --git a/test/e2e/requirements.ts b/test/e2e/requirements.ts index a338864298..06601d9a08 100644 --- a/test/e2e/requirements.ts +++ b/test/e2e/requirements.ts @@ -1935,12 +1935,7 @@ export const REQUIREMENTS: Record = { behavior: 'The client rejects authorization-server metadata whose issuer does not match the URL the metadata was retrieved from (RFC 8414 section 3.3).', transports: ['streamableHttp'], - note: 'This exercises the HTTP hosting/auth layer and OAuth client; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.', - knownFailures: [ - { - note: 'discoverAuthorizationServerMetadata never validates that the returned issuer matches the authorization-server URL the metadata was fetched from (RFC 8414 section 3.3), so spoofed-issuer metadata is accepted and the OAuth flow proceeds to registration and redirect.' - } - ] + note: 'This exercises the HTTP hosting/auth layer and OAuth client; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' }, 'client-auth:prm-discovery:no-prm-fallback': { source: 'sdk', From d7a564b49e17d96b0bee14d324df6973376b0a0e Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Thu, 25 Jun 2026 16:57:07 +0200 Subject: [PATCH 07/12] fix(client): clarify issuer metadata validation --- .changeset/sep-2468-iss-validation.md | 7 ++++-- docs/migration.md | 9 ++++++++ packages/client/src/client/auth.ts | 27 ++++++++++++++++++------ packages/client/test/client/auth.test.ts | 15 +++++++++++++ 4 files changed, 49 insertions(+), 9 deletions(-) diff --git a/.changeset/sep-2468-iss-validation.md b/.changeset/sep-2468-iss-validation.md index 784df78b0e..b1daf29f72 100644 --- a/.changeset/sep-2468-iss-validation.md +++ b/.changeset/sep-2468-iss-validation.md @@ -6,5 +6,8 @@ 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. +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 or cached 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. 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. diff --git a/docs/migration.md b/docs/migration.md index 1b6062225c..ffbc02a61c 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -157,6 +157,15 @@ a working demo with `better-auth`. Note: `AuthInfo` has moved from `server/auth/types.ts` to the core types and is now re-exported by `@modelcontextprotocol/client` and `@modelcontextprotocol/server`. +### Authorization server metadata issuer validation + +OAuth client discovery now validates authorization-server metadata issuer values per RFC 8414 Section 3.3. When protected resource metadata or cached discovery state identifies an authorization server URL, the discovered metadata's `issuer` must match that URL, except for a +trailing slash normalization. The public `discoverAuthorizationServerMetadata()` helper also throws when metadata has a mismatched or invalid issuer. If your deployment uses host aliases or proxies that serve metadata for a different issuer, publish the canonical issuer URL in +protected resource metadata. + +For legacy MCP servers without protected resource metadata, the SDK still discovers authorization-server metadata at the MCP server origin. If that origin-hosted metadata names a distinct issuer, the SDK now treats the metadata `issuer` as the authorization server URL saved in +discovery state and used for fallback endpoint construction. + ### `Headers` object instead of plain objects Transport APIs and `RequestInfo.headers` now use the Web Standard `Headers` object instead of plain `Record` (`IsomorphicHeaders` has been removed). diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index e9fb1c508c..d6ae64383e 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -596,8 +596,13 @@ export function validateAuthorizationResponseIssuer( } } -function normalizeDiscoveredIssuerIdentifier(issuer: string | URL): string { - const normalized = new URL(issuer).toString(); +function normalizeDiscoveredIssuerIdentifier(issuer: string | URL, label: string): string { + let normalized: string; + try { + normalized = new URL(issuer).toString(); + } catch { + throw new Error(`${label} is not a valid issuer identifier: got ${String(issuer)} (RFC 8414 Section 3.3)`); + } return normalized.endsWith('/') ? normalized.slice(0, -1) : normalized; } @@ -607,8 +612,8 @@ function validateAuthorizationServerMetadataIssuer(metadata: { issuer: string } return; } - const expectedIssuer = normalizeDiscoveredIssuerIdentifier(authorizationServerUrl); - const actualIssuer = normalizeDiscoveredIssuerIdentifier(metadata.issuer); + const expectedIssuer = normalizeDiscoveredIssuerIdentifier(authorizationServerUrl, 'Authorization server URL'); + const actualIssuer = normalizeDiscoveredIssuerIdentifier(metadata.issuer, 'Authorization server metadata issuer'); if (actualIssuer !== expectedIssuer) { throw new Error( @@ -1314,8 +1319,13 @@ export function buildDiscoveryUrls(authorizationServerUrl: string | URL): { url: return urlsToTry; } -interface DiscoverAuthorizationServerMetadataOptions { +/** + * Options for {@linkcode discoverAuthorizationServerMetadata}. + */ +export interface DiscoverAuthorizationServerMetadataOptions { + /** Optional fetch function for making HTTP requests, defaults to global fetch. */ fetchFn?: FetchLike; + /** MCP protocol version sent during metadata discovery. */ protocolVersion?: string; } @@ -1489,8 +1499,11 @@ export async function discoverOAuthServerInfo( }); if (!authorizationServerUrlFromResourceMetadata && authorizationServerMetadata) { - const fallbackIssuer = normalizeDiscoveredIssuerIdentifier(authorizationServerUrl); - const metadataIssuer = normalizeDiscoveredIssuerIdentifier(authorizationServerMetadata.issuer); + const fallbackIssuer = normalizeDiscoveredIssuerIdentifier(authorizationServerUrl, 'Authorization server URL'); + const metadataIssuer = normalizeDiscoveredIssuerIdentifier( + authorizationServerMetadata.issuer, + 'Authorization server metadata issuer' + ); if (metadataIssuer !== fallbackIssuer) { authorizationServerUrl = authorizationServerMetadata.issuer; } diff --git a/packages/client/test/client/auth.test.ts b/packages/client/test/client/auth.test.ts index 107fa9cedc..78e32a8802 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -966,6 +966,21 @@ describe('OAuth Authorization', () => { ); }); + it('rejects OAuth metadata whose issuer is not a valid URL with a descriptive error', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + ...validOAuthMetadata, + issuer: 'auth.example.com' + }) + }); + + await expect(discoverAuthorizationServerMetadata('https://auth.example.com')).rejects.toThrow( + /Authorization server metadata issuer is not a valid issuer identifier: got auth\.example\.com/ + ); + }); + it('rejects OpenID metadata whose issuer does not match the authorization server URL', async () => { mockFetch.mockResolvedValueOnce({ ok: false, From ad5f84f4e7b88bd696128d57185f27c7e08db042 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Thu, 25 Jun 2026 17:28:43 +0200 Subject: [PATCH 08/12] fix(client): recover stale OAuth discovery cache --- .changeset/sep-2468-iss-validation.md | 7 ++- docs/migration.md | 6 +- packages/client/src/client/auth.ts | 47 +++++++++++++-- packages/client/test/client/auth.test.ts | 74 ++++++++++++++++++++++++ 4 files changed, 124 insertions(+), 10 deletions(-) diff --git a/.changeset/sep-2468-iss-validation.md b/.changeset/sep-2468-iss-validation.md index b1daf29f72..d4e65787ec 100644 --- a/.changeset/sep-2468-iss-validation.md +++ b/.changeset/sep-2468-iss-validation.md @@ -8,6 +8,7 @@ Add RFC 9207 `iss` parameter validation for authorization responses (SEP-2468). 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 or cached 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. 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. +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. 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. diff --git a/docs/migration.md b/docs/migration.md index ffbc02a61c..f8d449f46e 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -159,9 +159,9 @@ Note: `AuthInfo` has moved from `server/auth/types.ts` to the core types and is ### Authorization server metadata issuer validation -OAuth client discovery now validates authorization-server metadata issuer values per RFC 8414 Section 3.3. When protected resource metadata or cached discovery state identifies an authorization server URL, the discovered metadata's `issuer` must match that URL, except for a -trailing slash normalization. The public `discoverAuthorizationServerMetadata()` helper also throws when metadata has a mismatched or invalid issuer. If your deployment uses host aliases or proxies that serve metadata for a different issuer, publish the canonical issuer URL in -protected resource metadata. +OAuth client discovery now validates authorization-server metadata issuer values per RFC 8414 Section 3.3. When protected resource metadata identifies an authorization server URL, the discovered metadata's `issuer` must match that URL, except for trailing slash normalization. +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. The public `discoverAuthorizationServerMetadata()` helper throws when metadata has a mismatched or +invalid issuer. If your deployment uses host aliases or proxies that serve metadata for a different issuer, publish the canonical issuer URL in protected resource metadata. For legacy MCP servers without protected resource metadata, the SDK still discovers authorization-server metadata at the MCP server origin. If that origin-hosted metadata names a distinct issuer, the SDK now treats the metadata `issuer` as the authorization server URL saved in discovery state and used for fallback endpoint construction. diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index d6ae64383e..f5559e9eee 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -622,6 +622,25 @@ function validateAuthorizationServerMetadataIssuer(metadata: { issuer: string } } } +function isStaleLegacyFallbackDiscoveryState( + cachedState: OAuthDiscoveryState, + metadata: { issuer: string } | undefined, + serverUrl: string | URL +): boolean { + if (!metadata || cachedState.resourceMetadata?.authorization_servers?.length) { + return false; + } + + try { + const cachedIssuer = normalizeDiscoveredIssuerIdentifier(cachedState.authorizationServerUrl, 'Cached authorization server URL'); + const legacyFallbackIssuer = normalizeDiscoveredIssuerIdentifier(new URL('/', serverUrl), 'MCP server URL'); + const metadataIssuer = normalizeDiscoveredIssuerIdentifier(metadata.issuer, 'Authorization server metadata issuer'); + return cachedIssuer === legacyFallbackIssuer && metadataIssuer !== cachedIssuer; + } catch { + return false; + } +} + /** * Orchestrates the full auth flow with a server. * @@ -725,7 +744,7 @@ async function authInternal( const cachedState = await provider.discoveryState?.(); let resourceMetadata: OAuthProtectedResourceMetadata | undefined; - let authorizationServerUrl: string | URL; + let authorizationServerUrl: string | URL | undefined; let metadata: AuthorizationServerMetadata | undefined; // If resourceMetadataUrl is not provided, try to load it from cached state @@ -735,14 +754,28 @@ async function authInternal( effectiveResourceMetadataUrl = new URL(cachedState.resourceMetadataUrl); } + let useCachedDiscoveryState = false; if (cachedState?.authorizationServerUrl) { + useCachedDiscoveryState = true; // Restore discovery state from cache authorizationServerUrl = cachedState.authorizationServerUrl; resourceMetadata = cachedState.resourceMetadata; metadata = - cachedState.authorizationServerMetadata ?? (await discoverAuthorizationServerMetadata(authorizationServerUrl, { fetchFn })); - validateAuthorizationServerMetadataIssuer(metadata, authorizationServerUrl); + cachedState.authorizationServerMetadata ?? + (await discoverAuthorizationServerMetadataInternal(authorizationServerUrl, { fetchFn, validateIssuer: false })); + try { + validateAuthorizationServerMetadataIssuer(metadata, authorizationServerUrl); + } catch (error) { + if (!isStaleLegacyFallbackDiscoveryState(cachedState, metadata, serverUrl)) { + throw error; + } + + await provider.invalidateCredentials?.('discovery'); + useCachedDiscoveryState = false; + } + } + if (useCachedDiscoveryState && cachedState?.authorizationServerUrl) { // If resource metadata wasn't cached, try to fetch it for selectResourceURL if (!resourceMetadata) { try { @@ -770,7 +803,9 @@ async function authInternal( authorizationServerMetadata: metadata }); } - } else { + } + + if (!useCachedDiscoveryState) { // Full discovery via RFC 9728 const serverInfo = await discoverOAuthServerInfo(serverUrl, { resourceMetadataUrl: effectiveResourceMetadataUrl, fetchFn }); authorizationServerUrl = serverInfo.authorizationServerUrl; @@ -790,6 +825,10 @@ async function authInternal( }); } + if (authorizationServerUrl === undefined) { + throw new Error('OAuth authorization server URL was not discovered'); + } + // Save authorization server URL for providers that need it (e.g., CrossAppAccessProvider) await provider.saveAuthorizationServerUrl?.(String(authorizationServerUrl)); diff --git a/packages/client/test/client/auth.test.ts b/packages/client/test/client/auth.test.ts index 78e32a8802..471af3921e 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -1479,6 +1479,80 @@ describe('OAuth Authorization', () => { ); }); + it('rediscovers stale legacy fallback state whose cached URL differs from the metadata issuer', async () => { + const legacyAuthMetadata = { + ...validAuthMetadata, + issuer: 'https://idp.example.com/oauth', + authorization_endpoint: 'https://idp.example.com/oauth/authorize', + token_endpoint: 'https://idp.example.com/oauth/token' + }; + const invalidateCredentials = vi.fn(); + const saveDiscoveryState = vi.fn(); + const provider = createMockProvider({ + discoveryState: vi.fn().mockResolvedValue({ + authorizationServerUrl: 'https://resource.example.com/', + authorizationServerMetadata: legacyAuthMetadata + }), + invalidateCredentials, + saveDiscoveryState, + tokens: vi.fn().mockResolvedValue({ + access_token: 'valid-token', + refresh_token: 'refresh-token', + token_type: 'bearer' + }) + }); + + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + if (urlString.includes('/.well-known/oauth-protected-resource')) { + return Promise.resolve({ + ok: false, + status: 404, + text: async () => '' + }); + } + + if (urlString === 'https://resource.example.com/.well-known/oauth-authorization-server') { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => legacyAuthMetadata + }); + } + + if (urlString === legacyAuthMetadata.token_endpoint) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + access_token: 'new-token', + token_type: 'bearer', + expires_in: 3600, + refresh_token: 'new-refresh-token' + }) + }); + } + + return Promise.reject(new Error(`Unexpected fetch: ${urlString}`)); + }); + + const result = await auth(provider, { serverUrl: 'https://resource.example.com' }); + + expect(result).toBe('AUTHORIZED'); + expect(invalidateCredentials).toHaveBeenCalledWith('discovery'); + expect(saveDiscoveryState).toHaveBeenCalledWith( + expect.objectContaining({ + authorizationServerUrl: legacyAuthMetadata.issuer, + resourceMetadata: undefined, + authorizationServerMetadata: legacyAuthMetadata + }) + ); + + const tokenCall = mockFetch.mock.calls.find(call => call[0].toString() === legacyAuthMetadata.token_endpoint); + expect(tokenCall).toBeDefined(); + }); + it('uses resourceMetadataUrl from cached discovery state for PRM discovery', async () => { const cachedPrmUrl = 'https://custom.example.com/.well-known/oauth-protected-resource'; const provider = createMockProvider({ From fcf958da59952ca105d07789e88572ac9fedaac4 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Thu, 25 Jun 2026 17:31:05 +0200 Subject: [PATCH 09/12] docs(client): clarify issuer normalization --- docs/migration.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/migration.md b/docs/migration.md index f8d449f46e..38c6e80168 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -159,9 +159,9 @@ Note: `AuthInfo` has moved from `server/auth/types.ts` to the core types and is ### Authorization server metadata issuer validation -OAuth client discovery now validates authorization-server metadata issuer values per RFC 8414 Section 3.3. When protected resource metadata identifies an authorization server URL, the discovered metadata's `issuer` must match that URL, except for trailing slash normalization. -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. The public `discoverAuthorizationServerMetadata()` helper throws when metadata has a mismatched or -invalid issuer. If your deployment uses host aliases or proxies that serve metadata for a different issuer, publish the canonical issuer URL in protected resource metadata. +OAuth client discovery now validates authorization-server metadata issuer values per RFC 8414 Section 3.3. When protected resource metadata identifies an authorization server URL, the discovered metadata's `issuer` must match that URL after standard URL parsing/serialization and +trailing slash normalization. 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. The public `discoverAuthorizationServerMetadata()` helper throws when +metadata has a mismatched or invalid issuer. If your deployment uses host aliases or proxies that serve metadata for a different issuer, publish the canonical issuer URL in protected resource metadata. For legacy MCP servers without protected resource metadata, the SDK still discovers authorization-server metadata at the MCP server origin. If that origin-hosted metadata names a distinct issuer, the SDK now treats the metadata `issuer` as the authorization server URL saved in discovery state and used for fallback endpoint construction. From 68d8bb1ca9c9885d6968a52dd9d4b6cbd6ff9eed Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Thu, 25 Jun 2026 23:24:39 +0200 Subject: [PATCH 10/12] fix(client): preserve Cross-App IdP issuer aliases --- .changeset/sep-2468-iss-validation.md | 8 +-- docs/migration-SKILL.md | 4 ++ docs/migration.md | 2 +- packages/client/src/client/auth.ts | 36 +++++++------ packages/client/src/client/crossAppAccess.ts | 4 +- packages/client/test/client/auth.test.ts | 53 +++++++++++++++++++ .../client/test/client/crossAppAccess.test.ts | 38 +++++++++++++ packages/codemod/src/generated/versions.ts | 12 ++--- 8 files changed, 129 insertions(+), 28 deletions(-) diff --git a/.changeset/sep-2468-iss-validation.md b/.changeset/sep-2468-iss-validation.md index d4e65787ec..7c0e587ec7 100644 --- a/.changeset/sep-2468-iss-validation.md +++ b/.changeset/sep-2468-iss-validation.md @@ -8,7 +8,7 @@ Add RFC 9207 `iss` parameter validation for authorization responses (SEP-2468). 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. 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. +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. diff --git a/docs/migration-SKILL.md b/docs/migration-SKILL.md index 73a2e4a0b9..7eecf9ff6f 100644 --- a/docs/migration-SKILL.md +++ b/docs/migration-SKILL.md @@ -515,6 +515,10 @@ members of the request/result/notification unions, the `tasks` capability key, ` `Client.listPrompts()`, `listResources()`, `listResourceTemplates()`, `listTools()` now return empty results when the server lacks the corresponding capability (instead of sending the request). Set `enforceStrictCapabilities: true` in `ClientOptions` to throw an error instead. +OAuth client discovery validates authorization-server metadata issuer values per RFC 8414 Section 3.3. Metadata discovered for a protected-resource metadata authorization server URL must have a matching `issuer`; cached discovery state is also revalidated. The public +`discoverAuthorizationServerMetadata()` helper throws for mismatched or invalid issuers unless called with `{ validateIssuer: false }`. Legacy no-PRM fallback still discovers metadata at the MCP server origin and adopts a distinct valid metadata `issuer` for saved discovery +state. + ### Server (Streamable HTTP transport) No code changes required; these are wire-behavior notes: diff --git a/docs/migration.md b/docs/migration.md index 38c6e80168..56fbbeb2a7 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -161,7 +161,7 @@ Note: `AuthInfo` has moved from `server/auth/types.ts` to the core types and is OAuth client discovery now validates authorization-server metadata issuer values per RFC 8414 Section 3.3. When protected resource metadata identifies an authorization server URL, the discovered metadata's `issuer` must match that URL after standard URL parsing/serialization and trailing slash normalization. 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. The public `discoverAuthorizationServerMetadata()` helper throws when -metadata has a mismatched or invalid issuer. If your deployment uses host aliases or proxies that serve metadata for a different issuer, publish the canonical issuer URL in protected resource metadata. +metadata has a mismatched or invalid issuer unless called with `{ validateIssuer: false }`. If your deployment uses host aliases or proxies that serve metadata for a different issuer, publish the canonical issuer URL in protected resource metadata. For legacy MCP servers without protected resource metadata, the SDK still discovers authorization-server metadata at the MCP server origin. If that origin-hosted metadata names a distinct issuer, the SDK now treats the metadata `issuer` as the authorization server URL saved in discovery state and used for fallback endpoint construction. diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index f5559e9eee..fbe0eae159 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -1366,19 +1366,17 @@ export interface DiscoverAuthorizationServerMetadataOptions { fetchFn?: FetchLike; /** MCP protocol version sent during metadata discovery. */ protocolVersion?: string; -} - -interface DiscoverAuthorizationServerMetadataInternalOptions extends DiscoverAuthorizationServerMetadataOptions { + /** + * Whether to validate discovered metadata's issuer against the discovery URL. + * Defaults to true. Set to false only when discovery intentionally starts from + * an alias URL whose metadata may name a canonical issuer. + */ validateIssuer?: boolean; } async function discoverAuthorizationServerMetadataInternal( authorizationServerUrl: string | URL, - { - fetchFn = fetch, - protocolVersion = LATEST_PROTOCOL_VERSION, - validateIssuer = true - }: DiscoverAuthorizationServerMetadataInternalOptions = {} + { fetchFn = fetch, protocolVersion = LATEST_PROTOCOL_VERSION, validateIssuer = true }: DiscoverAuthorizationServerMetadataOptions = {} ): Promise { const headers = { 'MCP-Protocol-Version': protocolVersion, @@ -1443,7 +1441,10 @@ async function discoverAuthorizationServerMetadataInternal( * @param options - Configuration options * @param options.fetchFn - Optional fetch function for making HTTP requests, defaults to global fetch * @param options.protocolVersion - MCP protocol version to use, defaults to {@linkcode LATEST_PROTOCOL_VERSION} + * @param options.validateIssuer - Whether to validate metadata's issuer against the discovery URL, defaults to true * @returns Promise resolving to authorization server metadata, or undefined if discovery fails + * @throws {Error} If discovered metadata has an invalid issuer or the issuer does not match + * the discovery URL while issuer validation is enabled */ export async function discoverAuthorizationServerMetadata( authorizationServerUrl: string | URL, @@ -1538,13 +1539,18 @@ export async function discoverOAuthServerInfo( }); if (!authorizationServerUrlFromResourceMetadata && authorizationServerMetadata) { - const fallbackIssuer = normalizeDiscoveredIssuerIdentifier(authorizationServerUrl, 'Authorization server URL'); - const metadataIssuer = normalizeDiscoveredIssuerIdentifier( - authorizationServerMetadata.issuer, - 'Authorization server metadata issuer' - ); - if (metadataIssuer !== fallbackIssuer) { - authorizationServerUrl = authorizationServerMetadata.issuer; + try { + const fallbackIssuer = normalizeDiscoveredIssuerIdentifier(authorizationServerUrl, 'Authorization server URL'); + const metadataIssuer = normalizeDiscoveredIssuerIdentifier( + authorizationServerMetadata.issuer, + 'Authorization server metadata issuer' + ); + if (metadataIssuer !== fallbackIssuer) { + authorizationServerUrl = authorizationServerMetadata.issuer; + } + } catch { + // Legacy no-PRM discovery intentionally disables issuer validation. Keep the + // fallback MCP origin when legacy metadata has an unparseable issuer value. } } diff --git a/packages/client/src/client/crossAppAccess.ts b/packages/client/src/client/crossAppAccess.ts index 2783f2002a..52e0d3bbeb 100644 --- a/packages/client/src/client/crossAppAccess.ts +++ b/packages/client/src/client/crossAppAccess.ts @@ -203,8 +203,8 @@ export async function requestJwtAuthorizationGrant(options: RequestJwtAuthGrantO export async function discoverAndRequestJwtAuthGrant(options: DiscoverAndRequestJwtAuthGrantOptions): Promise { 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 }); if (!metadata?.token_endpoint) { throw new Error(`Failed to discover token endpoint for IdP: ${idpUrl}`); diff --git a/packages/client/test/client/auth.test.ts b/packages/client/test/client/auth.test.ts index 471af3921e..978b8c1e28 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -981,6 +981,23 @@ describe('OAuth Authorization', () => { ); }); + it('can opt out of metadata issuer validation for alias discovery', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + ...validOAuthMetadata, + issuer: 'https://canonical-idp.example.com' + }) + }); + + const metadata = await discoverAuthorizationServerMetadata('https://idp-alias.example.com', { + validateIssuer: false + }); + + expect(metadata?.issuer).toBe('https://canonical-idp.example.com'); + }); + it('rejects OpenID metadata whose issuer does not match the authorization server URL', async () => { mockFetch.mockResolvedValueOnce({ ok: false, @@ -1242,6 +1259,42 @@ describe('OAuth Authorization', () => { expect(mockFetch.mock.calls[1]![0].toString()).toBe('https://resource.example.com/.well-known/oauth-authorization-server'); }); + it('keeps the legacy fallback MCP origin when unvalidated metadata has an invalid issuer', async () => { + const legacyAuthMetadata = { + ...validAuthMetadata, + issuer: 'auth.example.com', + authorization_endpoint: 'https://resource.example.com/authorize', + token_endpoint: 'https://resource.example.com/token' + }; + + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + if (urlString.includes('/.well-known/oauth-protected-resource')) { + return Promise.resolve({ + ok: false, + status: 404 + }); + } + + if (urlString.includes('/.well-known/oauth-authorization-server')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => legacyAuthMetadata + }); + } + + return Promise.reject(new Error(`Unexpected fetch: ${urlString}`)); + }); + + const result = await discoverOAuthServerInfo('https://resource.example.com'); + + expect(result.authorizationServerUrl).toBe('https://resource.example.com/'); + expect(result.resourceMetadata).toBeUndefined(); + expect(result.authorizationServerMetadata).toEqual(legacyAuthMetadata); + }); + it('forwards resourceMetadataUrl override to protected resource metadata discovery', async () => { const overrideUrl = new URL('https://custom.example.com/.well-known/oauth-protected-resource'); diff --git a/packages/client/test/client/crossAppAccess.test.ts b/packages/client/test/client/crossAppAccess.test.ts index f403bf80a2..fbef13937f 100644 --- a/packages/client/test/client/crossAppAccess.test.ts +++ b/packages/client/test/client/crossAppAccess.test.ts @@ -265,6 +265,44 @@ describe('crossAppAccess', () => { expect(String(mockFetch.mock.calls[1]![0])).toBe('https://idp.example.com/token'); }); + it('allows IdP discovery aliases whose metadata names a canonical issuer', async () => { + const mockFetch = vi.fn(); + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + issuer: 'https://login.microsoftonline.com/tenant-guid/v2.0', + authorization_endpoint: 'https://login.microsoftonline.com/tenant-guid/oauth2/v2.0/authorize', + token_endpoint: 'https://login.microsoftonline.com/tenant-guid/oauth2/v2.0/token', + jwks_uri: 'https://login.microsoftonline.com/tenant-guid/discovery/v2.0/keys', + response_types_supported: ['code'], + grant_types_supported: ['urn:ietf:params:oauth:grant-type:token-exchange'] + }) + } as Response); + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + issued_token_type: 'urn:ietf:params:oauth:token-type:id-jag', + access_token: 'jag-token', + token_type: 'N_A' + }) + } as Response); + + const result = await discoverAndRequestJwtAuthGrant({ + idpUrl: 'https://login.example-corp.com', + audience: 'https://auth.chat.example/', + resource: 'https://mcp.chat.example/', + idToken: 'id-token', + clientId: 'client', + fetchFn: mockFetch + }); + + expect(result.jwtAuthGrant).toBe('jag-token'); + expect(String(mockFetch.mock.calls[0]![0])).toBe('https://login.example-corp.com/.well-known/oauth-authorization-server'); + expect(String(mockFetch.mock.calls[1]![0])).toBe('https://login.microsoftonline.com/tenant-guid/oauth2/v2.0/token'); + }); + it('throws error when token endpoint is not discovered', async () => { const mockFetch = vi.fn().mockResolvedValue({ ok: true, diff --git a/packages/codemod/src/generated/versions.ts b/packages/codemod/src/generated/versions.ts index 4fa12a1a87..196a367508 100644 --- a/packages/codemod/src/generated/versions.ts +++ b/packages/codemod/src/generated/versions.ts @@ -1,9 +1,9 @@ // AUTO-GENERATED — do not edit. Run `pnpm run generate:versions` to regenerate. export const V2_PACKAGE_VERSIONS: Record = { - '@modelcontextprotocol/client': '^2.0.0-alpha.2', - '@modelcontextprotocol/server': '^2.0.0-alpha.2', - '@modelcontextprotocol/node': '^2.0.0-alpha.2', - '@modelcontextprotocol/express': '^2.0.0-alpha.2', - '@modelcontextprotocol/server-legacy': '^2.0.0-alpha.2', - '@modelcontextprotocol/core': '^2.0.0-alpha.0' + '@modelcontextprotocol/client': '^2.0.0-alpha.3', + '@modelcontextprotocol/server': '^2.0.0-alpha.3', + '@modelcontextprotocol/node': '^2.0.0-alpha.3', + '@modelcontextprotocol/express': '^2.0.0-alpha.3', + '@modelcontextprotocol/server-legacy': '^2.0.0-alpha.3', + '@modelcontextprotocol/core': '^2.0.0-alpha.1' }; From 7b3b4c700d1b0afd946359d50cc5708398cf030f Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Thu, 25 Jun 2026 23:57:58 +0200 Subject: [PATCH 11/12] fix(client): preserve legacy issuer fallback during auth --- packages/client/src/client/auth.ts | 24 +++++++- packages/client/src/index.ts | 1 + packages/client/test/client/auth.test.ts | 78 ++++++++++++++++++++++++ 3 files changed, 102 insertions(+), 1 deletion(-) diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index fbe0eae159..dd99d6caa8 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -810,7 +810,29 @@ async function authInternal( const serverInfo = await discoverOAuthServerInfo(serverUrl, { resourceMetadataUrl: effectiveResourceMetadataUrl, fetchFn }); authorizationServerUrl = serverInfo.authorizationServerUrl; metadata = serverInfo.authorizationServerMetadata; - validateAuthorizationServerMetadataIssuer(metadata, authorizationServerUrl); + try { + validateAuthorizationServerMetadataIssuer(metadata, authorizationServerUrl); + } catch (error) { + if (serverInfo.resourceMetadata?.authorization_servers?.length) { + throw error; + } + if (!metadata) { + throw error; + } + + let legacyIssuerIsMalformed = false; + try { + normalizeDiscoveredIssuerIdentifier(metadata.issuer, 'Authorization server metadata issuer'); + } catch { + // Legacy no-PRM discovery intentionally disables issuer validation. Keep the + // fallback MCP origin when legacy metadata has an unparseable issuer value. + legacyIssuerIsMalformed = true; + } + + if (!legacyIssuerIsMalformed) { + throw error; + } + } resourceMetadata = serverInfo.resourceMetadata; // Persist discovery state for future use diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 8c552ed92c..3f433de36f 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -11,6 +11,7 @@ export type { AuthProvider, AuthResult, ClientAuthMethod, + DiscoverAuthorizationServerMetadataOptions, OAuthClientProvider, OAuthDiscoveryState, OAuthServerInfo diff --git a/packages/client/test/client/auth.test.ts b/packages/client/test/client/auth.test.ts index 978b8c1e28..185e5f495c 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -2645,6 +2645,84 @@ describe('OAuth Authorization', () => { expect(authUrl.origin + authUrl.pathname).toBe('https://auth.example.com/oauth/authorize'); }); + it('keeps the fallback MCP origin when legacy no-PRM auth metadata has an invalid issuer', async () => { + const saveDiscoveryState = vi.fn(); + const saveAuthorizationServerUrl = vi.fn(); + const provider: OAuthClientProvider = { + ...mockProvider, + clientInformation: vi.fn().mockResolvedValue(undefined), + tokens: vi.fn().mockResolvedValue(undefined), + saveClientInformation: vi.fn(), + saveCodeVerifier: vi.fn(), + redirectToAuthorization: vi.fn(), + saveDiscoveryState, + saveAuthorizationServerUrl + }; + + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + if (urlString.includes('/.well-known/oauth-protected-resource')) { + return Promise.resolve({ + ok: false, + status: 404 + }); + } + + if (urlString === 'https://resource.example.com/.well-known/oauth-authorization-server') { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + issuer: 'auth.example.com', + authorization_endpoint: 'https://auth.example.com/oauth/authorize', + token_endpoint: 'https://auth.example.com/oauth/token', + registration_endpoint: 'https://auth.example.com/oauth/register', + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'] + }) + }); + } + + if (urlString === 'https://auth.example.com/oauth/register') { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + client_id: 'test-client-id', + client_secret: 'test-client-secret', + client_id_issued_at: 1_612_137_600, + client_secret_expires_at: 1_612_224_000, + redirect_uris: ['http://localhost:3000/callback'], + client_name: 'Test Client' + }) + }); + } + + return Promise.reject(new Error(`Unexpected fetch call: ${urlString}`)); + }); + + const result = await auth(provider, { + serverUrl: 'https://resource.example.com' + }); + + expect(result).toBe('REDIRECT'); + expect(saveDiscoveryState).toHaveBeenCalledWith( + expect.objectContaining({ + authorizationServerUrl: 'https://resource.example.com/', + resourceMetadata: undefined, + authorizationServerMetadata: expect.objectContaining({ + issuer: 'auth.example.com' + }) + }) + ); + expect(saveAuthorizationServerUrl).toHaveBeenCalledWith('https://resource.example.com/'); + + const redirectCall = (provider.redirectToAuthorization as Mock).mock.calls[0]!; + const authUrl: URL = redirectCall[0]; + expect(authUrl.origin + authUrl.pathname).toBe('https://auth.example.com/oauth/authorize'); + }); + it('uses base URL (with root path) as authorization server when protected-resource-metadata discovery fails', async () => { // Setup: First call to protected resource metadata fails (404) // When no authorization_servers are found in protected resource metadata, From 656767499ce235dc56b3f573479519735cf046ee Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Fri, 26 Jun 2026 11:34:44 +0200 Subject: [PATCH 12/12] fix(client): allow cached malformed legacy issuer fallback --- packages/client/src/client/auth.ts | 46 +++++++++++++++++++----- packages/client/test/client/auth.test.ts | 29 +++++++++++++++ 2 files changed, 67 insertions(+), 8 deletions(-) diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index dd99d6caa8..253c553fea 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -622,20 +622,46 @@ function validateAuthorizationServerMetadataIssuer(metadata: { issuer: string } } } +function isLegacyFallbackDiscoveryState(cachedState: OAuthDiscoveryState, serverUrl: string | URL): boolean { + if (cachedState.resourceMetadata?.authorization_servers?.length) { + return false; + } + + try { + const cachedIssuer = normalizeDiscoveredIssuerIdentifier(cachedState.authorizationServerUrl, 'Cached authorization server URL'); + const legacyFallbackIssuer = normalizeDiscoveredIssuerIdentifier(new URL('/', serverUrl), 'MCP server URL'); + return cachedIssuer === legacyFallbackIssuer; + } catch { + return false; + } +} + +function hasMalformedAuthorizationServerMetadataIssuer(metadata: { issuer: string } | undefined): boolean { + if (!metadata) { + return false; + } + + try { + normalizeDiscoveredIssuerIdentifier(metadata.issuer, 'Authorization server metadata issuer'); + return false; + } catch { + return true; + } +} + function isStaleLegacyFallbackDiscoveryState( cachedState: OAuthDiscoveryState, metadata: { issuer: string } | undefined, serverUrl: string | URL ): boolean { - if (!metadata || cachedState.resourceMetadata?.authorization_servers?.length) { + if (!metadata || !isLegacyFallbackDiscoveryState(cachedState, serverUrl)) { return false; } try { const cachedIssuer = normalizeDiscoveredIssuerIdentifier(cachedState.authorizationServerUrl, 'Cached authorization server URL'); - const legacyFallbackIssuer = normalizeDiscoveredIssuerIdentifier(new URL('/', serverUrl), 'MCP server URL'); const metadataIssuer = normalizeDiscoveredIssuerIdentifier(metadata.issuer, 'Authorization server metadata issuer'); - return cachedIssuer === legacyFallbackIssuer && metadataIssuer !== cachedIssuer; + return metadataIssuer !== cachedIssuer; } catch { return false; } @@ -766,12 +792,16 @@ async function authInternal( try { validateAuthorizationServerMetadataIssuer(metadata, authorizationServerUrl); } catch (error) { - if (!isStaleLegacyFallbackDiscoveryState(cachedState, metadata, serverUrl)) { - throw error; - } + const canUseMalformedLegacyFallbackState = + isLegacyFallbackDiscoveryState(cachedState, serverUrl) && hasMalformedAuthorizationServerMetadataIssuer(metadata); + if (!canUseMalformedLegacyFallbackState) { + if (!isStaleLegacyFallbackDiscoveryState(cachedState, metadata, serverUrl)) { + throw error; + } - await provider.invalidateCredentials?.('discovery'); - useCachedDiscoveryState = false; + await provider.invalidateCredentials?.('discovery'); + useCachedDiscoveryState = false; + } } } diff --git a/packages/client/test/client/auth.test.ts b/packages/client/test/client/auth.test.ts index 185e5f495c..fb90aa2717 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -4713,6 +4713,35 @@ describe('SEP-2468: RFC 9207 authorization response iss validation', () => { expect(provider.saveTokens).not.toHaveBeenCalled(); }); + it('uses cached legacy no-PRM fallback metadata with an invalid issuer during code exchange', async () => { + const legacyAuthMetadata: AuthorizationServerMetadata = { + ...authServerMetadata, + issuer: 'auth.example.com', + authorization_endpoint: 'https://auth.example.com/oauth/authorize', + token_endpoint: 'https://auth.example.com/oauth/token' + }; + const invalidateCredentials = vi.fn(); + const provider: OAuthClientProvider = { + ...createMockProvider(legacyAuthMetadata), + discoveryState: vi.fn().mockResolvedValue({ + authorizationServerUrl: 'https://resource.example.com/', + authorizationServerMetadata: legacyAuthMetadata + }), + invalidateCredentials + }; + + const result = await auth(provider, { + serverUrl: 'https://resource.example.com', + authorizationCode: 'code123' + }); + + expect(result).toBe('AUTHORIZED'); + expect(invalidateCredentials).not.toHaveBeenCalled(); + expect(tokenEndpointCalls()).toHaveLength(1); + expect(tokenEndpointCalls()[0]?.[0]?.toString()).toBe(legacyAuthMetadata.token_endpoint); + expect(provider.saveTokens).toHaveBeenCalled(); + }); + it('rejects when the AS advertises iss support and the caller reports a response without iss (null)', async () => { const provider = createMockProvider();