diff --git a/.changeset/fix-oauth-resource-trailing-slash.md b/.changeset/fix-oauth-resource-trailing-slash.md new file mode 100644 index 0000000000..1e37721e35 --- /dev/null +++ b/.changeset/fix-oauth-resource-trailing-slash.md @@ -0,0 +1,7 @@ +--- +'@modelcontextprotocol/sdk': patch +--- + +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 85398340b0..214a289cb7 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -503,7 +503,13 @@ async function authInternal( }); } - const resource: URL | 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,6 +635,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, @@ -1108,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. */ @@ -1126,7 +1147,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; @@ -1174,7 +1195,7 @@ export async function startAuthorization( } if (resource) { - authorizationUrl.searchParams.set('resource', resource.href); + authorizationUrl.searchParams.set('resource', resourceIndicatorToString(resource)); } return { authorizationUrl, codeVerifier }; @@ -1222,7 +1243,7 @@ async function executeTokenRequest( tokenRequestParams: URLSearchParams; clientInformation?: OAuthClientInformationMixed; addClientAuthentication?: OAuthClientProvider['addClientAuthentication']; - resource?: URL; + resource?: string | URL; fetchFn?: FetchLike; } ): Promise { @@ -1234,7 +1255,7 @@ async function executeTokenRequest( }); if (resource) { - tokenRequestParams.set('resource', resource.href); + tokenRequestParams.set('resource', resourceIndicatorToString(resource)); } if (addClientAuthentication) { @@ -1287,7 +1308,7 @@ export async function exchangeAuthorization( authorizationCode: string; codeVerifier: string; redirectUri: string | URL; - resource?: URL; + resource?: string | URL; addClientAuthentication?: OAuthClientProvider['addClientAuthentication']; fetchFn?: FetchLike; } @@ -1329,7 +1350,7 @@ export async function refreshAuthorization( metadata?: AuthorizationServerMetadata; clientInformation: OAuthClientInformationMixed; refreshToken: string; - resource?: URL; + resource?: string | URL; addClientAuthentication?: OAuthClientProvider['addClientAuthentication']; fetchFn?: FetchLike; } @@ -1388,7 +1409,7 @@ export async function fetchToken( fetchFn }: { metadata?: AuthorizationServerMetadata; - resource?: URL; + 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 6b70fbe942..56d1c5b943 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 () => { @@ -1364,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, @@ -2562,6 +2573,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