diff --git a/src/features/mcp-oauth/discovery.test.ts b/src/features/mcp-oauth/discovery.test.ts index 8fbced17f..5253b200e 100644 --- a/src/features/mcp-oauth/discovery.test.ts +++ b/src/features/mcp-oauth/discovery.test.ts @@ -90,6 +90,69 @@ describe("discoverOAuthServerMetadata", () => { }) }) + test("falls back to root well-known URL when resource has a sub-path", () => { + // given — resource URL has a /mcp path (e.g. https://mcp.sentry.dev/mcp) + const resource = "https://mcp.example.com/mcp" + const prmUrl = new URL("/.well-known/oauth-protected-resource", resource).toString() + const pathSuffixedAsUrl = "https://mcp.example.com/.well-known/oauth-authorization-server/mcp" + const rootAsUrl = "https://mcp.example.com/.well-known/oauth-authorization-server" + const calls: string[] = [] + const fetchMock = async (input: string | URL) => { + const url = typeof input === "string" ? input : input.toString() + calls.push(url) + if (url === prmUrl) { + return new Response("not found", { status: 404 }) + } + if (url === pathSuffixedAsUrl) { + return new Response("not found", { status: 404 }) + } + if (url === rootAsUrl) { + return new Response( + JSON.stringify({ + authorization_endpoint: "https://mcp.example.com/oauth/authorize", + token_endpoint: "https://mcp.example.com/oauth/token", + registration_endpoint: "https://mcp.example.com/oauth/register", + }), + { status: 200 } + ) + } + return new Response("not found", { status: 404 }) + } + Object.defineProperty(globalThis, "fetch", { value: fetchMock, configurable: true }) + + // when + return discoverOAuthServerMetadata(resource).then((result) => { + // then + expect(result).toEqual({ + authorizationEndpoint: "https://mcp.example.com/oauth/authorize", + tokenEndpoint: "https://mcp.example.com/oauth/token", + registrationEndpoint: "https://mcp.example.com/oauth/register", + resource, + }) + expect(calls).toEqual([prmUrl, pathSuffixedAsUrl, rootAsUrl]) + }) + }) + + test("throws when PRM, path-suffixed AS, and root AS all return 404", () => { + // given + const resource = "https://mcp.example.com/mcp" + const prmUrl = new URL("/.well-known/oauth-protected-resource", resource).toString() + const fetchMock = async (input: string | URL) => { + const url = typeof input === "string" ? input : input.toString() + if (url === prmUrl || url.includes(".well-known/oauth-authorization-server")) { + return new Response("not found", { status: 404 }) + } + return new Response("not found", { status: 404 }) + } + Object.defineProperty(globalThis, "fetch", { value: fetchMock, configurable: true }) + + // when + const result = discoverOAuthServerMetadata(resource) + + // then + return expect(result).rejects.toThrow("OAuth authorization server metadata not found") + }) + test("throws when both PRM and AS discovery return 404", () => { // given const resource = "https://mcp.example.com" diff --git a/src/features/mcp-oauth/discovery.ts b/src/features/mcp-oauth/discovery.ts index 619520d42..65707049b 100644 --- a/src/features/mcp-oauth/discovery.ts +++ b/src/features/mcp-oauth/discovery.ts @@ -36,28 +36,16 @@ async function fetchMetadata(url: string): Promise<{ ok: true; json: Record { - const issuerUrl = parseHttpsUrl(issuer, "Authorization server URL") - const issuerPath = issuerUrl.pathname.replace(/\/+$/, "") - const metadataUrl = new URL(`/.well-known/oauth-authorization-server${issuerPath}`, issuerUrl).toString() - const metadata = await fetchMetadata(metadataUrl) - - if (!metadata.ok) { - if (metadata.status === 404) { - throw new Error("OAuth authorization server metadata not found") - } - throw new Error(`OAuth authorization server metadata fetch failed (${metadata.status})`) - } - +function parseMetadataFields(json: Record, resource: string): OAuthServerMetadata { const authorizationEndpoint = parseHttpsUrl( - readStringField(metadata.json, "authorization_endpoint"), + readStringField(json, "authorization_endpoint"), "authorization_endpoint" ).toString() const tokenEndpoint = parseHttpsUrl( - readStringField(metadata.json, "token_endpoint"), + readStringField(json, "token_endpoint"), "token_endpoint" ).toString() - const registrationEndpointValue = metadata.json.registration_endpoint + const registrationEndpointValue = json.registration_endpoint const registrationEndpoint = typeof registrationEndpointValue === "string" && registrationEndpointValue.length > 0 ? parseHttpsUrl(registrationEndpointValue, "registration_endpoint").toString() @@ -71,6 +59,29 @@ async function fetchAuthorizationServerMetadata(issuer: string, resource: string } } +async function fetchAuthorizationServerMetadata(issuer: string, resource: string): Promise { + const issuerUrl = parseHttpsUrl(issuer, "Authorization server URL") + const issuerPath = issuerUrl.pathname.replace(/\/+$/, "") + const metadataUrl = new URL(`/.well-known/oauth-authorization-server${issuerPath}`, issuerUrl).toString() + const metadata = await fetchMetadata(metadataUrl) + + if (!metadata.ok) { + if (metadata.status === 404 && issuerPath !== "") { + const rootMetadataUrl = new URL("/.well-known/oauth-authorization-server", issuerUrl).toString() + const rootMetadata = await fetchMetadata(rootMetadataUrl) + if (rootMetadata.ok) { + return parseMetadataFields(rootMetadata.json, resource) + } + } + if (metadata.status === 404) { + throw new Error("OAuth authorization server metadata not found") + } + throw new Error(`OAuth authorization server metadata fetch failed (${metadata.status})`) + } + + return parseMetadataFields(metadata.json, resource) +} + function parseAuthorizationServers(metadata: Record): string[] { const servers = metadata.authorization_servers if (!Array.isArray(servers)) return []