From 74d37874eb6eb3e1934a65afc87b953eddd1617c Mon Sep 17 00:00:00 2001 From: Mukunda Rao Katta Date: Mon, 27 Apr 2026 20:48:51 -0700 Subject: [PATCH 1/3] fix(auth): preserve resource URI without trailing slash (#1968) When handling RFC 9728 protected resource metadata, `selectResourceURL` routed the metadata's `resource` value through `new URL(...).href`. For bare-origin URIs that round trip appends a trailing slash: new URL("https://example.com").href === "https://example.com/" The resulting `resource` parameter no longer matches what the server published in PRM, which breaks providers that require an exact match. Microsoft Entra ID rejects the request with AADSTS9010010 when the `resource` parameter does not match the audience of the requested scope. Return the original metadata string verbatim from `selectResourceURL` and serialize it with `String(resource)` instead of `URL.href` in the authorization and token request paths. The validation step still parses the value as a URL via `checkResourceAllowed`. Also adjusted the cached discovery-state test to expect the un-normalized resource value, and added a regression test for the bare-domain case. Fixes #1968 --- src/client/auth.ts | 26 ++++++++++-------- test/client/auth.test.ts | 59 ++++++++++++++++++++++++++++++++++++++-- 2 files changed, 72 insertions(+), 13 deletions(-) diff --git a/src/client/auth.ts b/src/client/auth.ts index 85398340b..53840d1ae 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -503,7 +503,7 @@ async function authInternal( }); } - const resource: URL | undefined = await selectResourceURL(serverUrl, provider, resourceMetadata); + const resource: URL | string | undefined = await selectResourceURL(serverUrl, provider, resourceMetadata); // Apply scope selection strategy (SEP-835): // 1. WWW-Authenticate scope (passed via `scope` param) @@ -633,7 +633,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 @@ -650,8 +650,12 @@ 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 verbatim so we don't re-serialize through `URL.href`, which + // appends a trailing slash to bare-origin URIs (e.g. `https://example.com` becomes + // `https://example.com/`) and breaks providers that require an exact match against the + // configured resource indicator (RFC 8707), such as Microsoft Entra ID. + return resourceMetadata.resource; } /** @@ -1126,7 +1130,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; @@ -1174,7 +1178,7 @@ export async function startAuthorization( } if (resource) { - authorizationUrl.searchParams.set('resource', resource.href); + authorizationUrl.searchParams.set('resource', String(resource)); } return { authorizationUrl, codeVerifier }; @@ -1222,7 +1226,7 @@ async function executeTokenRequest( tokenRequestParams: URLSearchParams; clientInformation?: OAuthClientInformationMixed; addClientAuthentication?: OAuthClientProvider['addClientAuthentication']; - resource?: URL; + resource?: URL | string; fetchFn?: FetchLike; } ): Promise { @@ -1234,7 +1238,7 @@ async function executeTokenRequest( }); if (resource) { - tokenRequestParams.set('resource', resource.href); + tokenRequestParams.set('resource', String(resource)); } if (addClientAuthentication) { @@ -1287,7 +1291,7 @@ export async function exchangeAuthorization( authorizationCode: string; codeVerifier: string; redirectUri: string | URL; - resource?: URL; + resource?: URL | string; addClientAuthentication?: OAuthClientProvider['addClientAuthentication']; fetchFn?: FetchLike; } @@ -1329,7 +1333,7 @@ export async function refreshAuthorization( metadata?: AuthorizationServerMetadata; clientInformation: OAuthClientInformationMixed; refreshToken: string; - resource?: URL; + resource?: URL | string; addClientAuthentication?: OAuthClientProvider['addClientAuthentication']; fetchFn?: FetchLike; } @@ -1388,7 +1392,7 @@ export async function fetchToken( fetchFn }: { metadata?: AuthorizationServerMetadata; - resource?: URL; + resource?: URL | string; /** Authorization code for the default authorization_code grant flow */ authorizationCode?: string; fetchFn?: FetchLike; diff --git a/test/client/auth.test.ts b/test/client/auth.test.ts index 6b70fbe94..fdadcebf6 100644 --- a/test/client/auth.test.ts +++ b/test/client/auth.test.ts @@ -1153,11 +1153,12 @@ describe('OAuth Authorization', () => { ); expect(discoveryCalls).toHaveLength(0); - // Verify the token request includes the resource parameter from cached metadata + // Verify the token request includes the resource parameter from cached metadata, + // preserved verbatim (no trailing slash added — see #1968). 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/'); + expect(body.get('resource')).toBe('https://resource.example.com'); }); it('re-saves enriched state when partial cache is supplemented with fetched metadata', async () => { @@ -2562,6 +2563,60 @@ describe('OAuth Authorization', () => { expect(authUrl.searchParams.get('resource')).toBe('https://api.example.com/'); }); + it('preserves bare-domain resource URI from PRM without adding a trailing slash', async () => { + // Regression test for #1968: `new URL("https://example.com").href` adds a trailing + // slash, which breaks providers (e.g. Microsoft Entra ID) that require an exact + // match between the OAuth `resource` parameter and the configured resource indicator. + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + if (urlString.includes('/.well-known/oauth-protected-resource')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + // Bare-origin resource URI with no trailing slash + resource: 'https://api.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://api.example.com/mcp-server/endpoint' + }); + + expect(result).toBe('REDIRECT'); + + const redirectCall = (mockProvider.redirectToAuthorization as Mock).mock.calls[0]; + const authUrl: URL = redirectCall[0]; + // Resource indicator must round-trip exactly as published in PRM (no trailing slash) + expect(authUrl.searchParams.get('resource')).toBe('https://api.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 From f5a6c7c81e7a1fcf27c747451cffd8e4d1218df0 Mon Sep 17 00:00:00 2001 From: Mukunda Rao Katta Date: Tue, 28 Apr 2026 08:21:58 -0700 Subject: [PATCH 2/3] chore: add changeset for #1968 fix --- .changeset/fix-oauth-resource-trailing-slash.md | 5 +++++ 1 file changed, 5 insertions(+) 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 000000000..e41b1aa0d --- /dev/null +++ b/.changeset/fix-oauth-resource-trailing-slash.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/sdk': patch +--- + +Preserve the OAuth protected-resource URI without adding a trailing slash. Previously `selectResourceURL` returned `new URL(metadata.resource).href` which appended `/` to bare-domain URIs (e.g. `https://example.com` became `https://example.com/`), breaking OAuth interop with Microsoft Entra ID. Resolves #1968. From d61bfde2e78ec38eae6c87a17615eef3f297cb3f Mon Sep 17 00:00:00 2001 From: Mukunda Rao Katta Date: Tue, 28 Apr 2026 08:29:26 -0700 Subject: [PATCH 3/3] chore: wrap changeset prose to satisfy prettier --- .changeset/fix-oauth-resource-trailing-slash.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.changeset/fix-oauth-resource-trailing-slash.md b/.changeset/fix-oauth-resource-trailing-slash.md index e41b1aa0d..e34f270d2 100644 --- a/.changeset/fix-oauth-resource-trailing-slash.md +++ b/.changeset/fix-oauth-resource-trailing-slash.md @@ -2,4 +2,8 @@ '@modelcontextprotocol/sdk': patch --- -Preserve the OAuth protected-resource URI without adding a trailing slash. Previously `selectResourceURL` returned `new URL(metadata.resource).href` which appended `/` to bare-domain URIs (e.g. `https://example.com` became `https://example.com/`), breaking OAuth interop with Microsoft Entra ID. Resolves #1968. +Preserve the OAuth protected-resource URI without adding a trailing slash. +Previously `selectResourceURL` returned `new URL(metadata.resource).href` which +appended `/` to bare-domain URIs (e.g. `https://example.com` became +`https://example.com/`), breaking OAuth interop with Microsoft Entra ID. +Resolves #1968.