diff --git a/src/mcp.ts b/src/mcp.ts index 07f41c0..f4c65a7 100644 --- a/src/mcp.ts +++ b/src/mcp.ts @@ -1529,6 +1529,91 @@ export function createDodoMcpServer(env: Env, userEmail: string, depth = 0): Mcp }, ); + // Two-step DCR OAuth flow: Dodo runs DCR + token exchange server-side; a + // local helper handles only the loopback callback. Lets Dodo have its own + // refresh chain that doesn't share fate with any local OAuth client. + server.tool( + "start_dcr_oauth_flow", + [ + "Start an OAuth flow against an MCP server. Dodo registers itself via", + "DCR (with a loopback redirect URI the upstream will accept) and", + "returns an authorize URL for a local helper to open. The helper", + "catches the redirect on 127.0.0.1, then calls", + "`complete_dcr_oauth_flow` with the auth code to finish the dance.", + "Use this for MCP servers whose OAuth provider only accepts loopback", + "redirect URIs (e.g. cf-portal). For servers that accept arbitrary", + "redirect URIs, the regular browser-based OAuth catalog entry works.", + ].join(" "), + { + mcpUrl: z.string().url().describe("MCP server URL — used as the OAuth `resource` parameter"), + mcpName: z.string().min(1).describe("Display name for the resulting integration"), + redirectPort: z + .number() + .int() + .min(1024) + .max(65535) + .default(19876) + .describe("Port the local helper will bind on 127.0.0.1 (default: 19876, matches OpenCode's default)"), + registrationEndpoint: z + .string() + .url() + .optional() + .describe("Override the auto-discovered OAuth registration endpoint"), + authorizationEndpoint: z + .string() + .url() + .optional() + .describe("Override the auto-discovered OAuth authorization endpoint"), + tokenEndpoint: z + .string() + .url() + .optional() + .describe("Override the auto-discovered OAuth token endpoint"), + scope: z.string().optional().describe("Optional OAuth scopes to request"), + }, + async (input) => { + const res = await userControlFetch(env, "/oauth-dcr/start", { + body: JSON.stringify(input), + headers: { "content-type": "application/json" }, + method: "POST", + }); + if (!res.ok) { + const err = await res.json(); + return errorResult(err); + } + const payload = (await res.json()) as { state: string; authUrl: string; redirectUri: string }; + return textResult(payload); + }, + ); + + server.tool( + "complete_dcr_oauth_flow", + [ + "Complete a DCR OAuth flow that was started with", + "`start_dcr_oauth_flow`. Pass the auth code and state nonce that the", + "local helper caught on its loopback callback. Dodo exchanges the", + "code for tokens server-side and stores them as a refresh_token MCP", + "integration that auto-refreshes as the access token expires.", + ].join(" "), + { + state: z.string().min(1).describe("The state nonce returned by start_dcr_oauth_flow"), + code: z.string().min(1).describe("The authorization code from the loopback callback's `?code=…` query param"), + }, + async (input) => { + const res = await userControlFetch(env, "/oauth-dcr/complete", { + body: JSON.stringify(input), + headers: { "content-type": "application/json" }, + method: "POST", + }); + if (!res.ok) { + const err = await res.json(); + return errorResult(err); + } + const payload = (await res.json()) as { id: string; name: string; url: string }; + return textResult(payload); + }, + ); + server.tool("remove_mcp_config", "Remove an MCP integration by id", { id: z.string().describe("MCP config id to remove"), }, async ({ id }) => { diff --git a/src/user-control.ts b/src/user-control.ts index 8a1ff38..e16174f 100644 --- a/src/user-control.ts +++ b/src/user-control.ts @@ -162,6 +162,71 @@ const refreshTokenMcpUpsertSchema = z }) .strict(); +/** + * Schema for `/oauth-dcr/start`: Dodo runs DCR against the upstream's + * registration endpoint, generates PKCE + state, and returns a fully-formed + * authorize URL for the local helper to open. The redirect_uri is loopback + * because the upstream's authorize endpoint demands it; Dodo never listens + * there — only the helper does. + */ +const oauthDcrStartSchema = z + .object({ + mcpUrl: z.string().url().describe("MCP server URL — used as the OAuth `resource` parameter"), + mcpName: z.string().min(1).describe("Display name for the resulting mcp_configs row"), + redirectPort: z + .number() + .int() + .min(1024) + .max(65535) + .default(19876) + .describe("Port the local helper will bind on 127.0.0.1 to receive the callback"), + // OAuth endpoints — auto-discovered if not provided. Useful overrides for + // providers whose .well-known docs don't match runtime endpoints. + registrationEndpoint: z.string().url().optional(), + authorizationEndpoint: z.string().url().optional(), + tokenEndpoint: z.string().url().optional(), + scope: z.string().optional(), + }) + .strict(); + +/** + * Schema for `/oauth-dcr/complete`: the local helper hands back the auth code + * it caught on its loopback callback, plus the state nonce that ties this + * request to the pending dance. + */ +const oauthDcrCompleteSchema = z + .object({ + state: z.string().min(1), + code: z.string().min(1), + }) + .strict(); + +/** Pending-DCR-flow record kept (encrypted) in `encrypted_secrets`. */ +interface OAuthDcrPending { + clientId: string; + codeVerifier: string; + redirectUri: string; + tokenEndpoint: string; + mcpUrl: string; + mcpName: string; + createdAt: number; +} + +/** TTL for a pending OAuth DCR flow — the user has this long to complete SSO + * before the helper has to start over. Authorize endpoint requests typically + * complete in <2 minutes; 10 minutes is comfortable margin. */ +const OAUTH_DCR_PENDING_TTL_MS = 10 * 60 * 1000; + +/** + * URL-safe base64 encoding without padding — required for PKCE + * `code_verifier` and `code_challenge` per RFC 7636 §4.1 / §4.2. + */ +function bytesToBase64Url(bytes: Uint8Array): string { + let binary = ""; + for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]); + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + const workerRunStatusEnum = z.enum([ "session_created", "repo_ready", @@ -804,6 +869,36 @@ export class UserControl extends DurableObject { } } + // Dodo-owned DCR flow: kick off a fresh OAuth dance against the upstream + // and return an authorize URL. A local helper opens the URL in a + // browser, catches the redirect on loopback, and posts the code back to + // /oauth-dcr/complete. This lets Dodo run its own DCR client (separate + // refresh chain from any local OpenCode / Cursor / etc) while still + // honouring the upstream's loopback-only redirect URI policy. + if (request.method === "POST" && url.pathname === "/oauth-dcr/start") { + const body = oauthDcrStartSchema.parse(await request.json()); + const ownerEmail = request.headers.get("x-owner-email") ?? ""; + try { + const result = await this.startOauthDcrFlow(body, ownerEmail); + return Response.json(result); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return Response.json({ error: message }, { status: 502 }); + } + } + + if (request.method === "POST" && url.pathname === "/oauth-dcr/complete") { + const body = oauthDcrCompleteSchema.parse(await request.json()); + const ownerEmail = request.headers.get("x-owner-email") ?? ""; + try { + const result = await this.completeOauthDcrFlow(body, ownerEmail); + return Response.json(result); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return Response.json({ error: message }, { status: 502 }); + } + } + if (request.method === "POST" && url.pathname === "/user-mcp-tokens") { const { email, label } = await request.json() as { email: string; label?: string }; const result = await this.createUserMcpToken(email, label); @@ -2423,6 +2518,258 @@ export class UserControl extends DurableObject { return payload.access_token; } + // ─── DCR-driven OAuth flow ────────────────────────────────────────────── + // + // Dodo runs its own DCR client against the upstream and asks a local helper + // to handle the browser-side authorize step on loopback. Dodo then exchanges + // the auth code for tokens server-side and stores them in `mcp_configs`. + // + // This gives Dodo a refresh chain that doesn't share fate with any local + // OAuth client (OpenCode, Cursor, etc). + + /** + * Discover OAuth endpoints from the MCP server's well-known docs, falling + * back to any explicit overrides the caller provided. Returns the full set + * of endpoints we need to run the dance. + */ + private async resolveOauthEndpoints( + mcpUrl: string, + overrides: { + registrationEndpoint?: string; + authorizationEndpoint?: string; + tokenEndpoint?: string; + }, + ): Promise<{ registrationEndpoint: string; authorizationEndpoint: string; tokenEndpoint: string }> { + // Allow caller to short-circuit discovery entirely. + if ( + overrides.registrationEndpoint && + overrides.authorizationEndpoint && + overrides.tokenEndpoint + ) { + return { + registrationEndpoint: overrides.registrationEndpoint, + authorizationEndpoint: overrides.authorizationEndpoint, + tokenEndpoint: overrides.tokenEndpoint, + }; + } + + // The MCP server exposes its own `/.well-known/oauth-protected-resource` + // which points at the actual authorization server. That server then + // exposes `/.well-known/oauth-authorization-server` with the endpoints. + const protectedResourceUrl = new URL("/.well-known/oauth-protected-resource", mcpUrl).toString(); + const prRes = await fetch(protectedResourceUrl, { + headers: { accept: "application/json" }, + }); + if (!prRes.ok) { + throw new Error(`oauth-protected-resource fetch failed (${prRes.status}) from ${protectedResourceUrl}`); + } + const pr = (await prRes.json()) as { authorization_servers?: string[] }; + const issuer = pr.authorization_servers?.[0]; + if (!issuer) { + throw new Error("No authorization_servers in oauth-protected-resource doc"); + } + + const asUrl = new URL("/.well-known/oauth-authorization-server", issuer).toString(); + const asRes = await fetch(asUrl, { headers: { accept: "application/json" } }); + if (!asRes.ok) { + throw new Error(`oauth-authorization-server fetch failed (${asRes.status}) from ${asUrl}`); + } + const as = (await asRes.json()) as { + registration_endpoint?: string; + authorization_endpoint?: string; + token_endpoint?: string; + }; + + return { + registrationEndpoint: overrides.registrationEndpoint ?? as.registration_endpoint ?? "", + authorizationEndpoint: overrides.authorizationEndpoint ?? as.authorization_endpoint ?? "", + tokenEndpoint: overrides.tokenEndpoint ?? as.token_endpoint ?? "", + }; + } + + /** + * Start a DCR-backed OAuth flow. Registers a new client (with a loopback + * redirect URI so the upstream accepts it), generates PKCE + state, persists + * the pending dance, and returns the authorize URL for the helper to open. + */ + public async startOauthDcrFlow( + input: z.infer, + ownerEmail: string, + ): Promise<{ state: string; authUrl: string; redirectUri: string }> { + if (!ownerEmail || !this.hasKeyEnvelope()) { + throw new Error( + "DCR OAuth flow requires the user passkey envelope. Run /api/passkey/init first.", + ); + } + + const endpoints = await this.resolveOauthEndpoints(input.mcpUrl, { + registrationEndpoint: input.registrationEndpoint, + authorizationEndpoint: input.authorizationEndpoint, + tokenEndpoint: input.tokenEndpoint, + }); + if (!endpoints.registrationEndpoint || !endpoints.authorizationEndpoint || !endpoints.tokenEndpoint) { + throw new Error( + `Missing OAuth endpoints (registration=${!!endpoints.registrationEndpoint} authorize=${!!endpoints.authorizationEndpoint} token=${!!endpoints.tokenEndpoint})`, + ); + } + + const redirectUri = `http://127.0.0.1:${input.redirectPort}/callback`; + + // DCR registration. Public client with PKCE, exactly like OpenCode does it + // — matches the upstream's loopback redirect URI policy. + const dcrRes = await fetch(endpoints.registrationEndpoint, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + client_name: "Dodo", + client_uri: "https://dodo.jonnyparris.workers.dev", + grant_types: ["authorization_code", "refresh_token"], + redirect_uris: [redirectUri], + response_types: ["code"], + token_endpoint_auth_method: "none", + }), + }); + if (!dcrRes.ok) { + const text = await dcrRes.text().catch(() => ""); + throw new Error(`DCR failed (${dcrRes.status}): ${text.slice(0, 200)}`); + } + const dcr = (await dcrRes.json()) as { client_id?: string }; + if (!dcr.client_id) { + throw new Error("DCR response missing client_id"); + } + + // Generate PKCE pair (S256 — required by cf-portal and most modern providers). + const codeVerifierBytes = crypto.getRandomValues(new Uint8Array(32)); + const codeVerifier = bytesToBase64Url(codeVerifierBytes); + const codeChallengeBytes = new Uint8Array( + await crypto.subtle.digest("SHA-256", new TextEncoder().encode(codeVerifier).buffer as ArrayBuffer), + ); + const codeChallenge = bytesToBase64Url(codeChallengeBytes); + + // State nonce — 32 random bytes hex. + const stateBytes = crypto.getRandomValues(new Uint8Array(32)); + const state = Array.from(stateBytes).map((b) => b.toString(16).padStart(2, "0")).join(""); + + // Persist the pending dance. Keyed by state so /oauth-dcr/complete can + // recover everything it needs from just the (untrusted) callback params. + const pending: OAuthDcrPending = { + clientId: dcr.client_id, + codeVerifier, + redirectUri, + tokenEndpoint: endpoints.tokenEndpoint, + mcpUrl: input.mcpUrl, + mcpName: input.mcpName, + createdAt: Date.now(), + }; + await this.setSecret( + `oauth_dcr_pending:${state}`, + JSON.stringify(pending), + ownerEmail, + ); + + // Build the authorize URL the helper will open. + const authUrl = new URL(endpoints.authorizationEndpoint); + authUrl.searchParams.set("response_type", "code"); + authUrl.searchParams.set("client_id", dcr.client_id); + authUrl.searchParams.set("code_challenge", codeChallenge); + authUrl.searchParams.set("code_challenge_method", "S256"); + authUrl.searchParams.set("redirect_uri", redirectUri); + authUrl.searchParams.set("state", state); + authUrl.searchParams.set("resource", input.mcpUrl); + if (input.scope) authUrl.searchParams.set("scope", input.scope); + + return { state, authUrl: authUrl.toString(), redirectUri }; + } + + /** + * Exchange the auth code for tokens, store them via the existing + * `upsertRefreshTokenMcp` path, and clean up the pending state. + */ + public async completeOauthDcrFlow( + input: z.infer, + ownerEmail: string, + ): Promise<{ id: string; name: string; url: string }> { + if (!ownerEmail || !this.hasKeyEnvelope()) { + throw new Error( + "DCR OAuth flow requires the user passkey envelope. Run /api/passkey/init first.", + ); + } + + const pendingRaw = await this.getSecret( + `oauth_dcr_pending:${input.state}`, + ownerEmail, + ); + if (!pendingRaw) { + throw new Error("Unknown or expired state — start a new DCR flow"); + } + const pending = JSON.parse(pendingRaw) as OAuthDcrPending; + if (Date.now() - pending.createdAt > OAUTH_DCR_PENDING_TTL_MS) { + // Best-effort cleanup; we still treat the dance as expired regardless. + this.ctx.storage.sql.exec( + "DELETE FROM encrypted_secrets WHERE key = ?", + `oauth_dcr_pending:${input.state}`, + ); + throw new Error("Pending DCR flow has expired (10 min TTL)"); + } + + // Token exchange — same shape as a normal authorization_code grant. + // redirect_uri must match the value we sent in the authorize request + // (RFC 6749 §4.1.3), so we use the stored value. + const body = new URLSearchParams({ + grant_type: "authorization_code", + code: input.code, + redirect_uri: pending.redirectUri, + client_id: pending.clientId, + code_verifier: pending.codeVerifier, + }); + const tokenRes = await fetch(pending.tokenEndpoint, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body, + }); + if (!tokenRes.ok) { + const text = await tokenRes.text().catch(() => ""); + throw new Error(`Token exchange failed (${tokenRes.status}): ${text.slice(0, 200)}`); + } + const tokens = (await tokenRes.json()) as { + access_token?: string; + refresh_token?: string; + expires_in?: number; + }; + if (!tokens.access_token || !tokens.refresh_token) { + throw new Error( + `Token exchange response missing tokens (access=${!!tokens.access_token} refresh=${!!tokens.refresh_token})`, + ); + } + + const nowSec = Math.floor(Date.now() / 1000); + const expiresAt = tokens.expires_in ? nowSec + tokens.expires_in : 0; + + // Store via the existing refresh-token MCP upsert path. Idempotent on + // mcpUrl, so re-running the dance just rotates the tokens in place. + const result = await this.upsertRefreshTokenMcp( + { + name: pending.mcpName, + url: pending.mcpUrl, + tokenEndpoint: pending.tokenEndpoint, + clientId: pending.clientId, + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresAt, + enabled: true, + }, + ownerEmail, + ); + + // Clean up the pending state — it's single-use. + this.ctx.storage.sql.exec( + "DELETE FROM encrypted_secrets WHERE key = ?", + `oauth_dcr_pending:${input.state}`, + ); + + return { id: result.id, name: result.name, url: result.url }; + } + public async createUserMcpToken(email: string, label?: string): Promise<{ token: string; created_at: number }> { const token = `dodo_${crypto.randomUUID().replace(/-/g, "")}`; const normalisedEmail = email.trim().toLowerCase(); diff --git a/test/oauth-dcr-flow-unit.test.ts b/test/oauth-dcr-flow-unit.test.ts new file mode 100644 index 0000000..0204463 --- /dev/null +++ b/test/oauth-dcr-flow-unit.test.ts @@ -0,0 +1,296 @@ +/** + * Unit tests for the Dodo-owned DCR OAuth flow. + * + * Dodo runs DCR + token exchange server-side; a local helper handles only + * the browser-side authorize step on a loopback callback. The two-step + * dance is keyed by an opaque `state` nonce, persisted (encrypted) in + * UserControl between the two calls. + * + * These tests exercise UserControl directly. Outbound fetches (discovery, + * DCR, token exchange) are mocked via `globalThis.fetch`. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { env } from "cloudflare:workers"; +import type { Env } from "../src/types"; + +vi.mock("@cloudflare/codemode", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + DynamicWorkerExecutor: vi.fn(function () { + return { execute: vi.fn().mockResolvedValue({ logs: [], result: null }) }; + }) as unknown as typeof import("@cloudflare/codemode").DynamicWorkerExecutor, + }; +}); +vi.mock("../src/agentic", async () => await import("./helpers/agentic-mock")); +vi.mock("../src/notify", () => ({ dispatchNotification: vi.fn() })); + +const typedEnv = env as Env; +const OWNER = "dcr-flow-tester@dodo.test"; + +const MCP_URL = "https://dcr-target.example.com/mcp"; +const AUTH_SERVER = "https://dcr-auth.example.com"; +const REGISTRATION = `${AUTH_SERVER}/oauth/registration`; +const AUTHORIZE = `${AUTH_SERVER}/oauth/authorize`; +const TOKEN_ENDPOINT = `${AUTH_SERVER}/oauth/token`; + +function userControlStub() { + return typedEnv.USER_CONTROL.get(typedEnv.USER_CONTROL.idFromName(OWNER)); +} + +async function userControlFetch(path: string, init?: RequestInit): Promise { + const headers = new Headers(init?.headers); + headers.set("x-owner-email", OWNER); + return userControlStub().fetch(`https://user-control${path}`, { ...init, headers }); +} + +async function initPasskeyIfNeeded(): Promise { + await userControlFetch("/passkey/init", { + method: "POST", + body: JSON.stringify({ passkey: "dcr-flow-tester-passkey" }), + headers: { "content-type": "application/json" }, + }).catch(() => undefined); +} + +/** Mock fetch impl that routes based on URL. Returns a mock that lets each + * test set per-call behaviour for the well-known + DCR + token endpoints. */ +function makeRoutingFetch(responses: Record Response>) { + return vi.fn(async (input: RequestInfo | URL) => { + const requestUrl = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + for (const [pattern, factory] of Object.entries(responses)) { + if (requestUrl.includes(pattern)) return factory(); + } + throw new Error(`Unmocked fetch to ${requestUrl}`); + }); +} + +describe("DCR OAuth flow — start", () => { + let originalFetch: typeof fetch; + + beforeEach(async () => { + await initPasskeyIfNeeded(); + originalFetch = globalThis.fetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it("discovers endpoints, runs DCR, and returns an authorize URL with PKCE + state", async () => { + const fetchSpy = makeRoutingFetch({ + "/.well-known/oauth-protected-resource": () => + new Response(JSON.stringify({ authorization_servers: [AUTH_SERVER] }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + "/.well-known/oauth-authorization-server": () => + new Response( + JSON.stringify({ + registration_endpoint: REGISTRATION, + authorization_endpoint: AUTHORIZE, + token_endpoint: TOKEN_ENDPOINT, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + "/oauth/registration": () => + new Response(JSON.stringify({ client_id: "issued-client-id" }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + }); + globalThis.fetch = fetchSpy as unknown as typeof fetch; + + const res = await userControlFetch("/oauth-dcr/start", { + method: "POST", + body: JSON.stringify({ + mcpUrl: MCP_URL, + mcpName: "DCR Target", + }), + headers: { "content-type": "application/json" }, + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { state: string; authUrl: string; redirectUri: string }; + expect(body.state).toMatch(/^[0-9a-f]{64}$/); + expect(body.redirectUri).toBe("http://127.0.0.1:19876/callback"); + + const parsed = new URL(body.authUrl); + expect(parsed.origin + parsed.pathname).toBe(AUTHORIZE); + expect(parsed.searchParams.get("response_type")).toBe("code"); + expect(parsed.searchParams.get("client_id")).toBe("issued-client-id"); + expect(parsed.searchParams.get("code_challenge_method")).toBe("S256"); + // code_challenge is base64-url with no padding + expect(parsed.searchParams.get("code_challenge")).toMatch(/^[A-Za-z0-9_-]+$/); + expect(parsed.searchParams.get("redirect_uri")).toBe("http://127.0.0.1:19876/callback"); + expect(parsed.searchParams.get("state")).toBe(body.state); + expect(parsed.searchParams.get("resource")).toBe(MCP_URL); + }); + + it("honours explicit endpoint overrides and skips discovery", async () => { + const fetchSpy = makeRoutingFetch({ + "/oauth/registration": () => + new Response(JSON.stringify({ client_id: "no-discovery-client" }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + }); + globalThis.fetch = fetchSpy as unknown as typeof fetch; + + const res = await userControlFetch("/oauth-dcr/start", { + method: "POST", + body: JSON.stringify({ + mcpUrl: MCP_URL, + mcpName: "Override Target", + registrationEndpoint: REGISTRATION, + authorizationEndpoint: AUTHORIZE, + tokenEndpoint: TOKEN_ENDPOINT, + }), + headers: { "content-type": "application/json" }, + }); + expect(res.status).toBe(200); + // Only the DCR endpoint should have been called — no well-known fetch. + const calls = (fetchSpy as unknown as { mock: { calls: unknown[][] } }).mock.calls; + expect(calls).toHaveLength(1); + const firstArg = calls[0][0]; + const firstUrl = typeof firstArg === "string" ? firstArg : (firstArg as { url?: string }).url ?? String(firstArg); + expect(firstUrl).toBe(REGISTRATION); + }); + + it("surfaces 502 when DCR fails", async () => { + const fetchSpy = makeRoutingFetch({ + "/oauth/registration": () => + new Response(JSON.stringify({ error: "invalid_request" }), { + status: 400, + headers: { "content-type": "application/json" }, + }), + }); + globalThis.fetch = fetchSpy as unknown as typeof fetch; + + const res = await userControlFetch("/oauth-dcr/start", { + method: "POST", + body: JSON.stringify({ + mcpUrl: MCP_URL, + mcpName: "Failing Target", + registrationEndpoint: REGISTRATION, + authorizationEndpoint: AUTHORIZE, + tokenEndpoint: TOKEN_ENDPOINT, + }), + headers: { "content-type": "application/json" }, + }); + expect(res.status).toBe(502); + }); +}); + +describe("DCR OAuth flow — complete", () => { + let originalFetch: typeof fetch; + let pendingState: string; + + beforeEach(async () => { + await initPasskeyIfNeeded(); + originalFetch = globalThis.fetch; + + // Set up a pending dance the complete-step can finish. + const startFetchSpy = makeRoutingFetch({ + "/oauth/registration": () => + new Response(JSON.stringify({ client_id: "completable-client" }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + }); + globalThis.fetch = startFetchSpy as unknown as typeof fetch; + + const startRes = await userControlFetch("/oauth-dcr/start", { + method: "POST", + body: JSON.stringify({ + mcpUrl: "https://dcr-complete-target.example.com/mcp", + mcpName: "Complete Target", + registrationEndpoint: REGISTRATION, + authorizationEndpoint: AUTHORIZE, + tokenEndpoint: TOKEN_ENDPOINT, + }), + headers: { "content-type": "application/json" }, + }); + const startBody = (await startRes.json()) as { state: string }; + pendingState = startBody.state; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it("exchanges the auth code and persists tokens via the refresh-token path", async () => { + type Captured = { url: string; body: string }; + const capturedHolder: { value: Captured | null } = { value: null }; + const fetchSpy = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const requestUrl = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + let bodyStr = ""; + if (typeof init?.body === "string") bodyStr = init.body; + else if (init?.body instanceof URLSearchParams) bodyStr = init.body.toString(); + capturedHolder.value = { url: requestUrl, body: bodyStr }; + return new Response( + JSON.stringify({ + access_token: "ac-token-1", + refresh_token: "rf-token-1", + expires_in: 900, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + }); + globalThis.fetch = fetchSpy as unknown as typeof fetch; + + const res = await userControlFetch("/oauth-dcr/complete", { + method: "POST", + body: JSON.stringify({ state: pendingState, code: "auth-code-1" }), + headers: { "content-type": "application/json" }, + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { id: string; name: string; url: string }; + expect(body.name).toBe("Complete Target"); + expect(body.url).toBe("https://dcr-complete-target.example.com/mcp"); + + // The token exchange request must include redirect_uri, code_verifier, + // and grant_type=authorization_code. + expect(capturedHolder.value?.url).toBe(TOKEN_ENDPOINT); + expect(capturedHolder.value?.body).toContain("grant_type=authorization_code"); + expect(capturedHolder.value?.body).toContain("code=auth-code-1"); + expect(capturedHolder.value?.body).toContain("client_id=completable-client"); + expect(capturedHolder.value?.body).toContain("code_verifier="); + // redirect_uri is URL-encoded so check the encoded form + expect(capturedHolder.value?.body).toContain("redirect_uri=http%3A%2F%2F127.0.0.1%3A19876%2Fcallback"); + + // The new config is listed with auth_type=refresh_token. + const listRes = await userControlFetch("/mcp-configs"); + const list = (await listRes.json()) as { configs: Array<{ id: string; auth_type: string; url?: string }> }; + const entry = list.configs.find((c) => c.url === "https://dcr-complete-target.example.com/mcp"); + expect(entry).toBeDefined(); + expect(entry?.auth_type).toBe("refresh_token"); + }); + + it("rejects an unknown state with 502", async () => { + globalThis.fetch = (async () => { + throw new Error("Should not be called"); + }) as unknown as typeof fetch; + + const res = await userControlFetch("/oauth-dcr/complete", { + method: "POST", + body: JSON.stringify({ state: "bogus-state", code: "any" }), + headers: { "content-type": "application/json" }, + }); + expect(res.status).toBe(502); + }); + + it("rejects when the token endpoint returns an error", async () => { + globalThis.fetch = (async () => + new Response(JSON.stringify({ error: "invalid_grant" }), { + status: 400, + headers: { "content-type": "application/json" }, + })) as unknown as typeof fetch; + + const res = await userControlFetch("/oauth-dcr/complete", { + method: "POST", + body: JSON.stringify({ state: pendingState, code: "bad-code" }), + headers: { "content-type": "application/json" }, + }); + expect(res.status).toBe(502); + }); +}); diff --git a/test/refresh-token-mcp-unit.test.ts b/test/refresh-token-mcp-unit.test.ts index c8ef9f8..9acc526 100644 --- a/test/refresh-token-mcp-unit.test.ts +++ b/test/refresh-token-mcp-unit.test.ts @@ -195,13 +195,14 @@ describe("refresh-token MCP — access token retrieval", () => { // Capture the request for post-call assertions — putting `expect`s // inside the mock function turns assertion failures into rejected // promises, which surface as opaque 502s from the DO endpoint. - let captured: { url: string; method?: string; body: string } | null = null; + type Captured = { url: string; method?: string; body: string }; + const capturedHolder: { value: Captured | null } = { value: null }; const fetchSpy = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { const requestUrl = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; let bodyStr = ""; if (typeof init?.body === "string") bodyStr = init.body; else if (init?.body instanceof URLSearchParams) bodyStr = init.body.toString(); - captured = { url: requestUrl, method: init?.method, body: bodyStr }; + capturedHolder.value = { url: requestUrl, method: init?.method, body: bodyStr }; return new Response( JSON.stringify({ access_token: "fresh-token", @@ -218,11 +219,11 @@ describe("refresh-token MCP — access token retrieval", () => { const body = (await tokenRes.json()) as { accessToken: string }; expect(body.accessToken).toBe("fresh-token"); expect(fetchSpy).toHaveBeenCalledTimes(1); - expect(captured?.url).toBe(TOKEN_ENDPOINT); - expect(captured?.method).toBe("POST"); - expect(captured?.body).toContain("grant_type=refresh_token"); - expect(captured?.body).toContain("refresh_token=old-refresh"); - expect(captured?.body).toContain("client_id=client-expired"); + expect(capturedHolder.value?.url).toBe(TOKEN_ENDPOINT); + expect(capturedHolder.value?.method).toBe("POST"); + expect(capturedHolder.value?.body).toContain("grant_type=refresh_token"); + expect(capturedHolder.value?.body).toContain("refresh_token=old-refresh"); + expect(capturedHolder.value?.body).toContain("client_id=client-expired"); }); it("force=1 refreshes even if the cached token has not yet expired", async () => {