From adba268eccc0219512b6e3dded13981d5c246831 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Tue, 9 Jun 2026 21:42:35 +0100 Subject: [PATCH 01/10] 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 | 59 +++++++ packages/client/test/client/auth.test.ts | 209 +++++++++++++++++++++++ 3 files changed, 273 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 9e47a38203..40f0d227c7 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -167,6 +167,12 @@ export interface OAuthClientProvider { * Loads information about this OAuth client, as registered already with the * server, or returns `undefined` if the client is not registered with the * server. + * + * Per SEP-2352 (authorization server binding), implementations that persist + * client credentials SHOULD key them by the authorization server's `issuer` + * identifier, and SHOULD NOT return credentials that were issued by a + * different authorization server. CIMD (HTTPS URL) client IDs are exempt: + * they are portable across authorization servers. */ clientInformation(): OAuthClientInformationMixed | undefined | Promise; @@ -177,6 +183,11 @@ export interface OAuthClientProvider { * * This method is not required to be implemented if client information is * statically known (e.g., pre-registered). + * + * Per SEP-2352 (authorization server binding), implementations SHOULD persist + * client credentials keyed by the authorization server's `issuer` identifier, + * so credentials registered with one authorization server are never reused + * with another. */ saveClientInformation?(clientInformation: OAuthClientInformationMixed): void | Promise; @@ -681,6 +692,40 @@ async function authInternal( }); } + // 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), falling back to the authorization server URL + // when metadata is unavailable. Under RFC 8414 the issuer and the URL used for + // discovery coincide, so a match on either is treated as the same authorization + // server to avoid false-positive invalidation. + const previousAuthServerIdentities = [ + cachedState?.authorizationServerMetadata?.issuer, + cachedState?.authorizationServerUrl, + await provider.authorizationServerUrl?.() + ] + .filter((value): value is string => typeof value === 'string' && value.length > 0) + .map(value => normalizeAuthorizationServerIdentity(value)); + const currentAuthServerIdentities = [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.some(identity => previousAuthServerIdentities.includes(identity)); + + if (authorizationServerChanged) { + const staleClientInformation = await Promise.resolve(provider.clientInformation()); + // CIMD (URL-based) client IDs are portable across authorization servers + // (SEP-991/SEP-2352) — no invalidation or re-registration is needed. + if (staleClientInformation && !isHttpsUrl(staleClientInformation.client_id)) { + await provider.invalidateCredentials?.('client'); + await provider.invalidateCredentials?.('tokens'); + } + } + // Save authorization server URL for providers that need it (e.g., CrossAppAccessProvider) await provider.saveAuthorizationServerUrl?.(String(authorizationServerUrl)); @@ -840,6 +885,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 a4b22e0c44..97e8514ba5 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -4149,3 +4149,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 94887888b7b6b620971464a2d0b2b64addfc5716 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Thu, 25 Jun 2026 11:09:49 +0200 Subject: [PATCH 02/10] 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 40f0d227c7..4399ef2b28 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -632,14 +632,15 @@ async function authInternal( let authorizationServerUrl: string | URL; let metadata: AuthorizationServerMetadata | 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 97e8514ba5..1906c08a7d 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -4307,6 +4307,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 95607541160071edbc45363abdde7f19cdf025f2 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Thu, 25 Jun 2026 11:59:07 +0200 Subject: [PATCH 03/10] fix(client): avoid false AS-change invalidation --- packages/client/src/client/auth.ts | 18 +++++--- packages/client/test/client/auth.test.ts | 57 ++++++++++++++++++++++-- 2 files changed, 65 insertions(+), 10 deletions(-) diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index 4399ef2b28..f380459f4d 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -699,10 +699,10 @@ async function authInternal( // 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), falling back to the authorization server URL - // when metadata is unavailable. Under RFC 8414 the issuer and the URL used for - // discovery coincide, so a match on either is treated as the same authorization - // server to avoid false-positive invalidation. + // (the identifier SEP-2352 specifies). The authorization server URL is only an + // additional alias when metadata was successfully discovered: if PRM discovery + // falls back to the resource server origin after a transient failure, treating + // that fallback as authoritative would destructively invalidate valid credentials. const previousAuthServerIdentities = [ cachedState?.authorizationServerMetadata?.issuer, cachedState?.authorizationServerUrl, @@ -710,20 +710,24 @@ async function authInternal( ] .filter((value): value is string => typeof value === 'string' && value.length > 0) .map(value => normalizeAuthorizationServerIdentity(value)); - const currentAuthServerIdentities = [metadata?.issuer, String(authorizationServerUrl)] + const currentIssuer = metadata?.issuer; + const hasValidatedCurrentAuthorizationServer = typeof currentIssuer === 'string' && currentIssuer.length > 0; + const currentAuthServerIdentities = (hasValidatedCurrentAuthorizationServer ? [currentIssuer, String(authorizationServerUrl)] : []) .filter((value): value is string => typeof value === 'string' && value.length > 0) .map(value => normalizeAuthorizationServerIdentity(value)); const authorizationServerChanged = + hasValidatedCurrentAuthorizationServer && previousAuthServerIdentities.length > 0 && !currentAuthServerIdentities.some(identity => previousAuthServerIdentities.includes(identity)); 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 invalidation or re-registration is needed. + // (SEP-991/SEP-2352) — no client invalidation or re-registration is needed. if (staleClientInformation && !isHttpsUrl(staleClientInformation.client_id)) { await provider.invalidateCredentials?.('client'); - await provider.invalidateCredentials?.('tokens'); } } diff --git a/packages/client/test/client/auth.test.ts b/packages/client/test/client/auth.test.ts index 1906c08a7d..f9720fb9f9 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -4355,6 +4355,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', @@ -4379,7 +4429,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 @@ -4394,8 +4444,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 2d081d677e06823bb676abb6ea06834703c2cbd2 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Thu, 25 Jun 2026 12:25:49 +0200 Subject: [PATCH 04/10] fix(client): keep cached discovery after failed challenge --- packages/client/src/client/auth.ts | 36 +++++++++++++++--------- packages/client/test/client/auth.test.ts | 4 ++- 2 files changed, 25 insertions(+), 15 deletions(-) diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index f380459f4d..96f875c621 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -677,20 +677,28 @@ async function authInternal( } else { // Full discovery via RFC 9728 const serverInfo = await discoverOAuthServerInfo(serverUrl, { resourceMetadataUrl: effectiveResourceMetadataUrl, fetchFn }); - authorizationServerUrl = serverInfo.authorizationServerUrl; - metadata = serverInfo.authorizationServerMetadata; - resourceMetadata = serverInfo.resourceMetadata; - - // 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. - await provider.saveDiscoveryState?.({ - authorizationServerUrl: String(authorizationServerUrl), - resourceMetadataUrl: effectiveResourceMetadataUrl?.toString(), - resourceMetadata, - authorizationServerMetadata: metadata - }); + const challengedDiscoveryWasUnvalidated = shouldRefreshCachedDiscovery && serverInfo.resourceMetadata === undefined; + + if (challengedDiscoveryWasUnvalidated && cachedState?.authorizationServerUrl) { + authorizationServerUrl = cachedState.authorizationServerUrl; + resourceMetadata = cachedState.resourceMetadata; + metadata = cachedState.authorizationServerMetadata; + } else { + authorizationServerUrl = serverInfo.authorizationServerUrl; + metadata = serverInfo.authorizationServerMetadata; + resourceMetadata = serverInfo.resourceMetadata; + + // 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. + await provider.saveDiscoveryState?.({ + authorizationServerUrl: String(authorizationServerUrl), + resourceMetadataUrl: effectiveResourceMetadataUrl?.toString(), + resourceMetadata, + authorizationServerMetadata: metadata + }); + } } // SEP-2352: Authorization server binding. Client credentials are bound to the diff --git a/packages/client/test/client/auth.test.ts b/packages/client/test/client/auth.test.ts index f9720fb9f9..6c2fad1360 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -4367,6 +4367,7 @@ describe('SEP-2352: authorization server binding', () => { resourceMetadata: sameResourceMetadata, authorizationServerMetadata: sameAuthMetadata }); + provider.saveDiscoveryState = vi.fn(); mockFetch.mockImplementation(url => { const urlString = url.toString(); @@ -4399,9 +4400,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 409b03fda44647d59d850f2f531368beea8e68c4 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Thu, 25 Jun 2026 13:58:16 +0200 Subject: [PATCH 05/10] fix(client): preserve authoritative AS discovery --- .changeset/sep-2352-as-binding.md | 2 +- packages/client/src/client/auth.ts | 97 ++++++++--- packages/client/test/client/auth.test.ts | 197 +++++++++++++++++++++++ 3 files changed, 270 insertions(+), 26 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 96f875c621..2478d7b3f2 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -627,10 +627,14 @@ async function authInternal( ): Promise { // 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; let metadata: AuthorizationServerMetadata | undefined; + let discoveryStateToSave: OAuthDiscoveryState | undefined; + let authorizationServerSource: OAuthServerInfo['authorizationServerSource']; + let reusedSavedAuthorizationServerAfterUnvalidatedDiscovery = false; // If resourceMetadataUrl is not provided, try to load it from cached state. // This handles browser redirects where the URL was saved before navigation. @@ -644,6 +648,7 @@ async function authInternal( // Restore discovery state from cache authorizationServerUrl = cachedState.authorizationServerUrl; resourceMetadata = cachedState.resourceMetadata; + authorizationServerSource = cachedState.authorizationServerSource; metadata = cachedState.authorizationServerMetadata ?? (await discoverAuthorizationServerMetadata(authorizationServerUrl, { fetchFn })); @@ -667,37 +672,57 @@ 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 const serverInfo = await discoverOAuthServerInfo(serverUrl, { resourceMetadataUrl: effectiveResourceMetadataUrl, fetchFn }); - const challengedDiscoveryWasUnvalidated = shouldRefreshCachedDiscovery && serverInfo.resourceMetadata === undefined; - - if (challengedDiscoveryWasUnvalidated && cachedState?.authorizationServerUrl) { - authorizationServerUrl = cachedState.authorizationServerUrl; - resourceMetadata = cachedState.resourceMetadata; - metadata = cachedState.authorizationServerMetadata; + const discoveryWasUnvalidated = serverInfo.authorizationServerSource !== 'protected-resource-metadata'; + const fallbackAuthorizationServerUrl = cachedState?.authorizationServerUrl ?? savedAuthorizationServerUrl; + + if (discoveryWasUnvalidated && fallbackAuthorizationServerUrl) { + authorizationServerUrl = fallbackAuthorizationServerUrl; + resourceMetadata = cachedState?.resourceMetadata; + authorizationServerSource = cachedState?.authorizationServerSource; + reusedSavedAuthorizationServerAfterUnvalidatedDiscovery = cachedState?.authorizationServerUrl === undefined; + metadata = + cachedState?.authorizationServerMetadata ?? + (await discoverAuthorizationServerMetadata(fallbackAuthorizationServerUrl, { fetchFn })); + + if (cachedState?.authorizationServerUrl && metadata !== cachedState.authorizationServerMetadata) { + discoveryStateToSave = { + authorizationServerUrl: String(authorizationServerUrl), + authorizationServerSource, + resourceMetadataUrl: effectiveResourceMetadataUrl?.toString(), + resourceMetadata, + authorizationServerMetadata: metadata + }; + } } else { authorizationServerUrl = serverInfo.authorizationServerUrl; metadata = serverInfo.authorizationServerMetadata; resourceMetadata = serverInfo.resourceMetadata; + authorizationServerSource = serverInfo.authorizationServerSource; // 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. - await provider.saveDiscoveryState?.({ - authorizationServerUrl: String(authorizationServerUrl), - resourceMetadataUrl: effectiveResourceMetadataUrl?.toString(), - resourceMetadata, - authorizationServerMetadata: metadata - }); + if (authorizationServerSource === 'protected-resource-metadata') { + discoveryStateToSave = { + authorizationServerUrl: String(authorizationServerUrl), + authorizationServerSource, + resourceMetadataUrl: effectiveResourceMetadataUrl?.toString(), + resourceMetadata, + authorizationServerMetadata: metadata + }; + } } } @@ -707,25 +732,26 @@ async function authInternal( // 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 an - // additional alias when metadata was successfully discovered: if PRM discovery - // falls back to the resource server origin after a transient failure, treating - // that fallback as authoritative would destructively invalidate valid credentials. + // (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, - await provider.authorizationServerUrl?.() + savedAuthorizationServerUrl ] .filter((value): value is string => typeof value === 'string' && value.length > 0) .map(value => normalizeAuthorizationServerIdentity(value)); - const currentIssuer = metadata?.issuer; - const hasValidatedCurrentAuthorizationServer = typeof currentIssuer === 'string' && currentIssuer.length > 0; - const currentAuthServerIdentities = (hasValidatedCurrentAuthorizationServer ? [currentIssuer, String(authorizationServerUrl)] : []) + const currentAuthServerIdentities = ( + authorizationServerSource === 'legacy-fallback' + ? [] + : [metadata?.issuer, ...(authorizationServerSource === 'protected-resource-metadata' ? [String(authorizationServerUrl)] : [])] + ) .filter((value): value is string => typeof value === 'string' && value.length > 0) .map(value => normalizeAuthorizationServerIdentity(value)); const authorizationServerChanged = - hasValidatedCurrentAuthorizationServer && previousAuthServerIdentities.length > 0 && + currentAuthServerIdentities.length > 0 && !currentAuthServerIdentities.some(identity => previousAuthServerIdentities.includes(identity)); if (authorizationServerChanged) { @@ -739,8 +765,19 @@ async function authInternal( } } - // Save authorization server URL for providers that need it (e.g., CrossAppAccessProvider) - await provider.saveAuthorizationServerUrl?.(String(authorizationServerUrl)); + 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); @@ -1364,6 +1401,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'; } /** @@ -1395,6 +1438,7 @@ export async function discoverOAuthServerInfo( ): Promise { let resourceMetadata: OAuthProtectedResourceMetadata | undefined; let authorizationServerUrl: string | undefined; + let authorizationServerSource: OAuthServerInfo['authorizationServerSource']; try { resourceMetadata = await discoverOAuthProtectedResourceMetadata( @@ -1404,6 +1448,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 @@ -1419,12 +1464,14 @@ 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, { fetchFn: opts?.fetchFn }); return { authorizationServerUrl, + authorizationServerSource, authorizationServerMetadata, resourceMetadata }; diff --git a/packages/client/test/client/auth.test.ts b/packages/client/test/client/auth.test.ts index 6c2fad1360..5710aa920e 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -1109,6 +1109,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); }); @@ -1143,6 +1144,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(); }); @@ -4355,6 +4357,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', @@ -4407,6 +4483,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 e03a309a689e5a1a3e14ca94489d1750596ec764 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Thu, 25 Jun 2026 14:24:30 +0200 Subject: [PATCH 06/10] fix(client): avoid cached AS false invalidation --- packages/client/src/client/auth.ts | 14 +-- packages/client/test/client/auth.test.ts | 109 +++++++++++++++++++++++ 2 files changed, 118 insertions(+), 5 deletions(-) diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index 2478d7b3f2..46c17bb9ee 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -635,6 +635,7 @@ async function authInternal( 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. @@ -688,11 +689,15 @@ async function authInternal( if (discoveryWasUnvalidated && fallbackAuthorizationServerUrl) { authorizationServerUrl = fallbackAuthorizationServerUrl; - resourceMetadata = cachedState?.resourceMetadata; + 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 })); if (cachedState?.authorizationServerUrl && metadata !== cachedState.authorizationServerMetadata) { @@ -709,12 +714,13 @@ async function authInternal( metadata = serverInfo.authorizationServerMetadata; 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') { + if (authorizationServerSource === 'protected-resource-metadata' || !fallbackAuthorizationServerUrl) { discoveryStateToSave = { authorizationServerUrl: String(authorizationServerUrl), authorizationServerSource, @@ -743,9 +749,7 @@ async function authInternal( .filter((value): value is string => typeof value === 'string' && value.length > 0) .map(value => normalizeAuthorizationServerIdentity(value)); const currentAuthServerIdentities = ( - authorizationServerSource === 'legacy-fallback' - ? [] - : [metadata?.issuer, ...(authorizationServerSource === 'protected-resource-metadata' ? [String(authorizationServerUrl)] : [])] + currentAuthorizationServerWasPrmValidated ? [metadata?.issuer, String(authorizationServerUrl)] : [] ) .filter((value): value is string => typeof value === 'string' && value.length > 0) .map(value => normalizeAuthorizationServerIdentity(value)); diff --git a/packages/client/test/client/auth.test.ts b/packages/client/test/client/auth.test.ts index 5710aa920e..c43ff6bddd 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -4483,6 +4483,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', @@ -4604,6 +4660,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 e0a8b3fa0dab0d9aaf3ee1184818bd0477423031 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Thu, 25 Jun 2026 14:49:22 +0200 Subject: [PATCH 07/10] fix(client): preserve cached AS metadata on rediscovery --- packages/client/src/client/auth.ts | 8 ++- packages/client/test/client/auth.test.ts | 65 ++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index 46c17bb9ee..d569495fe0 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -711,7 +711,13 @@ async function authInternal( } } else { authorizationServerUrl = serverInfo.authorizationServerUrl; - metadata = serverInfo.authorizationServerMetadata; + 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'; diff --git a/packages/client/test/client/auth.test.ts b/packages/client/test/client/auth.test.ts index c43ff6bddd..692071329f 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -4483,6 +4483,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 ed0212f34c273c3419ef4f125c4a69e1421446fb Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Thu, 25 Jun 2026 15:45:42 +0200 Subject: [PATCH 08/10] fix(client): preserve AS-bound client during code exchange --- packages/client/src/client/auth.ts | 12 ++- packages/client/test/client/auth.test.ts | 96 +++++++++++++++++++++++- 2 files changed, 103 insertions(+), 5 deletions(-) diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index d569495fe0..a6fd77fa58 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -770,7 +770,9 @@ async function authInternal( 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. - if (staleClientInformation && !isHttpsUrl(staleClientInformation.client_id)) { + // 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'); } } @@ -948,12 +950,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 692071329f..6c149bf7c9 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -4192,6 +4192,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; @@ -4204,6 +4205,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 = { @@ -4219,7 +4221,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'), @@ -4227,13 +4229,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(); @@ -4268,6 +4271,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}`)); }); } @@ -4309,6 +4323,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', @@ -4778,6 +4832,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 7e183cd59ed8005fe1401551c0cdf69178a58208 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Thu, 25 Jun 2026 23:42:35 +0200 Subject: [PATCH 09/10] chore(codemod): update generated package versions --- packages/codemod/src/generated/versions.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/codemod/src/generated/versions.ts b/packages/codemod/src/generated/versions.ts index 4fa12a1a87..196a367508 100644 --- a/packages/codemod/src/generated/versions.ts +++ b/packages/codemod/src/generated/versions.ts @@ -1,9 +1,9 @@ // AUTO-GENERATED — do not edit. Run `pnpm run generate:versions` to regenerate. export const V2_PACKAGE_VERSIONS: Record = { - '@modelcontextprotocol/client': '^2.0.0-alpha.2', - '@modelcontextprotocol/server': '^2.0.0-alpha.2', - '@modelcontextprotocol/node': '^2.0.0-alpha.2', - '@modelcontextprotocol/express': '^2.0.0-alpha.2', - '@modelcontextprotocol/server-legacy': '^2.0.0-alpha.2', - '@modelcontextprotocol/core': '^2.0.0-alpha.0' + '@modelcontextprotocol/client': '^2.0.0-alpha.3', + '@modelcontextprotocol/server': '^2.0.0-alpha.3', + '@modelcontextprotocol/node': '^2.0.0-alpha.3', + '@modelcontextprotocol/express': '^2.0.0-alpha.3', + '@modelcontextprotocol/server-legacy': '^2.0.0-alpha.3', + '@modelcontextprotocol/core': '^2.0.0-alpha.1' }; From fb424ab7b285084330f45eedaa4f525217b9faf0 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Fri, 26 Jun 2026 00:01:34 +0200 Subject: [PATCH 10/10] fix(client): persist refreshed resource metadata fallback --- packages/client/src/client/auth.ts | 5 +++- packages/client/test/client/auth.test.ts | 31 +++++++++++++++++++----- 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index a6fd77fa58..b2cca6f882 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -700,7 +700,10 @@ async function authInternal( (fallbackMatchesDiscoveredAuthorizationServer ? serverInfo.authorizationServerMetadata : undefined) ?? (await discoverAuthorizationServerMetadata(fallbackAuthorizationServerUrl, { fetchFn })); - if (cachedState?.authorizationServerUrl && metadata !== cachedState.authorizationServerMetadata) { + if ( + cachedState?.authorizationServerUrl && + (metadata !== cachedState.authorizationServerMetadata || resourceMetadata !== cachedState.resourceMetadata) + ) { discoveryStateToSave = { authorizationServerUrl: String(authorizationServerUrl), authorizationServerSource, diff --git a/packages/client/test/client/auth.test.ts b/packages/client/test/client/auth.test.ts index 6c149bf7c9..d15a4b0751 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -4607,18 +4607,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 }); } @@ -4646,10 +4656,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');