From abcf91b4634c21befca147bc4790956c838fbd22 Mon Sep 17 00:00:00 2001 From: Henry Mao <1828968+calclavia@users.noreply.github.com> Date: Fri, 4 Jul 2025 11:10:07 +0800 Subject: [PATCH 1/6] Fix oauth-protected-resource to also be path aware --- src/client/auth.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/client/auth.ts b/src/client/auth.ts index 71101a42..9aa5dda5 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -261,7 +261,9 @@ export async function discoverOAuthProtectedResourceMetadata( if (opts?.resourceMetadataUrl) { url = new URL(opts?.resourceMetadataUrl); } else { - url = new URL("/.well-known/oauth-protected-resource", serverUrl); + const issuer = new URL(serverUrl); + const wellKnownPath = buildWellKnownPath('oauth-protected-resource', issuer.pathname); + url = new URL(wellKnownPath, issuer); } let response: Response; @@ -318,8 +320,8 @@ async function fetchWithCorsRetry( /** * Constructs the well-known path for OAuth metadata discovery */ -function buildWellKnownPath(pathname: string): string { - let wellKnownPath = `/.well-known/oauth-authorization-server${pathname}`; +function buildWellKnownPath(wellKnownPath: string, pathname: string): string { + let wellKnownPath = `/.well-known/${wellKnownPath}${pathname}`; if (pathname.endsWith('/')) { // Strip trailing slash from pathname to avoid double slashes wellKnownPath = wellKnownPath.slice(0, -1); @@ -361,7 +363,7 @@ export async function discoverOAuthMetadata( const protocolVersion = opts?.protocolVersion ?? LATEST_PROTOCOL_VERSION; // Try path-aware discovery first (RFC 8414 compliant) - const wellKnownPath = buildWellKnownPath(issuer.pathname); + const wellKnownPath = buildWellKnownPath('oauth-authorization-server', issuer.pathname); const pathAwareUrl = new URL(wellKnownPath, issuer); let response = await tryMetadataDiscovery(pathAwareUrl, protocolVersion); From c5fcba4617222cd1d1e58ecd318b5cf7346397e7 Mon Sep 17 00:00:00 2001 From: Henry Mao <1828968+calclavia@users.noreply.github.com> Date: Fri, 4 Jul 2025 11:14:14 +0800 Subject: [PATCH 2/6] Update auth.ts --- 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 9aa5dda5..8839ecf0 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -320,8 +320,8 @@ async function fetchWithCorsRetry( /** * Constructs the well-known path for OAuth metadata discovery */ -function buildWellKnownPath(wellKnownPath: string, pathname: string): string { - let wellKnownPath = `/.well-known/${wellKnownPath}${pathname}`; +function buildWellKnownPath(wellKnownPrefix: string, pathname: string): string { + let wellKnownPath = `/.well-known/${wellKnownPrefix}${pathname}`; if (pathname.endsWith('/')) { // Strip trailing slash from pathname to avoid double slashes wellKnownPath = wellKnownPath.slice(0, -1); From 7b02c5cda377c7cd9ebcf827ab368bf96e0534cf Mon Sep 17 00:00:00 2001 From: Henry Mao <1828968+calclavia@users.noreply.github.com> Date: Mon, 7 Jul 2025 03:56:42 +0800 Subject: [PATCH 3/6] Retain URL search parameter --- src/client/auth.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/client/auth.ts b/src/client/auth.ts index 8839ecf0..495f62a4 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -263,6 +263,7 @@ export async function discoverOAuthProtectedResourceMetadata( } else { const issuer = new URL(serverUrl); const wellKnownPath = buildWellKnownPath('oauth-protected-resource', issuer.pathname); + wellKnownPath.search = issuer.search; url = new URL(wellKnownPath, issuer); } @@ -365,6 +366,7 @@ export async function discoverOAuthMetadata( // Try path-aware discovery first (RFC 8414 compliant) const wellKnownPath = buildWellKnownPath('oauth-authorization-server', issuer.pathname); const pathAwareUrl = new URL(wellKnownPath, issuer); + pathAwareUrl.search = issuer.search; let response = await tryMetadataDiscovery(pathAwareUrl, protocolVersion); // If path-aware discovery fails with 404, try fallback to root discovery From 3bdecfc1b9618cbc9e8f9635a14d46ea0d3925d6 Mon Sep 17 00:00:00 2001 From: Henry Mao <1828968+calclavia@users.noreply.github.com> Date: Mon, 7 Jul 2025 03:59:06 +0800 Subject: [PATCH 4/6] Update auth.ts --- src/client/auth.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client/auth.ts b/src/client/auth.ts index 495f62a4..eb3473ad 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -263,8 +263,8 @@ export async function discoverOAuthProtectedResourceMetadata( } else { const issuer = new URL(serverUrl); const wellKnownPath = buildWellKnownPath('oauth-protected-resource', issuer.pathname); - wellKnownPath.search = issuer.search; url = new URL(wellKnownPath, issuer); + url.search = issuer.search; } let response: Response; From 72cb9a700f7304a54ba075f610e27fbc1fdc8d54 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Thu, 10 Jul 2025 09:30:12 +0100 Subject: [PATCH 5/6] add tests --- src/client/auth.test.ts | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index 8155e134..4c643f6c 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -177,6 +177,36 @@ describe("OAuth Authorization", () => { await expect(discoverOAuthProtectedResourceMetadata("https://resource.example.com")) .rejects.toThrow(); }); + + it("returns metadata when discovery succeeds with path", 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, + status: 200, + json: async () => validMetadata, + }); + + const metadata = await discoverOAuthProtectedResourceMetadata("https://resource.example.com/path?param=value"); + 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?param=value"); + }); }); describe("discoverOAuthMetadata", () => { From 5eacdf174c61607582d5d0858f4eb841909c2efc Mon Sep 17 00:00:00 2001 From: ihrpr Date: Thu, 10 Jul 2025 11:36:57 +0100 Subject: [PATCH 6/6] fallback and refactor --- src/client/auth.test.ts | 138 ++++++++++++++++++++++++++++++++++++++++ src/client/auth.ts | 93 ++++++++++++++------------- 2 files changed, 188 insertions(+), 43 deletions(-) diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index 4c643f6c..c1526d82 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -207,6 +207,144 @@ describe("OAuth Authorization", () => { const [url] = calls[0]; expect(url.toString()).toBe("https://resource.example.com/.well-known/oauth-protected-resource/path?param=value"); }); + + it("falls back to root discovery when path-aware discovery returns 404", async () => { + // First call (path-aware) returns 404 + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + }); + + // Second call (root fallback) succeeds + 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(2); + + // First call should be path-aware + const [firstUrl, firstOptions] = calls[0]; + expect(firstUrl.toString()).toBe("https://resource.example.com/.well-known/oauth-protected-resource/path/name"); + expect(firstOptions.headers).toEqual({ + "MCP-Protocol-Version": LATEST_PROTOCOL_VERSION + }); + + // Second call should be root fallback + const [secondUrl, secondOptions] = calls[1]; + expect(secondUrl.toString()).toBe("https://resource.example.com/.well-known/oauth-protected-resource"); + expect(secondOptions.headers).toEqual({ + "MCP-Protocol-Version": LATEST_PROTOCOL_VERSION + }); + }); + + it("throws error when both path-aware and root discovery return 404", async () => { + // First call (path-aware) returns 404 + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + }); + + // Second call (root fallback) also returns 404 + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + }); + + await expect(discoverOAuthProtectedResourceMetadata("https://resource.example.com/path/name")) + .rejects.toThrow("Resource server does not implement OAuth 2.0 Protected Resource Metadata."); + + const calls = mockFetch.mock.calls; + expect(calls.length).toBe(2); + }); + + it("does not fallback when the original URL is already at root path", async () => { + // First call (path-aware for root) 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); // Should not attempt fallback + + const [url] = calls[0]; + expect(url.toString()).toBe("https://resource.example.com/.well-known/oauth-protected-resource"); + }); + + it("does not fallback when the original URL has no path", async () => { + // First call (path-aware for no path) 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); // Should not attempt fallback + + 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", async () => { + // First call (path-aware) fails with TypeError (CORS) + mockFetch.mockImplementationOnce(() => Promise.reject(new TypeError("CORS error"))); + + // Retry path-aware without headers (simulating CORS retry) + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + }); + + // Second call (root fallback) succeeds + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validMetadata, + }); + + const metadata = await discoverOAuthProtectedResourceMetadata("https://resource.example.com/deep/path"); + expect(metadata).toEqual(validMetadata); + + const calls = mockFetch.mock.calls; + expect(calls.length).toBe(3); + + // Final call should be root fallback + const [lastUrl, lastOptions] = calls[2]; + expect(lastUrl.toString()).toBe("https://resource.example.com/.well-known/oauth-protected-resource"); + expect(lastOptions.headers).toEqual({ + "MCP-Protocol-Version": LATEST_PROTOCOL_VERSION + }); + }); + + it("does not fallback when resourceMetadataUrl is provided", async () => { + // Call with explicit URL returns 404 + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + }); + + await expect(discoverOAuthProtectedResourceMetadata("https://resource.example.com/path", { + resourceMetadataUrl: "https://custom.example.com/metadata" + })).rejects.toThrow("Resource server does not implement OAuth 2.0 Protected Resource Metadata."); + + const calls = mockFetch.mock.calls; + expect(calls.length).toBe(1); // Should not attempt fallback when explicit URL is provided + + const [url] = calls[0]; + expect(url.toString()).toBe("https://custom.example.com/metadata"); + }); }); describe("discoverOAuthMetadata", () => { diff --git a/src/client/auth.ts b/src/client/auth.ts index eb3473ad..90d7eb62 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -107,12 +107,13 @@ export async function auth( serverUrl: string | URL; authorizationCode?: string; scope?: string; - resourceMetadataUrl?: URL }): Promise { + resourceMetadataUrl?: URL + }): Promise { let resourceMetadata: OAuthProtectedResourceMetadata | undefined; let authorizationServerUrl = serverUrl; try { - resourceMetadata = await discoverOAuthProtectedResourceMetadata(serverUrl, {resourceMetadataUrl}); + resourceMetadata = await discoverOAuthProtectedResourceMetadata(serverUrl, { resourceMetadataUrl }); if (resourceMetadata.authorization_servers && resourceMetadata.authorization_servers.length > 0) { authorizationServerUrl = resourceMetadata.authorization_servers[0]; } @@ -197,7 +198,7 @@ export async function auth( return "REDIRECT"; } -export async function selectResourceURL(serverUrl: string| URL, provider: OAuthClientProvider, resourceMetadata?: OAuthProtectedResourceMetadata): Promise { +export async function selectResourceURL(serverUrl: string | URL, provider: OAuthClientProvider, resourceMetadata?: OAuthProtectedResourceMetadata): Promise { const defaultResource = resourceUrlFromServerUrl(serverUrl); // If provider has custom validation, delegate to it @@ -256,34 +257,16 @@ export async function discoverOAuthProtectedResourceMetadata( serverUrl: string | URL, opts?: { protocolVersion?: string, resourceMetadataUrl?: string | URL }, ): Promise { + const response = await discoverMetadataWithFallback( + serverUrl, + 'oauth-protected-resource', + { + protocolVersion: opts?.protocolVersion, + metadataUrl: opts?.resourceMetadataUrl, + }, + ); - let url: URL - if (opts?.resourceMetadataUrl) { - url = new URL(opts?.resourceMetadataUrl); - } else { - const issuer = new URL(serverUrl); - const wellKnownPath = buildWellKnownPath('oauth-protected-resource', issuer.pathname); - url = new URL(wellKnownPath, issuer); - url.search = issuer.search; - } - - let response: Response; - try { - response = await fetch(url, { - headers: { - "MCP-Protocol-Version": opts?.protocolVersion ?? LATEST_PROTOCOL_VERSION - } - }); - } catch (error) { - // CORS errors come back as TypeError - if (error instanceof TypeError) { - response = await fetch(url); - } else { - throw error; - } - } - - if (response.status === 404) { + if (!response || response.status === 404) { throw new Error(`Resource server does not implement OAuth 2.0 Protected Resource Metadata.`); } @@ -350,6 +333,38 @@ function shouldAttemptFallback(response: Response | undefined, pathname: string) return !response || response.status === 404 && pathname !== '/'; } +/** + * Generic function for discovering OAuth metadata with fallback support + */ +async function discoverMetadataWithFallback( + serverUrl: string | URL, + wellKnownType: 'oauth-authorization-server' | 'oauth-protected-resource', + opts?: { protocolVersion?: string; metadataUrl?: string | URL }, +): Promise { + const issuer = new URL(serverUrl); + const protocolVersion = opts?.protocolVersion ?? LATEST_PROTOCOL_VERSION; + + let url: URL; + if (opts?.metadataUrl) { + url = new URL(opts.metadataUrl); + } else { + // Try path-aware discovery first + const wellKnownPath = buildWellKnownPath(wellKnownType, issuer.pathname); + url = new URL(wellKnownPath, issuer); + url.search = issuer.search; + } + + let response = await tryMetadataDiscovery(url, protocolVersion); + + // If path-aware discovery fails with 404 and we're not already at root, try fallback to root discovery + if (!opts?.metadataUrl && shouldAttemptFallback(response, issuer.pathname)) { + const rootUrl = new URL(`/.well-known/${wellKnownType}`, issuer); + response = await tryMetadataDiscovery(rootUrl, protocolVersion); + } + + return response; +} + /** * Looks up RFC 8414 OAuth 2.0 Authorization Server Metadata. * @@ -360,20 +375,12 @@ export async function discoverOAuthMetadata( authorizationServerUrl: string | URL, opts?: { protocolVersion?: string }, ): Promise { - const issuer = new URL(authorizationServerUrl); - const protocolVersion = opts?.protocolVersion ?? LATEST_PROTOCOL_VERSION; - - // Try path-aware discovery first (RFC 8414 compliant) - const wellKnownPath = buildWellKnownPath('oauth-authorization-server', issuer.pathname); - const pathAwareUrl = new URL(wellKnownPath, issuer); - pathAwareUrl.search = issuer.search; - let response = await tryMetadataDiscovery(pathAwareUrl, protocolVersion); + const response = await discoverMetadataWithFallback( + authorizationServerUrl, + 'oauth-authorization-server', + opts, + ); - // If path-aware discovery fails with 404, try fallback to root discovery - if (shouldAttemptFallback(response, issuer.pathname)) { - const rootUrl = new URL("/.well-known/oauth-authorization-server", issuer); - response = await tryMetadataDiscovery(rootUrl, protocolVersion); - } if (!response || response.status === 404) { return undefined; }