From 177b62c3c729ce37f0da4c9967e03f95361e9b0c Mon Sep 17 00:00:00 2001 From: Raashish Aggarwal <94279692+raashish1601@users.noreply.github.com> Date: Sat, 30 May 2026 17:09:55 +0530 Subject: [PATCH 1/2] chore(client): normalize trailing slashes in discovery paths --- packages/client/src/client/auth.ts | 14 +++++-------- packages/client/test/client/auth.test.ts | 26 ++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 9 deletions(-) diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index 5f55fb7a0..f09521967 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -1035,10 +1035,9 @@ function buildWellKnownPath( pathname: string = '', options: { prependPathname?: boolean } = {} ): string { - // Strip trailing slash from pathname to avoid double slashes - if (pathname.endsWith('/')) { - pathname = pathname.slice(0, -1); - } + // Strip trailing slashes from pathname to avoid malformed discovery paths like + // "/foo//.well-known/oauth-authorization-server". + pathname = pathname.replace(/\/+$/, ''); return options.prependPathname ? `${pathname}/.well-known/${wellKnownPrefix}` : `/.well-known/${wellKnownPrefix}${pathname}`; } @@ -1173,11 +1172,8 @@ export function buildDiscoveryUrls(authorizationServerUrl: string | URL): { url: return urlsToTry; } - // Strip trailing slash from pathname to avoid double slashes - let pathname = url.pathname; - if (pathname.endsWith('/')) { - pathname = pathname.slice(0, -1); - } + // Strip trailing slashes from pathname to avoid malformed discovery paths. + let pathname = url.pathname.replace(/\/+$/, ''); urlsToTry.push( // 1. OAuth metadata at the given URL diff --git a/packages/client/test/client/auth.test.ts b/packages/client/test/client/auth.test.ts index 04d7f4a3f..927c79a27 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -265,6 +265,21 @@ describe('OAuth Authorization', () => { expect(url.toString()).toBe('https://resource.example.com/.well-known/oauth-protected-resource/path/name'); }); + it('normalizes duplicate trailing slashes in path before metadata discovery', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validMetadata + }); + + const metadata = await discoverOAuthProtectedResourceMetadata('https://resource.example.com/path/name//'); + expect(metadata).toEqual(validMetadata); + const calls = mockFetch.mock.calls; + expect(calls.length).toBe(1); + const [url] = calls[0]!; + expect(url.toString()).toBe('https://resource.example.com/.well-known/oauth-protected-resource/path/name'); + }); + it('preserves query parameters in path-aware discovery', async () => { mockFetch.mockResolvedValueOnce({ ok: true, @@ -853,6 +868,17 @@ describe('OAuth Authorization', () => { ]); }); + it('normalizes trailing slashes in server URLs before discovery', () => { + const urls = buildDiscoveryUrls('https://auth.example.com/tenant1//'); + + expect(urls).toHaveLength(3); + expect(urls.map(u => u.url.toString())).toEqual([ + 'https://auth.example.com/.well-known/oauth-authorization-server/tenant1', + 'https://auth.example.com/.well-known/openid-configuration/tenant1', + 'https://auth.example.com/tenant1/.well-known/openid-configuration' + ]); + }); + it('handles URL object input', () => { const urls = buildDiscoveryUrls(new URL('https://auth.example.com/tenant1')); From 6e46b57848e55e173c63892b08c86c445c397fc3 Mon Sep 17 00:00:00 2001 From: Raashish Aggarwal <94279692+raashish1601@users.noreply.github.com> Date: Sat, 30 May 2026 17:29:13 +0530 Subject: [PATCH 2/2] fix(client): avoid redundant fallback on root-like discovery paths --- packages/client/src/client/auth.ts | 19 ++++++++++------ packages/client/test/client/auth.test.ts | 28 ++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 7 deletions(-) diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index f09521967..ac8aaa863 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -1035,9 +1035,7 @@ function buildWellKnownPath( pathname: string = '', options: { prependPathname?: boolean } = {} ): string { - // Strip trailing slashes from pathname to avoid malformed discovery paths like - // "/foo//.well-known/oauth-authorization-server". - pathname = pathname.replace(/\/+$/, ''); + pathname = normalizeDiscoveryPath(pathname); return options.prependPathname ? `${pathname}/.well-known/${wellKnownPrefix}` : `/.well-known/${wellKnownPrefix}${pathname}`; } @@ -1057,10 +1055,15 @@ async function tryMetadataDiscovery(url: URL, protocolVersion: string, fetchFn: */ function shouldAttemptFallback(response: Response | undefined, pathname: string): boolean { if (!response) return true; // CORS error — always try fallback - if (pathname === '/') return false; // Already at root + if (pathname === '') return false; // Already at root return (response.status >= 400 && response.status < 500) || response.status === 502; } +function normalizeDiscoveryPath(pathname: string): string { + const normalizedPathname = pathname.replace(/\/+$/, ''); + return normalizedPathname === '/' ? '' : normalizedPathname; +} + /** * Generic function for discovering OAuth metadata with fallback support */ @@ -1072,6 +1075,7 @@ async function discoverMetadataWithFallback( ): Promise { const issuer = new URL(serverUrl); const protocolVersion = opts?.protocolVersion ?? LATEST_PROTOCOL_VERSION; + const normalizedPathname = normalizeDiscoveryPath(issuer.pathname); let url: URL; if (opts?.metadataUrl) { @@ -1086,7 +1090,7 @@ async function discoverMetadataWithFallback( let response = await tryMetadataDiscovery(url, protocolVersion, fetchFn); // If path-aware discovery fails (4xx or 502 Bad Gateway) and we're not already at root, try fallback to root discovery - if (!opts?.metadataUrl && shouldAttemptFallback(response, issuer.pathname)) { + if (!opts?.metadataUrl && shouldAttemptFallback(response, normalizedPathname)) { const rootUrl = new URL(`/.well-known/${wellKnownType}`, issuer); response = await tryMetadataDiscovery(rootUrl, protocolVersion, fetchFn); } @@ -1150,7 +1154,8 @@ export async function discoverOAuthMetadata( */ export function buildDiscoveryUrls(authorizationServerUrl: string | URL): { url: URL; type: 'oauth' | 'oidc' }[] { const url = typeof authorizationServerUrl === 'string' ? new URL(authorizationServerUrl) : authorizationServerUrl; - const hasPath = url.pathname !== '/'; + const normalizedPathname = normalizeDiscoveryPath(url.pathname); + const hasPath = normalizedPathname !== ''; const urlsToTry: { url: URL; type: 'oauth' | 'oidc' }[] = []; if (!hasPath) { @@ -1173,7 +1178,7 @@ export function buildDiscoveryUrls(authorizationServerUrl: string | URL): { url: } // Strip trailing slashes from pathname to avoid malformed discovery paths. - let pathname = url.pathname.replace(/\/+$/, ''); + const pathname = normalizedPathname; urlsToTry.push( // 1. OAuth metadata at the given URL diff --git a/packages/client/test/client/auth.test.ts b/packages/client/test/client/auth.test.ts index 927c79a27..0098d84c0 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -422,6 +422,24 @@ describe('OAuth Authorization', () => { expect(url.toString()).toBe('https://resource.example.com/.well-known/oauth-protected-resource'); }); + it('does not fallback for URLs that normalize to root with extra slashes', async () => { + // First call returns 404 + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404 + }); + + await expect(discoverOAuthProtectedResourceMetadata('https://resource.example.com//')).rejects.toThrow( + 'Resource server does not implement OAuth 2.0 Protected Resource Metadata.' + ); + + const calls = mockFetch.mock.calls; + expect(calls.length).toBe(1); + + const [url] = calls[0]!; + expect(url.toString()).toBe('https://resource.example.com/.well-known/oauth-protected-resource'); + }); + it('falls back when path-aware discovery encounters CORS error (browser)', async () => { withBrowserLikeEnvironment(); // First call (path-aware) fails with TypeError (CORS) @@ -879,6 +897,16 @@ describe('OAuth Authorization', () => { ]); }); + it('treats double-slashed paths as root', () => { + const urls = buildDiscoveryUrls('https://auth.example.com//'); + + expect(urls).toHaveLength(2); + expect(urls.map(u => u.url.toString())).toEqual([ + 'https://auth.example.com/.well-known/oauth-authorization-server', + 'https://auth.example.com/.well-known/openid-configuration' + ]); + }); + it('handles URL object input', () => { const urls = buildDiscoveryUrls(new URL('https://auth.example.com/tenant1'));