Skip to content

Commit 126d30c

Browse files
committed
fix(client): preserve OAuth resource metadata indicator
1 parent db83829 commit 126d30c

2 files changed

Lines changed: 63 additions & 14 deletions

File tree

packages/client/src/client/auth.ts

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -236,7 +236,7 @@ export interface OAuthClientProvider {
236236
*
237237
* Implementations must verify the returned resource matches the MCP server.
238238
*/
239-
validateResourceURL?(serverUrl: string | URL, resource?: string): Promise<URL | undefined>;
239+
validateResourceURL?(serverUrl: string | URL, resource?: string): Promise<OAuthResourceIndicator | undefined>;
240240

241241
/**
242242
* If implemented, provides a way for the client to invalidate (e.g. delete) the specified
@@ -384,6 +384,11 @@ function isClientAuthMethod(method: string): method is ClientAuthMethod {
384384

385385
const AUTHORIZATION_CODE_RESPONSE_TYPE = 'code';
386386
const AUTHORIZATION_CODE_CHALLENGE_METHOD = 'S256';
387+
type OAuthResourceIndicator = string | URL;
388+
389+
function serializeResourceIndicator(resource: OAuthResourceIndicator): string {
390+
return typeof resource === 'string' ? resource : resource.href;
391+
}
387392

388393
/**
389394
* Determines the best client authentication method to use based on server support and client configuration.
@@ -684,11 +689,11 @@ async function authInternal(
684689
// Save authorization server URL for providers that need it (e.g., CrossAppAccessProvider)
685690
await provider.saveAuthorizationServerUrl?.(String(authorizationServerUrl));
686691

687-
const resource: URL | undefined = await selectResourceURL(serverUrl, provider, resourceMetadata);
692+
const resource: OAuthResourceIndicator | undefined = await selectResourceURL(serverUrl, provider, resourceMetadata);
688693

689694
// Save resource URL for providers that need it (e.g., CrossAppAccessProvider)
690695
if (resource) {
691-
await provider.saveResourceUrl?.(String(resource));
696+
await provider.saveResourceUrl?.(serializeResourceIndicator(resource));
692697
}
693698

694699
// Scope selection used consistently for DCR and the authorization request.
@@ -844,7 +849,7 @@ export async function selectResourceURL(
844849
serverUrl: string | URL,
845850
provider: OAuthClientProvider,
846851
resourceMetadata?: OAuthProtectedResourceMetadata
847-
): Promise<URL | undefined> {
852+
): Promise<OAuthResourceIndicator | undefined> {
848853
const defaultResource = resourceUrlFromServerUrl(serverUrl);
849854

850855
// If provider has custom validation, delegate to it
@@ -861,8 +866,9 @@ export async function selectResourceURL(
861866
if (!checkResourceAllowed({ requestedResource: defaultResource, configuredResource: resourceMetadata.resource })) {
862867
throw new Error(`Protected resource ${resourceMetadata.resource} does not match expected ${defaultResource} (or origin)`);
863868
}
864-
// Prefer the resource from metadata since it's what the server is telling us to request
865-
return new URL(resourceMetadata.resource);
869+
// Prefer the exact resource indicator from metadata since it's what the server is telling us to request.
870+
// Constructing a URL would normalize pathless origins by appending "/", which can change the audience.
871+
return resourceMetadata.resource;
866872
}
867873

868874
/**
@@ -1376,7 +1382,7 @@ export async function startAuthorization(
13761382
redirectUrl: string | URL;
13771383
scope?: string;
13781384
state?: string;
1379-
resource?: URL;
1385+
resource?: OAuthResourceIndicator;
13801386
}
13811387
): Promise<{ authorizationUrl: URL; codeVerifier: string }> {
13821388
let authorizationUrl: URL;
@@ -1424,7 +1430,7 @@ export async function startAuthorization(
14241430
}
14251431

14261432
if (resource) {
1427-
authorizationUrl.searchParams.set('resource', resource.href);
1433+
authorizationUrl.searchParams.set('resource', serializeResourceIndicator(resource));
14281434
}
14291435

14301436
return { authorizationUrl, codeVerifier };
@@ -1472,7 +1478,7 @@ export async function executeTokenRequest(
14721478
tokenRequestParams: URLSearchParams;
14731479
clientInformation?: OAuthClientInformationMixed;
14741480
addClientAuthentication?: OAuthClientProvider['addClientAuthentication'];
1475-
resource?: URL;
1481+
resource?: OAuthResourceIndicator;
14761482
fetchFn?: FetchLike;
14771483
}
14781484
): Promise<OAuthTokens> {
@@ -1484,7 +1490,7 @@ export async function executeTokenRequest(
14841490
});
14851491

14861492
if (resource) {
1487-
tokenRequestParams.set('resource', resource.href);
1493+
tokenRequestParams.set('resource', serializeResourceIndicator(resource));
14881494
}
14891495

14901496
if (addClientAuthentication) {
@@ -1548,7 +1554,7 @@ export async function exchangeAuthorization(
15481554
authorizationCode: string;
15491555
codeVerifier: string;
15501556
redirectUri: string | URL;
1551-
resource?: URL;
1557+
resource?: OAuthResourceIndicator;
15521558
addClientAuthentication?: OAuthClientProvider['addClientAuthentication'];
15531559
fetchFn?: FetchLike;
15541560
}
@@ -1590,7 +1596,7 @@ export async function refreshAuthorization(
15901596
metadata?: AuthorizationServerMetadata;
15911597
clientInformation: OAuthClientInformationMixed;
15921598
refreshToken: string;
1593-
resource?: URL;
1599+
resource?: OAuthResourceIndicator;
15941600
addClientAuthentication?: OAuthClientProvider['addClientAuthentication'];
15951601
fetchFn?: FetchLike;
15961602
}
@@ -1651,7 +1657,7 @@ export async function fetchToken(
16511657
fetchFn
16521658
}: {
16531659
metadata?: AuthorizationServerMetadata;
1654-
resource?: URL;
1660+
resource?: OAuthResourceIndicator;
16551661
/** Authorization code for the default `authorization_code` grant flow */
16561662
authorizationCode?: string;
16571663
/** Optional scope parameter from auth() options */

packages/client/test/client/auth.test.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
refreshAuthorization,
1919
registerClient,
2020
selectClientAuthMethod,
21+
selectResourceURL,
2122
startAuthorization,
2223
validateClientMetadataUrl
2324
} from '../../src/client/auth.js';
@@ -1301,7 +1302,7 @@ describe('OAuth Authorization', () => {
13011302
const tokenCall = mockFetch.mock.calls.find(call => call[0].toString().includes('/token'));
13021303
expect(tokenCall).toBeDefined();
13031304
const body = tokenCall![1].body as URLSearchParams;
1304-
expect(body.get('resource')).toBe('https://resource.example.com/');
1305+
expect(body.get('resource')).toBe('https://resource.example.com');
13051306
});
13061307

13071308
it('re-saves enriched state when partial cache is supplemented with fetched metadata', async () => {
@@ -1464,6 +1465,17 @@ describe('OAuth Authorization', () => {
14641465
});
14651466
});
14661467

1468+
describe('selectResourceURL', () => {
1469+
it('preserves the exact protected resource metadata resource indicator', async () => {
1470+
const resource = await selectResourceURL('https://api.example.com/mcp-server', {} as OAuthClientProvider, {
1471+
resource: 'https://api.example.com',
1472+
authorization_servers: ['https://auth.example.com']
1473+
});
1474+
1475+
expect(resource).toBe('https://api.example.com');
1476+
});
1477+
});
1478+
14671479
describe('startAuthorization', () => {
14681480
const validMetadata = {
14691481
issuer: 'https://auth.example.com',
@@ -1508,6 +1520,17 @@ describe('OAuth Authorization', () => {
15081520
expect(codeVerifier).toBe('test_verifier');
15091521
});
15101522

1523+
it('preserves string resource indicators exactly', async () => {
1524+
const { authorizationUrl } = await startAuthorization('https://auth.example.com', {
1525+
metadata: undefined,
1526+
clientInformation: validClientInfo,
1527+
redirectUrl: 'http://localhost:3000/callback',
1528+
resource: 'https://api.example.com'
1529+
});
1530+
1531+
expect(authorizationUrl.searchParams.get('resource')).toBe('https://api.example.com');
1532+
});
1533+
15111534
it('includes scope parameter when provided', async () => {
15121535
const { authorizationUrl } = await startAuthorization('https://auth.example.com', {
15131536
clientInformation: validClientInfo,
@@ -1686,6 +1709,26 @@ describe('OAuth Authorization', () => {
16861709
expect(body.get('resource')).toBe('https://api.example.com/mcp-server');
16871710
});
16881711

1712+
it('preserves string resource indicators exactly in token requests', async () => {
1713+
mockFetch.mockResolvedValueOnce({
1714+
ok: true,
1715+
status: 200,
1716+
json: async () => validTokens
1717+
});
1718+
1719+
await exchangeAuthorization('https://auth.example.com', {
1720+
clientInformation: validClientInfo,
1721+
authorizationCode: 'code123',
1722+
codeVerifier: 'verifier123',
1723+
redirectUri: 'http://localhost:3000/callback',
1724+
resource: 'https://api.example.com'
1725+
});
1726+
1727+
const options = mockFetch.mock.calls[0]![1];
1728+
const body = options.body as URLSearchParams;
1729+
expect(body.get('resource')).toBe('https://api.example.com');
1730+
});
1731+
16891732
it('allows for string "expires_in" values', async () => {
16901733
mockFetch.mockResolvedValueOnce({
16911734
ok: true,

0 commit comments

Comments
 (0)