// --------------------------------------------------------------------------- // Cloud API × MCP OAuth — real HTTP end-to-end // --------------------------------------------------------------------------- // // Drives the ProtectedCloudApi through the node-pool harness against a // real in-process OAuth + MCP server (Node `http.createServer` bound to // a random port). Every layer between the test and the plugin is real: // // test → HttpApiClient → in-process webHandler → ProtectedCloudApi // → Core OAuthHandlers → executor.oauth.start / complete // → MCP SDK `auth()` // → fake OAuth server (DCR, /authorize → 302, /token, AS metadata, // protected resource metadata) // // Two scenarios: // // 1. Single user: startOAuth → follow redirect → completeOAuth. Asserts // the response carries the Connection id the exchange minted. // // 2. Two users, same source: both users complete the shared OAuth flow // and end up with their own Connection (same id, different scope) // via the SDK's innermost-wins shadowing. // --------------------------------------------------------------------------- import { afterAll, beforeAll, describe, expect, it } from "@effect/vitest"; import { createServer, type Server } from "node:http"; import type { AddressInfo } from "node:net"; import { createHash, randomBytes } from "node:crypto"; import { Effect, Option, Result, Schema } from "effect"; import { ScopeId } from "@executor-js/sdk"; import { asOrg, asUser, testUserOrgScopeId } from "./__test-harness__/api-harness"; // --------------------------------------------------------------------------- // Fake OAuth + MCP server // --------------------------------------------------------------------------- interface FakeServer { readonly url: string; readonly registrations: () => number; readonly tokens: () => number; readonly close: () => Promise; } const RegistrationBody = Schema.Struct({ redirect_uris: Schema.optional(Schema.Array(Schema.String)), grant_types: Schema.optional(Schema.Array(Schema.String)), response_types: Schema.optional(Schema.Array(Schema.String)), }); const decodeRegistrationBody = Schema.decodeUnknownOption(Schema.fromJsonString(RegistrationBody)); const startFakeServer = async (): Promise => { const clients = new Map(); const codes = new Map(); const accessTokens = new Map(); const refreshTokens = new Map(); let seq = 0; const next = (p: string) => `${p}_${++seq}_${randomBytes(6).toString("hex")}`; let registrations = 0; let tokenCalls = 0; const readBody = (req: import("node:http").IncomingMessage): Promise => new Promise((resolve, reject) => { let buf = ""; req.on("data", (chunk) => (buf += chunk)); req.on("end", () => resolve(buf)); req.on("error", reject); }); const server: Server = createServer(async (req, res) => { const url = new URL(req.url!, `http://${req.headers.host}`); const send = (status: number, body: unknown, headers: Record = {}) => { const payload = typeof body === "string" ? body : JSON.stringify(body); res.writeHead(status, { "content-type": typeof body === "string" ? "text/plain" : "application/json", ...headers, }); res.end(payload); }; // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: fake HTTP server returns stable 500 responses for unexpected handler failures try { if (url.pathname === "/.well-known/oauth-protected-resource") { const origin = `http://${req.headers.host}`; return send(200, { resource: origin, authorization_servers: [origin], bearer_methods_supported: ["header"], }); } if (url.pathname === "/.well-known/oauth-authorization-server") { const issuer = `http://${req.headers.host}`; return send(200, { issuer, authorization_endpoint: `${issuer}/authorize`, token_endpoint: `${issuer}/token`, registration_endpoint: `${issuer}/register`, response_types_supported: ["code"], grant_types_supported: ["authorization_code", "refresh_token"], code_challenge_methods_supported: ["S256"], token_endpoint_auth_methods_supported: ["none"], }); } if (url.pathname === "/register" && req.method === "POST") { const body = await readBody(req); const parsedOption = decodeRegistrationBody(body); if (Option.isNone(parsedOption)) { return send(400, { error: "invalid_registration" }); } const parsed = parsedOption.value; const clientId = next("client"); clients.set(clientId, { redirect_uris: parsed.redirect_uris ?? [] }); registrations += 1; return send(201, { client_id: clientId, client_id_issued_at: Math.floor(Date.now() / 1000), redirect_uris: parsed.redirect_uris ?? [], grant_types: parsed.grant_types ?? ["authorization_code", "refresh_token"], response_types: parsed.response_types ?? ["code"], token_endpoint_auth_method: "none", }); } if (url.pathname === "/authorize" && req.method === "GET") { const clientId = url.searchParams.get("client_id") ?? ""; const redirectUri = url.searchParams.get("redirect_uri") ?? ""; const state = url.searchParams.get("state") ?? ""; const codeChallenge = url.searchParams.get("code_challenge") ?? ""; const method = url.searchParams.get("code_challenge_method") ?? ""; if (!clients.has(clientId)) { return send(400, { error: "unknown_client" }); } if (method !== "S256" || !codeChallenge) { return send(400, { error: "invalid_request" }); } const code = next("code"); codes.set(code, { clientId, codeChallenge }); const destination = new URL(redirectUri); destination.searchParams.set("code", code); if (state) destination.searchParams.set("state", state); return send(302, "", { location: destination.toString() }); } if (url.pathname === "/token" && req.method === "POST") { tokenCalls += 1; const body = await readBody(req); const params = new URLSearchParams(body); const grant = params.get("grant_type"); if (grant === "authorization_code") { const code = params.get("code") ?? ""; const verifier = params.get("code_verifier") ?? ""; const record = codes.get(code); if (!record) return send(400, { error: "invalid_grant" }); codes.delete(code); const computed = createHash("sha256").update(verifier).digest("base64url"); if (computed !== record.codeChallenge) { return send(400, { error: "invalid_grant" }); } const access = next("at"); const refresh = next("rt"); accessTokens.set(access, { refresh }); refreshTokens.set(refresh, access); return send(200, { access_token: access, refresh_token: refresh, token_type: "Bearer", expires_in: 3600, }); } if (grant === "refresh_token") { const rt = params.get("refresh_token") ?? ""; const prev = refreshTokens.get(rt); if (!prev) return send(400, { error: "invalid_grant" }); refreshTokens.delete(rt); accessTokens.delete(prev); const access = next("at"); const refresh = next("rt"); accessTokens.set(access, { refresh }); refreshTokens.set(refresh, access); return send(200, { access_token: access, refresh_token: refresh, token_type: "Bearer", expires_in: 3600, }); } return send(400, { error: "unsupported_grant_type" }); } // Default: 401 with WWW-Authenticate so any MCP probe on /mcp // gets the resource-metadata pointer the auth() discovery uses. if (url.pathname === "/mcp") { const origin = `http://${req.headers.host}`; return send( 401, { error: "unauthorized" }, { "www-authenticate": `Bearer resource_metadata="${origin}/.well-known/oauth-protected-resource"`, }, ); } send(404, { error: "not_found", params: url.pathname }); } catch { send(500, { error: "server_error", message: "fake server failed" }); } }); await new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve())); const address = server.address() as AddressInfo; return { url: `http://127.0.0.1:${address.port}`, registrations: () => registrations, tokens: () => tokenCalls, close: () => new Promise((resolve) => server.close(() => resolve())), }; }; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- // Browser popup equivalent: GET the authorization URL, pull the code + // state out of the 302 Location. const followAuthorize = async ( authorizationUrl: string, ): Promise<{ code: string; state: string }> => { const response = await fetch(authorizationUrl, { redirect: "manual" }); expect(response.status).toBe(302); const location = response.headers.get("location"); // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: browser redirect helper rejects malformed fake OAuth responses if (!location) throw new Error("no location header on authorize redirect"); const dest = new URL(location); const code = dest.searchParams.get("code"); const state = dest.searchParams.get("state"); // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: browser redirect helper rejects malformed fake OAuth responses if (!code || !state) throw new Error(`redirect missing code/state: ${location}`); return { code, state }; }; // --------------------------------------------------------------------------- // Lifecycle // --------------------------------------------------------------------------- let fake: FakeServer; beforeAll(async () => { fake = await startFakeServer(); }); afterAll(async () => { await fake.close(); }); // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe("mcp oauth end-to-end (node pool, real OAuth + MCP server)", () => { it.effect( "start rejects a redirectUrl on a different origin before discovery", () => Effect.gen(function* () { const org = `org_${crypto.randomUUID()}`; const scopeId = ScopeId.make(org); const start = Date.now(); const result = yield* asOrg(org, (client) => client.oauth.start({ params: { scopeId }, payload: { endpoint: "https://example.test/api", redirectUrl: "https://other.example/cb", connectionId: "conn-foreign-redirect", tokenScope: String(scopeId), pluginId: "mcp", strategy: { kind: "dynamic-dcr" }, }, }), ).pipe(Effect.result); expect(Result.isFailure(result)).toBe(true); expect(Date.now() - start).toBeLessThan(1000); }), { timeout: 30_000 }, ); it.effect( "start rejects non-http redirectUrl schemes", () => Effect.gen(function* () { const org = `org_${crypto.randomUUID()}`; const scopeId = ScopeId.make(org); for (const redirectUrl of [ "javascript:alert(1)", "data:text/html,", ]) { const start = Date.now(); const result = yield* asOrg(org, (client) => client.oauth.start({ params: { scopeId }, payload: { endpoint: "https://example.test/api", redirectUrl, connectionId: `conn-${crypto.randomUUID().slice(0, 8)}`, tokenScope: String(scopeId), pluginId: "mcp", strategy: { kind: "dynamic-dcr" }, }, }), ).pipe(Effect.result); expect(Result.isFailure(result)).toBe(true); expect(Date.now() - start).toBeLessThan(1000); } }), { timeout: 30_000 }, ); it.effect( "startOAuth → authorize → completeOAuth writes tokens at the invoker scope", () => Effect.gen(function* () { const orgId = `org_${crypto.randomUUID()}`; const userId = `user_${crypto.randomUUID()}`; const userScope = ScopeId.make(testUserOrgScopeId(userId, orgId)); const namespace = `ns_${crypto.randomUUID().slice(0, 8)}`; const connectionId = `mcp-oauth2-${namespace}`; const redirectUrl = "http://test.local/api/mcp/oauth/callback"; const started = yield* asUser(userId, orgId, (client) => client.oauth.start({ params: { scopeId: userScope }, payload: { endpoint: `${fake.url}/mcp`, redirectUrl, connectionId, tokenScope: String(userScope), strategy: { kind: "dynamic-dcr" }, pluginId: "mcp", }, }), ); expect(started.sessionId).toMatch(/^oauth2_session_/); expect(started.authorizationUrl).not.toBeNull(); const { code, state } = yield* Effect.promise(() => followAuthorize(started.authorizationUrl!), ); expect(state).toBe(started.sessionId); const completed = yield* asUser(userId, orgId, (client) => client.oauth.complete({ params: { scopeId: userScope }, payload: { state, code }, }), ); expect(completed.connectionId).toBe(connectionId); }), 30_000, ); it.effect( "second user on same source re-uses DCR client: registration endpoint is not re-hit", () => Effect.gen(function* () { const orgId = `org_${crypto.randomUUID()}`; const userA = `user_${crypto.randomUUID()}`; const userB = `user_${crypto.randomUUID()}`; const scopeA = ScopeId.make(testUserOrgScopeId(userA, orgId)); const scopeB = ScopeId.make(testUserOrgScopeId(userB, orgId)); const namespace = `ns_${crypto.randomUUID().slice(0, 8)}`; const connectionId = `mcp-oauth2-${namespace}`; const endpoint = `${fake.url}/mcp`; const redirectUrl = "http://test.local/api/mcp/oauth/callback"; const regsBefore = fake.registrations(); // --- User A: full OAuth round-trip, fresh DCR. --- const startedA = yield* asUser(userA, orgId, (client) => client.oauth.start({ params: { scopeId: scopeA }, payload: { endpoint, redirectUrl, connectionId, tokenScope: String(scopeA), strategy: { kind: "dynamic-dcr" }, pluginId: "mcp", }, }), ); const redirA = yield* Effect.promise(() => followAuthorize(startedA.authorizationUrl!)); const completedA = yield* asUser(userA, orgId, (client) => client.oauth.complete({ params: { scopeId: scopeA }, payload: { state: redirA.state, code: redirA.code }, }), ); expect(completedA.connectionId).toBe(connectionId); expect(fake.registrations()).toBe(regsBefore + 1); // --- User B: gets the same logical connection id in a different scope. --- const startedB = yield* asUser(userB, orgId, (client) => client.oauth.start({ params: { scopeId: scopeB }, payload: { endpoint, redirectUrl, connectionId, tokenScope: String(scopeB), strategy: { kind: "dynamic-dcr" }, pluginId: "mcp", }, }), ); const redirB = yield* Effect.promise(() => followAuthorize(startedB.authorizationUrl!)); const completedB = yield* asUser(userB, orgId, (client) => client.oauth.complete({ params: { scopeId: scopeB }, payload: { state: redirB.state, code: redirB.code }, }), ); expect(completedB.connectionId).toBe(connectionId); expect(fake.registrations()).toBe(regsBefore + 2); }), 30_000, ); });