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/5] 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 85398340b0..53840d1aec 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 6b70fbe942..fdadcebf63 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/5] 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 0000000000..e41b1aa0da --- /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/5] 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 e41b1aa0da..e34f270d27 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. From 5dddce59266deecde2b255cfd7022fcdb92d7f15 Mon Sep 17 00:00:00 2001 From: mukunda katta Date: Thu, 30 Apr 2026 14:17:10 -0700 Subject: [PATCH 4/5] chore: format oauth resource changeset --- .changeset/fix-oauth-resource-trailing-slash.md | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/.changeset/fix-oauth-resource-trailing-slash.md b/.changeset/fix-oauth-resource-trailing-slash.md index e34f270d27..4312085d3a 100644 --- a/.changeset/fix-oauth-resource-trailing-slash.md +++ b/.changeset/fix-oauth-resource-trailing-slash.md @@ -2,8 +2,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. +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 1f14cb29ee14d18f1ffb3dc41ba4531cefbb9cbe Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Thu, 3 Sep 2026 18:45:27 +0300 Subject: [PATCH 5/5] fix(client): keep selectResourceURL returning a URL and send the PRM resource string from auth() (#1968) Align the v1.x fix with the v2 shape (#2581): the public signature of selectResourceURL stays Promise; auth() sends the protected resource metadata's `resource` string verbatim when the provider has no custom validateResourceURL, and the OAuth helpers accept `string | URL` for `resource` via a small resourceIndicatorToString serializer. - auth.test.ts: the regression test now drives both the authorization redirect and the authorization-code token exchange with PRM `resource: https://example.com` and asserts neither gains a trailing slash; add a startAuthorization string case - auth.ts: JSDoc on selectResourceURL and a comment at the decision site in auth() - changeset: describe the normalization, the Entra rejection, the widened inputs, and that selectResourceURL's signature is unchanged --- .../fix-oauth-resource-trailing-slash.md | 5 +- src/client/auth.ts | 47 ++++++++++----- test/client/auth.test.ts | 57 +++++++++++++------ 3 files changed, 76 insertions(+), 33 deletions(-) diff --git a/.changeset/fix-oauth-resource-trailing-slash.md b/.changeset/fix-oauth-resource-trailing-slash.md index 4312085d3a..1e37721e35 100644 --- a/.changeset/fix-oauth-resource-trailing-slash.md +++ b/.changeset/fix-oauth-resource-trailing-slash.md @@ -2,5 +2,6 @@ '@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 exact OAuth resource indicator from protected resource metadata when building authorization and token requests. Previously a pathless `resource` such as `https://example.com` was normalized to `https://example.com/` via `URL.href`, which breaks authorization servers +that require the `resource` parameter to match the published value exactly (Microsoft Entra ID rejects it with `AADSTS9010010`). The exported OAuth helpers (`startAuthorization`, `exchangeAuthorization`, `refreshAuthorization`, `fetchToken`) now also accept a `string` for +`resource`; `selectResourceURL` still returns a `URL`, and a provider's `validateResourceURL` result is used unchanged. Fixes #1968. diff --git a/src/client/auth.ts b/src/client/auth.ts index 53840d1aec..214a289cb7 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -503,7 +503,13 @@ async function authInternal( }); } - const resource: URL | string | undefined = await selectResourceURL(serverUrl, provider, resourceMetadata); + // Send the metadata's resource indicator verbatim: `selectResourceURL` returns a parsed + // `URL`, and `URL.href` appends "/" to a pathless indicator such as `https://example.com`, + // which exact-match authorization servers reject (#1968). A URL returned by the + // provider's own `validateResourceURL` is used as returned. + const selectedResource = await selectResourceURL(serverUrl, provider, resourceMetadata); + const resource: string | URL | undefined = + selectedResource && resourceMetadata && !provider.validateResourceURL ? resourceMetadata.resource : selectedResource; // Apply scope selection strategy (SEP-835): // 1. WWW-Authenticate scope (passed via `scope` param) @@ -629,11 +635,22 @@ export function isHttpsUrl(value?: string): boolean { } } +/** + * Selects the RFC 8707 resource indicator for an MCP server: the provider's + * {@linkcode OAuthClientProvider.validateResourceURL | validateResourceURL} result when + * implemented, otherwise the protected resource metadata's `resource` (checked against the + * server URL with `checkResourceAllowed`), or `undefined` when there is no metadata. + * + * The result is a parsed `URL`, so a pathless indicator such as `https://example.com` has + * the `href` `https://example.com/`. {@linkcode auth} therefore sends the metadata string + * verbatim instead of this URL's `href` (#1968); callers that emit the `resource` + * parameter themselves should do the same. + */ 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,12 +667,8 @@ 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 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; + // Prefer the resource from metadata since it's what the server is telling us to request + return new URL(resourceMetadata.resource); } /** @@ -1112,6 +1125,10 @@ export async function discoverOAuthServerInfo( }; } +function resourceIndicatorToString(resource: string | URL): string { + return typeof resource === 'string' ? resource : resource.href; +} + /** * Begins the authorization flow with the given server, by generating a PKCE challenge and constructing the authorization URL. */ @@ -1130,7 +1147,7 @@ export async function startAuthorization( redirectUrl: string | URL; scope?: string; state?: string; - resource?: URL | string; + resource?: string | URL; } ): Promise<{ authorizationUrl: URL; codeVerifier: string }> { let authorizationUrl: URL; @@ -1178,7 +1195,7 @@ export async function startAuthorization( } if (resource) { - authorizationUrl.searchParams.set('resource', String(resource)); + authorizationUrl.searchParams.set('resource', resourceIndicatorToString(resource)); } return { authorizationUrl, codeVerifier }; @@ -1226,7 +1243,7 @@ async function executeTokenRequest( tokenRequestParams: URLSearchParams; clientInformation?: OAuthClientInformationMixed; addClientAuthentication?: OAuthClientProvider['addClientAuthentication']; - resource?: URL | string; + resource?: string | URL; fetchFn?: FetchLike; } ): Promise { @@ -1238,7 +1255,7 @@ async function executeTokenRequest( }); if (resource) { - tokenRequestParams.set('resource', String(resource)); + tokenRequestParams.set('resource', resourceIndicatorToString(resource)); } if (addClientAuthentication) { @@ -1291,7 +1308,7 @@ export async function exchangeAuthorization( authorizationCode: string; codeVerifier: string; redirectUri: string | URL; - resource?: URL | string; + resource?: string | URL; addClientAuthentication?: OAuthClientProvider['addClientAuthentication']; fetchFn?: FetchLike; } @@ -1333,7 +1350,7 @@ export async function refreshAuthorization( metadata?: AuthorizationServerMetadata; clientInformation: OAuthClientInformationMixed; refreshToken: string; - resource?: URL | string; + resource?: string | URL; addClientAuthentication?: OAuthClientProvider['addClientAuthentication']; fetchFn?: FetchLike; } @@ -1392,7 +1409,7 @@ export async function fetchToken( fetchFn }: { metadata?: AuthorizationServerMetadata; - resource?: URL | string; + resource?: string | URL; /** 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 fdadcebf63..56d1c5b943 100644 --- a/test/client/auth.test.ts +++ b/test/client/auth.test.ts @@ -1365,6 +1365,16 @@ describe('OAuth Authorization', () => { expect(codeVerifier).toBe('test_verifier'); }); + it('preserves a string resource indicator without URL normalization', async () => { + const { authorizationUrl } = await startAuthorization('https://auth.example.com', { + clientInformation: validClientInfo, + redirectUrl: 'http://localhost:3000/callback', + resource: 'https://api.example.com' + }); + + expect(authorizationUrl.searchParams.get('resource')).toBe('https://api.example.com'); + }); + it('includes scope parameter when provided', async () => { const { authorizationUrl } = await startAuthorization('https://auth.example.com', { clientInformation: validClientInfo, @@ -2563,10 +2573,11 @@ 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. + it('sends a pathless PRM resource verbatim on the authorization and token requests (#1968)', async () => { + // RFC 9728 publishes the resource identifier and RFC 8707 requires it to be + // sent unchanged. `new URL('https://example.com').href` is 'https://example.com/', + // and authorization servers that match the indicator exactly (Microsoft Entra + // ID: AADSTS9010010) reject the extra slash. mockFetch.mockImplementation(url => { const urlString = url.toString(); @@ -2575,9 +2586,9 @@ describe('OAuth Authorization', () => { 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'] + resource: 'https://example.com', + authorization_servers: ['https://auth.example.com'], + scopes_supported: ['https://example.com/mcp:tools'] }) }); } else if (urlString.includes('/.well-known/oauth-authorization-server')) { @@ -2592,6 +2603,12 @@ describe('OAuth Authorization', () => { code_challenge_methods_supported: ['S256'] }) }); + } else if (urlString.includes('/token')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ access_token: 'access123', token_type: 'bearer', expires_in: 3600 }) + }); } return Promise.resolve({ ok: false, status: 404 }); @@ -2604,17 +2621,25 @@ describe('OAuth Authorization', () => { (mockProvider.tokens as Mock).mockResolvedValue(undefined); (mockProvider.saveCodeVerifier as Mock).mockResolvedValue(undefined); (mockProvider.redirectToAuthorization as Mock).mockResolvedValue(undefined); + (mockProvider.codeVerifier as Mock).mockResolvedValue('verifier123'); + (mockProvider.saveTokens as Mock).mockResolvedValue(undefined); - const result = await auth(mockProvider, { - serverUrl: 'https://api.example.com/mcp-server/endpoint' - }); - - expect(result).toBe('REDIRECT'); + // Authorization request: the redirect carries the metadata value byte for byte. + const redirectResult = await auth(mockProvider, { serverUrl: 'https://example.com/mcp' }); + expect(redirectResult).toBe('REDIRECT'); + const authUrl: URL = (mockProvider.redirectToAuthorization as Mock).mock.calls[0][0]; + expect(authUrl.searchParams.get('resource')).toBe('https://example.com'); - 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'); + // Token request: the authorization-code exchange sends the same value. + const exchangeResult = await auth(mockProvider, { + serverUrl: 'https://example.com/mcp', + authorizationCode: 'code123' + }); + expect(exchangeResult).toBe('AUTHORIZED'); + 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://example.com'); }); it('excludes resource parameter when Protected Resource Metadata is not present', async () => {