Merge pull request #2673 from sanoyphilippe/fix/oauth-discovery-root-fallback

fix(mcp-oauth): fall back to root well-known URL for non-root resource paths (fixes #2675)
This commit is contained in:
YeonGyu-Kim
2026-03-25 21:48:13 +09:00
committed by GitHub
2 changed files with 90 additions and 16 deletions

View File

@@ -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"

View File

@@ -36,28 +36,16 @@ async function fetchMetadata(url: string): Promise<{ ok: true; json: Record<stri
return { ok: true, json }
}
async function fetchAuthorizationServerMetadata(issuer: string, resource: string): Promise<OAuthServerMetadata> {
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<string, unknown>, 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<OAuthServerMetadata> {
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, unknown>): string[] {
const servers = metadata.authorization_servers
if (!Array.isArray(servers)) return []