From a42b551d3bca4cdb76ddbc8731a30ab849e31123 Mon Sep 17 00:00:00 2001 From: hugosmoreira Date: Wed, 29 Jul 2026 22:08:47 -0700 Subject: [PATCH 1/3] fix: preserve exact OAuth resource indicators --- packages/client/src/client/auth.ts | 24 +++++++++++++++--------- packages/client/test/client/auth.test.ts | 18 ++++++++++++++++-- 2 files changed, 31 insertions(+), 11 deletions(-) diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index 9ebc6fd251..77ebb0d52b 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -1185,11 +1185,13 @@ async function authInternal( await provider.saveDiscoveryState?.(freshDiscoveryState); } - const resource: URL | undefined = await selectResourceURL(serverUrl, provider, resourceMetadata); + const selectedResource = await selectResourceURL(serverUrl, provider, resourceMetadata); + const resource: string | URL | undefined = + selectedResource && resourceMetadata && !provider.validateResourceURL ? resourceMetadata.resource : selectedResource; // Save resource URL for providers that need it (e.g., CrossAppAccessProvider) if (resource) { - await provider.saveResourceUrl?.(String(resource)); + await provider.saveResourceUrl?.(resourceIndicatorToString(resource)); } // Scope selection used consistently for DCR and the authorization request. @@ -1950,6 +1952,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. */ @@ -1968,7 +1974,7 @@ export async function startAuthorization( redirectUrl: string | URL; scope?: string; state?: string; - resource?: URL; + resource?: string | URL; } ): Promise<{ authorizationUrl: URL; codeVerifier: string }> { let authorizationUrl: URL; @@ -2016,7 +2022,7 @@ export async function startAuthorization( } if (resource) { - authorizationUrl.searchParams.set('resource', resource.href); + authorizationUrl.searchParams.set('resource', resourceIndicatorToString(resource)); } return { authorizationUrl, codeVerifier }; @@ -2064,7 +2070,7 @@ export async function executeTokenRequest( tokenRequestParams: URLSearchParams; clientInformation?: OAuthClientInformationMixed; addClientAuthentication?: OAuthClientProvider['addClientAuthentication']; - resource?: URL; + resource?: string | URL; fetchFn?: FetchLike; } ): Promise { @@ -2076,7 +2082,7 @@ export async function executeTokenRequest( }); if (resource) { - tokenRequestParams.set('resource', resource.href); + tokenRequestParams.set('resource', resourceIndicatorToString(resource)); } if (addClientAuthentication) { @@ -2147,7 +2153,7 @@ export async function exchangeAuthorization( iss?: string; codeVerifier: string; redirectUri: string | URL; - resource?: URL; + resource?: string | URL; addClientAuthentication?: OAuthClientProvider['addClientAuthentication']; fetchFn?: FetchLike; } @@ -2195,7 +2201,7 @@ export async function refreshAuthorization( metadata?: AuthorizationServerMetadata; clientInformation: OAuthClientInformationMixed; refreshToken: string; - resource?: URL; + resource?: string | URL; addClientAuthentication?: OAuthClientProvider['addClientAuthentication']; fetchFn?: FetchLike; } @@ -2257,7 +2263,7 @@ export async function fetchToken( fetchFn }: { metadata?: AuthorizationServerMetadata; - resource?: URL; + resource?: string | URL; /** Authorization code for the default `authorization_code` grant flow */ authorizationCode?: string; /** diff --git a/packages/client/test/client/auth.test.ts b/packages/client/test/client/auth.test.ts index 62c6faed9a..1616045ad5 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -1496,7 +1496,8 @@ describe('OAuth Authorization', () => { it('calls saveDiscoveryState after discovery when provider implements it', async () => { const saveDiscoveryState = vi.fn(); - const provider = createMockProvider({ saveDiscoveryState }); + const saveResourceUrl = vi.fn(); + const provider = createMockProvider({ saveDiscoveryState, saveResourceUrl }); mockFetch.mockImplementation(url => { const urlString = url.toString(); @@ -1529,6 +1530,9 @@ describe('OAuth Authorization', () => { authorizationServerMetadata: validAuthMetadata }) ); + expect(saveResourceUrl).toHaveBeenCalledWith('https://resource.example.com'); + const authorizationUrl = vi.mocked(provider.redirectToAuthorization).mock.calls[0]![0]; + expect(authorizationUrl.searchParams.get('resource')).toBe('https://resource.example.com'); }); it('restores full discovery state from cache including resource metadata', async () => { @@ -1580,7 +1584,7 @@ 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/'); + expect(body.get('resource')).toBe('https://resource.example.com'); }); it('re-saves enriched state when partial cache is supplemented with fetched metadata', async () => { @@ -1787,6 +1791,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, From 248e166e7a3c2131808070daa2de3a217088afcd Mon Sep 17 00:00:00 2001 From: hugosmoreira Date: Wed, 29 Jul 2026 22:22:24 -0700 Subject: [PATCH 2/3] chore: add OAuth resource indicator changeset --- .changeset/plenty-plums-sip.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/plenty-plums-sip.md diff --git a/.changeset/plenty-plums-sip.md b/.changeset/plenty-plums-sip.md new file mode 100644 index 0000000000..24eb3ebcca --- /dev/null +++ b/.changeset/plenty-plums-sip.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/client': patch +--- + +Preserve exact OAuth resource indicators from protected resource metadata. From de246440cd632cfa155d10b2b0e13399de0fc559 Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Thu, 3 Sep 2026 18:35:10 +0300 Subject: [PATCH 3/3] test(client): pin pathless PRM resource end to end, document selectResourceURL normalization (#1968) - auth.test.ts: drive auth() with PRM `resource: https://example.com` and assert the authorization redirect and the authorization-code token exchange both carry it without a trailing slash (fails on main with 'https://example.com/') - auth.ts: JSDoc on selectResourceURL explaining that it returns a parsed URL and that auth() sends the metadata string verbatim; comment at the decision site in auth() - changeset: describe the trailing-slash normalization, the Entra rejection, the widened `resource` inputs, and that selectResourceURL's signature is unchanged --- .changeset/plenty-plums-sip.md | 2 +- packages/client/src/client/auth.ts | 15 ++++++ packages/client/test/client/auth.test.ts | 69 ++++++++++++++++++++++++ 3 files changed, 85 insertions(+), 1 deletion(-) diff --git a/.changeset/plenty-plums-sip.md b/.changeset/plenty-plums-sip.md index 24eb3ebcca..334cf24a33 100644 --- a/.changeset/plenty-plums-sip.md +++ b/.changeset/plenty-plums-sip.md @@ -2,4 +2,4 @@ '@modelcontextprotocol/client': patch --- -Preserve exact OAuth resource indicators from protected resource metadata. +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`, `executeTokenRequest`) 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/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index 8388d00d00..4c37339297 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -1243,6 +1243,10 @@ async function authInternal( await provider.saveDiscoveryState?.(freshDiscoveryState); } + // 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; @@ -1462,6 +1466,17 @@ 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, diff --git a/packages/client/test/client/auth.test.ts b/packages/client/test/client/auth.test.ts index d87997bcd5..3ac9c7ddff 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -3367,6 +3367,75 @@ describe('OAuth Authorization', () => { expect(authUrl.searchParams.get('resource')).toBe('https://api.example.com/'); }); + 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(); + + if (urlString.includes('/.well-known/oauth-protected-resource')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + 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')) { + 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'] + }) + }); + } 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 }); + }); + + (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); + (mockProvider.codeVerifier as Mock).mockResolvedValue('verifier123'); + (mockProvider.saveTokens as Mock).mockResolvedValue(undefined); + + // 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'); + + // 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 () => { // Mock metadata discovery where protected resource metadata is not available (404) // but authorization server metadata is available