Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/plenty-plums-sip.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@modelcontextprotocol/client': patch
---

Preserve exact OAuth resource indicators from protected resource metadata.
24 changes: 15 additions & 9 deletions packages/client/src/client/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
*/
Expand All @@ -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;
Expand Down Expand Up @@ -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 };
Expand Down Expand Up @@ -2064,7 +2070,7 @@ export async function executeTokenRequest(
tokenRequestParams: URLSearchParams;
clientInformation?: OAuthClientInformationMixed;
addClientAuthentication?: OAuthClientProvider['addClientAuthentication'];
resource?: URL;
resource?: string | URL;
fetchFn?: FetchLike;
}
): Promise<OAuthTokens> {
Expand All @@ -2076,7 +2082,7 @@ export async function executeTokenRequest(
});

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

if (addClientAuthentication) {
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -2195,7 +2201,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 @@ -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;
/**
Expand Down
18 changes: 16 additions & 2 deletions packages/client/test/client/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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,
Expand Down
Loading