diff --git a/.changeset/sep-2352-as-binding.md b/.changeset/sep-2352-as-binding.md new file mode 100644 index 0000000000..353364cb09 --- /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 when the new authorization server advertises `client_id_metadata_document_supported`; otherwise the client falls back to dynamic registration. Tokens are still invalidated when the authorization server changes. Provider implementations should persist client credentials keyed by the authorization server's `issuer` identifier. diff --git a/docs/migration/upgrade-to-v2.md b/docs/migration/upgrade-to-v2.md index 453eb53dcc..8193c570a2 100644 --- a/docs/migration/upgrade-to-v2.md +++ b/docs/migration/upgrade-to-v2.md @@ -1247,13 +1247,17 @@ no `ctx`). New TypeScript-only aliases `StoredOAuthTokens` / `StoredOAuthClientI add an optional `issuer?: string` field on top of the wire types. `OAuthClientProvider.saveAuthorizationServerUrl()` / `authorizationServerUrl()` are -`@deprecated` (still written for back-compat, never read by the SDK). The bundled -`ClientCredentialsProvider`, `PrivateKeyJwtProvider`, `StaticPrivateKeyJwtProvider`, and -`CrossAppAccessProvider` gain `expectedIssuer?: string` and no longer define -`saveClientInformation()`. Implement `discoveryState()` / `saveDiscoveryState()` so the -callback leg can verify it is exchanging the code at the same AS the redirect targeted; -without it the SDK `console.warn`s once per callback (`discoveryState` must persist with -the same durability as `codeVerifier`). Both methods are optional on +`@deprecated`: `auth()` still writes `saveAuthorizationServerUrl()` for back-compat and +may read `authorizationServerUrl()` as a previously recorded AS identity for SEP-2352 +change detection; when fresh protected-resource-metadata discovery validates a different +AS, that value can trigger token/client invalidation. It may also be used as a legacy +fallback when no persisted `discoveryState()` is available and discovery is unvalidated. +The bundled `ClientCredentialsProvider`, `PrivateKeyJwtProvider`, +`StaticPrivateKeyJwtProvider`, and `CrossAppAccessProvider` gain `expectedIssuer?: string` +and no longer define `saveClientInformation()`. Implement `discoveryState()` / +`saveDiscoveryState()` so the callback leg can verify it is exchanging the code at the +same AS the redirect targeted; without it the SDK `console.warn`s once per callback +(`discoveryState` must persist with the same durability as `codeVerifier`). Both methods are optional on `OAuthClientProvider` and may be sync or async; `OAuthDiscoveryState` (exported from `@modelcontextprotocol/client`) extends `OAuthServerInfo` with the optional `resourceMetadataUrl` the protected-resource metadata was found at: diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index a4d5b14c62..bc74f6a50a 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -260,6 +260,8 @@ export interface OAuthClientProvider { * @param ctx - Carries the resolved authorization-server `issuer`. Providers * that persist credentials per authorization server should return the entry * keyed by `ctx.issuer`. Providers with a single credential set may ignore it. + * `ctx` is omitted only for legacy/single-slot compatibility; issuer-keyed + * providers may return `undefined` when no `ctx` is supplied. */ clientInformation( ctx?: OAuthClientInformationContext @@ -394,23 +396,32 @@ export interface OAuthClientProvider { prepareTokenRequest?(scope?: string): URLSearchParams | Promise | undefined; /** - * Saves the resolved authorization-server **issuer**. Called after a successful - * token exchange (timing changed in v2: was post-discovery, now post-`saveTokens`). + * Saves the resolved authorization-server URL/issuer identity for legacy providers. + * Called after discovery, SEP-2352 authorization-server change detection, any resulting + * credential invalidation, and callback-leg authorization-server binding succeed. * - * @deprecated Superseded by the `issuer` stamp on stored tokens / client credentials - * (SEP-2352). {@linkcode auth} still **writes** this for back-compat with providers - * that read it (e.g. Cross-App Access), but the SDK never reads it. Prefer reading - * the `issuer` field on the value passed to {@linkcode saveTokens} / - * {@linkcode saveClientInformation}, or the `ctx.issuer` argument. + * @deprecated Superseded by {@linkcode saveDiscoveryState} and by the `issuer` stamp on + * stored tokens / client credentials (SEP-2352). {@linkcode auth} still **writes** this + * for back-compat with providers that read it internally (e.g. Cross-App Access), and + * may read {@linkcode authorizationServerUrl} as a previously recorded AS identity for + * SEP-2352 change detection; when fresh protected-resource-metadata discovery validates a + * different AS, that value can trigger token/client invalidation. It may also be used as a + * legacy fallback when no `discoveryState()` is available and discovery is unvalidated. + * Prefer {@linkcode discoveryState} plus the `issuer` field on the value passed to + * {@linkcode saveTokens} / {@linkcode saveClientInformation}, or the `ctx.issuer` argument. */ saveAuthorizationServerUrl?(authorizationServerUrl: string): void | Promise; /** * Returns the previously saved authorization server URL, if available. * - * @deprecated Superseded by the `issuer` stamp on stored tokens / client credentials - * (SEP-2352). The SDK never reads this method; it remains for provider implementations - * that consume the value internally (e.g. Cross-App Access). + * @deprecated Superseded by {@linkcode discoveryState} and by the `issuer` stamp on + * stored tokens / client credentials (SEP-2352). {@linkcode auth} may read this as a + * previously recorded AS identity for SEP-2352 change detection; when fresh + * protected-resource-metadata discovery validates a different AS, that value can trigger + * token/client invalidation. It may also be used as a legacy fallback when no persisted + * `discoveryState()` is available and discovery is unvalidated. New providers should + * implement {@linkcode discoveryState} / {@linkcode saveDiscoveryState} instead. */ authorizationServerUrl?(): string | undefined | Promise; @@ -438,13 +449,16 @@ export interface OAuthClientProvider { /** * Saves the OAuth discovery state after RFC 9728 and authorization server metadata - * discovery. Providers can persist this state to avoid redundant discovery requests - * on subsequent {@linkcode auth} calls. + * discovery. Providers can persist this state so ordinary subsequent + * {@linkcode auth} calls can reuse discovery results without repeating the + * RFC 9728 / AS metadata probes. * * This state can also be provided out-of-band (e.g., from a previous session or * external configuration) to bootstrap the OAuth flow without discovery. * - * Called by {@linkcode auth} after successful discovery. + * Called by {@linkcode auth} after successful discovery, including when a fresh + * `WWW-Authenticate: Bearer ... resource_metadata="..."` challenge causes cached + * discovery to be refreshed. * * MUST persist with the same durability as `codeVerifier` (survives the redirect * round-trip). @@ -454,13 +468,17 @@ export interface OAuthClientProvider { /** * Returns previously saved discovery state, or `undefined` if none is cached. * - * When available, {@linkcode auth} restores the discovery state (authorization server - * URL, resource metadata, etc.) instead of performing RFC 9728 discovery, reducing - * latency on subsequent calls. + * When available, {@linkcode auth} normally restores the discovery state + * (authorization server URL, resource metadata, etc.) instead of performing + * RFC 9728 discovery, reducing latency on subsequent calls. * - * Hosts should call {@linkcode invalidateCredentials} with scope `'discovery'` - * on repeated 401s so a changed `authorization_servers` list is picked up; the - * SDK does not invoke that scope itself. + * A non-code-exchange {@linkcode auth} call that carries a fresh + * `WWW-Authenticate` `resource_metadata` challenge may refresh cached discovery + * before selecting the authorization server. If that refreshed metadata names a + * different authorization server, the SDK invalidates the stale tokens/client + * credentials it owns and re-registers as needed. Hosts may still call + * {@linkcode invalidateCredentials} with scope `'discovery'` when they know the + * persisted discovery document itself should be discarded. * * MUST persist with the same durability as `codeVerifier` (survives the redirect * round-trip). @@ -472,8 +490,10 @@ export interface OAuthClientProvider { * Discovery state that can be persisted across sessions by an {@linkcode OAuthClientProvider}. * * Contains the results of RFC 9728 protected resource metadata discovery and - * authorization server metadata discovery. Persisting this state avoids - * redundant discovery HTTP requests on subsequent {@linkcode auth} calls. + * authorization server metadata discovery. Persisting this state lets ordinary + * subsequent {@linkcode auth} calls avoid redundant discovery HTTP requests; fresh + * `WWW-Authenticate` resource metadata challenges can still ask {@linkcode auth} to + * refresh the state before comparing authorization servers. */ // TODO: Consider adding `authorizationServerMetadataUrl` to capture the exact well-known URL // at which authorization server metadata was discovered. This would require @@ -923,6 +943,15 @@ export interface AuthOptions { scope?: string; /** Explicit `resource_metadata` URL from a `WWW-Authenticate` challenge. */ resourceMetadataUrl?: URL; + /** + * Whether a provided {@linkcode resourceMetadataUrl} is a fresh challenge signal + * that should refresh cached discovery before authorization-server comparison. + * + * Defaults to `true` when `resourceMetadataUrl` is provided. Transports that are + * merely carrying a previously observed resource metadata URL, rather than one + * from the current response, set this to `false` so cached discovery can be reused. + */ + refreshCachedDiscovery?: boolean; /** Custom `fetch` implementation. */ fetchFn?: FetchLike; /** @@ -1026,6 +1055,7 @@ async function authInternal( iss, scope, resourceMetadataUrl, + refreshCachedDiscovery, fetchFn, skipIssuerMetadataValidation, forceReauthorization @@ -1037,23 +1067,33 @@ async function authInternal( // Check if the provider has cached discovery state to skip discovery const cachedState = await provider.discoveryState?.(); + const savedAuthorizationServerUrl = await provider.authorizationServerUrl?.(); let resourceMetadata: OAuthProtectedResourceMetadata | undefined; let authorizationServerUrl: string | URL; let metadata: AuthorizationServerMetadata | undefined; - let freshDiscoveryState: OAuthDiscoveryState | undefined; + let discoveryStateToSave: OAuthDiscoveryState | undefined; + let authorizationServerSource: OAuthServerInfo['authorizationServerSource']; + let reusedSavedAuthorizationServerAfterUnvalidatedDiscovery = false; + let currentAuthorizationServerWasPrmValidated = false; - // If resourceMetadataUrl is not provided, try to load it from cached state - // This handles browser redirects where the URL was saved before navigation + // 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 = + authorizationCode === undefined && + cachedState?.authorizationServerUrl !== undefined && + resourceMetadataUrl !== undefined && + refreshCachedDiscovery !== false; - 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, { @@ -1081,12 +1121,13 @@ async function authInternal( // Re-save if we enriched the cached state with missing metadata if (metadata !== cachedState.authorizationServerMetadata || resourceMetadata !== cachedState.resourceMetadata) { - await provider.saveDiscoveryState?.({ + discoveryStateToSave = { authorizationServerUrl: String(authorizationServerUrl), + authorizationServerSource, resourceMetadataUrl: effectiveResourceMetadataUrl?.toString(), resourceMetadata, authorizationServerMetadata: metadata - }); + }; } } else { // Full discovery via RFC 9728 @@ -1095,33 +1136,100 @@ async function authInternal( fetchFn, skipIssuerMetadataValidation }); - authorizationServerUrl = serverInfo.authorizationServerUrl; - metadata = serverInfo.authorizationServerMetadata; - resourceMetadata = serverInfo.resourceMetadata; - - // Captured now, persisted only after the SEP-2352 callback-leg gate below โ€” so a - // gate throw cannot leave a freshly resolved (potentially PRM-poisoned) AS recorded - // for the retry to read back as `recordedIssuer`. - // TODO: resourceMetadataUrl is only populated when explicitly provided via options - // or loaded from cached state. The URL derived internally by - // discoverOAuthProtectedResourceMetadata() is not captured back here. - freshDiscoveryState = { - authorizationServerUrl: String(authorizationServerUrl), - resourceMetadataUrl: effectiveResourceMetadataUrl?.toString(), - resourceMetadata, - authorizationServerMetadata: metadata - }; + const discoveryWasUnvalidated = serverInfo.authorizationServerSource !== 'protected-resource-metadata'; + const fallbackAuthorizationServerUrl = cachedState?.authorizationServerUrl ?? savedAuthorizationServerUrl; + + if (discoveryWasUnvalidated && fallbackAuthorizationServerUrl) { + authorizationServerUrl = fallbackAuthorizationServerUrl; + resourceMetadata = serverInfo.resourceMetadata ?? cachedState?.resourceMetadata; + authorizationServerSource = cachedState?.authorizationServerSource; + reusedSavedAuthorizationServerAfterUnvalidatedDiscovery = cachedState?.authorizationServerUrl === undefined; + const fallbackMatchesDiscoveredAuthorizationServer = + normalizeAuthorizationServerIdentity(String(fallbackAuthorizationServerUrl)) === + normalizeAuthorizationServerIdentity(String(serverInfo.authorizationServerUrl)); + metadata = + cachedState?.authorizationServerMetadata ?? + (fallbackMatchesDiscoveredAuthorizationServer ? serverInfo.authorizationServerMetadata : undefined) ?? + (await discoverAuthorizationServerMetadata(fallbackAuthorizationServerUrl, { + fetchFn, + skipIssuerValidation: skipIssuerMetadataValidation + })); + + if ( + cachedState?.authorizationServerUrl && + (metadata !== cachedState.authorizationServerMetadata || resourceMetadata !== cachedState.resourceMetadata) + ) { + discoveryStateToSave = { + authorizationServerUrl: String(authorizationServerUrl), + authorizationServerSource, + resourceMetadataUrl: effectiveResourceMetadataUrl?.toString(), + resourceMetadata, + authorizationServerMetadata: metadata + }; + } + } else { + authorizationServerUrl = serverInfo.authorizationServerUrl; + const discoveredAuthorizationServerMatchesCached = + cachedState?.authorizationServerUrl !== undefined && + normalizeAuthorizationServerIdentity(String(serverInfo.authorizationServerUrl)) === + normalizeAuthorizationServerIdentity(cachedState.authorizationServerUrl); + metadata = + serverInfo.authorizationServerMetadata ?? + (discoveredAuthorizationServerMatchesCached ? cachedState?.authorizationServerMetadata : undefined); + resourceMetadata = serverInfo.resourceMetadata; + authorizationServerSource = serverInfo.authorizationServerSource; + currentAuthorizationServerWasPrmValidated = authorizationServerSource === 'protected-resource-metadata'; + + // Persist discovery state for future use. + // TODO: resourceMetadataUrl is only populated when explicitly provided via options + // or loaded from cached state. The URL derived internally by + // discoverOAuthProtectedResourceMetadata() is not captured back here. + if (authorizationServerSource === 'protected-resource-metadata' || !fallbackAuthorizationServerUrl) { + discoveryStateToSave = { + authorizationServerUrl: String(authorizationServerUrl), + authorizationServerSource, + resourceMetadataUrl: effectiveResourceMetadataUrl?.toString(), + resourceMetadata, + authorizationServerMetadata: metadata + }; + } + } } + // SEP-2352: Authorization server binding. Client credentials are bound to the + // authorization server that issued them; when discovery shows the authorization + // server has changed (e.g., via updated protected resource metadata), stale client + // credentials and tokens MUST NOT be reused and the client MUST re-register. + // + // Canonical comparison key: the validated authorization server metadata `issuer` + // (the identifier SEP-2352 specifies). The authorization server URL is only + // comparable when it came from protected resource metadata. Legacy fallback to + // the MCP server origin is not authoritative enough to invalidate credentials. + const previousAuthorizationServerIssuer = + cachedState?.authorizationServerMetadata?.issuer ?? cachedState?.authorizationServerUrl ?? savedAuthorizationServerUrl; + const previousAuthServerIdentities = [ + cachedState?.authorizationServerMetadata?.issuer, + cachedState?.authorizationServerUrl, + savedAuthorizationServerUrl + ] + .filter((value): value is string => typeof value === 'string' && value.length > 0) + .map(value => normalizeAuthorizationServerIdentity(value)); + const currentAuthServerIdentities = ( + currentAuthorizationServerWasPrmValidated ? [metadata?.issuer, String(authorizationServerUrl)] : [] + ) + .filter((value): value is string => typeof value === 'string' && value.length > 0) + .map(value => normalizeAuthorizationServerIdentity(value)); + const authorizationServerChanged = + previousAuthServerIdentities.length > 0 && + currentAuthServerIdentities.length > 0 && + !currentAuthServerIdentities.some(identity => previousAuthServerIdentities.includes(identity)); + // SEP-2352: the canonical authorization-server identity for this flow. `metadata.issuer` // is RFC 8414 ยง3.3-validated to equal the discovery URL; when no metadata document was // found (legacy fallback) the discovery URL itself is the only identifier available. const issuer = metadata?.issuer ?? String(authorizationServerUrl); const infoCtx: OAuthClientInformationContext = { issuer }; - - // Deprecated write-only hook, kept for providers (e.g. Cross-App Access) that read it - // internally. The SDK never reads `authorizationServerUrl()`. - await provider.saveAuthorizationServerUrl?.(issuer); + const supportsUrlBasedClientId = metadata?.client_id_metadata_document_supported === true; // SEP-2352 callback-leg gate. Stored credentials are protected structurally by the // issuer stamp, but the in-flight `authorization_code` + PKCE `code_verifier` are not @@ -1151,8 +1259,35 @@ async function authInternal( } } - if (freshDiscoveryState) { - await provider.saveDiscoveryState?.(freshDiscoveryState); + if (authorizationServerChanged) { + await provider.invalidateCredentials?.('tokens'); + + const staleClientInformation = await Promise.resolve( + provider.clientInformation(previousAuthorizationServerIssuer ? { issuer: previousAuthorizationServerIssuer } : undefined) + ); + const staleClientIsPortableAtCurrentAs = + staleClientInformation !== undefined && isHttpsUrl(staleClientInformation.client_id) && supportsUrlBasedClientId; + // CIMD (URL-based) client IDs are portable across authorization servers + // (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 && !staleClientIsPortableAtCurrentAs && 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); @@ -1174,9 +1309,29 @@ async function authInternal( // stamp names a different authorization server reads back as `undefined`, so the flow // re-registers exactly as if nothing were stored. const rawClientInfo = await Promise.resolve(provider.clientInformation(infoCtx)); - let clientInformation = discardIfIssuerMismatch(rawClientInfo, issuer, { - canPersistStamp: provider.saveClientInformation !== undefined - }); + const discardUnstampedClientInfoAfterAsChange = + authorizationServerChanged && + authorizationCode === undefined && + rawClientInfo !== undefined && + rawClientInfo.issuer === undefined && + !isHttpsUrl(rawClientInfo.client_id); + const restampPortableClientInfo = + authorizationCode === undefined && + supportsUrlBasedClientId && + rawClientInfo !== undefined && + isHttpsUrl(rawClientInfo.client_id) && + (rawClientInfo.issuer === undefined || !issuersMatch(rawClientInfo.issuer, issuer)); + let clientInformation: StoredOAuthClientInformation | undefined; + if (restampPortableClientInfo) { + clientInformation = { ...rawClientInfo, issuer }; + } else if (!discardUnstampedClientInfoAfterAsChange) { + clientInformation = discardIfIssuerMismatch(rawClientInfo, issuer, { + canPersistStamp: provider.saveClientInformation !== undefined + }); + } + if (restampPortableClientInfo && clientInformation) { + await provider.saveClientInformation?.(clientInformation, infoCtx); + } if (clientInformation === undefined && rawClientInfo?.issuer && provider.saveClientInformation === undefined) { // Static-credential provider (no DCR) whose `expectedIssuer` stamp names a different // AS โ€” surface the typed error with both issuers rather than the generic @@ -1195,7 +1350,6 @@ async function authInternal( throw new Error('Existing OAuth client information is required when exchanging an authorization code'); } - const supportsUrlBasedClientId = metadata?.client_id_metadata_document_supported === true; const clientMetadataUrl = provider.clientMetadataUrl; if (clientMetadataUrl && !isHttpsUrl(clientMetadataUrl)) { @@ -1260,7 +1414,8 @@ async function authInternal( // SEP-2352: a refresh_token stamped for a different authorization server reads back // as `undefined`, so it is never POSTed to this AS's token endpoint. - let tokens = discardIfIssuerMismatch(await provider.tokens(infoCtx), issuer); + const rawTokens = await provider.tokens(infoCtx); + let tokens = authorizationServerChanged && rawTokens?.issuer === undefined ? undefined : discardIfIssuerMismatch(rawTokens, issuer); if (tokens && tokens.issuer === undefined) { // SEP-2352 back-stamp: bind a legacy unstamped token set to the first-resolved AS // so the stamp check is effective from the next call onward. @@ -1356,6 +1511,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, @@ -1846,6 +2019,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'; } /** @@ -1882,6 +2061,7 @@ export async function discoverOAuthServerInfo( ): Promise { let resourceMetadata: OAuthProtectedResourceMetadata | undefined; let authorizationServerUrl: string | undefined; + let authorizationServerSource: OAuthServerInfo['authorizationServerSource']; try { resourceMetadata = await discoverOAuthProtectedResourceMetadata( @@ -1891,6 +2071,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 @@ -1906,6 +2087,7 @@ export async function discoverOAuthServerInfo( // fall back to the legacy MCP spec behavior: MCP server base URL acts as the authorization server if (!authorizationServerUrl) { authorizationServerUrl = String(new URL('/', serverUrl)); + authorizationServerSource = 'legacy-fallback'; } const authorizationServerMetadata = await discoverAuthorizationServerMetadata(authorizationServerUrl, { @@ -1915,6 +2097,7 @@ export async function discoverOAuthServerInfo( return { authorizationServerUrl, + authorizationServerSource, authorizationServerMetadata, resourceMetadata }; diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index 04c7ac6f84..77ed251a97 100644 --- a/packages/client/src/client/streamableHttp.ts +++ b/packages/client/src/client/streamableHttp.ts @@ -411,6 +411,7 @@ export class StreamableHTTPClientTransport implements Transport { return auth(this._oauthProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl, + refreshCachedDiscovery: challenge.resourceMetadataUrl !== undefined, scope: unionScope, forceReauthorization, fetchFn: this._fetchWithInit, diff --git a/packages/client/test/client/auth.test.ts b/packages/client/test/client/auth.test.ts index 62c6faed9a..e399bdf9ed 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -1370,6 +1370,7 @@ describe('OAuth Authorization', () => { const result = await discoverOAuthServerInfo('https://resource.example.com'); expect(result.authorizationServerUrl).toBe('https://auth.example.com'); + expect(result.authorizationServerSource).toBe('protected-resource-metadata'); expect(result.resourceMetadata).toEqual(validResourceMetadata); expect(result.authorizationServerMetadata).toEqual(validAuthMetadata); }); @@ -1404,6 +1405,7 @@ describe('OAuth Authorization', () => { // Should fall back to server URL origin expect(result.authorizationServerUrl).toBe('https://resource.example.com/'); + expect(result.authorizationServerSource).toBe('legacy-fallback'); expect(result.resourceMetadata).toBeUndefined(); expect(result.authorizationServerMetadata).toBeDefined(); }); @@ -5055,3 +5057,1270 @@ 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; client_id_metadata_document_supported?: boolean }; + 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' }), + expect.objectContaining({ issuer: 'https://new-auth.example.com' }) + ); + + // The authorization redirect uses the newly registered client, not the stale one + const redirectUrl: URL = redirectToAuthorization.mock.calls[0]![0]; + expect(redirectUrl.origin).toBe('https://new-auth.example.com'); + expect(redirectUrl.searchParams.get('client_id')).toBe('new-client-id'); + }); + + it('invalidates issuer-keyed stale client credentials when the authorization server changes', async () => { + const clientsByIssuer = new Map([ + [oldAuthServerUrl, { client_id: 'old-client-id', client_secret: 'old-client-secret', issuer: oldAuthServerUrl }] + ]); + const clientInformation = vi.fn(async (ctx?: OAuthClientInformationContext) => + ctx === undefined ? undefined : clientsByIssuer.get(ctx.issuer) + ); + const invalidateCredentials = vi.fn(async (scope: 'all' | 'client' | 'tokens' | 'verifier' | 'discovery') => { + if (scope === 'all' || scope === 'client') { + clientsByIssuer.delete(oldAuthServerUrl); + } + }); + const saveClientInformation = vi.fn(async (info: StoredOAuthClientInformation, ctx?: OAuthClientInformationContext) => { + if (ctx) clientsByIssuer.set(ctx.issuer, info); + }); + const redirectToAuthorization = vi.fn(); + const provider: OAuthClientProvider = { + get redirectUrl() { + return 'http://localhost:3000/callback'; + }, + get clientMetadata() { + return { + redirect_uris: ['http://localhost:3000/callback'], + client_name: 'Test Client' + }; + }, + clientInformation, + saveClientInformation, + tokens: vi.fn().mockResolvedValue(undefined), + saveTokens: vi.fn(), + redirectToAuthorization, + saveCodeVerifier: vi.fn(), + codeVerifier: vi.fn().mockResolvedValue('test_verifier'), + authorizationServerUrl: vi.fn().mockResolvedValue(oldAuthServerUrl), + invalidateCredentials + }; + + mockDiscoveryAndRegistration({ + resourceMetadata: newResourceMetadata, + authMetadata: newAuthMetadata, + registeredClient: { client_id: 'new-client-id', client_secret: 'new-client-secret' } + }); + + const result = await auth(provider, { serverUrl: 'https://resource.example.com' }); + + expect(result).toBe('REDIRECT'); + expect(clientInformation).toHaveBeenCalledWith(expect.objectContaining({ issuer: oldAuthServerUrl })); + expect(invalidateCredentials).toHaveBeenCalledWith('client'); + expect(invalidateCredentials).toHaveBeenCalledWith('tokens'); + expect(saveClientInformation).toHaveBeenCalledWith( + expect.objectContaining({ client_id: 'new-client-id' }), + expect.objectContaining({ issuer: 'https://new-auth.example.com' }) + ); + + const redirectUrl: URL = redirectToAuthorization.mock.calls[0]![0]; + expect(redirectUrl.origin).toBe('https://new-auth.example.com'); + expect(redirectUrl.searchParams.get('client_id')).toBe('new-client-id'); + }); + + it('treats unstamped legacy credentials as stale after an AS migration without an invalidation hook', async () => { + let clientInformation: { client_id: string; client_secret?: string; issuer?: string } | undefined = { + client_id: 'old-client-id', + client_secret: 'old-client-secret' + }; + let tokens: StoredOAuthTokens | undefined = { + access_token: 'old-access-token', + refresh_token: 'old-refresh-token', + token_type: 'Bearer' + }; + const saveClientInformation = vi.fn(async (info: StoredOAuthClientInformation) => { + clientInformation = info; + }); + const saveTokens = vi.fn(async (nextTokens: StoredOAuthTokens) => { + tokens = nextTokens; + }); + const redirectToAuthorization = vi.fn(); + const provider: OAuthClientProvider = { + get redirectUrl() { + return 'http://localhost:3000/callback'; + }, + get clientMetadata() { + return { + redirect_uris: ['http://localhost:3000/callback'], + client_name: 'Test Client' + }; + }, + clientInformation: vi.fn(async () => clientInformation), + saveClientInformation, + tokens: vi.fn(async () => tokens), + saveTokens, + redirectToAuthorization, + saveCodeVerifier: vi.fn(), + codeVerifier: vi.fn().mockResolvedValue('test_verifier'), + authorizationServerUrl: vi.fn().mockResolvedValue(oldAuthServerUrl) + }; + + mockDiscoveryAndRegistration({ + resourceMetadata: newResourceMetadata, + authMetadata: newAuthMetadata, + registeredClient: { client_id: 'new-client-id', client_secret: 'new-client-secret' }, + tokens: { access_token: 'refreshed-token', token_type: 'Bearer' } + }); + + const result = await auth(provider, { serverUrl: 'https://resource.example.com' }); + + expect(result).toBe('REDIRECT'); + + const registrationCalls = mockFetch.mock.calls.filter(call => call[0].toString().includes('/register')); + expect(registrationCalls).toHaveLength(1); + expect(saveClientInformation).toHaveBeenCalledWith( + expect.objectContaining({ client_id: 'new-client-id', issuer: 'https://new-auth.example.com' }), + expect.objectContaining({ issuer: 'https://new-auth.example.com' }) + ); + const tokenCalls = mockFetch.mock.calls.filter(call => call[0].toString().includes('/token')); + expect(tokenCalls).toHaveLength(0); + expect(saveTokens).not.toHaveBeenCalled(); + + const redirectUrl: URL = redirectToAuthorization.mock.calls[0]![0]; + expect(redirectUrl.origin).toBe('https://new-auth.example.com'); + expect(redirectUrl.searchParams.get('client_id')).toBe('new-client-id'); + }); + + it('keeps the re-registered client while exchanging the authorization code after an AS migration', async () => { + const { provider, invalidateCredentials, saveClientInformation, saveTokens, redirectToAuthorization } = createBoundProvider({ + client_id: 'old-client-id', + 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' }), + expect.objectContaining({ issuer: 'https://new-auth.example.com' }) + ); + + 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', issuer: 'https://new-auth.example.com' }, + expect.objectContaining({ issuer: 'https://new-auth.example.com' }) + ); + expect(redirectToAuthorization).toHaveBeenCalledTimes(1); + + const registrationCalls = mockFetch.mock.calls.filter(call => call[0].toString().includes('/register')); + expect(registrationCalls).toHaveLength(0); + const tokenCalls = mockFetch.mock.calls.filter(call => call[0].toString().includes('/token')); + expect(tokenCalls).toHaveLength(1); + expect(tokenCalls[0]![0].toString()).toBe('https://new-auth.example.com/token'); + }); + + it('keeps an issuer-stripped re-registered client while exchanging the authorization code after an AS migration', async () => { + let clientInformation: { client_id: string; client_secret?: string } | undefined = { + client_id: 'old-client-id', + client_secret: 'old-client-secret' + }; + const invalidateCredentials = vi.fn(async (scope: 'all' | 'client' | 'tokens' | 'verifier' | 'discovery') => { + if (scope === 'all' || scope === 'client') { + clientInformation = undefined; + } + }); + const saveClientInformation = vi.fn(async (info: StoredOAuthClientInformation) => { + clientInformation = { client_id: info.client_id, client_secret: info.client_secret }; + }); + const saveTokens = vi.fn(); + const redirectToAuthorization = vi.fn(); + const provider: OAuthClientProvider = { + get redirectUrl() { + return 'http://localhost:3000/callback'; + }, + get clientMetadata() { + return { + redirect_uris: ['http://localhost:3000/callback'], + client_name: 'Test Client' + }; + }, + clientInformation: vi.fn(async () => clientInformation), + saveClientInformation, + tokens: vi.fn().mockResolvedValue(undefined), + saveTokens, + redirectToAuthorization, + saveCodeVerifier: vi.fn(), + codeVerifier: vi.fn().mockResolvedValue('test_verifier'), + authorizationServerUrl: vi.fn().mockResolvedValue(oldAuthServerUrl), + invalidateCredentials + }; + + mockDiscoveryAndRegistration({ + resourceMetadata: newResourceMetadata, + authMetadata: newAuthMetadata, + registeredClient: { client_id: 'new-client-id', client_secret: 'new-client-secret' }, + tokens: { access_token: 'new-access-token', token_type: 'Bearer' } + }); + + const redirectResult = await auth(provider, { serverUrl: 'https://resource.example.com' }); + + expect(redirectResult).toBe('REDIRECT'); + expect(invalidateCredentials).toHaveBeenCalledWith('client'); + expect(saveClientInformation).toHaveBeenCalledWith( + expect.objectContaining({ client_id: 'new-client-id', issuer: 'https://new-auth.example.com' }), + expect.objectContaining({ issuer: 'https://new-auth.example.com' }) + ); + + invalidateCredentials.mockClear(); + saveClientInformation.mockClear(); + mockFetch.mockClear(); + + const exchangeResult = await auth(provider, { + serverUrl: 'https://resource.example.com', + authorizationCode: 'returned-code' + }); + + expect(exchangeResult).toBe('AUTHORIZED'); + expect(invalidateCredentials).toHaveBeenCalledWith('tokens'); + expect(invalidateCredentials).not.toHaveBeenCalledWith('client'); + expect(saveClientInformation).toHaveBeenCalledWith( + expect.objectContaining({ client_id: 'new-client-id', issuer: 'https://new-auth.example.com' }), + expect.objectContaining({ issuer: 'https://new-auth.example.com' }) + ); + expect(saveTokens).toHaveBeenCalledWith( + { access_token: 'new-access-token', token_type: 'Bearer', issuer: 'https://new-auth.example.com' }, + expect.objectContaining({ issuer: 'https://new-auth.example.com' }) + ); + expect(redirectToAuthorization).toHaveBeenCalledTimes(1); + + const registrationCalls = mockFetch.mock.calls.filter(call => call[0].toString().includes('/register')); + expect(registrationCalls).toHaveLength(0); + const tokenCalls = mockFetch.mock.calls.filter(call => call[0].toString().includes('/token')); + expect(tokenCalls).toHaveLength(1); + expect(tokenCalls[0]![0].toString()).toBe('https://new-auth.example.com/token'); + }); + + it('preserves and restamps URL-based client IDs after an AS migration', async () => { + const clientMetadataUrl = 'https://client.example.com/metadata.json'; + let clientInformation: StoredOAuthClientInformation | undefined = { + client_id: clientMetadataUrl, + issuer: oldAuthServerUrl + }; + const invalidateCredentials = vi.fn(); + const saveClientInformation = vi.fn(async (info: StoredOAuthClientInformation) => { + clientInformation = info; + }); + const redirectToAuthorization = vi.fn(); + const provider: OAuthClientProvider = { + get redirectUrl() { + return 'http://localhost:3000/callback'; + }, + get clientMetadata() { + return { + redirect_uris: ['http://localhost:3000/callback'], + client_name: 'Test Client' + }; + }, + clientInformation: vi.fn(async () => clientInformation), + saveClientInformation, + tokens: vi.fn().mockResolvedValue(undefined), + saveTokens: vi.fn(), + redirectToAuthorization, + saveCodeVerifier: vi.fn(), + codeVerifier: vi.fn().mockResolvedValue('test_verifier'), + authorizationServerUrl: vi.fn().mockResolvedValue(oldAuthServerUrl), + invalidateCredentials + }; + + mockDiscoveryAndRegistration({ + resourceMetadata: newResourceMetadata, + authMetadata: { ...newAuthMetadata, client_id_metadata_document_supported: true } + }); + + const result = await auth(provider, { serverUrl: 'https://resource.example.com' }); + + expect(result).toBe('REDIRECT'); + expect(invalidateCredentials).toHaveBeenCalledWith('tokens'); + expect(invalidateCredentials).not.toHaveBeenCalledWith('client'); + expect(saveClientInformation).toHaveBeenCalledWith( + { client_id: clientMetadataUrl, issuer: 'https://new-auth.example.com' }, + expect.objectContaining({ issuer: 'https://new-auth.example.com' }) + ); + + const registrationCalls = mockFetch.mock.calls.filter(call => call[0].toString().includes('/register')); + expect(registrationCalls).toHaveLength(0); + const redirectUrl: URL = redirectToAuthorization.mock.calls[0]![0]; + expect(redirectUrl.origin).toBe('https://new-auth.example.com'); + expect(redirectUrl.searchParams.get('client_id')).toBe(clientMetadataUrl); + }); + + it('falls back to DCR for URL-based client IDs when the new AS does not advertise CIMD support', async () => { + const clientMetadataUrl = 'https://client.example.com/metadata.json'; + let clientInformation: StoredOAuthClientInformation | undefined = { + client_id: clientMetadataUrl, + issuer: oldAuthServerUrl + }; + const invalidateCredentials = vi.fn(); + const saveClientInformation = vi.fn(async (info: StoredOAuthClientInformation) => { + clientInformation = info; + }); + const redirectToAuthorization = vi.fn(); + const provider: OAuthClientProvider = { + get redirectUrl() { + return 'http://localhost:3000/callback'; + }, + get clientMetadata() { + return { + redirect_uris: ['http://localhost:3000/callback'], + client_name: 'Test Client' + }; + }, + clientInformation: vi.fn(async () => clientInformation), + saveClientInformation, + tokens: vi.fn().mockResolvedValue(undefined), + saveTokens: vi.fn(), + redirectToAuthorization, + saveCodeVerifier: vi.fn(), + codeVerifier: vi.fn().mockResolvedValue('test_verifier'), + authorizationServerUrl: vi.fn().mockResolvedValue(oldAuthServerUrl), + invalidateCredentials + }; + + mockDiscoveryAndRegistration({ + resourceMetadata: newResourceMetadata, + authMetadata: newAuthMetadata, + registeredClient: { client_id: 'new-dcr-client-id', client_secret: 'new-dcr-client-secret' } + }); + + const result = await auth(provider, { serverUrl: 'https://resource.example.com' }); + + expect(result).toBe('REDIRECT'); + expect(invalidateCredentials).toHaveBeenCalledWith('tokens'); + expect(invalidateCredentials).toHaveBeenCalledWith('client'); + expect(saveClientInformation).toHaveBeenCalledWith( + expect.objectContaining({ client_id: 'new-dcr-client-id', issuer: 'https://new-auth.example.com' }), + expect.objectContaining({ issuer: 'https://new-auth.example.com' }) + ); + + const registrationCalls = mockFetch.mock.calls.filter(call => call[0].toString().includes('/register')); + expect(registrationCalls).toHaveLength(1); + const redirectUrl: URL = redirectToAuthorization.mock.calls[0]![0]; + expect(redirectUrl.searchParams.get('client_id')).toBe('new-dcr-client-id'); + }); + + it('does not restamp URL-based client IDs on an authorization-code exchange after AS mismatch', async () => { + const clientMetadataUrl = 'https://client.example.com/metadata.json'; + const saveClientInformation = vi.fn(); + const provider: OAuthClientProvider = { + get redirectUrl() { + return 'http://localhost:3000/callback'; + }, + get clientMetadata() { + return { + redirect_uris: ['http://localhost:3000/callback'], + client_name: 'Test Client' + }; + }, + clientInformation: vi.fn(async () => ({ + client_id: clientMetadataUrl, + issuer: oldAuthServerUrl + })), + saveClientInformation, + tokens: vi.fn().mockResolvedValue(undefined), + saveTokens: vi.fn(), + redirectToAuthorization: vi.fn(), + saveCodeVerifier: vi.fn(), + codeVerifier: vi.fn().mockResolvedValue('test_verifier'), + authorizationServerUrl: vi.fn().mockResolvedValue(oldAuthServerUrl), + invalidateCredentials: vi.fn() + }; + + mockDiscoveryAndRegistration({ + resourceMetadata: newResourceMetadata, + authMetadata: { ...newAuthMetadata, client_id_metadata_document_supported: true }, + tokens: { access_token: 'new-access-token', token_type: 'Bearer' } + }); + + await expect( + auth(provider, { + serverUrl: 'https://resource.example.com', + authorizationCode: 'returned-code' + }) + ).rejects.toThrow('Existing OAuth client information is required when exchanging an authorization code'); + + expect(saveClientInformation).not.toHaveBeenCalled(); + const tokenCalls = mockFetch.mock.calls.filter(call => call[0].toString().includes('/token')); + expect(tokenCalls).toHaveLength(0); + }); + + it('refreshes cached discovery from an explicit resource metadata challenge before comparing authorization servers', async () => { + const { provider, invalidateCredentials, saveClientInformation, redirectToAuthorization } = createBoundProvider({ + client_id: 'old-client-id', + 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' }), + expect.objectContaining({ issuer: 'https://new-auth.example.com' }) + ); + + const redirectUrl: URL = redirectToAuthorization.mock.calls[0]![0]; + expect(redirectUrl.origin).toBe('https://new-auth.example.com'); + expect(redirectUrl.searchParams.get('client_id')).toBe('new-client-id'); + }); + + it('uses cached discovery on authorization-code exchange even when the original PRM URL is present', async () => { + const { provider, saveTokens } = createBoundProvider({ + client_id: 'old-client-id', + client_secret: 'old-client-secret' + }); + const resourceMetadataUrl = new URL('https://resource.example.com/.well-known/oauth-protected-resource'); + + provider.discoveryState = vi.fn().mockResolvedValue({ + authorizationServerUrl: oldAuthServerUrl, + authorizationServerSource: 'protected-resource-metadata', + resourceMetadataUrl: resourceMetadataUrl.toString(), + resourceMetadata: sameResourceMetadata, + authorizationServerMetadata: sameAuthMetadata + }); + provider.saveDiscoveryState = vi.fn(); + + mockFetch.mockImplementation((url, init) => { + const urlString = url.toString(); + + if (urlString === resourceMetadataUrl.toString()) { + return Promise.reject(new TypeError('network temporarily unavailable')); + } + + if (urlString === `${oldAuthServerUrl}/token` && init?.method === 'POST') { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ access_token: 'new-access-token', token_type: 'Bearer' }) + }); + } + + return Promise.reject(new Error(`Unexpected fetch: ${urlString}`)); + }); + + const result = await auth(provider, { + serverUrl: 'https://resource.example.com', + authorizationCode: 'returned-code', + resourceMetadataUrl + }); + + expect(result).toBe('AUTHORIZED'); + const prmCalls = mockFetch.mock.calls.filter(call => call[0].toString() === resourceMetadataUrl.toString()); + expect(prmCalls).toHaveLength(0); + const tokenCalls = mockFetch.mock.calls.filter(call => call[0].toString().includes('/token')); + expect(tokenCalls).toHaveLength(1); + expect(saveTokens).toHaveBeenCalledWith( + { access_token: 'new-access-token', token_type: 'Bearer', issuer: oldAuthServerUrl }, + expect.objectContaining({ issuer: oldAuthServerUrl }) + ); + }); + + it('uses cached discovery when a caller carries a prior PRM URL without requesting refresh', async () => { + const { provider, saveTokens } = createBoundProvider({ + client_id: 'old-client-id', + client_secret: 'old-client-secret' + }); + const resourceMetadataUrl = new URL('https://resource.example.com/.well-known/oauth-protected-resource'); + + provider.discoveryState = vi.fn().mockResolvedValue({ + authorizationServerUrl: oldAuthServerUrl, + authorizationServerSource: 'protected-resource-metadata', + resourceMetadataUrl: resourceMetadataUrl.toString(), + resourceMetadata: sameResourceMetadata, + authorizationServerMetadata: sameAuthMetadata + }); + provider.saveDiscoveryState = vi.fn(); + provider.tokens = vi.fn().mockResolvedValue({ + access_token: 'current-access-token', + refresh_token: 'refresh-token', + token_type: 'Bearer', + issuer: oldAuthServerUrl + }); + + mockFetch.mockImplementation((url, init) => { + const urlString = url.toString(); + + if (urlString === resourceMetadataUrl.toString()) { + return Promise.reject(new TypeError('network temporarily unavailable')); + } + + if (urlString === `${oldAuthServerUrl}/token` && init?.method === 'POST') { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ access_token: 'new-access-token', token_type: 'Bearer' }) + }); + } + + return Promise.reject(new Error(`Unexpected fetch: ${urlString}`)); + }); + + const result = await auth(provider, { + serverUrl: 'https://resource.example.com', + resourceMetadataUrl, + refreshCachedDiscovery: false + }); + + expect(result).toBe('AUTHORIZED'); + const prmCalls = mockFetch.mock.calls.filter(call => call[0].toString() === resourceMetadataUrl.toString()); + expect(prmCalls).toHaveLength(0); + expect(saveTokens).toHaveBeenLastCalledWith( + { access_token: 'new-access-token', token_type: 'Bearer', refresh_token: 'refresh-token', issuer: oldAuthServerUrl }, + expect.objectContaining({ issuer: oldAuthServerUrl }) + ); + }); + + it('invalidates when challenged PRM names a new authorization server without AS metadata', async () => { + const { provider, invalidateCredentials, saveClientInformation, redirectToAuthorization } = createBoundProvider({ + client_id: 'old-client-id', + 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' }), + expect.objectContaining({ issuer: 'https://new-auth.example.com' }) + ); + + const redirectUrl: URL = redirectToAuthorization.mock.calls[0]![0]; + expect(redirectUrl.origin).toBe('https://new-auth.example.com'); + expect(redirectUrl.searchParams.get('client_id')).toBe('new-client-id'); + }); + + it('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, client_id_metadata_document_supported: true } + }); + + 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/client/test/client/streamableHttp.test.ts b/packages/client/test/client/streamableHttp.test.ts index a20fb92252..844f7bb032 100644 --- a/packages/client/test/client/streamableHttp.test.ts +++ b/packages/client/test/client/streamableHttp.test.ts @@ -948,6 +948,57 @@ describe('StreamableHTTPClientTransport', () => { authSpy.mockRestore(); }); + it('does not refresh cached discovery on step-up without a fresh resource_metadata challenge', async () => { + const message: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'test', + params: {}, + id: 'test-id' + }; + const priorResourceMetadataUrl = new URL('http://example.com/original-resource-metadata'); + (transport as unknown as { _resourceMetadataUrl?: URL })._resourceMetadataUrl = priorResourceMetadataUrl; + mockAuthProvider.tokens.mockResolvedValue({ + access_token: 'current-token', + token_type: 'Bearer', + scope: 'read' + }); + + const fetchMock = globalThis.fetch as Mock; + fetchMock + .mockResolvedValueOnce({ + ok: false, + status: 403, + statusText: 'Forbidden', + headers: new Headers({ + 'WWW-Authenticate': 'Bearer error="insufficient_scope", scope="write"' + }), + text: () => Promise.resolve('Insufficient scope') + }) + .mockResolvedValueOnce({ + ok: true, + status: 202, + headers: new Headers() + }); + + const authModule = await import('../../src/client/auth'); + const authSpy = vi.spyOn(authModule, 'auth'); + authSpy.mockResolvedValue('AUTHORIZED'); + + await transport.send(message); + + expect(authSpy).toHaveBeenCalledWith( + mockAuthProvider, + expect.objectContaining({ + resourceMetadataUrl: priorResourceMetadataUrl, + refreshCachedDiscovery: false, + scope: 'read write', + forceReauthorization: true + }) + ); + + authSpy.mockRestore(); + }); + it('caps step-up retries per send (bounded counter)', async () => { const message: JSONRPCMessage = { jsonrpc: '2.0',