Skip to content

Commit a42b551

Browse files
committed
fix: preserve exact OAuth resource indicators
1 parent cc4b416 commit a42b551

2 files changed

Lines changed: 31 additions & 11 deletions

File tree

packages/client/src/client/auth.ts

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1185,11 +1185,13 @@ async function authInternal(
11851185
await provider.saveDiscoveryState?.(freshDiscoveryState);
11861186
}
11871187

1188-
const resource: URL | undefined = await selectResourceURL(serverUrl, provider, resourceMetadata);
1188+
const selectedResource = await selectResourceURL(serverUrl, provider, resourceMetadata);
1189+
const resource: string | URL | undefined =
1190+
selectedResource && resourceMetadata && !provider.validateResourceURL ? resourceMetadata.resource : selectedResource;
11891191

11901192
// Save resource URL for providers that need it (e.g., CrossAppAccessProvider)
11911193
if (resource) {
1192-
await provider.saveResourceUrl?.(String(resource));
1194+
await provider.saveResourceUrl?.(resourceIndicatorToString(resource));
11931195
}
11941196

11951197
// Scope selection used consistently for DCR and the authorization request.
@@ -1950,6 +1952,10 @@ export async function discoverOAuthServerInfo(
19501952
};
19511953
}
19521954

1955+
function resourceIndicatorToString(resource: string | URL): string {
1956+
return typeof resource === 'string' ? resource : resource.href;
1957+
}
1958+
19531959
/**
19541960
* Begins the authorization flow with the given server, by generating a PKCE challenge and constructing the authorization URL.
19551961
*/
@@ -1968,7 +1974,7 @@ export async function startAuthorization(
19681974
redirectUrl: string | URL;
19691975
scope?: string;
19701976
state?: string;
1971-
resource?: URL;
1977+
resource?: string | URL;
19721978
}
19731979
): Promise<{ authorizationUrl: URL; codeVerifier: string }> {
19741980
let authorizationUrl: URL;
@@ -2016,7 +2022,7 @@ export async function startAuthorization(
20162022
}
20172023

20182024
if (resource) {
2019-
authorizationUrl.searchParams.set('resource', resource.href);
2025+
authorizationUrl.searchParams.set('resource', resourceIndicatorToString(resource));
20202026
}
20212027

20222028
return { authorizationUrl, codeVerifier };
@@ -2064,7 +2070,7 @@ export async function executeTokenRequest(
20642070
tokenRequestParams: URLSearchParams;
20652071
clientInformation?: OAuthClientInformationMixed;
20662072
addClientAuthentication?: OAuthClientProvider['addClientAuthentication'];
2067-
resource?: URL;
2073+
resource?: string | URL;
20682074
fetchFn?: FetchLike;
20692075
}
20702076
): Promise<OAuthTokens> {
@@ -2076,7 +2082,7 @@ export async function executeTokenRequest(
20762082
});
20772083

20782084
if (resource) {
2079-
tokenRequestParams.set('resource', resource.href);
2085+
tokenRequestParams.set('resource', resourceIndicatorToString(resource));
20802086
}
20812087

20822088
if (addClientAuthentication) {
@@ -2147,7 +2153,7 @@ export async function exchangeAuthorization(
21472153
iss?: string;
21482154
codeVerifier: string;
21492155
redirectUri: string | URL;
2150-
resource?: URL;
2156+
resource?: string | URL;
21512157
addClientAuthentication?: OAuthClientProvider['addClientAuthentication'];
21522158
fetchFn?: FetchLike;
21532159
}
@@ -2195,7 +2201,7 @@ export async function refreshAuthorization(
21952201
metadata?: AuthorizationServerMetadata;
21962202
clientInformation: OAuthClientInformationMixed;
21972203
refreshToken: string;
2198-
resource?: URL;
2204+
resource?: string | URL;
21992205
addClientAuthentication?: OAuthClientProvider['addClientAuthentication'];
22002206
fetchFn?: FetchLike;
22012207
}
@@ -2257,7 +2263,7 @@ export async function fetchToken(
22572263
fetchFn
22582264
}: {
22592265
metadata?: AuthorizationServerMetadata;
2260-
resource?: URL;
2266+
resource?: string | URL;
22612267
/** Authorization code for the default `authorization_code` grant flow */
22622268
authorizationCode?: string;
22632269
/**

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

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1496,7 +1496,8 @@ describe('OAuth Authorization', () => {
14961496

14971497
it('calls saveDiscoveryState after discovery when provider implements it', async () => {
14981498
const saveDiscoveryState = vi.fn();
1499-
const provider = createMockProvider({ saveDiscoveryState });
1499+
const saveResourceUrl = vi.fn();
1500+
const provider = createMockProvider({ saveDiscoveryState, saveResourceUrl });
15001501

15011502
mockFetch.mockImplementation(url => {
15021503
const urlString = url.toString();
@@ -1529,6 +1530,9 @@ describe('OAuth Authorization', () => {
15291530
authorizationServerMetadata: validAuthMetadata
15301531
})
15311532
);
1533+
expect(saveResourceUrl).toHaveBeenCalledWith('https://resource.example.com');
1534+
const authorizationUrl = vi.mocked(provider.redirectToAuthorization).mock.calls[0]![0];
1535+
expect(authorizationUrl.searchParams.get('resource')).toBe('https://resource.example.com');
15321536
});
15331537

15341538
it('restores full discovery state from cache including resource metadata', async () => {
@@ -1580,7 +1584,7 @@ describe('OAuth Authorization', () => {
15801584
const tokenCall = mockFetch.mock.calls.find(call => call[0].toString().includes('/token'));
15811585
expect(tokenCall).toBeDefined();
15821586
const body = tokenCall![1].body as URLSearchParams;
1583-
expect(body.get('resource')).toBe('https://resource.example.com/');
1587+
expect(body.get('resource')).toBe('https://resource.example.com');
15841588
});
15851589

15861590
it('re-saves enriched state when partial cache is supplemented with fetched metadata', async () => {
@@ -1787,6 +1791,16 @@ describe('OAuth Authorization', () => {
17871791
expect(codeVerifier).toBe('test_verifier');
17881792
});
17891793

1794+
it('preserves a string resource indicator without URL normalization', async () => {
1795+
const { authorizationUrl } = await startAuthorization('https://auth.example.com', {
1796+
clientInformation: validClientInfo,
1797+
redirectUrl: 'http://localhost:3000/callback',
1798+
resource: 'https://api.example.com'
1799+
});
1800+
1801+
expect(authorizationUrl.searchParams.get('resource')).toBe('https://api.example.com');
1802+
});
1803+
17901804
it('includes scope parameter when provided', async () => {
17911805
const { authorizationUrl } = await startAuthorization('https://auth.example.com', {
17921806
clientInformation: validClientInfo,

0 commit comments

Comments
 (0)