From a42b551d3bca4cdb76ddbc8731a30ab849e31123 Mon Sep 17 00:00:00 2001 From: hugosmoreira Date: Wed, 29 Jul 2026 22:08:47 -0700 Subject: [PATCH 1/2] 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/2] 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.