2026-01-29 19:48:36 +09:00
|
|
|
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", () => {
|
2026-02-01 16:47:50 +09:00
|
|
|
// given
|
2026-01-29 19:48:36 +09:00
|
|
|
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 })
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
// when
|
2026-01-29 19:48:36 +09:00
|
|
|
return discoverOAuthServerMetadata(resource).then((result) => {
|
2026-02-01 16:47:50 +09:00
|
|
|
// then
|
2026-01-29 19:48:36 +09:00
|
|
|
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", () => {
|
2026-02-01 16:47:50 +09:00
|
|
|
// given
|
2026-01-29 19:48:36 +09:00
|
|
|
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 })
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
// when
|
2026-01-29 19:48:36 +09:00
|
|
|
return discoverOAuthServerMetadata(resource).then((result) => {
|
2026-02-01 16:47:50 +09:00
|
|
|
// then
|
2026-01-29 19:48:36 +09:00
|
|
|
expect(result).toEqual({
|
|
|
|
|
authorizationEndpoint: "https://mcp.example.com/authorize",
|
|
|
|
|
tokenEndpoint: "https://mcp.example.com/token",
|
|
|
|
|
registrationEndpoint: undefined,
|
|
|
|
|
resource,
|
|
|
|
|
})
|
|
|
|
|
expect(calls).toEqual([prmUrl, asUrl])
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("throws when both PRM and AS discovery return 404", () => {
|
2026-02-01 16:47:50 +09:00
|
|
|
// given
|
2026-01-29 19:48:36 +09:00
|
|
|
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 })
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
// when
|
2026-01-29 19:48:36 +09:00
|
|
|
const result = discoverOAuthServerMetadata(resource)
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
// then
|
2026-01-29 19:48:36 +09:00
|
|
|
return expect(result).rejects.toThrow("OAuth authorization server metadata not found")
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("throws when AS metadata is malformed", () => {
|
2026-02-01 16:47:50 +09:00
|
|
|
// given
|
2026-01-29 19:48:36 +09:00
|
|
|
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 })
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
// when
|
2026-01-29 19:48:36 +09:00
|
|
|
const result = discoverOAuthServerMetadata(resource)
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
// then
|
2026-01-29 19:48:36 +09:00
|
|
|
return expect(result).rejects.toThrow("token_endpoint")
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("caches discovery results per resource URL", () => {
|
2026-02-01 16:47:50 +09:00
|
|
|
// given
|
2026-01-29 19:48:36 +09:00
|
|
|
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 })
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
// when
|
2026-01-29 19:48:36 +09:00
|
|
|
return discoverOAuthServerMetadata(resource)
|
|
|
|
|
.then(() => discoverOAuthServerMetadata(resource))
|
|
|
|
|
.then(() => {
|
2026-02-01 16:47:50 +09:00
|
|
|
// then
|
2026-01-29 19:48:36 +09:00
|
|
|
expect(calls).toEqual([prmUrl, asUrl])
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
})
|