Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/fix-oauth-resource-trailing-slash.md
Original file line number Diff line number Diff line change
@@ -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.
37 changes: 29 additions & 8 deletions src/client/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.
*/
Expand All @@ -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;
Expand Down Expand Up @@ -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 };
Expand Down Expand Up @@ -1222,7 +1243,7 @@ async function executeTokenRequest(
tokenRequestParams: URLSearchParams;
clientInformation?: OAuthClientInformationMixed;
addClientAuthentication?: OAuthClientProvider['addClientAuthentication'];
resource?: URL;
resource?: string | URL;
fetchFn?: FetchLike;
}
): Promise<OAuthTokens> {
Expand All @@ -1234,7 +1255,7 @@ async function executeTokenRequest(
});

if (resource) {
tokenRequestParams.set('resource', resource.href);
tokenRequestParams.set('resource', resourceIndicatorToString(resource));
}

if (addClientAuthentication) {
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -1329,7 +1350,7 @@ export async function refreshAuthorization(
metadata?: AuthorizationServerMetadata;
clientInformation: OAuthClientInformationMixed;
refreshToken: string;
resource?: URL;
resource?: string | URL;
addClientAuthentication?: OAuthClientProvider['addClientAuthentication'];
fetchFn?: FetchLike;
}
Expand Down Expand Up @@ -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;
Expand Down
84 changes: 82 additions & 2 deletions test/client/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
Loading