diff --git a/src/client/auth.ts b/src/client/auth.ts index 4c82b5114..b59862052 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -150,7 +150,7 @@ export interface OAuthClientProvider { * credentials, in the case where the server has indicated that they are no longer valid. * This avoids requiring the user to intervene manually. */ - invalidateCredentials?(scope: 'all' | 'client' | 'tokens' | 'verifier'): void | Promise; + invalidateCredentials?(scope: 'all' | 'client' | 'tokens' | 'verifier' | 'discovery'): void | Promise; /** * Prepares grant-specific parameters for a token request. @@ -189,6 +189,46 @@ export interface OAuthClientProvider { * } */ prepareTokenRequest?(scope?: string): URLSearchParams | Promise | undefined; + + /** + * 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. + * + * 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. + */ + saveDiscoveryState?(state: OAuthDiscoveryState): void | Promise; + + /** + * 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. + * + * Providers should clear cached discovery state on repeated authentication failures + * (via {@linkcode invalidateCredentials} with scope `'discovery'` or `'all'`) to allow + * re-discovery in case the authorization server has changed. + */ + discoveryState?(): OAuthDiscoveryState | undefined | Promise; +} + +/** + * 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. + */ +// TODO: Consider adding `authorizationServerMetadataUrl` to capture the exact well-known URL +// at which authorization server metadata was discovered. This would require +// `discoverAuthorizationServerMetadata()` to return the successful discovery URL. +export interface OAuthDiscoveryState extends OAuthServerInfo { + /** The URL at which the protected resource metadata was found, if available. */ + resourceMetadataUrl?: string; } export type AuthResult = 'AUTHORIZED' | 'REDIRECT'; @@ -397,32 +437,70 @@ async function authInternal( fetchFn?: FetchLike; } ): Promise { + // Check if the provider has cached discovery state to skip discovery + const cachedState = await provider.discoveryState?.(); + let resourceMetadata: OAuthProtectedResourceMetadata | undefined; - let authorizationServerUrl: string | URL | undefined; + let authorizationServerUrl: string | URL; + let metadata: AuthorizationServerMetadata | undefined; + + // If resourceMetadataUrl is not provided, try to load it from cached state + // This handles browser redirects where the URL was saved before navigation + let effectiveResourceMetadataUrl = resourceMetadataUrl; + if (!effectiveResourceMetadataUrl && cachedState?.resourceMetadataUrl) { + effectiveResourceMetadataUrl = new URL(cachedState.resourceMetadataUrl); + } - try { - resourceMetadata = await discoverOAuthProtectedResourceMetadata(serverUrl, { resourceMetadataUrl }, fetchFn); - if (resourceMetadata.authorization_servers && resourceMetadata.authorization_servers.length > 0) { - authorizationServerUrl = resourceMetadata.authorization_servers[0]; + if (cachedState?.authorizationServerUrl) { + // Restore discovery state from cache + authorizationServerUrl = cachedState.authorizationServerUrl; + resourceMetadata = cachedState.resourceMetadata; + metadata = + cachedState.authorizationServerMetadata ?? (await discoverAuthorizationServerMetadata(authorizationServerUrl, { fetchFn })); + + // If resource metadata wasn't cached, try to fetch it for selectResourceURL + if (!resourceMetadata) { + try { + resourceMetadata = await discoverOAuthProtectedResourceMetadata( + serverUrl, + { resourceMetadataUrl: effectiveResourceMetadataUrl }, + fetchFn + ); + } catch { + // RFC 9728 not available — selectResourceURL will handle undefined + } } - } catch { - // Ignore errors and fall back to /.well-known/oauth-authorization-server - } - /** - * If we don't get a valid authorization server metadata from protected resource metadata, - * fallback to the legacy MCP spec's implementation (version 2025-03-26): MCP server base URL acts as the Authorization server. - */ - if (!authorizationServerUrl) { - authorizationServerUrl = new URL('/', serverUrl); + // Re-save if we enriched the cached state with missing metadata + if (metadata !== cachedState.authorizationServerMetadata || resourceMetadata !== cachedState.resourceMetadata) { + await provider.saveDiscoveryState?.({ + authorizationServerUrl: String(authorizationServerUrl), + 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 resource: URL | undefined = await selectResourceURL(serverUrl, provider, resourceMetadata); - const metadata = await discoverAuthorizationServerMetadata(authorizationServerUrl, { - fetchFn - }); - // Handle client registration if needed let clientInformation = await Promise.resolve(provider.clientInformation()); if (!clientInformation) { @@ -937,6 +1015,87 @@ export async function discoverAuthorizationServerMetadata( return undefined; } +/** + * Result of {@linkcode discoverOAuthServerInfo}. + */ +export interface OAuthServerInfo { + /** + * The authorization server URL, either discovered via RFC 9728 + * or derived from the MCP server URL as a fallback. + */ + authorizationServerUrl: string; + + /** + * The authorization server metadata (endpoints, capabilities), + * or `undefined` if metadata discovery failed. + */ + authorizationServerMetadata?: AuthorizationServerMetadata; + + /** + * The OAuth 2.0 Protected Resource Metadata from RFC 9728, + * or `undefined` if the server does not support it. + */ + resourceMetadata?: OAuthProtectedResourceMetadata; +} + +/** + * Discovers the authorization server for an MCP server following + * {@link https://datatracker.ietf.org/doc/html/rfc9728 | RFC 9728} (OAuth 2.0 Protected + * Resource Metadata), with fallback to treating the server URL as the + * authorization server. + * + * This function combines two discovery steps into one call: + * 1. Probes `/.well-known/oauth-protected-resource` on the MCP server to find the + * authorization server URL (RFC 9728). + * 2. Fetches authorization server metadata from that URL (RFC 8414 / OpenID Connect Discovery). + * + * Use this when you need the authorization server metadata for operations outside the + * {@linkcode auth} orchestrator, such as token refresh or token revocation. + * + * @param serverUrl - The MCP resource server URL + * @param opts - Optional configuration + * @param opts.resourceMetadataUrl - Override URL for the protected resource metadata endpoint + * @param opts.fetchFn - Custom fetch function for HTTP requests + * @returns Authorization server URL, metadata, and resource metadata (if available) + */ +export async function discoverOAuthServerInfo( + serverUrl: string | URL, + opts?: { + resourceMetadataUrl?: URL; + fetchFn?: FetchLike; + } +): Promise { + let resourceMetadata: OAuthProtectedResourceMetadata | undefined; + let authorizationServerUrl: string | undefined; + + try { + resourceMetadata = await discoverOAuthProtectedResourceMetadata( + serverUrl, + { resourceMetadataUrl: opts?.resourceMetadataUrl }, + opts?.fetchFn + ); + if (resourceMetadata.authorization_servers && resourceMetadata.authorization_servers.length > 0) { + authorizationServerUrl = resourceMetadata.authorization_servers[0]; + } + } catch { + // RFC 9728 not supported -- fall back to treating the server URL as the authorization server + } + + // If we don't get a valid authorization server from protected resource metadata, + // fall back to the legacy MCP spec behavior: MCP server base URL acts as the authorization server + if (!authorizationServerUrl) { + authorizationServerUrl = String(new URL('/', serverUrl)); + } + + const authorizationServerMetadata = await discoverAuthorizationServerMetadata(authorizationServerUrl, { fetchFn: opts?.fetchFn }); + + return { + authorizationServerUrl, + authorizationServerMetadata, + resourceMetadata + }; +} + /** * Begins the authorization flow with the given server, by generating a PKCE challenge and constructing the authorization URL. */ diff --git a/test/client/auth.test.ts b/test/client/auth.test.ts index d6e7e8684..8df325eed 100644 --- a/test/client/auth.test.ts +++ b/test/client/auth.test.ts @@ -8,6 +8,7 @@ import { refreshAuthorization, registerClient, discoverOAuthProtectedResourceMetadata, + discoverOAuthServerInfo, extractWWWAuthenticateParams, auth, type OAuthClientProvider, @@ -916,6 +917,369 @@ describe('OAuth Authorization', () => { }); }); + describe('discoverOAuthServerInfo', () => { + const validResourceMetadata = { + resource: 'https://resource.example.com', + authorization_servers: ['https://auth.example.com'] + }; + + const validAuthMetadata = { + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'https://auth.example.com/token', + response_types_supported: ['code'] + }; + + it('returns auth server from RFC 9728 protected resource metadata', async () => { + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + if (urlString.includes('/.well-known/oauth-protected-resource')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => validResourceMetadata + }); + } + + if (urlString.includes('/.well-known/oauth-authorization-server')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => validAuthMetadata + }); + } + + return Promise.reject(new Error(`Unexpected fetch: ${urlString}`)); + }); + + const result = await discoverOAuthServerInfo('https://resource.example.com'); + + expect(result.authorizationServerUrl).toBe('https://auth.example.com'); + expect(result.resourceMetadata).toEqual(validResourceMetadata); + expect(result.authorizationServerMetadata).toEqual(validAuthMetadata); + }); + + it('falls back to server URL when RFC 9728 is not supported', async () => { + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + // RFC 9728 returns 404 + if (urlString.includes('/.well-known/oauth-protected-resource')) { + return Promise.resolve({ + ok: false, + status: 404 + }); + } + + if (urlString.includes('/.well-known/oauth-authorization-server')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + ...validAuthMetadata, + issuer: 'https://resource.example.com' + }) + }); + } + + return Promise.reject(new Error(`Unexpected fetch: ${urlString}`)); + }); + + const result = await discoverOAuthServerInfo('https://resource.example.com'); + + // Should fall back to server URL origin + expect(result.authorizationServerUrl).toBe('https://resource.example.com/'); + expect(result.resourceMetadata).toBeUndefined(); + expect(result.authorizationServerMetadata).toBeDefined(); + }); + + it('forwards resourceMetadataUrl override to protected resource metadata discovery', async () => { + const overrideUrl = new URL('https://custom.example.com/.well-known/oauth-protected-resource'); + + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + if (urlString === overrideUrl.toString()) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => validResourceMetadata + }); + } + + if (urlString.includes('/.well-known/oauth-authorization-server')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => validAuthMetadata + }); + } + + return Promise.reject(new Error(`Unexpected fetch: ${urlString}`)); + }); + + const result = await discoverOAuthServerInfo('https://resource.example.com', { + resourceMetadataUrl: overrideUrl + }); + + expect(result.resourceMetadata).toEqual(validResourceMetadata); + // Verify the override URL was used instead of the default well-known path + expect(mockFetch.mock.calls[0]![0].toString()).toBe(overrideUrl.toString()); + }); + }); + + describe('auth with provider authorization server URL caching', () => { + const validResourceMetadata = { + resource: 'https://resource.example.com', + authorization_servers: ['https://auth.example.com'] + }; + + const validAuthMetadata = { + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'https://auth.example.com/token', + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'] + }; + + function createMockProvider(overrides: Partial = {}): OAuthClientProvider { + return { + get redirectUrl() { + return 'http://localhost:3000/callback'; + }, + get clientMetadata() { + return { + redirect_uris: ['http://localhost:3000/callback'], + client_name: 'Test Client' + }; + }, + clientInformation: vi.fn().mockResolvedValue({ + client_id: 'test-client-id', + client_secret: 'test-client-secret' + }), + tokens: vi.fn().mockResolvedValue(undefined), + saveTokens: vi.fn(), + redirectToAuthorization: vi.fn(), + saveCodeVerifier: vi.fn(), + codeVerifier: vi.fn(), + ...overrides + }; + } + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('calls saveDiscoveryState after discovery when provider implements it', async () => { + const saveDiscoveryState = vi.fn(); + const provider = createMockProvider({ saveDiscoveryState }); + + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + if (urlString.includes('/.well-known/oauth-protected-resource')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => validResourceMetadata + }); + } + + if (urlString.includes('/.well-known/oauth-authorization-server')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => validAuthMetadata + }); + } + + return Promise.reject(new Error(`Unexpected fetch: ${urlString}`)); + }); + + await auth(provider, { serverUrl: 'https://resource.example.com' }); + + expect(saveDiscoveryState).toHaveBeenCalledWith( + expect.objectContaining({ + authorizationServerUrl: 'https://auth.example.com', + resourceMetadata: validResourceMetadata, + authorizationServerMetadata: validAuthMetadata + }) + ); + }); + + it('restores full discovery state from cache including resource metadata', async () => { + const provider = createMockProvider({ + discoveryState: vi.fn().mockResolvedValue({ + authorizationServerUrl: 'https://auth.example.com', + resourceMetadata: validResourceMetadata, + authorizationServerMetadata: validAuthMetadata + }), + tokens: vi.fn().mockResolvedValue({ + access_token: 'valid-token', + refresh_token: 'refresh-token', + token_type: 'bearer' + }) + }); + + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + if (urlString.includes('/token')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + access_token: 'new-token', + token_type: 'bearer', + expires_in: 3600, + refresh_token: 'new-refresh-token' + }) + }); + } + + return Promise.reject(new Error(`Unexpected fetch: ${urlString}`)); + }); + + const result = await auth(provider, { + serverUrl: 'https://resource.example.com' + }); + + expect(result).toBe('AUTHORIZED'); + + // Should NOT have called any discovery endpoints -- all from cache + const discoveryCalls = mockFetch.mock.calls.filter( + call => call[0].toString().includes('oauth-protected-resource') || call[0].toString().includes('oauth-authorization-server') + ); + expect(discoveryCalls).toHaveLength(0); + + // Verify the token request includes the resource parameter from cached metadata + const tokenCall = mockFetch.mock.calls.find(call => call[0].toString().includes('/token')); + expect(tokenCall).toBeDefined(); + const body = tokenCall![1].body as URLSearchParams; + expect(body.get('resource')).toBe('https://resource.example.com/'); + }); + + it('re-saves enriched state when partial cache is supplemented with fetched metadata', async () => { + const saveDiscoveryState = vi.fn(); + const provider = createMockProvider({ + // Partial cache: auth server URL only, no metadata + discoveryState: vi.fn().mockResolvedValue({ + authorizationServerUrl: 'https://auth.example.com' + }), + saveDiscoveryState, + tokens: vi.fn().mockResolvedValue({ + access_token: 'valid-token', + refresh_token: 'refresh-token', + token_type: 'bearer' + }) + }); + + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + if (urlString.includes('/.well-known/oauth-protected-resource')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => validResourceMetadata + }); + } + + if (urlString.includes('/.well-known/oauth-authorization-server')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => validAuthMetadata + }); + } + + if (urlString.includes('/token')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + access_token: 'new-token', + token_type: 'bearer', + expires_in: 3600, + refresh_token: 'new-refresh-token' + }) + }); + } + + return Promise.reject(new Error(`Unexpected fetch: ${urlString}`)); + }); + + await auth(provider, { serverUrl: 'https://resource.example.com' }); + + // Should re-save with the enriched state including fetched metadata + expect(saveDiscoveryState).toHaveBeenCalledWith( + expect.objectContaining({ + authorizationServerUrl: 'https://auth.example.com', + authorizationServerMetadata: validAuthMetadata, + resourceMetadata: validResourceMetadata + }) + ); + }); + + it('uses resourceMetadataUrl from cached discovery state for PRM discovery', async () => { + const cachedPrmUrl = 'https://custom.example.com/.well-known/oauth-protected-resource'; + const provider = createMockProvider({ + // Cache has auth server URL + resourceMetadataUrl but no resourceMetadata + // (simulates browser redirect where PRM URL was saved but metadata wasn't) + discoveryState: vi.fn().mockResolvedValue({ + authorizationServerUrl: 'https://auth.example.com', + resourceMetadataUrl: cachedPrmUrl, + authorizationServerMetadata: validAuthMetadata + }), + tokens: vi.fn().mockResolvedValue({ + access_token: 'valid-token', + refresh_token: 'refresh-token', + token_type: 'bearer' + }) + }); + + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + // The cached PRM URL should be used for resource metadata discovery + if (urlString === cachedPrmUrl) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => validResourceMetadata + }); + } + + if (urlString.includes('/token')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + access_token: 'new-token', + token_type: 'bearer', + expires_in: 3600, + refresh_token: 'new-refresh-token' + }) + }); + } + + return Promise.reject(new Error(`Unexpected fetch: ${urlString}`)); + }); + + const result = await auth(provider, { + serverUrl: 'https://resource.example.com' + }); + + expect(result).toBe('AUTHORIZED'); + + // Should have used the cached PRM URL, not the default well-known path + const prmCalls = mockFetch.mock.calls.filter(call => call[0].toString().includes('oauth-protected-resource')); + expect(prmCalls).toHaveLength(1); + expect(prmCalls[0]![0].toString()).toBe(cachedPrmUrl); + }); + }); + describe('selectClientAuthMethod', () => { it('selects the correct client authentication method from client information', () => { const clientInfo = {