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", () => {
|
test("throws when both PRM and AS discovery return 404", () => {
|
||||||
// given
|
// given
|
||||||
const resource = "https://mcp.example.com"
|
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 }
|
return { ok: true, json }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchAuthorizationServerMetadata(issuer: string, resource: string): Promise<OAuthServerMetadata> {
|
function parseMetadataFields(json: Record<string, unknown>, resource: string): 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})`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const authorizationEndpoint = parseHttpsUrl(
|
const authorizationEndpoint = parseHttpsUrl(
|
||||||
readStringField(metadata.json, "authorization_endpoint"),
|
readStringField(json, "authorization_endpoint"),
|
||||||
"authorization_endpoint"
|
"authorization_endpoint"
|
||||||
).toString()
|
).toString()
|
||||||
const tokenEndpoint = parseHttpsUrl(
|
const tokenEndpoint = parseHttpsUrl(
|
||||||
readStringField(metadata.json, "token_endpoint"),
|
readStringField(json, "token_endpoint"),
|
||||||
"token_endpoint"
|
"token_endpoint"
|
||||||
).toString()
|
).toString()
|
||||||
const registrationEndpointValue = metadata.json.registration_endpoint
|
const registrationEndpointValue = json.registration_endpoint
|
||||||
const registrationEndpoint =
|
const registrationEndpoint =
|
||||||
typeof registrationEndpointValue === "string" && registrationEndpointValue.length > 0
|
typeof registrationEndpointValue === "string" && registrationEndpointValue.length > 0
|
||||||
? parseHttpsUrl(registrationEndpointValue, "registration_endpoint").toString()
|
? 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[] {
|
function parseAuthorizationServers(metadata: Record<string, unknown>): string[] {
|
||||||
const servers = metadata.authorization_servers
|
const servers = metadata.authorization_servers
|
||||||
if (!Array.isArray(servers)) return []
|
if (!Array.isArray(servers)) return []
|
||||||
|
|||||||
Reference in New Issue
Block a user