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