0d96e0d3bc
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.
239 lines
9.1 KiB
TypeScript
239 lines
9.1 KiB
TypeScript
import { describe, test, expect, beforeEach, afterEach } from "bun:test"
|
|
import { discoverOAuthServerMetadata, resetDiscoveryCache } from "./discovery"
|
|
|
|
describe("discoverOAuthServerMetadata", () => {
|
|
const originalFetch = globalThis.fetch
|
|
|
|
beforeEach(() => {
|
|
resetDiscoveryCache()
|
|
})
|
|
|
|
afterEach(() => {
|
|
Object.defineProperty(globalThis, "fetch", { value: originalFetch, configurable: true })
|
|
})
|
|
|
|
test("returns endpoints from PRM + AS discovery", () => {
|
|
// given
|
|
const resource = "https://mcp.example.com"
|
|
const prmUrl = new URL("/.well-known/oauth-protected-resource", resource).toString()
|
|
const authServer = "https://auth.example.com"
|
|
const asUrl = new URL("/.well-known/oauth-authorization-server", authServer).toString()
|
|
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(JSON.stringify({ authorization_servers: [authServer] }), { status: 200 })
|
|
}
|
|
if (url === asUrl) {
|
|
return new Response(
|
|
JSON.stringify({
|
|
authorization_endpoint: "https://auth.example.com/authorize",
|
|
token_endpoint: "https://auth.example.com/token",
|
|
registration_endpoint: "https://auth.example.com/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://auth.example.com/authorize",
|
|
tokenEndpoint: "https://auth.example.com/token",
|
|
registrationEndpoint: "https://auth.example.com/register",
|
|
resource,
|
|
})
|
|
expect(calls).toEqual([prmUrl, asUrl])
|
|
})
|
|
})
|
|
|
|
test("falls back to RFC 8414 when PRM returns 404", () => {
|
|
// given
|
|
const resource = "https://mcp.example.com"
|
|
const prmUrl = new URL("/.well-known/oauth-protected-resource", resource).toString()
|
|
const asUrl = new URL("/.well-known/oauth-authorization-server", resource).toString()
|
|
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 === asUrl) {
|
|
return new Response(
|
|
JSON.stringify({
|
|
authorization_endpoint: "https://mcp.example.com/authorize",
|
|
token_endpoint: "https://mcp.example.com/token",
|
|
}),
|
|
{ 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/authorize",
|
|
tokenEndpoint: "https://mcp.example.com/token",
|
|
registrationEndpoint: undefined,
|
|
resource,
|
|
})
|
|
expect(calls).toEqual([prmUrl, asUrl])
|
|
})
|
|
})
|
|
|
|
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"
|
|
const prmUrl = new URL("/.well-known/oauth-protected-resource", resource).toString()
|
|
const asUrl = new URL("/.well-known/oauth-authorization-server", resource).toString()
|
|
const fetchMock = async (input: string | URL) => {
|
|
const url = typeof input === "string" ? input : input.toString()
|
|
if (url === prmUrl || url === asUrl) {
|
|
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 AS metadata is malformed", () => {
|
|
// given
|
|
const resource = "https://mcp.example.com"
|
|
const prmUrl = new URL("/.well-known/oauth-protected-resource", resource).toString()
|
|
const authServer = "https://auth.example.com"
|
|
const asUrl = new URL("/.well-known/oauth-authorization-server", authServer).toString()
|
|
const fetchMock = async (input: string | URL) => {
|
|
const url = typeof input === "string" ? input : input.toString()
|
|
if (url === prmUrl) {
|
|
return new Response(JSON.stringify({ authorization_servers: [authServer] }), { status: 200 })
|
|
}
|
|
if (url === asUrl) {
|
|
return new Response(JSON.stringify({ authorization_endpoint: "https://auth.example.com/authorize" }), {
|
|
status: 200,
|
|
})
|
|
}
|
|
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("token_endpoint")
|
|
})
|
|
|
|
test("caches discovery results per resource URL", () => {
|
|
// given
|
|
const resource = "https://mcp.example.com"
|
|
const prmUrl = new URL("/.well-known/oauth-protected-resource", resource).toString()
|
|
const authServer = "https://auth.example.com"
|
|
const asUrl = new URL("/.well-known/oauth-authorization-server", authServer).toString()
|
|
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(JSON.stringify({ authorization_servers: [authServer] }), { status: 200 })
|
|
}
|
|
if (url === asUrl) {
|
|
return new Response(
|
|
JSON.stringify({
|
|
authorization_endpoint: "https://auth.example.com/authorize",
|
|
token_endpoint: "https://auth.example.com/token",
|
|
}),
|
|
{ status: 200 }
|
|
)
|
|
}
|
|
return new Response("not found", { status: 404 })
|
|
}
|
|
Object.defineProperty(globalThis, "fetch", { value: fetchMock, configurable: true })
|
|
|
|
// when
|
|
return discoverOAuthServerMetadata(resource)
|
|
.then(() => discoverOAuthServerMetadata(resource))
|
|
.then(() => {
|
|
// then
|
|
expect(calls).toEqual([prmUrl, asUrl])
|
|
})
|
|
})
|
|
})
|