From 60993b211201b6282a1d5ab13665b99c8a731b2c Mon Sep 17 00:00:00 2001 From: Christian-Sidak Date: Thu, 30 Apr 2026 08:15:51 +0000 Subject: [PATCH] fix: preserve exact resource URI from PRM without trailing slash normalization When protected resource metadata contains a pathless URI like "https://example.com", the SDK was normalizing it to "https://example.com/" via URL.href. This broke OAuth with providers like Microsoft Entra ID that require the resource parameter to exactly match the scopes' audience. Change selectResourceURL to return the original metadata string directly instead of wrapping it in new URL(), and update all serialization sites to use String() instead of .href so URL objects are still handled correctly. Fixes #1968 Co-Authored-By: Claude Sonnet 4.6 --- .../fix-oauth-resource-trailing-slash.md | 5 ++ packages/client/src/client/auth.ts | 24 ++++---- packages/client/test/client/auth.test.ts | 58 ++++++++++++++++++- 3 files changed, 75 insertions(+), 12 deletions(-) create mode 100644 .changeset/fix-oauth-resource-trailing-slash.md diff --git a/.changeset/fix-oauth-resource-trailing-slash.md b/.changeset/fix-oauth-resource-trailing-slash.md new file mode 100644 index 0000000000..ac34803f94 --- /dev/null +++ b/.changeset/fix-oauth-resource-trailing-slash.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/client': patch +--- + +Preserve the exact resource URI from protected resource metadata when building OAuth requests. Previously, a pathless URI like `https://example.com` was normalized to `https://example.com/` via `URL.href`, breaking providers such as Microsoft Entra ID that require the `resource` parameter to exactly match the value in metadata. diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index 93a03ece67..346d4e656a 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -684,7 +684,7 @@ async function authInternal( // Save authorization server URL for providers that need it (e.g., CrossAppAccessProvider) await provider.saveAuthorizationServerUrl?.(String(authorizationServerUrl)); - const resource: URL | undefined = await selectResourceURL(serverUrl, provider, resourceMetadata); + const resource: URL | string | undefined = await selectResourceURL(serverUrl, provider, resourceMetadata); // Save resource URL for providers that need it (e.g., CrossAppAccessProvider) if (resource) { @@ -822,7 +822,7 @@ export async function selectResourceURL( serverUrl: string | URL, provider: OAuthClientProvider, resourceMetadata?: OAuthProtectedResourceMetadata -): Promise { +): Promise { const defaultResource = resourceUrlFromServerUrl(serverUrl); // If provider has custom validation, delegate to it @@ -839,8 +839,10 @@ export async function selectResourceURL( if (!checkResourceAllowed({ requestedResource: defaultResource, configuredResource: resourceMetadata.resource })) { throw new Error(`Protected resource ${resourceMetadata.resource} does not match expected ${defaultResource} (or origin)`); } - // Prefer the resource from metadata since it's what the server is telling us to request - return new URL(resourceMetadata.resource); + // Prefer the resource from metadata since it's what the server is telling us to request. + // Return the original string to preserve the exact value from metadata (e.g. avoid adding + // a trailing slash to a pathless URI like "https://example.com" via URL.href normalization). + return resourceMetadata.resource; } /** @@ -1354,7 +1356,7 @@ export async function startAuthorization( redirectUrl: string | URL; scope?: string; state?: string; - resource?: URL; + resource?: URL | string; } ): Promise<{ authorizationUrl: URL; codeVerifier: string }> { let authorizationUrl: URL; @@ -1402,7 +1404,7 @@ export async function startAuthorization( } if (resource) { - authorizationUrl.searchParams.set('resource', resource.href); + authorizationUrl.searchParams.set('resource', String(resource)); } return { authorizationUrl, codeVerifier }; @@ -1450,7 +1452,7 @@ export async function executeTokenRequest( tokenRequestParams: URLSearchParams; clientInformation?: OAuthClientInformationMixed; addClientAuthentication?: OAuthClientProvider['addClientAuthentication']; - resource?: URL; + resource?: URL | string; fetchFn?: FetchLike; } ): Promise { @@ -1462,7 +1464,7 @@ export async function executeTokenRequest( }); if (resource) { - tokenRequestParams.set('resource', resource.href); + tokenRequestParams.set('resource', String(resource)); } if (addClientAuthentication) { @@ -1526,7 +1528,7 @@ export async function exchangeAuthorization( authorizationCode: string; codeVerifier: string; redirectUri: string | URL; - resource?: URL; + resource?: URL | string; addClientAuthentication?: OAuthClientProvider['addClientAuthentication']; fetchFn?: FetchLike; } @@ -1568,7 +1570,7 @@ export async function refreshAuthorization( metadata?: AuthorizationServerMetadata; clientInformation: OAuthClientInformationMixed; refreshToken: string; - resource?: URL; + resource?: URL | string; addClientAuthentication?: OAuthClientProvider['addClientAuthentication']; fetchFn?: FetchLike; } @@ -1629,7 +1631,7 @@ export async function fetchToken( fetchFn }: { metadata?: AuthorizationServerMetadata; - resource?: URL; + resource?: URL | string; /** Authorization code for the default `authorization_code` grant flow */ authorizationCode?: string; /** Optional scope parameter from auth() options */ diff --git a/packages/client/test/client/auth.test.ts b/packages/client/test/client/auth.test.ts index 53263ad8c3..46e2cb1f13 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -1300,7 +1300,8 @@ describe('OAuth Authorization', () => { 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/'); + // Should preserve the exact resource value from metadata without adding a trailing slash + expect(body.get('resource')).toBe('https://resource.example.com'); }); it('re-saves enriched state when partial cache is supplemented with fetched metadata', async () => { @@ -2712,6 +2713,61 @@ describe('OAuth Authorization', () => { expect(authUrl.searchParams.get('resource')).toBe('https://api.example.com/'); }); + it('preserves pathless resource URI from PRM without adding trailing slash', async () => { + // Regression test for https://github.com/modelcontextprotocol/typescript-sdk/issues/1968 + // A pathless resource URI like "https://example.com" must not be normalized to + // "https://example.com/" (with trailing slash) since providers like Microsoft Entra ID + // require an exact match between the resource parameter and the scopes' audience. + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + if (urlString.includes('/.well-known/oauth-protected-resource')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + // Pathless resource URI — must not get a trailing slash added + resource: 'https://example.com', + authorization_servers: ['https://auth.example.com'] + }) + }); + } else 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.resolve({ ok: false, status: 404 }); + }); + + (mockProvider.clientInformation as Mock).mockResolvedValue({ + client_id: 'test-client', + client_secret: 'test-secret' + }); + (mockProvider.tokens as Mock).mockResolvedValue(undefined); + (mockProvider.saveCodeVerifier as Mock).mockResolvedValue(undefined); + (mockProvider.redirectToAuthorization as Mock).mockResolvedValue(undefined); + + const result = await auth(mockProvider, { + serverUrl: 'https://example.com' + }); + + expect(result).toBe('REDIRECT'); + + const redirectCall = (mockProvider.redirectToAuthorization as Mock).mock.calls[0]!; + const authUrl: URL = redirectCall[0]; + // Must be the exact metadata value, without a trailing slash + expect(authUrl.searchParams.get('resource')).toBe('https://example.com'); + }); + it('excludes resource parameter when Protected Resource Metadata is not present', async () => { // Mock metadata discovery where protected resource metadata is not available (404) // but authorization server metadata is available