198 lines
7.3 KiB
TypeScript
198 lines
7.3 KiB
TypeScript
import { NodeHttpServer, NodeServices } from "@effect/platform-node"
|
|
import Http from "node:http"
|
|
import path from "node:path"
|
|
import { describe, expect } from "bun:test"
|
|
import { Config, Context, Effect, Layer, Ref } from "effect"
|
|
import {
|
|
HttpClient,
|
|
HttpClientRequest,
|
|
HttpRouter,
|
|
HttpServer,
|
|
HttpServerRequest,
|
|
HttpServerResponse,
|
|
} from "effect/unstable/http"
|
|
import { McpOAuthCallback } from "../../src/mcp/oauth-callback"
|
|
import { McpPaths } from "../../src/server/routes/instance/httpapi/groups/mcp"
|
|
import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server"
|
|
import { tmpdirScoped } from "../fixture/fixture"
|
|
import { testEffect } from "../lib/effect"
|
|
|
|
const servedRoutes: Layer.Layer<never, Config.ConfigError, HttpServer.HttpServer> = HttpRouter.serve(
|
|
HttpApiApp.routes,
|
|
{
|
|
disableListenLog: true,
|
|
disableLogger: true,
|
|
},
|
|
)
|
|
|
|
const it = testEffect(
|
|
servedRoutes.pipe(Layer.provideMerge(NodeHttpServer.layerTest), Layer.provideMerge(NodeServices.layer)),
|
|
)
|
|
|
|
const callbackPath = McpPaths.authCallback.replace(":name", "secure-oauth")
|
|
const authPath = McpPaths.auth.replace(":name", "secure-oauth")
|
|
|
|
function listenOAuthServer(tokenCalls: Ref.Ref<number>) {
|
|
return Effect.gen(function* () {
|
|
const context = yield* Layer.build(NodeHttpServer.layer(Http.createServer, { host: "127.0.0.1", port: 0 }))
|
|
const server = Context.get(context, HttpServer.HttpServer)
|
|
const origin = HttpServer.formatAddress(server.address)
|
|
yield* server.serve(
|
|
HttpServerRequest.HttpServerRequest.use((request) => {
|
|
const url = new URL(request.url, origin)
|
|
if (url.pathname === "/.well-known/oauth-protected-resource/mcp")
|
|
return HttpServerResponse.json({
|
|
resource: `${origin}/mcp`,
|
|
authorization_servers: [origin],
|
|
})
|
|
if (url.pathname === "/.well-known/oauth-authorization-server")
|
|
return HttpServerResponse.json({
|
|
issuer: origin,
|
|
authorization_endpoint: `${origin}/authorize`,
|
|
token_endpoint: `${origin}/token`,
|
|
response_types_supported: ["code"],
|
|
grant_types_supported: ["authorization_code", "refresh_token"],
|
|
code_challenge_methods_supported: ["S256"],
|
|
})
|
|
if (url.pathname === "/token")
|
|
return Effect.gen(function* () {
|
|
yield* request.text
|
|
yield* Ref.update(tokenCalls, (value) => value + 1)
|
|
return yield* HttpServerResponse.json({ access_token: "access-token", token_type: "Bearer" })
|
|
})
|
|
if (url.pathname !== "/mcp") return Effect.succeed(HttpServerResponse.empty({ status: 404 }))
|
|
if (request.headers.authorization !== "Bearer access-token")
|
|
return Effect.succeed(
|
|
HttpServerResponse.empty({
|
|
status: 401,
|
|
headers: {
|
|
"www-authenticate": `Bearer resource_metadata="${origin}/.well-known/oauth-protected-resource/mcp"`,
|
|
},
|
|
}),
|
|
)
|
|
return Effect.gen(function* () {
|
|
const body = yield* request.json
|
|
if (
|
|
typeof body === "object" &&
|
|
body !== null &&
|
|
"method" in body &&
|
|
body.method === "notifications/initialized"
|
|
)
|
|
return HttpServerResponse.empty({ status: 202 })
|
|
if (typeof body === "object" && body !== null && "method" in body && body.method === "tools/list")
|
|
return yield* HttpServerResponse.json({
|
|
jsonrpc: "2.0",
|
|
id: "id" in body ? body.id : null,
|
|
result: { tools: [] },
|
|
})
|
|
return yield* HttpServerResponse.json({
|
|
jsonrpc: "2.0",
|
|
id: typeof body === "object" && body !== null && "id" in body ? body.id : null,
|
|
result: {
|
|
protocolVersion: "2025-03-26",
|
|
capabilities: { tools: {} },
|
|
serverInfo: { name: "oauth-test", version: "1.0.0" },
|
|
},
|
|
})
|
|
})
|
|
}),
|
|
)
|
|
return origin
|
|
})
|
|
}
|
|
|
|
function availablePort() {
|
|
return Effect.promise(
|
|
() =>
|
|
new Promise<number>((resolve, reject) => {
|
|
const server = Http.createServer()
|
|
server.on("error", reject)
|
|
server.listen(0, "127.0.0.1", () => {
|
|
const address = server.address()
|
|
if (!address || typeof address === "string") return reject(new Error("Failed to allocate callback port"))
|
|
server.close((error) => (error ? reject(error) : resolve(address.port)))
|
|
})
|
|
}),
|
|
)
|
|
}
|
|
|
|
function assertPortAvailable(port: number) {
|
|
return Effect.promise(
|
|
() =>
|
|
new Promise<void>((resolve, reject) => {
|
|
const server = Http.createServer()
|
|
server.on("error", reject)
|
|
server.listen(port, "127.0.0.1", () => server.close((error) => (error ? reject(error) : resolve())))
|
|
}),
|
|
)
|
|
}
|
|
|
|
function setup() {
|
|
return Effect.gen(function* () {
|
|
const directory = yield* tmpdirScoped({ git: true })
|
|
const tokenCalls = yield* Ref.make(0)
|
|
const upstream = yield* listenOAuthServer(tokenCalls)
|
|
const callbackPort = yield* availablePort()
|
|
yield* Effect.promise(() =>
|
|
Bun.write(
|
|
path.join(directory, "opencode.json"),
|
|
JSON.stringify({
|
|
formatter: false,
|
|
lsp: false,
|
|
mcp: {
|
|
"secure-oauth": {
|
|
type: "remote",
|
|
url: `${upstream}/mcp`,
|
|
oauth: { clientId: "test-client", callbackPort },
|
|
},
|
|
},
|
|
}),
|
|
),
|
|
)
|
|
return { directory, tokenCalls, callbackPort }
|
|
})
|
|
}
|
|
|
|
function request(directory: string, route: string, payload?: object) {
|
|
const base = HttpClientRequest.post(route).pipe(HttpClientRequest.setHeader("x-opencode-directory", directory))
|
|
if (!payload) return HttpClient.execute(base)
|
|
return base.pipe(HttpClientRequest.bodyJson(payload), Effect.flatMap(HttpClient.execute))
|
|
}
|
|
|
|
describe("mcp HttpApi OAuth", () => {
|
|
it.live("requires, validates, consumes, and rejects replayed callback state", () =>
|
|
Effect.gen(function* () {
|
|
const test = yield* setup()
|
|
const started = yield* request(test.directory, authPath)
|
|
expect(started.status).toBe(200)
|
|
const first = (yield* started.json) as { oauthState: string }
|
|
|
|
const missing = yield* request(test.directory, callbackPath, { code: "missing-state" })
|
|
expect(missing.status).toBe(400)
|
|
expect(yield* Ref.get(test.tokenCalls)).toBe(0)
|
|
|
|
const wrong = yield* request(test.directory, callbackPath, { code: "wrong-state", state: "wrong" })
|
|
expect(wrong.status).toBe(400)
|
|
expect(yield* Ref.get(test.tokenCalls)).toBe(0)
|
|
|
|
const correct = yield* request(test.directory, callbackPath, { code: "valid-code", state: first.oauthState })
|
|
expect(correct.status).toBe(200)
|
|
expect(yield* Ref.get(test.tokenCalls)).toBe(1)
|
|
|
|
const replayed = yield* request(test.directory, callbackPath, { code: "replayed-code", state: first.oauthState })
|
|
expect(replayed.status).toBe(400)
|
|
expect(yield* Ref.get(test.tokenCalls)).toBe(1)
|
|
}),
|
|
)
|
|
|
|
it.live("does not bind the browser callback listener during manual start", () =>
|
|
Effect.gen(function* () {
|
|
const test = yield* setup()
|
|
const started = yield* request(test.directory, authPath)
|
|
expect(started.status).toBe(200)
|
|
expect(McpOAuthCallback.isRunning()).toBe(false)
|
|
yield* assertPortAvailable(test.callbackPort)
|
|
}),
|
|
)
|
|
})
|