Skip to content

Commit 93a5ca2

Browse files
committed
fix(client): preserve authorization server subpath in fallback URLs
When AS metadata discovery fails and the authorization server has a non-root path (e.g., https://example.com/admin), the fallback URL construction used `new URL('/authorize', serverUrl)` which silently discards the path, producing https://example.com/authorize instead of https://example.com/admin/authorize. Adds buildFallbackUrl() helper that appends the endpoint to the existing pathname. Applied to all three fallback locations: /authorize, /token, and /register. Closes #1716
1 parent babaa50 commit 93a5ca2

2 files changed

Lines changed: 75 additions & 3 deletions

File tree

packages/client/src/client/auth.ts

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1005,6 +1005,25 @@ async function fetchWithCorsRetry(url: URL, headers?: Record<string, string>, fe
10051005
}
10061006
}
10071007

1008+
/**
1009+
* Builds a fallback endpoint URL by appending the endpoint path to the
1010+
* authorization server's existing pathname, instead of replacing it.
1011+
*
1012+
* `new URL('/authorize', 'https://example.com/admin')` produces
1013+
* `https://example.com/authorize` (path lost). This helper produces
1014+
* `https://example.com/admin/authorize` (path preserved).
1015+
*/
1016+
function buildFallbackUrl(authorizationServerUrl: string | URL, endpoint: string): URL {
1017+
const url = typeof authorizationServerUrl === 'string'
1018+
? new URL(authorizationServerUrl)
1019+
: new URL(authorizationServerUrl.href);
1020+
const basePath = url.pathname.endsWith('/')
1021+
? url.pathname.slice(0, -1)
1022+
: url.pathname;
1023+
url.pathname = `${basePath}${endpoint}`;
1024+
return url;
1025+
}
1026+
10081027
/**
10091028
* Constructs the well-known path for auth-related metadata discovery
10101029
*/
@@ -1372,7 +1391,7 @@ export async function startAuthorization(
13721391
throw new Error(`Incompatible auth server: does not support code challenge method ${AUTHORIZATION_CODE_CHALLENGE_METHOD}`);
13731392
}
13741393
} else {
1375-
authorizationUrl = new URL('/authorize', authorizationServerUrl);
1394+
authorizationUrl = buildFallbackUrl(authorizationServerUrl, '/authorize');
13761395
}
13771396

13781397
// Generate PKCE challenge
@@ -1454,7 +1473,7 @@ export async function executeTokenRequest(
14541473
fetchFn?: FetchLike;
14551474
}
14561475
): Promise<OAuthTokens> {
1457-
const tokenUrl = metadata?.token_endpoint ? new URL(metadata.token_endpoint) : new URL('/token', authorizationServerUrl);
1476+
const tokenUrl = metadata?.token_endpoint ? new URL(metadata.token_endpoint) : buildFallbackUrl(authorizationServerUrl, '/token');
14581477

14591478
const headers = new Headers({
14601479
'Content-Type': 'application/x-www-form-urlencoded',
@@ -1701,7 +1720,7 @@ export async function registerClient(
17011720

17021721
registrationUrl = new URL(metadata.registration_endpoint);
17031722
} else {
1704-
registrationUrl = new URL('/register', authorizationServerUrl);
1723+
registrationUrl = buildFallbackUrl(authorizationServerUrl, '/register');
17051724
}
17061725

17071726
const response = await (fetchFn ?? fetch)(registrationUrl, {

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

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1621,6 +1621,59 @@ describe('OAuth Authorization', () => {
16211621
);
16221622
});
16231623

1624+
describe('fallback URL subpath preservation', () => {
1625+
const validClientInfo = {
1626+
client_id: 'client123',
1627+
client_secret: 'secret123',
1628+
redirect_uris: ['http://localhost:3000/callback'],
1629+
client_name: 'Test Client'
1630+
};
1631+
1632+
it('preserves authorization server subpath in fallback authorize URL', async () => {
1633+
const { authorizationUrl } = await startAuthorization('https://auth.example.com/admin', {
1634+
metadata: undefined,
1635+
clientInformation: validClientInfo,
1636+
redirectUrl: 'http://localhost:3000/callback'
1637+
});
1638+
1639+
expect(authorizationUrl.pathname).toBe('/admin/authorize');
1640+
});
1641+
1642+
it('preserves subpath in fallback token URL', async () => {
1643+
const mockFetch = vi.fn();
1644+
mockFetch.mockResolvedValueOnce({
1645+
ok: true,
1646+
status: 200,
1647+
json: async () => ({
1648+
access_token: 'access123',
1649+
token_type: 'Bearer'
1650+
})
1651+
});
1652+
1653+
await exchangeAuthorization('https://auth.example.com/admin', {
1654+
metadata: undefined,
1655+
clientInformation: validClientInfo,
1656+
authorizationCode: 'code123',
1657+
codeVerifier: 'verifier123',
1658+
redirectUri: 'http://localhost:3000/callback',
1659+
fetchFn: mockFetch,
1660+
});
1661+
1662+
const fetchUrl = new URL(mockFetch.mock.calls[0][0]);
1663+
expect(fetchUrl.pathname).toBe('/admin/token');
1664+
});
1665+
1666+
it('still works for root-path authorization servers', async () => {
1667+
const { authorizationUrl } = await startAuthorization('https://auth.example.com', {
1668+
metadata: undefined,
1669+
clientInformation: validClientInfo,
1670+
redirectUrl: 'http://localhost:3000/callback'
1671+
});
1672+
1673+
expect(authorizationUrl.pathname).toBe('/authorize');
1674+
});
1675+
});
1676+
16241677
describe('exchangeAuthorization', () => {
16251678
const validTokens: OAuthTokens = {
16261679
access_token: 'access123',

0 commit comments

Comments
 (0)