diff --git a/.changeset/sep-2352-as-binding.md b/.changeset/sep-2352-as-binding.md new file mode 100644 index 000000000..629a7f636 --- /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 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 9e47a3820..b2cca6f88 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; @@ -616,22 +627,29 @@ 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; + 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 + // 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; + authorizationServerSource = cachedState.authorizationServerSource; metadata = cachedState.authorizationServerMetadata ?? (await discoverAuthorizationServerMetadata(authorizationServerUrl, { fetchFn })); @@ -655,34 +673,126 @@ 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 }); - 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 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 })); + + 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 + }; + } + } } - // Save authorization server URL for providers that need it (e.g., CrossAppAccessProvider) - await provider.saveAuthorizationServerUrl?.(String(authorizationServerUrl)); + // 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)); + + 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); @@ -840,6 +950,24 @@ 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 issuer URL) do not + * register as an authorization server change. + */ +function normalizeAuthorizationServerIdentity(value: string): string { + try { + const url = new URL(value); + if (url.pathname !== '/') { + url.pathname = url.pathname.replace(/\/+$/, '') || '/'; + } + return url.href; + } catch { + return value; + } +} + export async function selectResourceURL( serverUrl: string | URL, provider: OAuthClientProvider, @@ -1292,6 +1420,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'; } /** @@ -1323,6 +1457,7 @@ export async function discoverOAuthServerInfo( ): Promise { let resourceMetadata: OAuthProtectedResourceMetadata | undefined; let authorizationServerUrl: string | undefined; + let authorizationServerSource: OAuthServerInfo['authorizationServerSource']; try { resourceMetadata = await discoverOAuthProtectedResourceMetadata( @@ -1332,6 +1467,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 @@ -1347,12 +1483,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 a4b22e0c4..d15a4b075 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(); }); @@ -4149,3 +4151,793 @@ 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; + saveTokens: 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 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 + }; + + 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(); + + 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 + }) + }); + } + + 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}`)); + }); + } + + 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('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', + 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('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', + 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(); + + 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(); + expect(provider.saveDiscoveryState).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('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', + 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 === resourceMetadataUrl.toString()) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => freshResourceMetadata + }); + } + + 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', 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'); + 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', + 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 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 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', + 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('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 + }); + + 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, 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')); + 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); + }); +}); diff --git a/packages/codemod/src/generated/versions.ts b/packages/codemod/src/generated/versions.ts index 4fa12a1a8..196a36750 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' };