import assert from "node:assert/strict"; import { randomBytes } from "node:crypto"; import { mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; import { createServer } from "node:net"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { test } from "node:test"; import { unstable_dev } from "wrangler"; import { chromium } from "playwright"; import { parse } from "jsonc-parser"; import { oauthBindings, oauthConfig } from "./fixtures/oauth-config.mjs"; import { parseCapabilities, DEPLOYMENT_CAPABILITIES, } from "../configuration/oauth-capabilities.ts"; import { parseControlPlaneConfig } from "../configuration/control-plane.ts"; import "./fixtures/config.mjs"; const TX = "__Host-flarebot-oauth"; const SESSION = "__Host-flarebot-control-session"; const A = "a".repeat(32); const B = "b".repeat(32); async function freePort() { const server = createServer(); await new Promise((done) => server.listen(0, "127.0.0.1", done)); const { port } = server.address(); await new Promise((done) => server.close(done)); return port; } const id = () => randomBytes(32).toString("base64url"); const setCookie = (response, name) => response.headers .getSetCookie() .find((value) => value.startsWith(`${name}=`)) ?.split(";")[0]; test("catalog manifest requires reviewed exact IDs and full artifact capabilities", () => { const config = oauthConfig("https://control.example.com"); assert.deepEqual( parseControlPlaneConfig(config).oauthCapabilities, config.oauthCapabilities, ); for (const capability of DEPLOYMENT_CAPABILITIES) { const missing = structuredClone(config.oauthCapabilities); missing.scopes = missing.scopes.map((s) => ({ ...s, capabilities: s.capabilities.filter((c) => c !== capability), })); assert.throws( () => parseCapabilities(missing), /catalog-reviewed manifest/, ); } for (const scope of ["workers:write", "*", "offline_access", "offline"]) { const invalid = structuredClone(config.oauthCapabilities); invalid.scopes[0].id = scope; assert.throws(() => parseCapabilities(invalid)); } for (const mutation of [ (m) => (m.registeredClient.userinfoVerified = false), (m) => m.scopes.push(m.scopes[0]), (m) => (m.accessToken = "secret"), (m) => m.scopes[0].capabilities.push("invented-capability"), ]) { const invalid = structuredClone(config.oauthCapabilities); mutation(invalid); assert.throws(() => parseCapabilities(invalid)); } }); test( "native OAuth Worker/DO protects callback, grant, accounts, sessions and public browser flow", { timeout: 180_000 }, async (t) => { const temporary = await mkdtemp(join(tmpdir(), "flarebot-oauth-")); const port = await freePort(); const origin = `http://127.0.0.1:${port}`; const configPath = join(temporary, "wrangler.json"); // Production routes rewrite the request host in Miniflare. These direct // localhost requests must retain the fixture's configured origin instead. const { routes: _productionRoutes, workers_dev: _productionWorkersDev, ...base } = parse(await readFile("wrangler.control-plane.jsonc", "utf8")); await writeFile( configPath, JSON.stringify({ ...base, name: "flarebot-oauth-test", main: resolve("tests/fixtures/oauth-worker.ts"), assets: { directory: resolve("dist/control-plane/client"), binding: "ASSETS", }, }), ); let bindings = oauthBindings(origin); const start = () => unstable_dev("tests/fixtures/oauth-worker.ts", { config: configPath, vars: bindings, local: true, ip: "127.0.0.1", port, inspectorPort: 0, persist: true, persistTo: temporary, logLevel: "error", experimental: { disableExperimentalWarning: true, watch: false }, }); let worker; let browser; const logs = []; for (const method of ["log", "warn", "error"]) { const original = console[method]; t.mock.method(console, method, (...args) => { logs.push(args.map(String).join(" ")); original(...args); }); } const call = (path, options = {}) => fetch(`${origin}${path}`, { redirect: "manual", ...options, // The fixture restarts a different Worker behind this same origin. headers: { Origin: origin, Connection: "close", ...options.headers }, }).catch((error) => { throw new Error( `${options.method ?? "GET"} ${path.split("?")[0]} failed`, { cause: error }, ); }); const admin = async (path, body) => ( await call(`/__test__/${path}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }) ).json(); const stats = async () => (await call("/__test__/stats")).json(); async function begin(previous = "", mode = "normal") { const response = await call("/auth/start", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", Cookie: previous, }, body: "returnTo=%2Fconnect", }); assert.equal(response.status, 303); assert.equal(response.headers.get("Cache-Control"), "no-store"); const location = new URL(response.headers.get("Location")); const state = location.searchParams.get("state"); const code = id(); assert.equal( location.origin + location.pathname, "https://dash.cloudflare.com/oauth2/auth", ); assert.equal(location.searchParams.get("code_challenge_method"), "S256"); assert.equal(location.searchParams.get("response_type"), "code"); assert.equal( location.searchParams.get("redirect_uri"), `${origin}/auth/callback`, ); assert.notEqual(state, location.searchParams.get("code_challenge")); const txCookie = setCookie(response, TX); assert.ok(txCookie); assert.match( response.headers.get("Set-Cookie"), /Secure; HttpOnly; SameSite=Lax/, ); await admin("code", { code, challenge: location.searchParams.get("code_challenge"), mode, }); return { state, code, txCookie, cookies: [previous, txCookie].filter(Boolean).join("; "), path: `/auth/callback?state=${state}&code=${code}`, }; } const finish = (tx, path = tx.path, cookies = tx.cookies) => call(path, { headers: { Cookie: cookies } }); async function login(previous = "", mode = "normal") { const tx = await begin(previous, mode); const response = await finish(tx); assert.equal(response.headers.get("Location"), "/connect"); const cookie = setCookie(response, SESSION); assert.ok(cookie); return cookie; } const connection = (cookie) => call("/api/connection", { headers: { Cookie: cookie } }); const select = (cookie, accountId) => call("/api/account", { method: "POST", headers: { Cookie: cookie, "Content-Type": "application/x-www-form-urlencoded", }, body: new URLSearchParams({ accountId }).toString(), }); try { worker = await start(); assert.equal((await connection("")).status, 401); assert.equal( ( await call("/auth/start", { method: "POST", headers: { Origin: "https://evil.example", "Content-Type": "application/x-www-form-urlencoded", }, body: "", }) ).status, 403, ); assert.equal( ( await call("/auth/start", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: "returnTo=https%3A%2F%2Fevil.example", }) ).status, 400, ); assert.equal((await call("/auth/start")).status, 404); await t.test( "Cloudflare callback scope metadata permits a verified sign-in", async () => { const scoped = await begin(); const exchanges = (await stats()).exchangeCount; const path = `${scoped.path}&scope=${encodeURIComponent(oauthConfig(origin).oauthScopes.join(" "))}`; const response = await finish(scoped, path); assert.equal(response.headers.get("Location"), "/connect"); const session = setCookie(response, SESSION); assert.ok(session); assert.equal((await connection(session)).status, 200); assert.equal((await stats()).exchangeCount, exchanges + 1); assert.match( (await finish(scoped, path)).headers.get("Location"), /oauth_invalid_callback/, ); }, ); const tx = await begin(); const before = (await stats()).exchangeCount; for (const path of [ "/auth/callback", `${tx.path}&state=${tx.state}`, `${tx.path}&code=other`, `${tx.path}&scope=account.read&scope=workers-platform.write`, `${tx.path}&error=access_denied`, `${tx.path}&error_description=mixed`, `${tx.path}&iss=https://evil.example`, `/auth/callback?code=${tx.code}`, `/auth/callback?state=${tx.state}&code=`, `${tx.path}&untrusted=value`, ]) { assert.match( (await finish(tx, path)).headers.get("Location"), /oauth_invalid_callback/, ); } for (const cookies of [ "", `${TX}=${id()}`, `${tx.txCookie}; ${tx.txCookie}`, ]) assert.match( (await finish(tx, tx.path, cookies)).headers.get("Location"), /oauth_invalid_callback/, ); assert.equal((await stats()).exchangeCount, before); const race = await Promise.all([finish(tx), finish(tx)]); assert.equal( race.filter((r) => r.headers.get("Location") === "/connect").length, 1, ); assert.equal((await stats()).exchangeCount, before + 1); let session = setCookie( race.find((r) => r.headers.get("Location") === "/connect"), SESSION, ); assert.match( (await finish(tx)).headers.get("Location"), /oauth_invalid_callback/, ); const inspection = await ( await call("/__test__/inspect", { headers: { Cookie: session } }) ).json(); assert.equal(inspection.principal.subject, "verified-userinfo-subject"); assert.ok(!JSON.stringify(inspection).includes("oauth-secret-sentinel")); assert.ok( !JSON.stringify(inspection).includes("refresh-secret-sentinel"), ); assert.equal(inspection.record.kind, "grant"); assert.ok(inspection.record.ciphertext); const response = await connection(session); const data = await response.json(); assert.deepEqual( data.accounts.map((a) => a.id), [A, B], ); assert.equal(data.selectedAccountId, null); assert.equal(data.ownerSubject, inspection.principal.subject); assert.ok(!JSON.stringify(data).includes("untrusted-id-token-subject")); assert.ok(!JSON.stringify(data).includes("oauth-secret-sentinel")); assert.ok(!JSON.stringify(data).includes("refresh-secret-sentinel")); assert.ok(!JSON.stringify(data).includes("grantRef")); assert.equal((await select(session, "c".repeat(32))).status, 400); assert.equal( ( await call("/api/account", { method: "POST", headers: { Cookie: session, Origin: "https://evil.example", "Content-Type": "application/x-www-form-urlencoded", }, body: `accountId=${A}`, }) ).status, 403, ); assert.equal((await select("", A)).status, 401); assert.equal((await select(session, B)).status, 200); assert.equal( (await (await connection(session)).json()).selectedAccountId, B, ); assert.equal( ( await call("/__test__/deployment-seam", { headers: { Cookie: session }, }) ).status, 200, ); const ssr = await call("/connect", { headers: { Cookie: session } }); const html = await ssr.text(); assert.match(html, /Your personal Flarebot/); assert.equal(ssr.headers.get("Referrer-Policy"), "same-origin"); for (const marker of [ "verified-userinfo-subject", "oauth-secret-sentinel", "Personal account", "agents/personal-agent", "FLAREBOT_OAUTH_CLIENT_SECRET", ]) assert.ok(!html.includes(marker)); const expired = await begin(); await admin("expire", { kind: "transaction", ref: expired.state }); assert.match( (await finish(expired)).headers.get("Location"), /oauth_invalid_callback/, ); const alarm = await begin(); await admin("alarm", { ref: alarm.state }); await new Promise((done) => setTimeout(done, 200)); assert.match( (await finish(alarm)).headers.get("Location"), /oauth_invalid_callback/, ); const denial = await begin(); assert.match( ( await finish( denial, `/auth/callback?state=${denial.state}&error=access_denied&error_description=private-provider-error`, ) ).headers.get("Location"), /oauth_denied/, ); assert.match( (await finish(denial)).headers.get("Location"), /oauth_invalid_callback/, ); for (const [mode, category] of [ ["network", "temporarily_unavailable"], ["redirect", "temporarily_unavailable"], ["invalid-client", "oauth_capability_unavailable"], ["invalid-grant", "reauthorization_required"], ["scope-missing", "oauth_capability_unavailable"], ["bad-subject", "oauth_capability_unavailable"], ["expired", "oauth_capability_unavailable"], ]) { const broken = await begin("", mode); // Callback metadata must not override missing token-response grants. const failed = await finish( broken, `${broken.path}&scope=${encodeURIComponent(oauthConfig(origin).oauthScopes.join(" "))}`, ); assert.match(failed.headers.get("Location"), new RegExp(category)); assert.ok(!(await failed.text()).includes("private-provider-error")); assert.match( (await finish(broken)).headers.get("Location"), /oauth_invalid_callback/, ); } const switchTx = await begin(session, "second-owner"); assert.match( (await finish(switchTx, switchTx.path, switchTx.txCookie)).headers.get( "Location", ), /oauth_invalid_callback/, ); const rotated = await finish(switchTx); const oldSession = session; session = setCookie(rotated, SESSION); assert.ok(session); assert.notEqual(session, oldSession); assert.equal((await connection(oldSession)).status, 401); assert.equal( ( await ( await call("/__test__/inspect", { headers: { Cookie: session } }) ).json() ).principal.subject, "verified-second-owner", ); for (const [mode, category] of [ ["denied", "account_denied"], ["transient", "temporarily_unavailable"], ["huge", "temporarily_unavailable"], ]) { await admin("accounts", { mode }); assert.equal( (await (await connection(session)).json()).error, category, ); } await admin("accounts", { mode: "empty-zero" }); assert.deepEqual((await (await connection(session)).json()).accounts, []); await admin("accounts", { mode: "empty" }); assert.deepEqual((await (await connection(session)).json()).accounts, []); assert.equal((await select(session, A)).status, 400); await admin("accounts", { mode: "normal" }); assert.equal((await connection(session)).status, 200); await admin("accounts", { mode: "revoked" }); assert.equal((await connection(session)).status, 401); await admin("accounts", { mode: "normal" }); assert.equal((await connection(session)).status, 401); for (const mode of ["revoked-empty", "revoked-html"]) { session = await login(session); await admin("accounts", { mode }); assert.equal((await connection(session)).status, 401); await admin("accounts", { mode: "normal" }); } session = await login(session); const principal = ( await ( await call("/__test__/inspect", { headers: { Cookie: session } }) ).json() ).principal; await admin("expire", { kind: "grant", ref: principal.grantRef }); assert.equal((await connection(session)).status, 401); session = await login(session); await admin("expire", { kind: "session", ref: session.split("=")[1] }); assert.equal((await connection(session)).status, 401); const expiredLogout = await call("/auth/disconnect", { method: "POST", headers: { Cookie: session }, }); assert.equal(expiredLogout.status, 303); assert.match(expiredLogout.headers.get("Set-Cookie"), /Max-Age=0/); assert.equal( ( await call("/auth/disconnect", { method: "POST", headers: { Origin: "https://evil.example", Cookie: session }, }) ).status, 403, ); session = await login(); await worker.stop(); worker = await start(); assert.equal((await connection(session)).status, 200); await admin("accounts", { mode: "revoke-failed" }); const disconnect = await call("/auth/disconnect", { method: "POST", headers: { Cookie: session }, }); assert.match(disconnect.headers.get("Location"), /revocation_pending/); assert.equal((await connection(session)).status, 401); await admin("accounts", { mode: "normal" }); browser = await chromium.launch(); const context = await browser.newContext(); const page = await context.newPage(); const errors = []; const requests = []; page.on("pageerror", (error) => errors.push(error.message)); page.on("request", (request) => requests.push(request.url())); // CDP intercepts the provider request even within the form's redirect chain; // Playwright page.route only applies to the first request in that chain. const cdp = await context.newCDPSession(page); await cdp.send("Fetch.enable", { patterns: [ { urlPattern: "https://dash.cloudflare.com/*", requestStage: "Request", }, ], }); cdp.on("Fetch.requestPaused", async ({ requestId, request }) => { const authorization = new URL(request.url); if (authorization.pathname !== "/oauth2/auth") { await cdp.send("Fetch.failRequest", { requestId, errorReason: "BlockedByClient", }); return; } const code = id(); await admin("code", { code, challenge: authorization.searchParams.get("code_challenge"), mode: "normal", }); await cdp.send("Fetch.fulfillRequest", { requestId, responseCode: 302, responseHeaders: [ { name: "Location", value: `${origin}/auth/callback?state=${authorization.searchParams.get("state")}&code=${code}&scope=${encodeURIComponent(authorization.searchParams.get("scope"))}`, }, ], }); }); await page.goto(`${origin}/connect`); await page .getByRole("button", { name: "Connect Cloudflare", exact: true }) .waitFor(); assert.equal(await page.getByRole("alert").count(), 0); await page .getByRole("button", { name: "Connect Cloudflare", exact: true }) .click(); await page .getByRole("heading", { name: "Choose an account" }) .waitFor({ timeout: 8000 }) .catch(async () => { throw new Error( JSON.stringify({ pathname: new URL(page.url()).pathname, body: await page.locator("body").innerText(), cookies: (await context.cookies()).map((c) => ({ name: c.name, secure: c.secure, })), errors, }), ); }); await page .getByRole("button", { name: "Select Personal account", exact: true }) .click(); await page .getByRole("status") .filter({ hasText: "Account selected" }) .waitFor(); assert.match( await page .getByRole("button", { name: "Select Personal account", exact: true }) .innerText(), /Selected/, ); const cookies = await context.cookies(); for (const cookie of cookies.filter((c) => c.name.startsWith("__Host-"), )) { assert.equal(cookie.secure, true); assert.equal(cookie.httpOnly, true); assert.equal(cookie.sameSite, "Lax"); } assert.equal(await page.evaluate(() => document.cookie), ""); assert.equal(await page.evaluate(() => localStorage.length), 0); assert.equal( await page .getByRole("button", { name: "Select Personal account", exact: true }) .evaluate((element) => getComputedStyle(element).fontSize), "14px", ); assert.equal( await page.getByText("Team account