From 3a87ffa6b62381f50c0f8a2fc7d924f811d2963a Mon Sep 17 00:00:00 2001 From: ZAIN Date: Tue, 30 Dec 2025 09:20:46 +0900 Subject: [PATCH 1/2] fix: persist resource metadata URL across OAuth redirects In browser OAuth flows, when the user is redirected to the authorization server and back, the resourceMetadataUrl discovered from the WWW-Authenticate header was lost. This caused token exchange to fail because the SDK couldn't locate the correct token endpoint. This commit adds two optional methods to OAuthClientProvider: - saveResourceMetadataUrl(url): Saves the URL before redirect - resourceMetadataUrl(): Loads the saved URL after redirect The SDK now: 1. Loads resourceMetadataUrl from provider if not passed in options 2. Saves resourceMetadataUrl before calling redirectToAuthorization() This change is fully backwards-compatible as both methods are optional. Providers that don't implement them will continue to work as before. Fixes #1234 --- .../client/src/simpleOAuthClientProvider.ts | 17 + packages/client/src/client/auth.ts | 58 ++- packages/client/test/client/auth.test.ts | 350 +++++++++++++++++- 3 files changed, 423 insertions(+), 2 deletions(-) diff --git a/examples/client/src/simpleOAuthClientProvider.ts b/examples/client/src/simpleOAuthClientProvider.ts index 96655c9f6..52c45f6f9 100644 --- a/examples/client/src/simpleOAuthClientProvider.ts +++ b/examples/client/src/simpleOAuthClientProvider.ts @@ -8,6 +8,7 @@ export class InMemoryOAuthClientProvider implements OAuthClientProvider { private _clientInformation?: OAuthClientInformationMixed; private _tokens?: OAuthTokens; private _codeVerifier?: string; + private _resourceMetadataUrl?: URL; constructor( private readonly _redirectUrl: string | URL, @@ -62,4 +63,20 @@ export class InMemoryOAuthClientProvider implements OAuthClientProvider { } return this._codeVerifier; } + + /** + * Saves the resource metadata URL discovered during the initial OAuth challenge. + * In browser environments, you should persist this to sessionStorage. + */ + saveResourceMetadataUrl(url: URL): void { + this._resourceMetadataUrl = url; + } + + /** + * Loads a previously saved resource metadata URL. + * This is called when exchanging the authorization code for tokens after redirect. + */ + resourceMetadataUrl(): URL | undefined { + return this._resourceMetadataUrl; + } } diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index 93048e4b3..f1146f3bc 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -188,6 +188,45 @@ export interface OAuthClientProvider { * } */ prepareTokenRequest?(scope?: string): URLSearchParams | Promise | undefined; + + /** + * Saves the resource metadata URL discovered during the initial OAuth challenge. + * + * This optional method allows browser-based applications to persist the resource + * metadata URL (from the WWW-Authenticate header) before redirecting to the + * authorization server. This is necessary because the URL would otherwise be + * lost during the redirect, causing token exchange to fail. + * + * In browser environments, implementations should persist this to sessionStorage + * or a similar mechanism that survives page navigation. + * + * @param url - The resource metadata URL discovered from the WWW-Authenticate header + * + * @example + * // Browser implementation using sessionStorage: + * saveResourceMetadataUrl(url) { + * sessionStorage.setItem('oauth_resource_metadata_url', url.toString()); + * } + */ + saveResourceMetadataUrl?(url: URL): void | Promise; + + /** + * Loads a previously saved resource metadata URL. + * + * This optional method retrieves the resource metadata URL that was saved + * before the OAuth redirect. It's called when exchanging the authorization + * code for tokens, allowing the SDK to locate the correct token endpoint. + * + * @returns The saved resource metadata URL, or undefined if none was saved + * + * @example + * // Browser implementation using sessionStorage: + * resourceMetadataUrl() { + * const url = sessionStorage.getItem('oauth_resource_metadata_url'); + * return url ? new URL(url) : undefined; + * } + */ + resourceMetadataUrl?(): URL | undefined | Promise; } export type AuthResult = 'AUTHORIZED' | 'REDIRECT'; @@ -399,8 +438,19 @@ async function authInternal( let resourceMetadata: OAuthProtectedResourceMetadata | undefined; let authorizationServerUrl: string | URL | undefined; + // If resourceMetadataUrl is not provided, try to load it from the provider + // This handles the case where the URL was saved before a browser redirect + let effectiveResourceMetadataUrl = resourceMetadataUrl; + if (!effectiveResourceMetadataUrl && provider.resourceMetadataUrl) { + effectiveResourceMetadataUrl = await Promise.resolve(provider.resourceMetadataUrl()); + } + try { - resourceMetadata = await discoverOAuthProtectedResourceMetadata(serverUrl, { resourceMetadataUrl }, fetchFn); + resourceMetadata = await discoverOAuthProtectedResourceMetadata( + serverUrl, + { resourceMetadataUrl: effectiveResourceMetadataUrl }, + fetchFn + ); if (resourceMetadata.authorization_servers && resourceMetadata.authorization_servers.length > 0) { authorizationServerUrl = resourceMetadata.authorization_servers[0]; } @@ -509,6 +559,12 @@ async function authInternal( const state = provider.state ? await provider.state() : undefined; + // Save the resource metadata URL before redirect so it can be restored after + // This is essential for browser environments where the page navigates away + if (effectiveResourceMetadataUrl && provider.saveResourceMetadataUrl) { + await provider.saveResourceMetadataUrl(effectiveResourceMetadataUrl); + } + // Start new authorization flow const { authorizationUrl, codeVerifier } = await startAuthorization(authorizationServerUrl, { metadata, diff --git a/packages/client/test/client/auth.test.ts b/packages/client/test/client/auth.test.ts index cb01d37d5..f8602275a 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -2,6 +2,7 @@ import type { AuthorizationServerMetadata, OAuthTokens } from '@modelcontextprot import { InvalidClientMetadataError, LATEST_PROTOCOL_VERSION, ServerError } from '@modelcontextprotocol/core'; import { expect, type Mock, vi } from 'vitest'; +import { createPrivateKeyJwtAuth } from '../../src/client/auth-extensions.js'; import { auth, buildDiscoveryUrls, @@ -17,7 +18,6 @@ import { selectClientAuthMethod, startAuthorization } from '../../src/client/auth.js'; -import { createPrivateKeyJwtAuth } from '../../src/client/auth-extensions.js'; // Mock pkce-challenge vi.mock('pkce-challenge', () => ({ @@ -3244,4 +3244,352 @@ describe('OAuth Authorization', () => { }); }); }); + + describe('resource metadata URL persistence', () => { + const mockProvider: OAuthClientProvider = { + get redirectUrl() { + return 'http://localhost:3000/callback'; + }, + get clientMetadata() { + return { + redirect_uris: ['http://localhost:3000/callback'], + client_name: 'Test Client' + }; + }, + clientInformation: vi.fn().mockReturnValue({ + client_id: 'test-client-id' + }), + tokens: vi.fn().mockReturnValue(undefined), + saveTokens: vi.fn(), + redirectToAuthorization: vi.fn(), + saveCodeVerifier: vi.fn(), + codeVerifier: vi.fn().mockReturnValue('test_verifier'), + saveResourceMetadataUrl: vi.fn(), + resourceMetadataUrl: vi.fn() + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('saves resource metadata URL before redirect when saveResourceMetadataUrl is implemented', async () => { + const resourceMetadataUrl = new URL('https://resource.example.com/.well-known/oauth-protected-resource'); + + // Mock protected resource metadata discovery + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + if (urlString.includes('/.well-known/oauth-protected-resource')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + resource: 'https://resource.example.com', + authorization_servers: ['https://auth.example.com'] + }) + }); + } + + if (urlString.includes('/.well-known/oauth-authorization-server')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + 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'] + }) + }); + } + + return Promise.reject(new Error(`Unexpected fetch call: ${urlString}`)); + }); + + await auth(mockProvider, { + serverUrl: 'https://resource.example.com', + resourceMetadataUrl + }); + + // Verify that saveResourceMetadataUrl was called with the URL + expect(mockProvider.saveResourceMetadataUrl).toHaveBeenCalledWith(resourceMetadataUrl); + // Verify redirect was triggered + expect(mockProvider.redirectToAuthorization).toHaveBeenCalled(); + }); + + it('loads resource metadata URL from provider when not passed in options', async () => { + const savedResourceMetadataUrl = new URL('https://resource.example.com/.well-known/oauth-protected-resource'); + + // Configure provider to return saved URL + (mockProvider.resourceMetadataUrl as Mock).mockReturnValue(savedResourceMetadataUrl); + + // Mock protected resource metadata discovery using the saved URL + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + if (urlString.includes('/.well-known/oauth-protected-resource')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + resource: 'https://resource.example.com', + authorization_servers: ['https://auth.example.com'] + }) + }); + } + + if (urlString.includes('/.well-known/oauth-authorization-server')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + 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'] + }) + }); + } + + if (urlString.includes('/token')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + access_token: 'test_token', + token_type: 'bearer', + expires_in: 3600 + }) + }); + } + + return Promise.reject(new Error(`Unexpected fetch call: ${urlString}`)); + }); + + // Call auth with authorization code (simulating post-redirect) + const result = await auth(mockProvider, { + serverUrl: 'https://resource.example.com', + authorizationCode: 'test_code' + // resourceMetadataUrl is NOT passed - should be loaded from provider + }); + + expect(result).toBe('AUTHORIZED'); + // Verify provider.resourceMetadataUrl was called to retrieve saved URL + expect(mockProvider.resourceMetadataUrl).toHaveBeenCalled(); + }); + + it('does not call saveResourceMetadataUrl when provider does not implement it', async () => { + const providerWithoutPersistence: OAuthClientProvider = { + get redirectUrl() { + return 'http://localhost:3000/callback'; + }, + get clientMetadata() { + return { + redirect_uris: ['http://localhost:3000/callback'], + client_name: 'Test Client' + }; + }, + clientInformation: vi.fn().mockReturnValue({ + client_id: 'test-client-id' + }), + tokens: vi.fn().mockReturnValue(undefined), + saveTokens: vi.fn(), + redirectToAuthorization: vi.fn(), + saveCodeVerifier: vi.fn(), + codeVerifier: vi.fn().mockReturnValue('test_verifier') + // Note: saveResourceMetadataUrl and resourceMetadataUrl are NOT implemented + }; + + const resourceMetadataUrl = new URL('https://resource.example.com/.well-known/oauth-protected-resource'); + + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + if (urlString.includes('/.well-known/oauth-protected-resource')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + resource: 'https://resource.example.com', + authorization_servers: ['https://auth.example.com'] + }) + }); + } + + if (urlString.includes('/.well-known/oauth-authorization-server')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + 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'] + }) + }); + } + + return Promise.reject(new Error(`Unexpected fetch call: ${urlString}`)); + }); + + // Should not throw - provider without persistence is still valid + await auth(providerWithoutPersistence, { + serverUrl: 'https://resource.example.com', + resourceMetadataUrl + }); + + // Verify redirect was triggered (auth flow proceeded normally) + expect(providerWithoutPersistence.redirectToAuthorization).toHaveBeenCalled(); + }); + + it('handles async resourceMetadataUrl that returns a Promise', async () => { + const savedResourceMetadataUrl = new URL('https://resource.example.com/.well-known/oauth-protected-resource'); + + // Configure provider to return a Promise + (mockProvider.resourceMetadataUrl as Mock).mockResolvedValue(savedResourceMetadataUrl); + + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + if (urlString.includes('/.well-known/oauth-protected-resource')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + resource: 'https://resource.example.com', + authorization_servers: ['https://auth.example.com'] + }) + }); + } + + if (urlString.includes('/.well-known/oauth-authorization-server')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + 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'] + }) + }); + } + + if (urlString.includes('/token')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + access_token: 'test_token', + token_type: 'bearer', + expires_in: 3600 + }) + }); + } + + return Promise.reject(new Error(`Unexpected fetch call: ${urlString}`)); + }); + + const result = await auth(mockProvider, { + serverUrl: 'https://resource.example.com', + authorizationCode: 'test_code' + }); + + expect(result).toBe('AUTHORIZED'); + expect(mockProvider.resourceMetadataUrl).toHaveBeenCalled(); + }); + + it('does not call provider.resourceMetadataUrl when resourceMetadataUrl is passed in options', async () => { + const resourceMetadataUrl = new URL('https://resource.example.com/.well-known/oauth-protected-resource'); + + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + if (urlString.includes('/.well-known/oauth-protected-resource')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + resource: 'https://resource.example.com', + authorization_servers: ['https://auth.example.com'] + }) + }); + } + + if (urlString.includes('/.well-known/oauth-authorization-server')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + 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'] + }) + }); + } + + return Promise.reject(new Error(`Unexpected fetch call: ${urlString}`)); + }); + + await auth(mockProvider, { + serverUrl: 'https://resource.example.com', + resourceMetadataUrl // Explicitly passed + }); + + // Provider's resourceMetadataUrl should NOT be called when URL is passed in options + expect(mockProvider.resourceMetadataUrl).not.toHaveBeenCalled(); + }); + + it('proceeds normally when provider.resourceMetadataUrl returns undefined', async () => { + // Configure provider to return undefined (no saved URL) + (mockProvider.resourceMetadataUrl as Mock).mockReturnValue(undefined); + + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + if (urlString.includes('/.well-known/oauth-protected-resource')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + resource: 'https://resource.example.com', + authorization_servers: ['https://auth.example.com'] + }) + }); + } + + if (urlString.includes('/.well-known/oauth-authorization-server')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + 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'] + }) + }); + } + + return Promise.reject(new Error(`Unexpected fetch call: ${urlString}`)); + }); + + // Should not throw - undefined return is valid + await auth(mockProvider, { + serverUrl: 'https://resource.example.com' + }); + + expect(mockProvider.resourceMetadataUrl).toHaveBeenCalled(); + expect(mockProvider.redirectToAuthorization).toHaveBeenCalled(); + // saveResourceMetadataUrl should NOT be called when there's no URL to save + expect(mockProvider.saveResourceMetadataUrl).not.toHaveBeenCalled(); + }); + }); }); From f497c687c195790bfa7302d2797b2ba88d159dd8 Mon Sep 17 00:00:00 2001 From: ZAIN Date: Tue, 30 Dec 2025 09:34:55 +0900 Subject: [PATCH 2/2] chore: add changeset --- .changeset/oauth-resource-metadata-url.md | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .changeset/oauth-resource-metadata-url.md diff --git a/.changeset/oauth-resource-metadata-url.md b/.changeset/oauth-resource-metadata-url.md new file mode 100644 index 000000000..c51f20388 --- /dev/null +++ b/.changeset/oauth-resource-metadata-url.md @@ -0,0 +1,11 @@ +--- +"@modelcontextprotocol/client": patch +--- + +Fix OAuth resource metadata URL persistence across browser redirects + +Added two optional methods to `OAuthClientProvider`: +- `saveResourceMetadataUrl(url: URL)`: Saves the URL before redirect +- `resourceMetadataUrl()`: Loads the saved URL after redirect + +This fixes token exchange failures in browser OAuth flows where the resource metadata URL discovered from the WWW-Authenticate header was lost during redirects.