Fix OAuth discovery for servers with non-root resource paths

When the resource URL has a sub-path (e.g. https://mcp.sentry.dev/mcp),
the RFC 8414 path-suffixed well-known URL may not exist. Fall back to
the root well-known URL before giving up.

This matches OpenCode core's behavior and fixes authentication for
servers like Sentry that serve OAuth metadata only at the root path.
This commit is contained in:
sanoyphilippe
2026-03-18 16:45:54 +08:00
parent 23c0ff60f2
commit 0d96e0d3bc
2 changed files with 90 additions and 16 deletions
+63
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"
+27 -16
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 []