From 87b453d23fe7194920b061ebffab146f35b7d548 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Wed, 9 Jul 2025 17:58:53 +0100 Subject: [PATCH 1/5] auth: fetch AS metadata in well-known subpath from serverUrl when PRM returns external AS Co-Authored-By: Claude --- src/client/auth.test.ts | 58 +++++++++++++++++++++++++++++++++++++++++ src/client/auth.ts | 26 +++++++++++++----- 2 files changed, 78 insertions(+), 6 deletions(-) diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index 8155e134..28adbe41 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -1476,5 +1476,63 @@ describe("OAuth Authorization", () => { expect(body.get("grant_type")).toBe("refresh_token"); expect(body.get("refresh_token")).toBe("refresh123"); }); + + it("fetches AS metadata with path from serverUrl when PRM returns external AS", async () => { + // Mock PRM discovery that returns an external AS + mockFetch.mockImplementation((url) => { + const urlString = url.toString(); + + if (urlString === "https://my.resource.com/.well-known/oauth-protected-resource") { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + resource: "https://my.resource.com/", + authorization_servers: ["https://auth.example.com/"], + }), + }); + } else if (urlString === "https://auth.example.com/.well-known/oauth-authorization-server/path/name") { + // Path-aware discovery on AS with path from serverUrl + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + issuer: "https://auth.example.com", + authorization_endpoint: "https://auth.example.com/authorize", + token_endpoint: "https://auth.example.com/token", + response_types_supported: ["code"], + code_challenge_methods_supported: ["S256"], + }), + }); + } + + return Promise.resolve({ ok: false, status: 404 }); + }); + + // Mock provider methods + (mockProvider.clientInformation as jest.Mock).mockResolvedValue({ + client_id: "test-client", + client_secret: "test-secret", + }); + (mockProvider.tokens as jest.Mock).mockResolvedValue(undefined); + (mockProvider.saveCodeVerifier as jest.Mock).mockResolvedValue(undefined); + (mockProvider.redirectToAuthorization as jest.Mock).mockResolvedValue(undefined); + + // Call auth with serverUrl that has a path + const result = await auth(mockProvider, { + serverUrl: "https://my.resource.com/path/name", + }); + + expect(result).toBe("REDIRECT"); + + // Verify the correct URLs were fetched + const calls = mockFetch.mock.calls; + + // First call should be to PRM + expect(calls[0][0].toString()).toBe("https://my.resource.com/.well-known/oauth-protected-resource"); + + // Second call should be to AS metadata with the path from serverUrl + expect(calls[1][0].toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server/path/name"); + }); }); }); diff --git a/src/client/auth.ts b/src/client/auth.ts index 71101a42..e84bbe18 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -122,7 +122,9 @@ export async function auth( const resource: URL | undefined = await selectResourceURL(serverUrl, provider, resourceMetadata); - const metadata = await discoverOAuthMetadata(authorizationServerUrl); + const metadata = await discoverOAuthMetadata(serverUrl, { + authorizationServerUrl + }); // Handle client registration if needed let clientInformation = await Promise.resolve(provider.clientInformation()); @@ -354,15 +356,27 @@ function shouldAttemptFallback(response: Response | undefined, pathname: string) * return `undefined`. Any other errors will be thrown as exceptions. */ export async function discoverOAuthMetadata( - authorizationServerUrl: string | URL, - opts?: { protocolVersion?: string }, + issuer: string | URL, + { + authorizationServerUrl + }: { + authorizationServerUrl?: string | URL + } = {}, ): Promise { - const issuer = new URL(authorizationServerUrl); - const protocolVersion = opts?.protocolVersion ?? LATEST_PROTOCOL_VERSION; + if (typeof issuer === 'string') { + issuer = new URL(issuer); + } + if (!authorizationServerUrl) { + authorizationServerUrl = issuer; + } + if (typeof authorizationServerUrl === 'string') { + authorizationServerUrl = new URL(authorizationServerUrl); + } + protocolVersion ??= LATEST_PROTOCOL_VERSION; // Try path-aware discovery first (RFC 8414 compliant) const wellKnownPath = buildWellKnownPath(issuer.pathname); - const pathAwareUrl = new URL(wellKnownPath, issuer); + const pathAwareUrl = new URL(wellKnownPath, authorizationServerUrl); let response = await tryMetadataDiscovery(pathAwareUrl, protocolVersion); // If path-aware discovery fails with 404, try fallback to root discovery From c90537703b5bc2cf860038ea1c0996739b1ac7b0 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Wed, 9 Jul 2025 18:09:23 +0100 Subject: [PATCH 2/5] Update auth.ts Co-Authored-By: Claude --- src/client/auth.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/client/auth.ts b/src/client/auth.ts index e84bbe18..3dee5fbd 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -358,9 +358,9 @@ function shouldAttemptFallback(response: Response | undefined, pathname: string) export async function discoverOAuthMetadata( issuer: string | URL, { - authorizationServerUrl + authorizationServerUrl, }: { - authorizationServerUrl?: string | URL + authorizationServerUrl?: string | URL, } = {}, ): Promise { if (typeof issuer === 'string') { From de1ade29d45fc23de314d9bfe34e98c1c3b8e16d Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Thu, 10 Jul 2025 14:25:21 +0100 Subject: [PATCH 3/5] fix unmerge --- src/client/auth.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/client/auth.ts b/src/client/auth.ts index b54261d3..f77efe88 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -489,8 +489,10 @@ export async function discoverOAuthMetadata( issuer: string | URL, { authorizationServerUrl, + protocolVersion, }: { authorizationServerUrl?: string | URL, + protocolVersion?: string, } = {}, ): Promise { if (typeof issuer === 'string') { From 1b8d63ed6d9109ddad3307343500d1493bf8e024 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Thu, 10 Jul 2025 14:36:34 +0100 Subject: [PATCH 4/5] add metadataServerUrl to discoverMetadataWithFallback to allow external AS --- src/client/auth.test.ts | 4 ++-- src/client/auth.ts | 12 ++++++++---- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index d8bee458..93dd8e94 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -1753,7 +1753,7 @@ describe("OAuth Authorization", () => { mockFetch.mockImplementation((url) => { const urlString = url.toString(); - if (urlString === "https://my.resource.com/.well-known/oauth-protected-resource") { + if (urlString === "https://my.resource.com/.well-known/oauth-protected-resource/path/name") { return Promise.resolve({ ok: true, status: 200, @@ -1800,7 +1800,7 @@ describe("OAuth Authorization", () => { const calls = mockFetch.mock.calls; // First call should be to PRM - expect(calls[0][0].toString()).toBe("https://my.resource.com/.well-known/oauth-protected-resource"); + expect(calls[0][0].toString()).toBe("https://my.resource.com/.well-known/oauth-protected-resource/path/name"); // Second call should be to AS metadata with the path from serverUrl expect(calls[1][0].toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server/path/name"); diff --git a/src/client/auth.ts b/src/client/auth.ts index a5b47aed..64b47240 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -471,7 +471,7 @@ function shouldAttemptFallback(response: Response | undefined, pathname: string) async function discoverMetadataWithFallback( serverUrl: string | URL, wellKnownType: 'oauth-authorization-server' | 'oauth-protected-resource', - opts?: { protocolVersion?: string; metadataUrl?: string | URL }, + opts?: { protocolVersion?: string; metadataUrl?: string | URL, metadataServerUrl?: string | URL }, ): Promise { const issuer = new URL(serverUrl); const protocolVersion = opts?.protocolVersion ?? LATEST_PROTOCOL_VERSION; @@ -482,7 +482,7 @@ async function discoverMetadataWithFallback( } else { // Try path-aware discovery first const wellKnownPath = buildWellKnownPath(wellKnownType, issuer.pathname); - url = new URL(wellKnownPath, issuer); + url = new URL(wellKnownPath, opts?.metadataServerUrl ?? issuer); url.search = issuer.search; } @@ -525,9 +525,13 @@ export async function discoverOAuthMetadata( protocolVersion ??= LATEST_PROTOCOL_VERSION; const response = await discoverMetadataWithFallback( - authorizationServerUrl, + issuer, + // authorizationServerUrl, 'oauth-authorization-server', - {protocolVersion}, + { + protocolVersion, + metadataServerUrl: authorizationServerUrl, + }, ); if (!response || response.status === 404) { From 1e32f146c8d4edef827ac0f3905d586ff954211d Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Thu, 10 Jul 2025 14:37:10 +0100 Subject: [PATCH 5/5] Update auth.ts --- src/client/auth.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/client/auth.ts b/src/client/auth.ts index 64b47240..2b69a5d8 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -526,7 +526,6 @@ export async function discoverOAuthMetadata( const response = await discoverMetadataWithFallback( issuer, - // authorizationServerUrl, 'oauth-authorization-server', { protocolVersion,