diff --git a/.changeset/oauth-well-known-fallback.md b/.changeset/oauth-well-known-fallback.md new file mode 100644 index 0000000000..6bc582c8a4 --- /dev/null +++ b/.changeset/oauth-well-known-fallback.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/client': patch +--- + +OAuth well-known metadata discovery now falls back from a path-inserted URL to the root well-known document on any 4xx (not only 404), and builds that fallback on the authorization-server origin instead of the resource host (#2783, #2784). diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index 4c37339297..20251863d6 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -1707,7 +1707,11 @@ async function tryMetadataDiscovery(url: URL, protocolVersion: string, fetchFn: } /** - * Determines if fallback to root discovery should be attempted + * Determines if fallback to root discovery should be attempted. + * + * Path-inserted well-known URLs often 401/403 behind CDNs or bot protection + * instead of 404, so any 4xx triggers fallback. 502 Bad Gateway is also + * treated as a routing miss; other 5xx responses are not retried. */ function shouldAttemptFallback(response: Response | undefined, pathname: string): boolean { if (!response) return true; // CORS error — always try fallback @@ -1739,9 +1743,12 @@ 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 path-aware discovery fails (4xx or 502 Bad Gateway) and we're not already at root, try fallback to root discovery. + // The root well-known URL must stay on the metadata-server / authorization-server host + // (opts.metadataServerUrl), not the resource URL used only for the path prefix. if (!opts?.metadataUrl && shouldAttemptFallback(response, issuer.pathname)) { - const rootUrl = new URL(`/.well-known/${wellKnownType}`, issuer); + const fallbackBase = opts?.metadataServerUrl ?? issuer; + const rootUrl = new URL(`/.well-known/${wellKnownType}`, fallbackBase); response = await tryMetadataDiscovery(rootUrl, protocolVersion, fetchFn); } @@ -1778,7 +1785,9 @@ export async function discoverOAuthMetadata( } protocolVersion ??= LATEST_PROTOCOL_VERSION; - const response = await discoverMetadataWithFallback(authorizationServerUrl, 'oauth-authorization-server', fetchFn, { + // Path comes from `issuer` (often the MCP resource URL); host comes from the + // authorization server so root fallback stays on the AS origin (#2784). + const response = await discoverMetadataWithFallback(issuer, 'oauth-authorization-server', fetchFn, { protocolVersion, metadataServerUrl: authorizationServerUrl }); diff --git a/packages/client/test/client/auth.test.ts b/packages/client/test/client/auth.test.ts index 3ac9c7ddff..ae0229a697 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -626,11 +626,11 @@ describe('OAuth Authorization', () => { }); }); - it('falls back to root discovery when path-aware discovery returns 404', async () => { - // First call (path-aware) returns 404 + it.each([401, 403, 404])('falls back to root discovery when path-aware discovery returns %d', async statusCode => { + // First call (path-aware) returns 4xx (CDNs often use 401/403 instead of 404) mockFetch.mockResolvedValueOnce({ ok: false, - status: 404 + status: statusCode }); // Second call (root fallback) succeeds @@ -661,6 +661,48 @@ describe('OAuth Authorization', () => { }); }); + it('does not fall back when path-aware authorization-server discovery returns 200', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validMetadata + }); + + const metadata = await discoverOAuthMetadata('https://resource.example.com/apis/mcp', { + authorizationServerUrl: 'https://id.auth.example.com' + }); + expect(metadata).toEqual(validMetadata); + expect(mockFetch.mock.calls.map(([url]) => String(url))).toEqual([ + 'https://id.auth.example.com/.well-known/oauth-authorization-server/apis/mcp' + ]); + }); + + it('builds the authorization-server fallback URL on the AS host, not the resource host', async () => { + // Path-aware AS metadata on the issuer host is blocked (e.g. CDN 403) + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 403 + }); + + // Root well-known on the authorization-server origin succeeds + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validMetadata + }); + + const metadata = await discoverOAuthMetadata('https://resource.example.com/apis/mcp', { + authorizationServerUrl: 'https://id.auth.example.com' + }); + expect(metadata).toEqual(validMetadata); + + const urls = mockFetch.mock.calls.map(([url]) => String(url)); + expect(urls).toEqual([ + 'https://id.auth.example.com/.well-known/oauth-authorization-server/apis/mcp', + 'https://id.auth.example.com/.well-known/oauth-authorization-server' + ]); + }); + it('returns undefined when both path-aware and root discovery return 404', async () => { // First call (path-aware) returns 404 mockFetch.mockResolvedValueOnce({ @@ -1020,6 +1062,27 @@ describe('OAuth Authorization', () => { expect(metadata).toEqual(validOpenIdMetadata); }); + it.each([401, 403, 404])('continues from path-inserted AS metadata to the next URL on %d', async statusCode => { + const tenantOidcMetadata = { ...validOpenIdMetadata, issuer: 'https://auth.example.com/tenant1' }; + mockFetch.mockResolvedValueOnce({ + ok: false, + status: statusCode + }); + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => tenantOidcMetadata + }); + + const metadata = await discoverAuthorizationServerMetadata('https://auth.example.com/tenant1'); + + expect(metadata).toEqual(tenantOidcMetadata); + expect(mockFetch.mock.calls.map(([url]) => String(url))).toEqual([ + 'https://auth.example.com/.well-known/oauth-authorization-server/tenant1', + 'https://auth.example.com/.well-known/openid-configuration/tenant1' + ]); + }); + it('preserves authorization_response_iss_parameter_supported through OIDC discovery parse', async () => { // OAuth well-known 404s; OIDC well-known returns metadata advertising RFC 9207 support. // Regression-guard: OpenIdProviderDiscoveryMetadataSchema is a plain z.object(), so the