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:
@@ -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"
|
||||
|
||||
@@ -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 []
|
||||
|
||||
Reference in New Issue
Block a user