From 66edf5f73ccebaf125fee5f455102bb95f398dca Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Tue, 9 Jun 2026 21:42:35 +0100 Subject: [PATCH 01/18] fix(client): invalidate credentials and re-register when the authorization server changes (SEP-2352) --- .changeset/sep-2352-as-binding.md | 5 + packages/client/src/client/auth.ts | 14 ++ packages/client/test/client/auth.test.ts | 209 +++++++++++++++++++++++ 3 files changed, 228 insertions(+) create mode 100644 .changeset/sep-2352-as-binding.md diff --git a/.changeset/sep-2352-as-binding.md b/.changeset/sep-2352-as-binding.md new file mode 100644 index 0000000000..bd5c90a06c --- /dev/null +++ b/.changeset/sep-2352-as-binding.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/client': patch +--- + +Implement SEP-2352 authorization server binding: when OAuth discovery shows the authorization server has changed since client credentials were recorded, `auth()` now invalidates the stale client registration and tokens (`invalidateCredentials('client')` / `('tokens')`) and re-registers with the new authorization server. CIMD (HTTPS URL) client IDs are exempt, as they are portable across authorization servers. Provider implementations should persist client credentials keyed by the authorization server's `issuer` identifier. diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index a4d5b14c62..9ea3af6301 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -1356,6 +1356,20 @@ export function isHttpsUrl(value?: string): boolean { } } +/** + * SEP-2352: Normalizes an authorization server identity (issuer identifier or + * authorization server URL) for comparison, so that textual variations of the + * same URL (e.g. a missing trailing slash on an origin-only issuer) do not + * register as an authorization server change. + */ +function normalizeAuthorizationServerIdentity(value: string): string { + try { + return new URL(value).href; + } catch { + return value; + } +} + export async function selectResourceURL( serverUrl: string | URL, provider: OAuthClientProvider, diff --git a/packages/client/test/client/auth.test.ts b/packages/client/test/client/auth.test.ts index 62c6faed9a..4268fb9573 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -5055,3 +5055,212 @@ describe('OAuth Authorization', () => { }); }); }); + +describe('SEP-2352: authorization server binding', () => { + const oldAuthServerUrl = 'https://old-auth.example.com'; + + const newResourceMetadata = { + resource: 'https://resource.example.com', + authorization_servers: ['https://new-auth.example.com'] + }; + + const newAuthMetadata = { + issuer: 'https://new-auth.example.com', + authorization_endpoint: 'https://new-auth.example.com/authorize', + token_endpoint: 'https://new-auth.example.com/token', + registration_endpoint: 'https://new-auth.example.com/register', + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'] + }; + + const sameResourceMetadata = { + resource: 'https://resource.example.com', + authorization_servers: [oldAuthServerUrl] + }; + + const sameAuthMetadata = { + issuer: oldAuthServerUrl, + authorization_endpoint: `${oldAuthServerUrl}/authorize`, + token_endpoint: `${oldAuthServerUrl}/token`, + registration_endpoint: `${oldAuthServerUrl}/register`, + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'] + }; + + /** + * Creates a provider that previously completed an OAuth flow against + * `oldAuthServerUrl` (recorded via `authorizationServerUrl()`), holds stored + * client credentials, and honors `invalidateCredentials` by dropping them. + */ + function createBoundProvider(initialClientInformation: { client_id: string; client_secret?: string }): { + provider: OAuthClientProvider; + invalidateCredentials: Mock; + saveClientInformation: Mock; + redirectToAuthorization: Mock; + } { + let clientInformation: { client_id: string; client_secret?: string } | undefined = initialClientInformation; + + const invalidateCredentials = vi.fn(async (scope: 'all' | 'client' | 'tokens' | 'verifier' | 'discovery') => { + if (scope === 'all' || scope === 'client') { + clientInformation = undefined; + } + }); + const saveClientInformation = vi.fn(async (info: { client_id: string; client_secret?: string }) => { + clientInformation = info; + }); + const redirectToAuthorization = vi.fn(); + + const provider: OAuthClientProvider = { + get redirectUrl() { + return 'http://localhost:3000/callback'; + }, + get clientMetadata() { + return { + redirect_uris: ['http://localhost:3000/callback'], + client_name: 'Test Client' + }; + }, + clientInformation: vi.fn(async () => clientInformation), + saveClientInformation, + tokens: vi.fn().mockResolvedValue(undefined), + saveTokens: vi.fn(), + redirectToAuthorization, + saveCodeVerifier: vi.fn(), + codeVerifier: vi.fn().mockResolvedValue('test_verifier'), + authorizationServerUrl: vi.fn().mockResolvedValue(oldAuthServerUrl), + invalidateCredentials + }; + + return { provider, invalidateCredentials, saveClientInformation, redirectToAuthorization }; + } + + function mockDiscoveryAndRegistration(options: { + resourceMetadata: { resource: string; authorization_servers: string[] }; + authMetadata: { issuer: string }; + registeredClient?: { client_id: string; client_secret?: string }; + }): void { + mockFetch.mockImplementation((url, init) => { + const urlString = url.toString(); + + if (urlString.includes('/.well-known/oauth-protected-resource')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => options.resourceMetadata + }); + } + + if (urlString.includes('/.well-known/oauth-authorization-server')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => options.authMetadata + }); + } + + if (urlString.includes('/register') && init?.method === 'POST') { + if (!options.registeredClient) { + return Promise.reject(new Error(`Unexpected registration request: ${urlString}`)); + } + return Promise.resolve({ + ok: true, + status: 201, + json: async () => ({ + ...JSON.parse(init.body as string), + ...options.registeredClient + }) + }); + } + + return Promise.reject(new Error(`Unexpected fetch: ${urlString}`)); + }); + } + + beforeEach(() => { + mockFetch.mockReset(); + vi.clearAllMocks(); + }); + + it('invalidates client credentials and tokens, then re-registers, when the authorization server changes', async () => { + const { provider, invalidateCredentials, saveClientInformation, redirectToAuthorization } = createBoundProvider({ + client_id: 'old-client-id', + client_secret: 'old-client-secret' + }); + + mockDiscoveryAndRegistration({ + resourceMetadata: newResourceMetadata, + authMetadata: newAuthMetadata, + registeredClient: { client_id: 'new-client-id', client_secret: 'new-client-secret' } + }); + + const result = await auth(provider, { serverUrl: 'https://resource.example.com' }); + + expect(result).toBe('REDIRECT'); + + // Stale credentials bound to the old authorization server are invalidated + expect(invalidateCredentials).toHaveBeenCalledWith('client'); + expect(invalidateCredentials).toHaveBeenCalledWith('tokens'); + + // The client re-registers with the new authorization server + const registrationCalls = mockFetch.mock.calls.filter(call => call[0].toString().includes('/register')); + expect(registrationCalls).toHaveLength(1); + expect(registrationCalls[0]![0].toString()).toBe('https://new-auth.example.com/register'); + expect(saveClientInformation).toHaveBeenCalledWith(expect.objectContaining({ client_id: 'new-client-id' })); + + // The authorization redirect uses the newly registered client, not the stale one + const redirectUrl: URL = redirectToAuthorization.mock.calls[0]![0]; + expect(redirectUrl.origin).toBe('https://new-auth.example.com'); + expect(redirectUrl.searchParams.get('client_id')).toBe('new-client-id'); + }); + + it('does not invalidate credentials when the authorization server is unchanged', async () => { + const { provider, invalidateCredentials, redirectToAuthorization } = createBoundProvider({ + client_id: 'old-client-id', + client_secret: 'old-client-secret' + }); + + mockDiscoveryAndRegistration({ + resourceMetadata: sameResourceMetadata, + authMetadata: sameAuthMetadata + }); + + const result = await auth(provider, { serverUrl: 'https://resource.example.com' }); + + expect(result).toBe('REDIRECT'); + expect(invalidateCredentials).not.toHaveBeenCalled(); + + // No re-registration; the existing client credentials are reused + const registrationCalls = mockFetch.mock.calls.filter(call => call[0].toString().includes('/register')); + expect(registrationCalls).toHaveLength(0); + + const redirectUrl: URL = redirectToAuthorization.mock.calls[0]![0]; + expect(redirectUrl.searchParams.get('client_id')).toBe('old-client-id'); + }); + + it('does not invalidate CIMD (HTTPS URL) client IDs when the authorization server changes', async () => { + const cimdClientId = 'https://client.example.com/oauth/client-metadata.json'; + const { provider, invalidateCredentials, redirectToAuthorization } = createBoundProvider({ + client_id: cimdClientId + }); + + mockDiscoveryAndRegistration({ + resourceMetadata: newResourceMetadata, + authMetadata: newAuthMetadata + }); + + const result = await auth(provider, { serverUrl: 'https://resource.example.com' }); + + expect(result).toBe('REDIRECT'); + + // CIMD client IDs are portable across authorization servers — no invalidation + expect(invalidateCredentials).not.toHaveBeenCalled(); + + // No re-registration; the portable client ID is reused with the new server + const registrationCalls = mockFetch.mock.calls.filter(call => call[0].toString().includes('/register')); + expect(registrationCalls).toHaveLength(0); + + const redirectUrl: URL = redirectToAuthorization.mock.calls[0]![0]; + expect(redirectUrl.origin).toBe('https://new-auth.example.com'); + expect(redirectUrl.searchParams.get('client_id')).toBe(cimdClientId); + }); +}); From 951c6d4bbdd9f37f4530537e81a670fa4f914d85 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Thu, 25 Jun 2026 11:09:49 +0200 Subject: [PATCH 02/18] fix(client): refresh challenged discovery before AS binding --- packages/client/src/client/auth.ts | 7 ++-- packages/client/test/client/auth.test.ts | 48 ++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index 9ea3af6301..b3572d6ef1 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -1043,14 +1043,15 @@ async function authInternal( let metadata: AuthorizationServerMetadata | undefined; let freshDiscoveryState: OAuthDiscoveryState | undefined; - // If resourceMetadataUrl is not provided, try to load it from cached state - // This handles browser redirects where the URL was saved before navigation + // If resourceMetadataUrl is not provided, try to load it from cached state. + // This handles browser redirects where the URL was saved before navigation. let effectiveResourceMetadataUrl = resourceMetadataUrl; if (!effectiveResourceMetadataUrl && cachedState?.resourceMetadataUrl) { effectiveResourceMetadataUrl = new URL(cachedState.resourceMetadataUrl); } + const shouldRefreshCachedDiscovery = cachedState?.authorizationServerUrl !== undefined && resourceMetadataUrl !== undefined; - if (cachedState?.authorizationServerUrl) { + if (cachedState?.authorizationServerUrl && !shouldRefreshCachedDiscovery) { // Restore discovery state from cache authorizationServerUrl = cachedState.authorizationServerUrl; resourceMetadata = cachedState.resourceMetadata; diff --git a/packages/client/test/client/auth.test.ts b/packages/client/test/client/auth.test.ts index 4268fb9573..3cd541acd4 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -5213,6 +5213,54 @@ describe('SEP-2352: authorization server binding', () => { expect(redirectUrl.searchParams.get('client_id')).toBe('new-client-id'); }); + it('refreshes cached discovery from an explicit resource metadata challenge before comparing authorization servers', async () => { + const { provider, invalidateCredentials, saveClientInformation, redirectToAuthorization } = createBoundProvider({ + client_id: 'old-client-id', + client_secret: 'old-client-secret' + }); + const resourceMetadataUrl = new URL('https://resource.example.com/.well-known/oauth-protected-resource'); + + provider.discoveryState = vi.fn().mockResolvedValue({ + authorizationServerUrl: oldAuthServerUrl, + resourceMetadata: sameResourceMetadata, + authorizationServerMetadata: sameAuthMetadata + }); + provider.saveDiscoveryState = vi.fn(); + + mockDiscoveryAndRegistration({ + resourceMetadata: newResourceMetadata, + authMetadata: newAuthMetadata, + registeredClient: { client_id: 'new-client-id', client_secret: 'new-client-secret' } + }); + + const result = await auth(provider, { + serverUrl: 'https://resource.example.com', + resourceMetadataUrl + }); + + expect(result).toBe('REDIRECT'); + + const prmCalls = mockFetch.mock.calls.filter(call => call[0].toString().includes('oauth-protected-resource')); + expect(prmCalls).toHaveLength(1); + expect(prmCalls[0]![0].toString()).toBe(resourceMetadataUrl.toString()); + + expect(provider.saveDiscoveryState).toHaveBeenCalledWith( + expect.objectContaining({ + authorizationServerUrl: 'https://new-auth.example.com', + resourceMetadataUrl: resourceMetadataUrl.toString(), + resourceMetadata: newResourceMetadata, + authorizationServerMetadata: newAuthMetadata + }) + ); + expect(invalidateCredentials).toHaveBeenCalledWith('client'); + expect(invalidateCredentials).toHaveBeenCalledWith('tokens'); + expect(saveClientInformation).toHaveBeenCalledWith(expect.objectContaining({ client_id: 'new-client-id' })); + + const redirectUrl: URL = redirectToAuthorization.mock.calls[0]![0]; + expect(redirectUrl.origin).toBe('https://new-auth.example.com'); + expect(redirectUrl.searchParams.get('client_id')).toBe('new-client-id'); + }); + it('does not invalidate credentials when the authorization server is unchanged', async () => { const { provider, invalidateCredentials, redirectToAuthorization } = createBoundProvider({ client_id: 'old-client-id', From 87692084b70224ab91e2255158f450500e53cf24 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Thu, 25 Jun 2026 11:59:07 +0200 Subject: [PATCH 03/18] fix(client): avoid false AS-change invalidation --- packages/client/test/client/auth.test.ts | 57 ++++++++++++++++++++++-- 1 file changed, 54 insertions(+), 3 deletions(-) diff --git a/packages/client/test/client/auth.test.ts b/packages/client/test/client/auth.test.ts index 3cd541acd4..a81a8bd0f5 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -5261,6 +5261,56 @@ describe('SEP-2352: authorization server binding', () => { expect(redirectUrl.searchParams.get('client_id')).toBe('new-client-id'); }); + it('does not invalidate credentials when challenged PRM discovery transiently falls back', async () => { + const { provider, invalidateCredentials, redirectToAuthorization } = createBoundProvider({ + client_id: 'old-client-id', + client_secret: 'old-client-secret' + }); + const resourceMetadataUrl = new URL('https://resource.example.com/.well-known/oauth-protected-resource'); + + provider.discoveryState = vi.fn().mockResolvedValue({ + authorizationServerUrl: oldAuthServerUrl, + resourceMetadata: sameResourceMetadata, + authorizationServerMetadata: sameAuthMetadata + }); + + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + if (urlString === resourceMetadataUrl.toString()) { + return Promise.resolve({ + ok: false, + status: 503, + statusText: 'Service Unavailable', + text: async () => 'temporarily unavailable' + }); + } + + if (urlString.includes('/.well-known/oauth-authorization-server') || urlString.includes('/.well-known/openid-configuration')) { + return Promise.resolve({ + ok: false, + status: 404, + statusText: 'Not Found', + text: async () => 'not found' + }); + } + + return Promise.reject(new Error(`Unexpected fetch: ${urlString}`)); + }); + + const result = await auth(provider, { + serverUrl: 'https://resource.example.com', + resourceMetadataUrl + }); + + expect(result).toBe('REDIRECT'); + expect(invalidateCredentials).not.toHaveBeenCalled(); + + const redirectUrl: URL = redirectToAuthorization.mock.calls[0]![0]; + expect(redirectUrl.origin).toBe('https://resource.example.com'); + expect(redirectUrl.searchParams.get('client_id')).toBe('old-client-id'); + }); + it('does not invalidate credentials when the authorization server is unchanged', async () => { const { provider, invalidateCredentials, redirectToAuthorization } = createBoundProvider({ client_id: 'old-client-id', @@ -5285,7 +5335,7 @@ describe('SEP-2352: authorization server binding', () => { expect(redirectUrl.searchParams.get('client_id')).toBe('old-client-id'); }); - it('does not invalidate CIMD (HTTPS URL) client IDs when the authorization server changes', async () => { + it('invalidates tokens but does not re-register CIMD (HTTPS URL) client IDs when the authorization server changes', async () => { const cimdClientId = 'https://client.example.com/oauth/client-metadata.json'; const { provider, invalidateCredentials, redirectToAuthorization } = createBoundProvider({ client_id: cimdClientId @@ -5300,8 +5350,9 @@ describe('SEP-2352: authorization server binding', () => { expect(result).toBe('REDIRECT'); - // CIMD client IDs are portable across authorization servers — no invalidation - expect(invalidateCredentials).not.toHaveBeenCalled(); + // CIMD client IDs are portable across authorization servers, but tokens are still AS-bound. + expect(invalidateCredentials).toHaveBeenCalledWith('tokens'); + expect(invalidateCredentials).not.toHaveBeenCalledWith('client'); // No re-registration; the portable client ID is reused with the new server const registrationCalls = mockFetch.mock.calls.filter(call => call[0].toString().includes('/register')); From 849bc81ccd2a6620f2c9ae4eaf1cfa08b02f119e Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Thu, 25 Jun 2026 12:25:49 +0200 Subject: [PATCH 04/18] fix(client): keep cached discovery after failed challenge --- packages/client/test/client/auth.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/client/test/client/auth.test.ts b/packages/client/test/client/auth.test.ts index a81a8bd0f5..da25cca5e4 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -5273,6 +5273,7 @@ describe('SEP-2352: authorization server binding', () => { resourceMetadata: sameResourceMetadata, authorizationServerMetadata: sameAuthMetadata }); + provider.saveDiscoveryState = vi.fn(); mockFetch.mockImplementation(url => { const urlString = url.toString(); @@ -5305,9 +5306,10 @@ describe('SEP-2352: authorization server binding', () => { expect(result).toBe('REDIRECT'); expect(invalidateCredentials).not.toHaveBeenCalled(); + expect(provider.saveDiscoveryState).not.toHaveBeenCalled(); const redirectUrl: URL = redirectToAuthorization.mock.calls[0]![0]; - expect(redirectUrl.origin).toBe('https://resource.example.com'); + expect(redirectUrl.origin).toBe('https://old-auth.example.com'); expect(redirectUrl.searchParams.get('client_id')).toBe('old-client-id'); }); From 6736204e97d694a177780fc075fb7b872d6d7f87 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Thu, 25 Jun 2026 13:58:16 +0200 Subject: [PATCH 05/18] fix(client): preserve authoritative AS discovery --- .changeset/sep-2352-as-binding.md | 2 +- packages/client/src/client/auth.ts | 17 +- packages/client/test/client/auth.test.ts | 197 +++++++++++++++++++++++ 3 files changed, 213 insertions(+), 3 deletions(-) diff --git a/.changeset/sep-2352-as-binding.md b/.changeset/sep-2352-as-binding.md index bd5c90a06c..629a7f6365 100644 --- a/.changeset/sep-2352-as-binding.md +++ b/.changeset/sep-2352-as-binding.md @@ -2,4 +2,4 @@ '@modelcontextprotocol/client': patch --- -Implement SEP-2352 authorization server binding: when OAuth discovery shows the authorization server has changed since client credentials were recorded, `auth()` now invalidates the stale client registration and tokens (`invalidateCredentials('client')` / `('tokens')`) and re-registers with the new authorization server. CIMD (HTTPS URL) client IDs are exempt, as they are portable across authorization servers. Provider implementations should persist client credentials keyed by the authorization server's `issuer` identifier. +Implement SEP-2352 authorization server binding: when OAuth discovery shows the authorization server has changed since client credentials were recorded, `auth()` now invalidates the stale client registration and tokens (`invalidateCredentials('client')` / `('tokens')`) and re-registers with the new authorization server. CIMD (HTTPS URL) client IDs are portable across authorization servers, so they are exempt from client re-registration, but their tokens are still invalidated when the authorization server changes. Provider implementations should persist client credentials keyed by the authorization server's `issuer` identifier. diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index b3572d6ef1..f1cf45ee8b 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -1037,6 +1037,7 @@ async function authInternal( // Check if the provider has cached discovery state to skip discovery const cachedState = await provider.discoveryState?.(); + const savedAuthorizationServerUrl = await provider.authorizationServerUrl?.(); let resourceMetadata: OAuthProtectedResourceMetadata | undefined; let authorizationServerUrl: string | URL; @@ -1055,6 +1056,7 @@ async function authInternal( // Restore discovery state from cache authorizationServerUrl = cachedState.authorizationServerUrl; resourceMetadata = cachedState.resourceMetadata; + authorizationServerSource = cachedState.authorizationServerSource; metadata = cachedState.authorizationServerMetadata ?? (await discoverAuthorizationServerMetadata(authorizationServerUrl, { @@ -1082,12 +1084,13 @@ async function authInternal( // Re-save if we enriched the cached state with missing metadata if (metadata !== cachedState.authorizationServerMetadata || resourceMetadata !== cachedState.resourceMetadata) { - await provider.saveDiscoveryState?.({ + discoveryStateToSave = { authorizationServerUrl: String(authorizationServerUrl), + authorizationServerSource, resourceMetadataUrl: effectiveResourceMetadataUrl?.toString(), resourceMetadata, authorizationServerMetadata: metadata - }); + }; } } else { // Full discovery via RFC 9728 @@ -1861,6 +1864,12 @@ export interface OAuthServerInfo { * or `undefined` if the server does not support it. */ resourceMetadata?: OAuthProtectedResourceMetadata; + + /** + * Where the authorization server URL came from. Discovery calls set this + * field; it is optional so older persisted discovery state remains valid. + */ + authorizationServerSource?: 'protected-resource-metadata' | 'legacy-fallback'; } /** @@ -1897,6 +1906,7 @@ export async function discoverOAuthServerInfo( ): Promise { let resourceMetadata: OAuthProtectedResourceMetadata | undefined; let authorizationServerUrl: string | undefined; + let authorizationServerSource: OAuthServerInfo['authorizationServerSource']; try { resourceMetadata = await discoverOAuthProtectedResourceMetadata( @@ -1906,6 +1916,7 @@ export async function discoverOAuthServerInfo( ); if (resourceMetadata.authorization_servers && resourceMetadata.authorization_servers.length > 0) { authorizationServerUrl = resourceMetadata.authorization_servers[0]; + authorizationServerSource = 'protected-resource-metadata'; } } catch (error) { // Network failures (DNS, connection refused) surface as TypeError from fetch. Those are @@ -1921,6 +1932,7 @@ export async function discoverOAuthServerInfo( // fall back to the legacy MCP spec behavior: MCP server base URL acts as the authorization server if (!authorizationServerUrl) { authorizationServerUrl = String(new URL('/', serverUrl)); + authorizationServerSource = 'legacy-fallback'; } const authorizationServerMetadata = await discoverAuthorizationServerMetadata(authorizationServerUrl, { @@ -1930,6 +1942,7 @@ export async function discoverOAuthServerInfo( return { authorizationServerUrl, + authorizationServerSource, authorizationServerMetadata, resourceMetadata }; diff --git a/packages/client/test/client/auth.test.ts b/packages/client/test/client/auth.test.ts index da25cca5e4..6a82bf6f66 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -1370,6 +1370,7 @@ describe('OAuth Authorization', () => { const result = await discoverOAuthServerInfo('https://resource.example.com'); expect(result.authorizationServerUrl).toBe('https://auth.example.com'); + expect(result.authorizationServerSource).toBe('protected-resource-metadata'); expect(result.resourceMetadata).toEqual(validResourceMetadata); expect(result.authorizationServerMetadata).toEqual(validAuthMetadata); }); @@ -1404,6 +1405,7 @@ describe('OAuth Authorization', () => { // Should fall back to server URL origin expect(result.authorizationServerUrl).toBe('https://resource.example.com/'); + expect(result.authorizationServerSource).toBe('legacy-fallback'); expect(result.resourceMetadata).toBeUndefined(); expect(result.authorizationServerMetadata).toBeDefined(); }); @@ -5261,6 +5263,80 @@ describe('SEP-2352: authorization server binding', () => { expect(redirectUrl.searchParams.get('client_id')).toBe('new-client-id'); }); + it('invalidates when challenged PRM names a new authorization server without AS metadata', async () => { + const { provider, invalidateCredentials, saveClientInformation, redirectToAuthorization } = createBoundProvider({ + client_id: 'old-client-id', + client_secret: 'old-client-secret' + }); + const resourceMetadataUrl = new URL('https://resource.example.com/.well-known/oauth-protected-resource'); + + provider.discoveryState = vi.fn().mockResolvedValue({ + authorizationServerUrl: oldAuthServerUrl, + authorizationServerSource: 'protected-resource-metadata', + resourceMetadata: sameResourceMetadata, + authorizationServerMetadata: sameAuthMetadata + }); + provider.saveDiscoveryState = vi.fn(); + + mockFetch.mockImplementation((url, init) => { + const urlString = url.toString(); + + if (urlString === resourceMetadataUrl.toString()) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => newResourceMetadata + }); + } + + if (urlString.includes('/.well-known/oauth-authorization-server') || urlString.includes('/.well-known/openid-configuration')) { + return Promise.resolve({ + ok: false, + status: 404, + statusText: 'Not Found', + text: async () => 'not found' + }); + } + + if (urlString === 'https://new-auth.example.com/register' && init?.method === 'POST') { + return Promise.resolve({ + ok: true, + status: 201, + json: async () => ({ + ...JSON.parse(init.body as string), + client_id: 'new-client-id', + client_secret: 'new-client-secret' + }) + }); + } + + return Promise.reject(new Error(`Unexpected fetch: ${urlString}`)); + }); + + const result = await auth(provider, { + serverUrl: 'https://resource.example.com', + resourceMetadataUrl + }); + + expect(result).toBe('REDIRECT'); + expect(invalidateCredentials).toHaveBeenCalledWith('client'); + expect(invalidateCredentials).toHaveBeenCalledWith('tokens'); + expect(provider.saveDiscoveryState).toHaveBeenCalledWith( + expect.objectContaining({ + authorizationServerUrl: 'https://new-auth.example.com', + authorizationServerSource: 'protected-resource-metadata', + resourceMetadataUrl: resourceMetadataUrl.toString(), + resourceMetadata: newResourceMetadata, + authorizationServerMetadata: undefined + }) + ); + expect(saveClientInformation).toHaveBeenCalledWith(expect.objectContaining({ client_id: 'new-client-id' })); + + const redirectUrl: URL = redirectToAuthorization.mock.calls[0]![0]; + expect(redirectUrl.origin).toBe('https://new-auth.example.com'); + expect(redirectUrl.searchParams.get('client_id')).toBe('new-client-id'); + }); + it('does not invalidate credentials when challenged PRM discovery transiently falls back', async () => { const { provider, invalidateCredentials, redirectToAuthorization } = createBoundProvider({ client_id: 'old-client-id', @@ -5313,6 +5389,127 @@ describe('SEP-2352: authorization server binding', () => { expect(redirectUrl.searchParams.get('client_id')).toBe('old-client-id'); }); + it('enriches cached AS metadata when challenged PRM discovery falls back to a URL-only cache', async () => { + const { provider, invalidateCredentials, redirectToAuthorization } = createBoundProvider({ + client_id: 'old-client-id', + client_secret: 'old-client-secret' + }); + const resourceMetadataUrl = new URL('https://resource.example.com/.well-known/oauth-protected-resource'); + + provider.discoveryState = vi.fn().mockResolvedValue({ + authorizationServerUrl: oldAuthServerUrl, + resourceMetadata: sameResourceMetadata + }); + provider.saveDiscoveryState = vi.fn(); + + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + if (urlString === resourceMetadataUrl.toString()) { + return Promise.resolve({ + ok: false, + status: 503, + statusText: 'Service Unavailable', + text: async () => 'temporarily unavailable' + }); + } + + if (urlString.startsWith(`${oldAuthServerUrl}/.well-known/`)) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => sameAuthMetadata + }); + } + + if (urlString.includes('/.well-known/oauth-authorization-server') || urlString.includes('/.well-known/openid-configuration')) { + return Promise.resolve({ + ok: false, + status: 404, + statusText: 'Not Found', + text: async () => 'not found' + }); + } + + return Promise.reject(new Error(`Unexpected fetch: ${urlString}`)); + }); + + const result = await auth(provider, { + serverUrl: 'https://resource.example.com', + resourceMetadataUrl + }); + + expect(result).toBe('REDIRECT'); + expect(invalidateCredentials).not.toHaveBeenCalled(); + expect(provider.saveDiscoveryState).toHaveBeenCalledWith( + expect.objectContaining({ + authorizationServerUrl: oldAuthServerUrl, + resourceMetadataUrl: resourceMetadataUrl.toString(), + resourceMetadata: sameResourceMetadata, + authorizationServerMetadata: sameAuthMetadata + }) + ); + + const redirectUrl: URL = redirectToAuthorization.mock.calls[0]![0]; + expect(redirectUrl.origin).toBe('https://old-auth.example.com'); + expect(redirectUrl.searchParams.get('client_id')).toBe('old-client-id'); + }); + + it('keeps a saved AS URL when PRM discovery falls back without cached discovery state', async () => { + const { provider, invalidateCredentials, redirectToAuthorization } = createBoundProvider({ + client_id: 'old-client-id', + client_secret: 'old-client-secret' + }); + const saveAuthorizationServerUrl = vi.fn(); + provider.saveAuthorizationServerUrl = saveAuthorizationServerUrl; + + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + if (urlString.includes('/.well-known/oauth-protected-resource')) { + return Promise.resolve({ + ok: false, + status: 404, + statusText: 'Not Found', + text: async () => 'not found' + }); + } + + if (urlString.startsWith(`${oldAuthServerUrl}/.well-known/`)) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => sameAuthMetadata + }); + } + + if (urlString.startsWith('https://resource.example.com/.well-known/')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + issuer: 'https://resource.example.com', + authorization_endpoint: 'https://resource.example.com/authorize', + token_endpoint: 'https://resource.example.com/token', + response_types_supported: ['code'] + }) + }); + } + + return Promise.reject(new Error(`Unexpected fetch: ${urlString}`)); + }); + + const result = await auth(provider, { serverUrl: 'https://resource.example.com' }); + + expect(result).toBe('REDIRECT'); + expect(invalidateCredentials).not.toHaveBeenCalled(); + expect(saveAuthorizationServerUrl).not.toHaveBeenCalled(); + + const redirectUrl: URL = redirectToAuthorization.mock.calls[0]![0]; + expect(redirectUrl.origin).toBe('https://old-auth.example.com'); + expect(redirectUrl.searchParams.get('client_id')).toBe('old-client-id'); + }); + it('does not invalidate credentials when the authorization server is unchanged', async () => { const { provider, invalidateCredentials, redirectToAuthorization } = createBoundProvider({ client_id: 'old-client-id', From a6534d5728df7096fa46dddb17faec4b7718bde9 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Thu, 25 Jun 2026 14:24:30 +0200 Subject: [PATCH 06/18] fix(client): avoid cached AS false invalidation --- packages/client/test/client/auth.test.ts | 109 +++++++++++++++++++++++ 1 file changed, 109 insertions(+) diff --git a/packages/client/test/client/auth.test.ts b/packages/client/test/client/auth.test.ts index 6a82bf6f66..899f6dd1b3 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -5389,6 +5389,62 @@ describe('SEP-2352: authorization server binding', () => { expect(redirectUrl.searchParams.get('client_id')).toBe('old-client-id'); }); + it('preserves fresh PRM resource metadata when AS selection falls back to the saved server', async () => { + const { provider, invalidateCredentials, redirectToAuthorization } = createBoundProvider({ + client_id: 'old-client-id', + client_secret: 'old-client-secret' + }); + + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + if (urlString.includes('/.well-known/oauth-protected-resource')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + resource: 'https://resource.example.com', + scopes_supported: ['read:data'] + }) + }); + } + + if (urlString.startsWith(`${oldAuthServerUrl}/.well-known/`)) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => sameAuthMetadata + }); + } + + if (urlString.startsWith('https://resource.example.com/.well-known/')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + issuer: 'https://resource.example.com', + authorization_endpoint: 'https://resource.example.com/authorize', + token_endpoint: 'https://resource.example.com/token', + response_types_supported: ['code'] + }) + }); + } + + return Promise.reject(new Error(`Unexpected fetch: ${urlString}`)); + }); + + const result = await auth(provider, { serverUrl: 'https://resource.example.com' }); + + expect(result).toBe('REDIRECT'); + expect(invalidateCredentials).not.toHaveBeenCalled(); + + const redirectUrl: URL = redirectToAuthorization.mock.calls[0]![0]; + expect(redirectUrl.origin).toBe('https://old-auth.example.com'); + expect(redirectUrl.searchParams.get('client_id')).toBe('old-client-id'); + expect(redirectUrl.searchParams.get('resource')).toBe('https://resource.example.com/'); + expect(redirectUrl.searchParams.get('scope')).toBe('read:data'); + }); + it('enriches cached AS metadata when challenged PRM discovery falls back to a URL-only cache', async () => { const { provider, invalidateCredentials, redirectToAuthorization } = createBoundProvider({ client_id: 'old-client-id', @@ -5510,6 +5566,59 @@ describe('SEP-2352: authorization server binding', () => { expect(redirectUrl.searchParams.get('client_id')).toBe('old-client-id'); }); + it('does not invalidate cached URL-only discovery state when restored AS issuer differs textually', async () => { + const { provider, invalidateCredentials, redirectToAuthorization } = createBoundProvider({ + client_id: 'old-client-id', + client_secret: 'old-client-secret' + }); + const cachedAuthorizationServerUrl = 'https://auth.example.com/tenant1/'; + const cachedAuthMetadata = { + issuer: 'https://auth.example.com/tenant1', + authorization_endpoint: 'https://auth.example.com/tenant1/authorize', + token_endpoint: 'https://auth.example.com/tenant1/token', + registration_endpoint: 'https://auth.example.com/tenant1/register', + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'] + }; + + provider.authorizationServerUrl = vi.fn().mockResolvedValue(cachedAuthorizationServerUrl); + provider.discoveryState = vi.fn().mockResolvedValue({ + authorizationServerUrl: cachedAuthorizationServerUrl, + resourceMetadata: sameResourceMetadata + }); + provider.saveDiscoveryState = vi.fn(); + + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + if (urlString.startsWith('https://auth.example.com/.well-known/')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => cachedAuthMetadata + }); + } + + return Promise.reject(new Error(`Unexpected fetch: ${urlString}`)); + }); + + const result = await auth(provider, { serverUrl: 'https://resource.example.com' }); + + expect(result).toBe('REDIRECT'); + expect(invalidateCredentials).not.toHaveBeenCalled(); + expect(provider.saveDiscoveryState).toHaveBeenCalledWith( + expect.objectContaining({ + authorizationServerUrl: cachedAuthorizationServerUrl, + resourceMetadata: sameResourceMetadata, + authorizationServerMetadata: cachedAuthMetadata + }) + ); + + const redirectUrl: URL = redirectToAuthorization.mock.calls[0]![0]; + expect(redirectUrl.toString()).toContain('https://auth.example.com/tenant1/authorize'); + expect(redirectUrl.searchParams.get('client_id')).toBe('old-client-id'); + }); + it('does not invalidate credentials when the authorization server is unchanged', async () => { const { provider, invalidateCredentials, redirectToAuthorization } = createBoundProvider({ client_id: 'old-client-id', From 28bf20eb42ff450e7d55175652cf9b0abdfb04d8 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Thu, 25 Jun 2026 14:49:22 +0200 Subject: [PATCH 07/18] fix(client): preserve cached AS metadata on rediscovery --- packages/client/test/client/auth.test.ts | 65 ++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/packages/client/test/client/auth.test.ts b/packages/client/test/client/auth.test.ts index 899f6dd1b3..54f56fe559 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -5389,6 +5389,71 @@ describe('SEP-2352: authorization server binding', () => { expect(redirectUrl.searchParams.get('client_id')).toBe('old-client-id'); }); + it('keeps cached AS metadata when challenged PRM confirms the same AS but AS metadata discovery fails', async () => { + const { provider, invalidateCredentials, redirectToAuthorization } = createBoundProvider({ + client_id: 'old-client-id', + client_secret: 'old-client-secret' + }); + const resourceMetadataUrl = new URL('https://resource.example.com/.well-known/oauth-protected-resource'); + const cachedAuthMetadata = { + ...sameAuthMetadata, + authorization_endpoint: `${oldAuthServerUrl}/oauth2/v1/authorize`, + token_endpoint: `${oldAuthServerUrl}/oauth2/v1/token` + }; + + provider.discoveryState = vi.fn().mockResolvedValue({ + authorizationServerUrl: oldAuthServerUrl, + authorizationServerSource: 'protected-resource-metadata', + resourceMetadata: sameResourceMetadata, + authorizationServerMetadata: cachedAuthMetadata + }); + provider.saveDiscoveryState = vi.fn(); + + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + if (urlString === resourceMetadataUrl.toString()) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => sameResourceMetadata + }); + } + + if (urlString.startsWith(`${oldAuthServerUrl}/.well-known/`)) { + return Promise.resolve({ + ok: false, + status: 502, + statusText: 'Bad Gateway', + text: async () => 'temporarily unavailable' + }); + } + + return Promise.reject(new Error(`Unexpected fetch: ${urlString}`)); + }); + + const result = await auth(provider, { + serverUrl: 'https://resource.example.com', + resourceMetadataUrl + }); + + expect(result).toBe('REDIRECT'); + expect(invalidateCredentials).not.toHaveBeenCalled(); + expect(provider.saveDiscoveryState).toHaveBeenCalledWith( + expect.objectContaining({ + authorizationServerUrl: oldAuthServerUrl, + authorizationServerSource: 'protected-resource-metadata', + resourceMetadataUrl: resourceMetadataUrl.toString(), + resourceMetadata: sameResourceMetadata, + authorizationServerMetadata: cachedAuthMetadata + }) + ); + + const redirectUrl: URL = redirectToAuthorization.mock.calls[0]![0]; + expect(redirectUrl.toString()).toContain(`${oldAuthServerUrl}/oauth2/v1/authorize`); + expect(redirectUrl.searchParams.get('client_id')).toBe('old-client-id'); + }); + it('preserves fresh PRM resource metadata when AS selection falls back to the saved server', async () => { const { provider, invalidateCredentials, redirectToAuthorization } = createBoundProvider({ client_id: 'old-client-id', From 7146f9ccb4fc3db03b8ace916096d5b1d2903164 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Thu, 25 Jun 2026 15:45:42 +0200 Subject: [PATCH 08/18] fix(client): preserve AS-bound client during code exchange --- packages/client/src/client/auth.ts | 8 +- packages/client/test/client/auth.test.ts | 96 +++++++++++++++++++++++- 2 files changed, 100 insertions(+), 4 deletions(-) diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index f1cf45ee8b..ea3404ecfc 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -1363,12 +1363,16 @@ export function isHttpsUrl(value?: string): boolean { /** * SEP-2352: Normalizes an authorization server identity (issuer identifier or * authorization server URL) for comparison, so that textual variations of the - * same URL (e.g. a missing trailing slash on an origin-only issuer) do not + * same URL (e.g. a missing trailing slash on an issuer URL) do not * register as an authorization server change. */ function normalizeAuthorizationServerIdentity(value: string): string { try { - return new URL(value).href; + const url = new URL(value); + if (url.pathname !== '/') { + url.pathname = url.pathname.replace(/\/+$/, '') || '/'; + } + return url.href; } catch { return value; } diff --git a/packages/client/test/client/auth.test.ts b/packages/client/test/client/auth.test.ts index 54f56fe559..9149531c61 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -5098,6 +5098,7 @@ describe('SEP-2352: authorization server binding', () => { provider: OAuthClientProvider; invalidateCredentials: Mock; saveClientInformation: Mock; + saveTokens: Mock; redirectToAuthorization: Mock; } { let clientInformation: { client_id: string; client_secret?: string } | undefined = initialClientInformation; @@ -5110,6 +5111,7 @@ describe('SEP-2352: authorization server binding', () => { const saveClientInformation = vi.fn(async (info: { client_id: string; client_secret?: string }) => { clientInformation = info; }); + const saveTokens = vi.fn(); const redirectToAuthorization = vi.fn(); const provider: OAuthClientProvider = { @@ -5125,7 +5127,7 @@ describe('SEP-2352: authorization server binding', () => { clientInformation: vi.fn(async () => clientInformation), saveClientInformation, tokens: vi.fn().mockResolvedValue(undefined), - saveTokens: vi.fn(), + saveTokens, redirectToAuthorization, saveCodeVerifier: vi.fn(), codeVerifier: vi.fn().mockResolvedValue('test_verifier'), @@ -5133,13 +5135,14 @@ describe('SEP-2352: authorization server binding', () => { invalidateCredentials }; - return { provider, invalidateCredentials, saveClientInformation, redirectToAuthorization }; + return { provider, invalidateCredentials, saveClientInformation, saveTokens, redirectToAuthorization }; } function mockDiscoveryAndRegistration(options: { resourceMetadata: { resource: string; authorization_servers: string[] }; authMetadata: { issuer: string }; registeredClient?: { client_id: string; client_secret?: string }; + tokens?: OAuthTokens; }): void { mockFetch.mockImplementation((url, init) => { const urlString = url.toString(); @@ -5174,6 +5177,17 @@ describe('SEP-2352: authorization server binding', () => { }); } + if (urlString.includes('/token') && init?.method === 'POST') { + if (!options.tokens) { + return Promise.reject(new Error(`Unexpected token request: ${urlString}`)); + } + return Promise.resolve({ + ok: true, + status: 200, + json: async () => options.tokens + }); + } + return Promise.reject(new Error(`Unexpected fetch: ${urlString}`)); }); } @@ -5215,6 +5229,46 @@ describe('SEP-2352: authorization server binding', () => { expect(redirectUrl.searchParams.get('client_id')).toBe('new-client-id'); }); + it('keeps the re-registered client while exchanging the authorization code after an AS migration', async () => { + const { provider, invalidateCredentials, saveClientInformation, saveTokens, redirectToAuthorization } = createBoundProvider({ + client_id: 'old-client-id', + client_secret: 'old-client-secret' + }); + + mockDiscoveryAndRegistration({ + resourceMetadata: newResourceMetadata, + authMetadata: newAuthMetadata, + registeredClient: { client_id: 'new-client-id', client_secret: 'new-client-secret' }, + tokens: { access_token: 'new-access-token', token_type: 'Bearer' } + }); + + const redirectResult = await auth(provider, { serverUrl: 'https://resource.example.com' }); + + expect(redirectResult).toBe('REDIRECT'); + expect(invalidateCredentials).toHaveBeenCalledWith('client'); + expect(saveClientInformation).toHaveBeenCalledWith(expect.objectContaining({ client_id: 'new-client-id' })); + + invalidateCredentials.mockClear(); + mockFetch.mockClear(); + + const exchangeResult = await auth(provider, { + serverUrl: 'https://resource.example.com', + authorizationCode: 'returned-code' + }); + + expect(exchangeResult).toBe('AUTHORIZED'); + expect(invalidateCredentials).toHaveBeenCalledWith('tokens'); + expect(invalidateCredentials).not.toHaveBeenCalledWith('client'); + expect(saveTokens).toHaveBeenCalledWith({ access_token: 'new-access-token', token_type: 'Bearer' }); + expect(redirectToAuthorization).toHaveBeenCalledTimes(1); + + const registrationCalls = mockFetch.mock.calls.filter(call => call[0].toString().includes('/register')); + expect(registrationCalls).toHaveLength(0); + const tokenCalls = mockFetch.mock.calls.filter(call => call[0].toString().includes('/token')); + expect(tokenCalls).toHaveLength(1); + expect(tokenCalls[0]![0].toString()).toBe('https://new-auth.example.com/token'); + }); + it('refreshes cached discovery from an explicit resource metadata challenge before comparing authorization servers', async () => { const { provider, invalidateCredentials, saveClientInformation, redirectToAuthorization } = createBoundProvider({ client_id: 'old-client-id', @@ -5684,6 +5738,44 @@ describe('SEP-2352: authorization server binding', () => { expect(redirectUrl.searchParams.get('client_id')).toBe('old-client-id'); }); + it('does not invalidate credentials when path-bearing authorization server identities only differ by a trailing slash', async () => { + const tenantAuthServerUrl = 'https://auth.example.com/tenant1'; + const tenantAuthMetadata = { + issuer: tenantAuthServerUrl, + authorization_endpoint: `${tenantAuthServerUrl}/authorize`, + token_endpoint: `${tenantAuthServerUrl}/token`, + registration_endpoint: `${tenantAuthServerUrl}/register`, + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'] + }; + const { provider, invalidateCredentials, redirectToAuthorization } = createBoundProvider({ + client_id: 'tenant-client-id', + client_secret: 'tenant-client-secret' + }); + + provider.authorizationServerUrl = vi.fn().mockResolvedValue(`${tenantAuthServerUrl}/`); + + mockDiscoveryAndRegistration({ + resourceMetadata: { + resource: 'https://resource.example.com', + authorization_servers: [tenantAuthServerUrl] + }, + authMetadata: tenantAuthMetadata + }); + + const result = await auth(provider, { serverUrl: 'https://resource.example.com' }); + + expect(result).toBe('REDIRECT'); + expect(invalidateCredentials).not.toHaveBeenCalled(); + + const registrationCalls = mockFetch.mock.calls.filter(call => call[0].toString().includes('/register')); + expect(registrationCalls).toHaveLength(0); + + const redirectUrl: URL = redirectToAuthorization.mock.calls[0]![0]; + expect(redirectUrl.toString()).toContain(`${tenantAuthServerUrl}/authorize`); + expect(redirectUrl.searchParams.get('client_id')).toBe('tenant-client-id'); + }); + it('does not invalidate credentials when the authorization server is unchanged', async () => { const { provider, invalidateCredentials, redirectToAuthorization } = createBoundProvider({ client_id: 'old-client-id', From 2b933a644781417fb9975e42251c8cef40ce6ea0 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Fri, 26 Jun 2026 00:01:34 +0200 Subject: [PATCH 09/18] fix(client): persist refreshed resource metadata fallback --- packages/client/test/client/auth.test.ts | 31 +++++++++++++++++++----- 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/packages/client/test/client/auth.test.ts b/packages/client/test/client/auth.test.ts index 9149531c61..daaef3facf 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -5513,18 +5513,28 @@ describe('SEP-2352: authorization server binding', () => { client_id: 'old-client-id', client_secret: 'old-client-secret' }); + const freshResourceMetadata = { + resource: 'https://resource.example.com', + scopes_supported: ['read:data'] + }; + const resourceMetadataUrl = new URL('https://resource.example.com/.well-known/oauth-protected-resource'); + + provider.discoveryState = vi.fn().mockResolvedValue({ + authorizationServerUrl: oldAuthServerUrl, + authorizationServerSource: 'protected-resource-metadata', + resourceMetadata: sameResourceMetadata, + authorizationServerMetadata: sameAuthMetadata + }); + provider.saveDiscoveryState = vi.fn(); mockFetch.mockImplementation(url => { const urlString = url.toString(); - if (urlString.includes('/.well-known/oauth-protected-resource')) { + if (urlString === resourceMetadataUrl.toString()) { return Promise.resolve({ ok: true, status: 200, - json: async () => ({ - resource: 'https://resource.example.com', - scopes_supported: ['read:data'] - }) + json: async () => freshResourceMetadata }); } @@ -5552,10 +5562,19 @@ describe('SEP-2352: authorization server binding', () => { return Promise.reject(new Error(`Unexpected fetch: ${urlString}`)); }); - const result = await auth(provider, { serverUrl: 'https://resource.example.com' }); + const result = await auth(provider, { serverUrl: 'https://resource.example.com', resourceMetadataUrl }); expect(result).toBe('REDIRECT'); expect(invalidateCredentials).not.toHaveBeenCalled(); + expect(provider.saveDiscoveryState).toHaveBeenCalledWith( + expect.objectContaining({ + authorizationServerUrl: oldAuthServerUrl, + authorizationServerSource: 'protected-resource-metadata', + resourceMetadataUrl: resourceMetadataUrl.toString(), + resourceMetadata: freshResourceMetadata, + authorizationServerMetadata: sameAuthMetadata + }) + ); const redirectUrl: URL = redirectToAuthorization.mock.calls[0]![0]; expect(redirectUrl.origin).toBe('https://old-auth.example.com'); From 16c69abab978e60e1de5adce336adafc3150d29d Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Wed, 1 Jul 2026 10:03:31 +0100 Subject: [PATCH 10/18] fix(client): repair sep-2352 rebase --- packages/client/src/client/auth.ts | 136 +++++++++++++++++++---- packages/client/test/client/auth.test.ts | 25 ++++- 2 files changed, 133 insertions(+), 28 deletions(-) diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index ea3404ecfc..c526ae388a 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -1042,7 +1042,10 @@ async function authInternal( let resourceMetadata: OAuthProtectedResourceMetadata | undefined; let authorizationServerUrl: string | URL; let metadata: AuthorizationServerMetadata | undefined; - let freshDiscoveryState: OAuthDiscoveryState | undefined; + let discoveryStateToSave: OAuthDiscoveryState | undefined; + let authorizationServerSource: OAuthServerInfo['authorizationServerSource']; + let reusedSavedAuthorizationServerAfterUnvalidatedDiscovery = false; + let currentAuthorizationServerWasPrmValidated = false; // If resourceMetadataUrl is not provided, try to load it from cached state. // This handles browser redirects where the URL was saved before navigation. @@ -1099,34 +1102,98 @@ async function authInternal( fetchFn, skipIssuerMetadataValidation }); - authorizationServerUrl = serverInfo.authorizationServerUrl; - metadata = serverInfo.authorizationServerMetadata; - resourceMetadata = serverInfo.resourceMetadata; - - // Captured now, persisted only after the SEP-2352 callback-leg gate below — so a - // gate throw cannot leave a freshly resolved (potentially PRM-poisoned) AS recorded - // for the retry to read back as `recordedIssuer`. - // TODO: resourceMetadataUrl is only populated when explicitly provided via options - // or loaded from cached state. The URL derived internally by - // discoverOAuthProtectedResourceMetadata() is not captured back here. - freshDiscoveryState = { - authorizationServerUrl: String(authorizationServerUrl), - resourceMetadataUrl: effectiveResourceMetadataUrl?.toString(), - resourceMetadata, - authorizationServerMetadata: metadata - }; + const discoveryWasUnvalidated = serverInfo.authorizationServerSource !== 'protected-resource-metadata'; + const fallbackAuthorizationServerUrl = cachedState?.authorizationServerUrl ?? savedAuthorizationServerUrl; + + if (discoveryWasUnvalidated && fallbackAuthorizationServerUrl) { + authorizationServerUrl = fallbackAuthorizationServerUrl; + resourceMetadata = serverInfo.resourceMetadata ?? cachedState?.resourceMetadata; + authorizationServerSource = cachedState?.authorizationServerSource; + reusedSavedAuthorizationServerAfterUnvalidatedDiscovery = cachedState?.authorizationServerUrl === undefined; + const fallbackMatchesDiscoveredAuthorizationServer = + normalizeAuthorizationServerIdentity(String(fallbackAuthorizationServerUrl)) === + normalizeAuthorizationServerIdentity(String(serverInfo.authorizationServerUrl)); + metadata = + cachedState?.authorizationServerMetadata ?? + (fallbackMatchesDiscoveredAuthorizationServer ? serverInfo.authorizationServerMetadata : undefined) ?? + (await discoverAuthorizationServerMetadata(fallbackAuthorizationServerUrl, { + fetchFn, + skipIssuerValidation: skipIssuerMetadataValidation + })); + + if ( + cachedState?.authorizationServerUrl && + (metadata !== cachedState.authorizationServerMetadata || resourceMetadata !== cachedState.resourceMetadata) + ) { + discoveryStateToSave = { + authorizationServerUrl: String(authorizationServerUrl), + authorizationServerSource, + resourceMetadataUrl: effectiveResourceMetadataUrl?.toString(), + resourceMetadata, + authorizationServerMetadata: metadata + }; + } + } else { + authorizationServerUrl = serverInfo.authorizationServerUrl; + const discoveredAuthorizationServerMatchesCached = + cachedState?.authorizationServerUrl !== undefined && + normalizeAuthorizationServerIdentity(String(serverInfo.authorizationServerUrl)) === + normalizeAuthorizationServerIdentity(cachedState.authorizationServerUrl); + metadata = + serverInfo.authorizationServerMetadata ?? + (discoveredAuthorizationServerMatchesCached ? cachedState?.authorizationServerMetadata : undefined); + resourceMetadata = serverInfo.resourceMetadata; + authorizationServerSource = serverInfo.authorizationServerSource; + currentAuthorizationServerWasPrmValidated = authorizationServerSource === 'protected-resource-metadata'; + + // Persist discovery state for future use. + // TODO: resourceMetadataUrl is only populated when explicitly provided via options + // or loaded from cached state. The URL derived internally by + // discoverOAuthProtectedResourceMetadata() is not captured back here. + if (authorizationServerSource === 'protected-resource-metadata' || !fallbackAuthorizationServerUrl) { + discoveryStateToSave = { + authorizationServerUrl: String(authorizationServerUrl), + authorizationServerSource, + resourceMetadataUrl: effectiveResourceMetadataUrl?.toString(), + resourceMetadata, + authorizationServerMetadata: metadata + }; + } + } } + // SEP-2352: Authorization server binding. Client credentials are bound to the + // authorization server that issued them; when discovery shows the authorization + // server has changed (e.g., via updated protected resource metadata), stale client + // credentials and tokens MUST NOT be reused and the client MUST re-register. + // + // Canonical comparison key: the validated authorization server metadata `issuer` + // (the identifier SEP-2352 specifies). The authorization server URL is only + // comparable when it came from protected resource metadata. Legacy fallback to + // the MCP server origin is not authoritative enough to invalidate credentials. + const previousAuthServerIdentities = [ + cachedState?.authorizationServerMetadata?.issuer, + cachedState?.authorizationServerUrl, + savedAuthorizationServerUrl + ] + .filter((value): value is string => typeof value === 'string' && value.length > 0) + .map(value => normalizeAuthorizationServerIdentity(value)); + const currentAuthServerIdentities = ( + currentAuthorizationServerWasPrmValidated ? [metadata?.issuer, String(authorizationServerUrl)] : [] + ) + .filter((value): value is string => typeof value === 'string' && value.length > 0) + .map(value => normalizeAuthorizationServerIdentity(value)); + const authorizationServerChanged = + previousAuthServerIdentities.length > 0 && + currentAuthServerIdentities.length > 0 && + !currentAuthServerIdentities.some(identity => previousAuthServerIdentities.includes(identity)); + // SEP-2352: the canonical authorization-server identity for this flow. `metadata.issuer` // is RFC 8414 §3.3-validated to equal the discovery URL; when no metadata document was // found (legacy fallback) the discovery URL itself is the only identifier available. const issuer = metadata?.issuer ?? String(authorizationServerUrl); const infoCtx: OAuthClientInformationContext = { issuer }; - // Deprecated write-only hook, kept for providers (e.g. Cross-App Access) that read it - // internally. The SDK never reads `authorizationServerUrl()`. - await provider.saveAuthorizationServerUrl?.(issuer); - // SEP-2352 callback-leg gate. Stored credentials are protected structurally by the // issuer stamp, but the in-flight `authorization_code` + PKCE `code_verifier` are not // stored — they are bound to the AS the redirect targeted, recorded in `discoveryState()`. @@ -1155,8 +1222,31 @@ async function authInternal( } } - if (freshDiscoveryState) { - await provider.saveDiscoveryState?.(freshDiscoveryState); + if (authorizationServerChanged) { + await provider.invalidateCredentials?.('tokens'); + + const staleClientInformation = await Promise.resolve(provider.clientInformation()); + // CIMD (URL-based) client IDs are portable across authorization servers + // (SEP-991/SEP-2352) — no client invalidation or re-registration is needed. + // During code exchange, keep the client registered by the redirect flow + // that produced this authorization code. + if (staleClientInformation && !isHttpsUrl(staleClientInformation.client_id) && authorizationCode === undefined) { + await provider.invalidateCredentials?.('client'); + } + } + + if (discoveryStateToSave) { + await provider.saveDiscoveryState?.(discoveryStateToSave); + } + + // Save authorization server URL for providers that need it (e.g., CrossAppAccessProvider). + // Do not replace an existing AS with legacy fallback; fallback is not authoritative + // enough to overwrite a URL discovered from protected resource metadata. + if ( + !reusedSavedAuthorizationServerAfterUnvalidatedDiscovery && + (authorizationServerSource !== 'legacy-fallback' || previousAuthServerIdentities.length === 0) + ) { + await provider.saveAuthorizationServerUrl?.(String(authorizationServerUrl)); } const resource: URL | undefined = await selectResourceURL(serverUrl, provider, resourceMetadata); diff --git a/packages/client/test/client/auth.test.ts b/packages/client/test/client/auth.test.ts index daaef3facf..4780d5df04 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -5221,7 +5221,10 @@ describe('SEP-2352: authorization server binding', () => { const registrationCalls = mockFetch.mock.calls.filter(call => call[0].toString().includes('/register')); expect(registrationCalls).toHaveLength(1); expect(registrationCalls[0]![0].toString()).toBe('https://new-auth.example.com/register'); - expect(saveClientInformation).toHaveBeenCalledWith(expect.objectContaining({ client_id: 'new-client-id' })); + expect(saveClientInformation).toHaveBeenCalledWith( + expect.objectContaining({ client_id: 'new-client-id' }), + expect.objectContaining({ issuer: 'https://new-auth.example.com' }) + ); // The authorization redirect uses the newly registered client, not the stale one const redirectUrl: URL = redirectToAuthorization.mock.calls[0]![0]; @@ -5246,7 +5249,10 @@ describe('SEP-2352: authorization server binding', () => { expect(redirectResult).toBe('REDIRECT'); expect(invalidateCredentials).toHaveBeenCalledWith('client'); - expect(saveClientInformation).toHaveBeenCalledWith(expect.objectContaining({ client_id: 'new-client-id' })); + expect(saveClientInformation).toHaveBeenCalledWith( + expect.objectContaining({ client_id: 'new-client-id' }), + expect.objectContaining({ issuer: 'https://new-auth.example.com' }) + ); invalidateCredentials.mockClear(); mockFetch.mockClear(); @@ -5259,7 +5265,10 @@ describe('SEP-2352: authorization server binding', () => { expect(exchangeResult).toBe('AUTHORIZED'); expect(invalidateCredentials).toHaveBeenCalledWith('tokens'); expect(invalidateCredentials).not.toHaveBeenCalledWith('client'); - expect(saveTokens).toHaveBeenCalledWith({ access_token: 'new-access-token', token_type: 'Bearer' }); + expect(saveTokens).toHaveBeenCalledWith( + { access_token: 'new-access-token', token_type: 'Bearer', issuer: 'https://new-auth.example.com' }, + expect.objectContaining({ issuer: 'https://new-auth.example.com' }) + ); expect(redirectToAuthorization).toHaveBeenCalledTimes(1); const registrationCalls = mockFetch.mock.calls.filter(call => call[0].toString().includes('/register')); @@ -5310,7 +5319,10 @@ describe('SEP-2352: authorization server binding', () => { ); expect(invalidateCredentials).toHaveBeenCalledWith('client'); expect(invalidateCredentials).toHaveBeenCalledWith('tokens'); - expect(saveClientInformation).toHaveBeenCalledWith(expect.objectContaining({ client_id: 'new-client-id' })); + expect(saveClientInformation).toHaveBeenCalledWith( + expect.objectContaining({ client_id: 'new-client-id' }), + expect.objectContaining({ issuer: 'https://new-auth.example.com' }) + ); const redirectUrl: URL = redirectToAuthorization.mock.calls[0]![0]; expect(redirectUrl.origin).toBe('https://new-auth.example.com'); @@ -5384,7 +5396,10 @@ describe('SEP-2352: authorization server binding', () => { authorizationServerMetadata: undefined }) ); - expect(saveClientInformation).toHaveBeenCalledWith(expect.objectContaining({ client_id: 'new-client-id' })); + expect(saveClientInformation).toHaveBeenCalledWith( + expect.objectContaining({ client_id: 'new-client-id' }), + expect.objectContaining({ issuer: 'https://new-auth.example.com' }) + ); const redirectUrl: URL = redirectToAuthorization.mock.calls[0]![0]; expect(redirectUrl.origin).toBe('https://new-auth.example.com'); From 705548fd570a62c9aac076700565acd776ad6970 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Wed, 1 Jul 2026 10:45:38 +0100 Subject: [PATCH 11/18] fix(docs): clarify authorization server fallback hooks --- docs/migration/upgrade-to-v2.md | 16 +++++++++------- packages/client/src/client/auth.ts | 26 ++++++++++++++++---------- 2 files changed, 25 insertions(+), 17 deletions(-) diff --git a/docs/migration/upgrade-to-v2.md b/docs/migration/upgrade-to-v2.md index 453eb53dcc..3ad33b6bd9 100644 --- a/docs/migration/upgrade-to-v2.md +++ b/docs/migration/upgrade-to-v2.md @@ -1247,13 +1247,15 @@ no `ctx`). New TypeScript-only aliases `StoredOAuthTokens` / `StoredOAuthClientI add an optional `issuer?: string` field on top of the wire types. `OAuthClientProvider.saveAuthorizationServerUrl()` / `authorizationServerUrl()` are -`@deprecated` (still written for back-compat, never read by the SDK). The bundled -`ClientCredentialsProvider`, `PrivateKeyJwtProvider`, `StaticPrivateKeyJwtProvider`, and -`CrossAppAccessProvider` gain `expectedIssuer?: string` and no longer define -`saveClientInformation()`. Implement `discoveryState()` / `saveDiscoveryState()` so the -callback leg can verify it is exchanging the code at the same AS the redirect targeted; -without it the SDK `console.warn`s once per callback (`discoveryState` must persist with -the same durability as `codeVerifier`). Both methods are optional on +`@deprecated`: `auth()` still writes `saveAuthorizationServerUrl()` for back-compat and +may read `authorizationServerUrl()` only as a legacy fallback when no `discoveryState()` +is available and fresh discovery did not validate an AS through protected resource +metadata. The bundled `ClientCredentialsProvider`, `PrivateKeyJwtProvider`, +`StaticPrivateKeyJwtProvider`, and `CrossAppAccessProvider` gain `expectedIssuer?: string` +and no longer define `saveClientInformation()`. Implement `discoveryState()` / +`saveDiscoveryState()` so the callback leg can verify it is exchanging the code at the +same AS the redirect targeted; without it the SDK `console.warn`s once per callback +(`discoveryState` must persist with the same durability as `codeVerifier`). Both methods are optional on `OAuthClientProvider` and may be sync or async; `OAuthDiscoveryState` (exported from `@modelcontextprotocol/client`) extends `OAuthServerInfo` with the optional `resourceMetadataUrl` the protected-resource metadata was found at: diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index c526ae388a..aa39b726e6 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -394,23 +394,29 @@ export interface OAuthClientProvider { prepareTokenRequest?(scope?: string): URLSearchParams | Promise | undefined; /** - * Saves the resolved authorization-server **issuer**. Called after a successful - * token exchange (timing changed in v2: was post-discovery, now post-`saveTokens`). + * Saves the resolved authorization-server URL/issuer identity for legacy providers. + * Called after discovery and callback-leg authorization-server binding succeed, before + * client credentials or tokens are read or written. * - * @deprecated Superseded by the `issuer` stamp on stored tokens / client credentials - * (SEP-2352). {@linkcode auth} still **writes** this for back-compat with providers - * that read it (e.g. Cross-App Access), but the SDK never reads it. Prefer reading - * the `issuer` field on the value passed to {@linkcode saveTokens} / - * {@linkcode saveClientInformation}, or the `ctx.issuer` argument. + * @deprecated Superseded by {@linkcode saveDiscoveryState} and by the `issuer` stamp on + * stored tokens / client credentials (SEP-2352). {@linkcode auth} still **writes** this + * for back-compat with providers that read it internally (e.g. Cross-App Access), and + * may read {@linkcode authorizationServerUrl} only as a legacy fallback when no + * `discoveryState()` is available and fresh discovery did not validate an AS through + * protected resource metadata. Prefer {@linkcode discoveryState} plus the `issuer` field + * on the value passed to {@linkcode saveTokens} / {@linkcode saveClientInformation}, or + * the `ctx.issuer` argument. */ saveAuthorizationServerUrl?(authorizationServerUrl: string): void | Promise; /** * Returns the previously saved authorization server URL, if available. * - * @deprecated Superseded by the `issuer` stamp on stored tokens / client credentials - * (SEP-2352). The SDK never reads this method; it remains for provider implementations - * that consume the value internally (e.g. Cross-App Access). + * @deprecated Superseded by {@linkcode discoveryState} and by the `issuer` stamp on + * stored tokens / client credentials (SEP-2352). {@linkcode auth} may read this only as a + * legacy fallback when no persisted `discoveryState()` is available and fresh discovery did + * not validate an AS through protected resource metadata. New providers should implement + * {@linkcode discoveryState} / {@linkcode saveDiscoveryState} instead. */ authorizationServerUrl?(): string | undefined | Promise; From 8cbf10adb350416ea31cf65f446ccaa53d08595e Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Wed, 1 Jul 2026 11:18:32 +0100 Subject: [PATCH 12/18] fix(docs): clarify sep-2352 legacy url hooks --- docs/migration/upgrade-to-v2.md | 8 +++++--- packages/client/src/client/auth.ts | 25 ++++++++++++++----------- 2 files changed, 19 insertions(+), 14 deletions(-) diff --git a/docs/migration/upgrade-to-v2.md b/docs/migration/upgrade-to-v2.md index 3ad33b6bd9..8193c570a2 100644 --- a/docs/migration/upgrade-to-v2.md +++ b/docs/migration/upgrade-to-v2.md @@ -1248,9 +1248,11 @@ add an optional `issuer?: string` field on top of the wire types. `OAuthClientProvider.saveAuthorizationServerUrl()` / `authorizationServerUrl()` are `@deprecated`: `auth()` still writes `saveAuthorizationServerUrl()` for back-compat and -may read `authorizationServerUrl()` only as a legacy fallback when no `discoveryState()` -is available and fresh discovery did not validate an AS through protected resource -metadata. The bundled `ClientCredentialsProvider`, `PrivateKeyJwtProvider`, +may read `authorizationServerUrl()` as a previously recorded AS identity for SEP-2352 +change detection; when fresh protected-resource-metadata discovery validates a different +AS, that value can trigger token/client invalidation. It may also be used as a legacy +fallback when no persisted `discoveryState()` is available and discovery is unvalidated. +The bundled `ClientCredentialsProvider`, `PrivateKeyJwtProvider`, `StaticPrivateKeyJwtProvider`, and `CrossAppAccessProvider` gain `expectedIssuer?: string` and no longer define `saveClientInformation()`. Implement `discoveryState()` / `saveDiscoveryState()` so the callback leg can verify it is exchanging the code at the diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index aa39b726e6..1bc7915655 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -395,17 +395,18 @@ export interface OAuthClientProvider { /** * Saves the resolved authorization-server URL/issuer identity for legacy providers. - * Called after discovery and callback-leg authorization-server binding succeed, before - * client credentials or tokens are read or written. + * Called after discovery, SEP-2352 authorization-server change detection, any resulting + * credential invalidation, and callback-leg authorization-server binding succeed. * * @deprecated Superseded by {@linkcode saveDiscoveryState} and by the `issuer` stamp on * stored tokens / client credentials (SEP-2352). {@linkcode auth} still **writes** this * for back-compat with providers that read it internally (e.g. Cross-App Access), and - * may read {@linkcode authorizationServerUrl} only as a legacy fallback when no - * `discoveryState()` is available and fresh discovery did not validate an AS through - * protected resource metadata. Prefer {@linkcode discoveryState} plus the `issuer` field - * on the value passed to {@linkcode saveTokens} / {@linkcode saveClientInformation}, or - * the `ctx.issuer` argument. + * may read {@linkcode authorizationServerUrl} as a previously recorded AS identity for + * SEP-2352 change detection; when fresh protected-resource-metadata discovery validates a + * different AS, that value can trigger token/client invalidation. It may also be used as a + * legacy fallback when no `discoveryState()` is available and discovery is unvalidated. + * Prefer {@linkcode discoveryState} plus the `issuer` field on the value passed to + * {@linkcode saveTokens} / {@linkcode saveClientInformation}, or the `ctx.issuer` argument. */ saveAuthorizationServerUrl?(authorizationServerUrl: string): void | Promise; @@ -413,10 +414,12 @@ export interface OAuthClientProvider { * Returns the previously saved authorization server URL, if available. * * @deprecated Superseded by {@linkcode discoveryState} and by the `issuer` stamp on - * stored tokens / client credentials (SEP-2352). {@linkcode auth} may read this only as a - * legacy fallback when no persisted `discoveryState()` is available and fresh discovery did - * not validate an AS through protected resource metadata. New providers should implement - * {@linkcode discoveryState} / {@linkcode saveDiscoveryState} instead. + * stored tokens / client credentials (SEP-2352). {@linkcode auth} may read this as a + * previously recorded AS identity for SEP-2352 change detection; when fresh + * protected-resource-metadata discovery validates a different AS, that value can trigger + * token/client invalidation. It may also be used as a legacy fallback when no persisted + * `discoveryState()` is available and discovery is unvalidated. New providers should + * implement {@linkcode discoveryState} / {@linkcode saveDiscoveryState} instead. */ authorizationServerUrl?(): string | undefined | Promise; From 7a451448b8f751ad605f25d162ce83ea81f54357 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Wed, 1 Jul 2026 12:07:32 +0100 Subject: [PATCH 13/18] fix(client): discard unstamped credentials after AS change --- packages/client/src/client/auth.ts | 16 ++++-- packages/client/test/client/auth.test.ts | 63 ++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 4 deletions(-) diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index 1bc7915655..ae1387510f 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -1277,9 +1277,16 @@ async function authInternal( // stamp names a different authorization server reads back as `undefined`, so the flow // re-registers exactly as if nothing were stored. const rawClientInfo = await Promise.resolve(provider.clientInformation(infoCtx)); - let clientInformation = discardIfIssuerMismatch(rawClientInfo, issuer, { - canPersistStamp: provider.saveClientInformation !== undefined - }); + const discardUnstampedClientInfoAfterAsChange = + authorizationServerChanged && + rawClientInfo !== undefined && + rawClientInfo.issuer === undefined && + !isHttpsUrl(rawClientInfo.client_id); + let clientInformation = discardUnstampedClientInfoAfterAsChange + ? undefined + : discardIfIssuerMismatch(rawClientInfo, issuer, { + canPersistStamp: provider.saveClientInformation !== undefined + }); if (clientInformation === undefined && rawClientInfo?.issuer && provider.saveClientInformation === undefined) { // Static-credential provider (no DCR) whose `expectedIssuer` stamp names a different // AS — surface the typed error with both issuers rather than the generic @@ -1363,7 +1370,8 @@ async function authInternal( // SEP-2352: a refresh_token stamped for a different authorization server reads back // as `undefined`, so it is never POSTed to this AS's token endpoint. - let tokens = discardIfIssuerMismatch(await provider.tokens(infoCtx), issuer); + const rawTokens = await provider.tokens(infoCtx); + let tokens = authorizationServerChanged && rawTokens?.issuer === undefined ? undefined : discardIfIssuerMismatch(rawTokens, issuer); if (tokens && tokens.issuer === undefined) { // SEP-2352 back-stamp: bind a legacy unstamped token set to the first-resolved AS // so the stamp check is effective from the next call onward. diff --git a/packages/client/test/client/auth.test.ts b/packages/client/test/client/auth.test.ts index 4780d5df04..ddcd248367 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -5232,6 +5232,69 @@ describe('SEP-2352: authorization server binding', () => { expect(redirectUrl.searchParams.get('client_id')).toBe('new-client-id'); }); + it('treats unstamped legacy credentials as stale after an AS migration without an invalidation hook', async () => { + let clientInformation: { client_id: string; client_secret?: string; issuer?: string } | undefined = { + client_id: 'old-client-id', + client_secret: 'old-client-secret' + }; + let tokens: StoredOAuthTokens | undefined = { + access_token: 'old-access-token', + refresh_token: 'old-refresh-token', + token_type: 'Bearer' + }; + const saveClientInformation = vi.fn(async (info: StoredOAuthClientInformation) => { + clientInformation = info; + }); + const saveTokens = vi.fn(async (nextTokens: StoredOAuthTokens) => { + tokens = nextTokens; + }); + const redirectToAuthorization = vi.fn(); + const provider: OAuthClientProvider = { + get redirectUrl() { + return 'http://localhost:3000/callback'; + }, + get clientMetadata() { + return { + redirect_uris: ['http://localhost:3000/callback'], + client_name: 'Test Client' + }; + }, + clientInformation: vi.fn(async () => clientInformation), + saveClientInformation, + tokens: vi.fn(async () => tokens), + saveTokens, + redirectToAuthorization, + saveCodeVerifier: vi.fn(), + codeVerifier: vi.fn().mockResolvedValue('test_verifier'), + authorizationServerUrl: vi.fn().mockResolvedValue(oldAuthServerUrl) + }; + + mockDiscoveryAndRegistration({ + resourceMetadata: newResourceMetadata, + authMetadata: newAuthMetadata, + registeredClient: { client_id: 'new-client-id', client_secret: 'new-client-secret' }, + tokens: { access_token: 'refreshed-token', token_type: 'Bearer' } + }); + + const result = await auth(provider, { serverUrl: 'https://resource.example.com' }); + + expect(result).toBe('REDIRECT'); + + const registrationCalls = mockFetch.mock.calls.filter(call => call[0].toString().includes('/register')); + expect(registrationCalls).toHaveLength(1); + expect(saveClientInformation).toHaveBeenCalledWith( + expect.objectContaining({ client_id: 'new-client-id', issuer: 'https://new-auth.example.com' }), + expect.objectContaining({ issuer: 'https://new-auth.example.com' }) + ); + const tokenCalls = mockFetch.mock.calls.filter(call => call[0].toString().includes('/token')); + expect(tokenCalls).toHaveLength(0); + expect(saveTokens).not.toHaveBeenCalled(); + + const redirectUrl: URL = redirectToAuthorization.mock.calls[0]![0]; + expect(redirectUrl.origin).toBe('https://new-auth.example.com'); + expect(redirectUrl.searchParams.get('client_id')).toBe('new-client-id'); + }); + it('keeps the re-registered client while exchanging the authorization code after an AS migration', async () => { const { provider, invalidateCredentials, saveClientInformation, saveTokens, redirectToAuthorization } = createBoundProvider({ client_id: 'old-client-id', From 437d9228b5ac12ca811dcda0ecb14b15e504e011 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Wed, 1 Jul 2026 12:53:00 +0100 Subject: [PATCH 14/18] fix(client): preserve portable clients across AS changes --- packages/client/src/client/auth.ts | 21 +++- packages/client/test/client/auth.test.ts | 135 +++++++++++++++++++++++ 2 files changed, 151 insertions(+), 5 deletions(-) diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index ae1387510f..23fe615fc5 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -1279,14 +1279,25 @@ async function authInternal( const rawClientInfo = await Promise.resolve(provider.clientInformation(infoCtx)); const discardUnstampedClientInfoAfterAsChange = authorizationServerChanged && + authorizationCode === undefined && rawClientInfo !== undefined && rawClientInfo.issuer === undefined && !isHttpsUrl(rawClientInfo.client_id); - let clientInformation = discardUnstampedClientInfoAfterAsChange - ? undefined - : discardIfIssuerMismatch(rawClientInfo, issuer, { - canPersistStamp: provider.saveClientInformation !== undefined - }); + const restampPortableClientInfo = + rawClientInfo !== undefined && + isHttpsUrl(rawClientInfo.client_id) && + (rawClientInfo.issuer === undefined || !issuersMatch(rawClientInfo.issuer, issuer)); + let clientInformation: StoredOAuthClientInformation | undefined; + if (restampPortableClientInfo) { + clientInformation = { ...rawClientInfo, issuer }; + } else if (!discardUnstampedClientInfoAfterAsChange) { + clientInformation = discardIfIssuerMismatch(rawClientInfo, issuer, { + canPersistStamp: provider.saveClientInformation !== undefined + }); + } + if (restampPortableClientInfo && clientInformation) { + await provider.saveClientInformation?.(clientInformation, infoCtx); + } if (clientInformation === undefined && rawClientInfo?.issuer && provider.saveClientInformation === undefined) { // Static-credential provider (no DCR) whose `expectedIssuer` stamp names a different // AS — surface the typed error with both issuers rather than the generic diff --git a/packages/client/test/client/auth.test.ts b/packages/client/test/client/auth.test.ts index ddcd248367..46efd35654 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -5341,6 +5341,141 @@ describe('SEP-2352: authorization server binding', () => { expect(tokenCalls[0]![0].toString()).toBe('https://new-auth.example.com/token'); }); + it('keeps an issuer-stripped re-registered client while exchanging the authorization code after an AS migration', async () => { + let clientInformation: { client_id: string; client_secret?: string } | undefined = { + client_id: 'old-client-id', + client_secret: 'old-client-secret' + }; + const invalidateCredentials = vi.fn(async (scope: 'all' | 'client' | 'tokens' | 'verifier' | 'discovery') => { + if (scope === 'all' || scope === 'client') { + clientInformation = undefined; + } + }); + const saveClientInformation = vi.fn(async (info: StoredOAuthClientInformation) => { + clientInformation = { client_id: info.client_id, client_secret: info.client_secret }; + }); + const saveTokens = vi.fn(); + const redirectToAuthorization = vi.fn(); + const provider: OAuthClientProvider = { + get redirectUrl() { + return 'http://localhost:3000/callback'; + }, + get clientMetadata() { + return { + redirect_uris: ['http://localhost:3000/callback'], + client_name: 'Test Client' + }; + }, + clientInformation: vi.fn(async () => clientInformation), + saveClientInformation, + tokens: vi.fn().mockResolvedValue(undefined), + saveTokens, + redirectToAuthorization, + saveCodeVerifier: vi.fn(), + codeVerifier: vi.fn().mockResolvedValue('test_verifier'), + authorizationServerUrl: vi.fn().mockResolvedValue(oldAuthServerUrl), + invalidateCredentials + }; + + mockDiscoveryAndRegistration({ + resourceMetadata: newResourceMetadata, + authMetadata: newAuthMetadata, + registeredClient: { client_id: 'new-client-id', client_secret: 'new-client-secret' }, + tokens: { access_token: 'new-access-token', token_type: 'Bearer' } + }); + + const redirectResult = await auth(provider, { serverUrl: 'https://resource.example.com' }); + + expect(redirectResult).toBe('REDIRECT'); + expect(invalidateCredentials).toHaveBeenCalledWith('client'); + expect(saveClientInformation).toHaveBeenCalledWith( + expect.objectContaining({ client_id: 'new-client-id', issuer: 'https://new-auth.example.com' }), + expect.objectContaining({ issuer: 'https://new-auth.example.com' }) + ); + + invalidateCredentials.mockClear(); + saveClientInformation.mockClear(); + mockFetch.mockClear(); + + const exchangeResult = await auth(provider, { + serverUrl: 'https://resource.example.com', + authorizationCode: 'returned-code' + }); + + expect(exchangeResult).toBe('AUTHORIZED'); + expect(invalidateCredentials).toHaveBeenCalledWith('tokens'); + expect(invalidateCredentials).not.toHaveBeenCalledWith('client'); + expect(saveClientInformation).toHaveBeenCalledWith( + expect.objectContaining({ client_id: 'new-client-id', issuer: 'https://new-auth.example.com' }), + expect.objectContaining({ issuer: 'https://new-auth.example.com' }) + ); + expect(saveTokens).toHaveBeenCalledWith( + { access_token: 'new-access-token', token_type: 'Bearer', issuer: 'https://new-auth.example.com' }, + expect.objectContaining({ issuer: 'https://new-auth.example.com' }) + ); + expect(redirectToAuthorization).toHaveBeenCalledTimes(1); + + const registrationCalls = mockFetch.mock.calls.filter(call => call[0].toString().includes('/register')); + expect(registrationCalls).toHaveLength(0); + const tokenCalls = mockFetch.mock.calls.filter(call => call[0].toString().includes('/token')); + expect(tokenCalls).toHaveLength(1); + expect(tokenCalls[0]![0].toString()).toBe('https://new-auth.example.com/token'); + }); + + it('preserves and restamps URL-based client IDs after an AS migration', async () => { + const clientMetadataUrl = 'https://client.example.com/metadata.json'; + let clientInformation: StoredOAuthClientInformation | undefined = { + client_id: clientMetadataUrl, + issuer: oldAuthServerUrl + }; + const invalidateCredentials = vi.fn(); + const saveClientInformation = vi.fn(async (info: StoredOAuthClientInformation) => { + clientInformation = info; + }); + const redirectToAuthorization = vi.fn(); + const provider: OAuthClientProvider = { + get redirectUrl() { + return 'http://localhost:3000/callback'; + }, + get clientMetadata() { + return { + redirect_uris: ['http://localhost:3000/callback'], + client_name: 'Test Client' + }; + }, + clientInformation: vi.fn(async () => clientInformation), + saveClientInformation, + tokens: vi.fn().mockResolvedValue(undefined), + saveTokens: vi.fn(), + redirectToAuthorization, + saveCodeVerifier: vi.fn(), + codeVerifier: vi.fn().mockResolvedValue('test_verifier'), + authorizationServerUrl: vi.fn().mockResolvedValue(oldAuthServerUrl), + invalidateCredentials + }; + + mockDiscoveryAndRegistration({ + resourceMetadata: newResourceMetadata, + authMetadata: newAuthMetadata + }); + + const result = await auth(provider, { serverUrl: 'https://resource.example.com' }); + + expect(result).toBe('REDIRECT'); + expect(invalidateCredentials).toHaveBeenCalledWith('tokens'); + expect(invalidateCredentials).not.toHaveBeenCalledWith('client'); + expect(saveClientInformation).toHaveBeenCalledWith( + { client_id: clientMetadataUrl, issuer: 'https://new-auth.example.com' }, + expect.objectContaining({ issuer: 'https://new-auth.example.com' }) + ); + + const registrationCalls = mockFetch.mock.calls.filter(call => call[0].toString().includes('/register')); + expect(registrationCalls).toHaveLength(0); + const redirectUrl: URL = redirectToAuthorization.mock.calls[0]![0]; + expect(redirectUrl.origin).toBe('https://new-auth.example.com'); + expect(redirectUrl.searchParams.get('client_id')).toBe(clientMetadataUrl); + }); + it('refreshes cached discovery from an explicit resource metadata challenge before comparing authorization servers', async () => { const { provider, invalidateCredentials, saveClientInformation, redirectToAuthorization } = createBoundProvider({ client_id: 'old-client-id', From c3204944c2298d81ba9242ce41d9d42a8d873839 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Wed, 1 Jul 2026 13:25:23 +0100 Subject: [PATCH 15/18] fix(client): gate portable client restamping --- packages/client/src/client/auth.ts | 8 +- packages/client/test/client/auth.test.ts | 105 ++++++++++++++++++++++- 2 files changed, 108 insertions(+), 5 deletions(-) diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index 23fe615fc5..dcf96a31e2 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -1202,6 +1202,7 @@ async function authInternal( // found (legacy fallback) the discovery URL itself is the only identifier available. const issuer = metadata?.issuer ?? String(authorizationServerUrl); const infoCtx: OAuthClientInformationContext = { issuer }; + const supportsUrlBasedClientId = metadata?.client_id_metadata_document_supported === true; // SEP-2352 callback-leg gate. Stored credentials are protected structurally by the // issuer stamp, but the in-flight `authorization_code` + PKCE `code_verifier` are not @@ -1235,11 +1236,13 @@ async function authInternal( await provider.invalidateCredentials?.('tokens'); const staleClientInformation = await Promise.resolve(provider.clientInformation()); + const staleClientIsPortableAtCurrentAs = + staleClientInformation !== undefined && isHttpsUrl(staleClientInformation.client_id) && supportsUrlBasedClientId; // CIMD (URL-based) client IDs are portable across authorization servers // (SEP-991/SEP-2352) — no client invalidation or re-registration is needed. // During code exchange, keep the client registered by the redirect flow // that produced this authorization code. - if (staleClientInformation && !isHttpsUrl(staleClientInformation.client_id) && authorizationCode === undefined) { + if (staleClientInformation && !staleClientIsPortableAtCurrentAs && authorizationCode === undefined) { await provider.invalidateCredentials?.('client'); } } @@ -1284,6 +1287,8 @@ async function authInternal( rawClientInfo.issuer === undefined && !isHttpsUrl(rawClientInfo.client_id); const restampPortableClientInfo = + authorizationCode === undefined && + supportsUrlBasedClientId && rawClientInfo !== undefined && isHttpsUrl(rawClientInfo.client_id) && (rawClientInfo.issuer === undefined || !issuersMatch(rawClientInfo.issuer, issuer)); @@ -1316,7 +1321,6 @@ async function authInternal( throw new Error('Existing OAuth client information is required when exchanging an authorization code'); } - const supportsUrlBasedClientId = metadata?.client_id_metadata_document_supported === true; const clientMetadataUrl = provider.clientMetadataUrl; if (clientMetadataUrl && !isHttpsUrl(clientMetadataUrl)) { diff --git a/packages/client/test/client/auth.test.ts b/packages/client/test/client/auth.test.ts index 46efd35654..5028ed7a7b 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -5140,7 +5140,7 @@ describe('SEP-2352: authorization server binding', () => { function mockDiscoveryAndRegistration(options: { resourceMetadata: { resource: string; authorization_servers: string[] }; - authMetadata: { issuer: string }; + authMetadata: { issuer: string; client_id_metadata_document_supported?: boolean }; registeredClient?: { client_id: string; client_secret?: string }; tokens?: OAuthTokens; }): void { @@ -5456,7 +5456,7 @@ describe('SEP-2352: authorization server binding', () => { mockDiscoveryAndRegistration({ resourceMetadata: newResourceMetadata, - authMetadata: newAuthMetadata + authMetadata: { ...newAuthMetadata, client_id_metadata_document_supported: true } }); const result = await auth(provider, { serverUrl: 'https://resource.example.com' }); @@ -5476,6 +5476,105 @@ describe('SEP-2352: authorization server binding', () => { expect(redirectUrl.searchParams.get('client_id')).toBe(clientMetadataUrl); }); + it('falls back to DCR for URL-based client IDs when the new AS does not advertise CIMD support', async () => { + const clientMetadataUrl = 'https://client.example.com/metadata.json'; + let clientInformation: StoredOAuthClientInformation | undefined = { + client_id: clientMetadataUrl, + issuer: oldAuthServerUrl + }; + const invalidateCredentials = vi.fn(); + const saveClientInformation = vi.fn(async (info: StoredOAuthClientInformation) => { + clientInformation = info; + }); + const redirectToAuthorization = vi.fn(); + const provider: OAuthClientProvider = { + get redirectUrl() { + return 'http://localhost:3000/callback'; + }, + get clientMetadata() { + return { + redirect_uris: ['http://localhost:3000/callback'], + client_name: 'Test Client' + }; + }, + clientInformation: vi.fn(async () => clientInformation), + saveClientInformation, + tokens: vi.fn().mockResolvedValue(undefined), + saveTokens: vi.fn(), + redirectToAuthorization, + saveCodeVerifier: vi.fn(), + codeVerifier: vi.fn().mockResolvedValue('test_verifier'), + authorizationServerUrl: vi.fn().mockResolvedValue(oldAuthServerUrl), + invalidateCredentials + }; + + mockDiscoveryAndRegistration({ + resourceMetadata: newResourceMetadata, + authMetadata: newAuthMetadata, + registeredClient: { client_id: 'new-dcr-client-id', client_secret: 'new-dcr-client-secret' } + }); + + const result = await auth(provider, { serverUrl: 'https://resource.example.com' }); + + expect(result).toBe('REDIRECT'); + expect(invalidateCredentials).toHaveBeenCalledWith('tokens'); + expect(invalidateCredentials).toHaveBeenCalledWith('client'); + expect(saveClientInformation).toHaveBeenCalledWith( + expect.objectContaining({ client_id: 'new-dcr-client-id', issuer: 'https://new-auth.example.com' }), + expect.objectContaining({ issuer: 'https://new-auth.example.com' }) + ); + + const registrationCalls = mockFetch.mock.calls.filter(call => call[0].toString().includes('/register')); + expect(registrationCalls).toHaveLength(1); + const redirectUrl: URL = redirectToAuthorization.mock.calls[0]![0]; + expect(redirectUrl.searchParams.get('client_id')).toBe('new-dcr-client-id'); + }); + + it('does not restamp URL-based client IDs on an authorization-code exchange after AS mismatch', async () => { + const clientMetadataUrl = 'https://client.example.com/metadata.json'; + const saveClientInformation = vi.fn(); + const provider: OAuthClientProvider = { + get redirectUrl() { + return 'http://localhost:3000/callback'; + }, + get clientMetadata() { + return { + redirect_uris: ['http://localhost:3000/callback'], + client_name: 'Test Client' + }; + }, + clientInformation: vi.fn(async () => ({ + client_id: clientMetadataUrl, + issuer: oldAuthServerUrl + })), + saveClientInformation, + tokens: vi.fn().mockResolvedValue(undefined), + saveTokens: vi.fn(), + redirectToAuthorization: vi.fn(), + saveCodeVerifier: vi.fn(), + codeVerifier: vi.fn().mockResolvedValue('test_verifier'), + authorizationServerUrl: vi.fn().mockResolvedValue(oldAuthServerUrl), + invalidateCredentials: vi.fn() + }; + + mockDiscoveryAndRegistration({ + resourceMetadata: newResourceMetadata, + authMetadata: { ...newAuthMetadata, client_id_metadata_document_supported: true }, + tokens: { access_token: 'new-access-token', token_type: 'Bearer' } + }); + + await expect( + auth(provider, { + serverUrl: 'https://resource.example.com', + authorizationCode: 'returned-code' + }) + ).rejects.toThrow('Existing OAuth client information is required when exchanging an authorization code'); + + expect(saveClientInformation).not.toHaveBeenCalled(); + const tokenCalls = mockFetch.mock.calls.filter(call => call[0].toString().includes('/token')); + expect(tokenCalls).toHaveLength(0); + }); + it('refreshes cached discovery from an explicit resource metadata challenge before comparing authorization servers', async () => { const { provider, invalidateCredentials, saveClientInformation, redirectToAuthorization } = createBoundProvider({ client_id: 'old-client-id', @@ -6040,7 +6139,7 @@ describe('SEP-2352: authorization server binding', () => { mockDiscoveryAndRegistration({ resourceMetadata: newResourceMetadata, - authMetadata: newAuthMetadata + authMetadata: { ...newAuthMetadata, client_id_metadata_document_supported: true } }); const result = await auth(provider, { serverUrl: 'https://resource.example.com' }); From 3c02b0277a88746b96fc968f82bdcdad6438e90a Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Wed, 1 Jul 2026 13:57:58 +0100 Subject: [PATCH 16/18] fix(client): reuse cached discovery on code exchange --- .changeset/sep-2352-as-binding.md | 2 +- packages/client/src/client/auth.ts | 3 +- packages/client/test/client/auth.test.ts | 51 ++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 2 deletions(-) diff --git a/.changeset/sep-2352-as-binding.md b/.changeset/sep-2352-as-binding.md index 629a7f6365..353364cb09 100644 --- a/.changeset/sep-2352-as-binding.md +++ b/.changeset/sep-2352-as-binding.md @@ -2,4 +2,4 @@ '@modelcontextprotocol/client': patch --- -Implement SEP-2352 authorization server binding: when OAuth discovery shows the authorization server has changed since client credentials were recorded, `auth()` now invalidates the stale client registration and tokens (`invalidateCredentials('client')` / `('tokens')`) and re-registers with the new authorization server. CIMD (HTTPS URL) client IDs are portable across authorization servers, so they are exempt from client re-registration, but their tokens are still invalidated when the authorization server changes. Provider implementations should persist client credentials keyed by the authorization server's `issuer` identifier. +Implement SEP-2352 authorization server binding: when OAuth discovery shows the authorization server has changed since client credentials were recorded, `auth()` now invalidates the stale client registration and tokens (`invalidateCredentials('client')` / `('tokens')`) and re-registers with the new authorization server. CIMD (HTTPS URL) client IDs are portable across authorization servers when the new authorization server advertises `client_id_metadata_document_supported`; otherwise the client falls back to dynamic registration. Tokens are still invalidated when the authorization server changes. Provider implementations should persist client credentials keyed by the authorization server's `issuer` identifier. diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index dcf96a31e2..e462d3cde5 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -1062,7 +1062,8 @@ async function authInternal( if (!effectiveResourceMetadataUrl && cachedState?.resourceMetadataUrl) { effectiveResourceMetadataUrl = new URL(cachedState.resourceMetadataUrl); } - const shouldRefreshCachedDiscovery = cachedState?.authorizationServerUrl !== undefined && resourceMetadataUrl !== undefined; + const shouldRefreshCachedDiscovery = + authorizationCode === undefined && cachedState?.authorizationServerUrl !== undefined && resourceMetadataUrl !== undefined; if (cachedState?.authorizationServerUrl && !shouldRefreshCachedDiscovery) { // Restore discovery state from cache diff --git a/packages/client/test/client/auth.test.ts b/packages/client/test/client/auth.test.ts index 5028ed7a7b..b0e5b5e1ae 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -5626,6 +5626,57 @@ describe('SEP-2352: authorization server binding', () => { expect(redirectUrl.searchParams.get('client_id')).toBe('new-client-id'); }); + it('uses cached discovery on authorization-code exchange even when the original PRM URL is present', async () => { + const { provider, saveTokens } = createBoundProvider({ + client_id: 'old-client-id', + client_secret: 'old-client-secret' + }); + const resourceMetadataUrl = new URL('https://resource.example.com/.well-known/oauth-protected-resource'); + + provider.discoveryState = vi.fn().mockResolvedValue({ + authorizationServerUrl: oldAuthServerUrl, + authorizationServerSource: 'protected-resource-metadata', + resourceMetadataUrl: resourceMetadataUrl.toString(), + resourceMetadata: sameResourceMetadata, + authorizationServerMetadata: sameAuthMetadata + }); + provider.saveDiscoveryState = vi.fn(); + + mockFetch.mockImplementation((url, init) => { + const urlString = url.toString(); + + if (urlString === resourceMetadataUrl.toString()) { + return Promise.reject(new TypeError('network temporarily unavailable')); + } + + if (urlString === `${oldAuthServerUrl}/token` && init?.method === 'POST') { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ access_token: 'new-access-token', token_type: 'Bearer' }) + }); + } + + return Promise.reject(new Error(`Unexpected fetch: ${urlString}`)); + }); + + const result = await auth(provider, { + serverUrl: 'https://resource.example.com', + authorizationCode: 'returned-code', + resourceMetadataUrl + }); + + expect(result).toBe('AUTHORIZED'); + const prmCalls = mockFetch.mock.calls.filter(call => call[0].toString() === resourceMetadataUrl.toString()); + expect(prmCalls).toHaveLength(0); + const tokenCalls = mockFetch.mock.calls.filter(call => call[0].toString().includes('/token')); + expect(tokenCalls).toHaveLength(1); + expect(saveTokens).toHaveBeenCalledWith( + { access_token: 'new-access-token', token_type: 'Bearer', issuer: oldAuthServerUrl }, + expect.objectContaining({ issuer: oldAuthServerUrl }) + ); + }); + it('invalidates when challenged PRM names a new authorization server without AS metadata', async () => { const { provider, invalidateCredentials, saveClientInformation, redirectToAuthorization } = createBoundProvider({ client_id: 'old-client-id', From d05528b4e93e839c5262ffef76411408ecf82e9c Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Wed, 1 Jul 2026 14:38:09 +0100 Subject: [PATCH 17/18] fix(client): avoid step-up discovery refresh without fresh prm --- packages/client/src/client/auth.ts | 46 ++++++++++++---- packages/client/src/client/streamableHttp.ts | 1 + packages/client/test/client/auth.test.ts | 55 +++++++++++++++++++ .../client/test/client/streamableHttp.test.ts | 51 +++++++++++++++++ 4 files changed, 141 insertions(+), 12 deletions(-) diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index e462d3cde5..a7909ef60c 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -447,13 +447,16 @@ export interface OAuthClientProvider { /** * Saves the OAuth discovery state after RFC 9728 and authorization server metadata - * discovery. Providers can persist this state to avoid redundant discovery requests - * on subsequent {@linkcode auth} calls. + * discovery. Providers can persist this state so ordinary subsequent + * {@linkcode auth} calls can reuse discovery results without repeating the + * RFC 9728 / AS metadata probes. * * This state can also be provided out-of-band (e.g., from a previous session or * external configuration) to bootstrap the OAuth flow without discovery. * - * Called by {@linkcode auth} after successful discovery. + * Called by {@linkcode auth} after successful discovery, including when a fresh + * `WWW-Authenticate: Bearer ... resource_metadata="..."` challenge causes cached + * discovery to be refreshed. * * MUST persist with the same durability as `codeVerifier` (survives the redirect * round-trip). @@ -463,13 +466,17 @@ export interface OAuthClientProvider { /** * Returns previously saved discovery state, or `undefined` if none is cached. * - * When available, {@linkcode auth} restores the discovery state (authorization server - * URL, resource metadata, etc.) instead of performing RFC 9728 discovery, reducing - * latency on subsequent calls. + * When available, {@linkcode auth} normally restores the discovery state + * (authorization server URL, resource metadata, etc.) instead of performing + * RFC 9728 discovery, reducing latency on subsequent calls. * - * Hosts should call {@linkcode invalidateCredentials} with scope `'discovery'` - * on repeated 401s so a changed `authorization_servers` list is picked up; the - * SDK does not invoke that scope itself. + * A non-code-exchange {@linkcode auth} call that carries a fresh + * `WWW-Authenticate` `resource_metadata` challenge may refresh cached discovery + * before selecting the authorization server. If that refreshed metadata names a + * different authorization server, the SDK invalidates the stale tokens/client + * credentials it owns and re-registers as needed. Hosts may still call + * {@linkcode invalidateCredentials} with scope `'discovery'` when they know the + * persisted discovery document itself should be discarded. * * MUST persist with the same durability as `codeVerifier` (survives the redirect * round-trip). @@ -481,8 +488,10 @@ export interface OAuthClientProvider { * Discovery state that can be persisted across sessions by an {@linkcode OAuthClientProvider}. * * Contains the results of RFC 9728 protected resource metadata discovery and - * authorization server metadata discovery. Persisting this state avoids - * redundant discovery HTTP requests on subsequent {@linkcode auth} calls. + * authorization server metadata discovery. Persisting this state lets ordinary + * subsequent {@linkcode auth} calls avoid redundant discovery HTTP requests; fresh + * `WWW-Authenticate` resource metadata challenges can still ask {@linkcode auth} to + * refresh the state before comparing authorization servers. */ // TODO: Consider adding `authorizationServerMetadataUrl` to capture the exact well-known URL // at which authorization server metadata was discovered. This would require @@ -932,6 +941,15 @@ export interface AuthOptions { scope?: string; /** Explicit `resource_metadata` URL from a `WWW-Authenticate` challenge. */ resourceMetadataUrl?: URL; + /** + * Whether a provided {@linkcode resourceMetadataUrl} is a fresh challenge signal + * that should refresh cached discovery before authorization-server comparison. + * + * Defaults to `true` when `resourceMetadataUrl` is provided. Transports that are + * merely carrying a previously observed resource metadata URL, rather than one + * from the current response, set this to `false` so cached discovery can be reused. + */ + refreshCachedDiscovery?: boolean; /** Custom `fetch` implementation. */ fetchFn?: FetchLike; /** @@ -1035,6 +1053,7 @@ async function authInternal( iss, scope, resourceMetadataUrl, + refreshCachedDiscovery, fetchFn, skipIssuerMetadataValidation, forceReauthorization @@ -1063,7 +1082,10 @@ async function authInternal( effectiveResourceMetadataUrl = new URL(cachedState.resourceMetadataUrl); } const shouldRefreshCachedDiscovery = - authorizationCode === undefined && cachedState?.authorizationServerUrl !== undefined && resourceMetadataUrl !== undefined; + authorizationCode === undefined && + cachedState?.authorizationServerUrl !== undefined && + resourceMetadataUrl !== undefined && + refreshCachedDiscovery !== false; if (cachedState?.authorizationServerUrl && !shouldRefreshCachedDiscovery) { // Restore discovery state from cache diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index 04c7ac6f84..77ed251a97 100644 --- a/packages/client/src/client/streamableHttp.ts +++ b/packages/client/src/client/streamableHttp.ts @@ -411,6 +411,7 @@ export class StreamableHTTPClientTransport implements Transport { return auth(this._oauthProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl, + refreshCachedDiscovery: challenge.resourceMetadataUrl !== undefined, scope: unionScope, forceReauthorization, fetchFn: this._fetchWithInit, diff --git a/packages/client/test/client/auth.test.ts b/packages/client/test/client/auth.test.ts index b0e5b5e1ae..525e5d48ac 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -5677,6 +5677,61 @@ describe('SEP-2352: authorization server binding', () => { ); }); + it('uses cached discovery when a caller carries a prior PRM URL without requesting refresh', async () => { + const { provider, saveTokens } = createBoundProvider({ + client_id: 'old-client-id', + client_secret: 'old-client-secret' + }); + const resourceMetadataUrl = new URL('https://resource.example.com/.well-known/oauth-protected-resource'); + + provider.discoveryState = vi.fn().mockResolvedValue({ + authorizationServerUrl: oldAuthServerUrl, + authorizationServerSource: 'protected-resource-metadata', + resourceMetadataUrl: resourceMetadataUrl.toString(), + resourceMetadata: sameResourceMetadata, + authorizationServerMetadata: sameAuthMetadata + }); + provider.saveDiscoveryState = vi.fn(); + provider.tokens = vi.fn().mockResolvedValue({ + access_token: 'current-access-token', + refresh_token: 'refresh-token', + token_type: 'Bearer', + issuer: oldAuthServerUrl + }); + + mockFetch.mockImplementation((url, init) => { + const urlString = url.toString(); + + if (urlString === resourceMetadataUrl.toString()) { + return Promise.reject(new TypeError('network temporarily unavailable')); + } + + if (urlString === `${oldAuthServerUrl}/token` && init?.method === 'POST') { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ access_token: 'new-access-token', token_type: 'Bearer' }) + }); + } + + return Promise.reject(new Error(`Unexpected fetch: ${urlString}`)); + }); + + const result = await auth(provider, { + serverUrl: 'https://resource.example.com', + resourceMetadataUrl, + refreshCachedDiscovery: false + }); + + expect(result).toBe('AUTHORIZED'); + const prmCalls = mockFetch.mock.calls.filter(call => call[0].toString() === resourceMetadataUrl.toString()); + expect(prmCalls).toHaveLength(0); + expect(saveTokens).toHaveBeenLastCalledWith( + { access_token: 'new-access-token', token_type: 'Bearer', refresh_token: 'refresh-token', issuer: oldAuthServerUrl }, + expect.objectContaining({ issuer: oldAuthServerUrl }) + ); + }); + it('invalidates when challenged PRM names a new authorization server without AS metadata', async () => { const { provider, invalidateCredentials, saveClientInformation, redirectToAuthorization } = createBoundProvider({ client_id: 'old-client-id', diff --git a/packages/client/test/client/streamableHttp.test.ts b/packages/client/test/client/streamableHttp.test.ts index a20fb92252..844f7bb032 100644 --- a/packages/client/test/client/streamableHttp.test.ts +++ b/packages/client/test/client/streamableHttp.test.ts @@ -948,6 +948,57 @@ describe('StreamableHTTPClientTransport', () => { authSpy.mockRestore(); }); + it('does not refresh cached discovery on step-up without a fresh resource_metadata challenge', async () => { + const message: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'test', + params: {}, + id: 'test-id' + }; + const priorResourceMetadataUrl = new URL('http://example.com/original-resource-metadata'); + (transport as unknown as { _resourceMetadataUrl?: URL })._resourceMetadataUrl = priorResourceMetadataUrl; + mockAuthProvider.tokens.mockResolvedValue({ + access_token: 'current-token', + token_type: 'Bearer', + scope: 'read' + }); + + const fetchMock = globalThis.fetch as Mock; + fetchMock + .mockResolvedValueOnce({ + ok: false, + status: 403, + statusText: 'Forbidden', + headers: new Headers({ + 'WWW-Authenticate': 'Bearer error="insufficient_scope", scope="write"' + }), + text: () => Promise.resolve('Insufficient scope') + }) + .mockResolvedValueOnce({ + ok: true, + status: 202, + headers: new Headers() + }); + + const authModule = await import('../../src/client/auth'); + const authSpy = vi.spyOn(authModule, 'auth'); + authSpy.mockResolvedValue('AUTHORIZED'); + + await transport.send(message); + + expect(authSpy).toHaveBeenCalledWith( + mockAuthProvider, + expect.objectContaining({ + resourceMetadataUrl: priorResourceMetadataUrl, + refreshCachedDiscovery: false, + scope: 'read write', + forceReauthorization: true + }) + ); + + authSpy.mockRestore(); + }); + it('caps step-up retries per send (bounded counter)', async () => { const message: JSONRPCMessage = { jsonrpc: '2.0', From 4ecaf7dbf08e42e5539cdd6632bd0c443286a6e5 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Wed, 1 Jul 2026 15:21:31 +0100 Subject: [PATCH 18/18] fix(client): read stale client with issuer context --- packages/client/src/client/auth.ts | 8 +++- packages/client/test/client/auth.test.ts | 59 ++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index a7909ef60c..bc74f6a50a 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -260,6 +260,8 @@ export interface OAuthClientProvider { * @param ctx - Carries the resolved authorization-server `issuer`. Providers * that persist credentials per authorization server should return the entry * keyed by `ctx.issuer`. Providers with a single credential set may ignore it. + * `ctx` is omitted only for legacy/single-slot compatibility; issuer-keyed + * providers may return `undefined` when no `ctx` is supplied. */ clientInformation( ctx?: OAuthClientInformationContext @@ -1203,6 +1205,8 @@ async function authInternal( // (the identifier SEP-2352 specifies). The authorization server URL is only // comparable when it came from protected resource metadata. Legacy fallback to // the MCP server origin is not authoritative enough to invalidate credentials. + const previousAuthorizationServerIssuer = + cachedState?.authorizationServerMetadata?.issuer ?? cachedState?.authorizationServerUrl ?? savedAuthorizationServerUrl; const previousAuthServerIdentities = [ cachedState?.authorizationServerMetadata?.issuer, cachedState?.authorizationServerUrl, @@ -1258,7 +1262,9 @@ async function authInternal( if (authorizationServerChanged) { await provider.invalidateCredentials?.('tokens'); - const staleClientInformation = await Promise.resolve(provider.clientInformation()); + const staleClientInformation = await Promise.resolve( + provider.clientInformation(previousAuthorizationServerIssuer ? { issuer: previousAuthorizationServerIssuer } : undefined) + ); const staleClientIsPortableAtCurrentAs = staleClientInformation !== undefined && isHttpsUrl(staleClientInformation.client_id) && supportsUrlBasedClientId; // CIMD (URL-based) client IDs are portable across authorization servers diff --git a/packages/client/test/client/auth.test.ts b/packages/client/test/client/auth.test.ts index 525e5d48ac..e399bdf9ed 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -5232,6 +5232,65 @@ describe('SEP-2352: authorization server binding', () => { expect(redirectUrl.searchParams.get('client_id')).toBe('new-client-id'); }); + it('invalidates issuer-keyed stale client credentials when the authorization server changes', async () => { + const clientsByIssuer = new Map([ + [oldAuthServerUrl, { client_id: 'old-client-id', client_secret: 'old-client-secret', issuer: oldAuthServerUrl }] + ]); + const clientInformation = vi.fn(async (ctx?: OAuthClientInformationContext) => + ctx === undefined ? undefined : clientsByIssuer.get(ctx.issuer) + ); + const invalidateCredentials = vi.fn(async (scope: 'all' | 'client' | 'tokens' | 'verifier' | 'discovery') => { + if (scope === 'all' || scope === 'client') { + clientsByIssuer.delete(oldAuthServerUrl); + } + }); + const saveClientInformation = vi.fn(async (info: StoredOAuthClientInformation, ctx?: OAuthClientInformationContext) => { + if (ctx) clientsByIssuer.set(ctx.issuer, info); + }); + const redirectToAuthorization = vi.fn(); + const provider: OAuthClientProvider = { + get redirectUrl() { + return 'http://localhost:3000/callback'; + }, + get clientMetadata() { + return { + redirect_uris: ['http://localhost:3000/callback'], + client_name: 'Test Client' + }; + }, + clientInformation, + saveClientInformation, + tokens: vi.fn().mockResolvedValue(undefined), + saveTokens: vi.fn(), + redirectToAuthorization, + saveCodeVerifier: vi.fn(), + codeVerifier: vi.fn().mockResolvedValue('test_verifier'), + authorizationServerUrl: vi.fn().mockResolvedValue(oldAuthServerUrl), + invalidateCredentials + }; + + mockDiscoveryAndRegistration({ + resourceMetadata: newResourceMetadata, + authMetadata: newAuthMetadata, + registeredClient: { client_id: 'new-client-id', client_secret: 'new-client-secret' } + }); + + const result = await auth(provider, { serverUrl: 'https://resource.example.com' }); + + expect(result).toBe('REDIRECT'); + expect(clientInformation).toHaveBeenCalledWith(expect.objectContaining({ issuer: oldAuthServerUrl })); + expect(invalidateCredentials).toHaveBeenCalledWith('client'); + expect(invalidateCredentials).toHaveBeenCalledWith('tokens'); + expect(saveClientInformation).toHaveBeenCalledWith( + expect.objectContaining({ client_id: 'new-client-id' }), + expect.objectContaining({ issuer: 'https://new-auth.example.com' }) + ); + + const redirectUrl: URL = redirectToAuthorization.mock.calls[0]![0]; + expect(redirectUrl.origin).toBe('https://new-auth.example.com'); + expect(redirectUrl.searchParams.get('client_id')).toBe('new-client-id'); + }); + it('treats unstamped legacy credentials as stale after an AS migration without an invalidation hook', async () => { let clientInformation: { client_id: string; client_secret?: string; issuer?: string } | undefined = { client_id: 'old-client-id',