/** * AI Software Factory — E2E: OAuth2 authorization-code + PKCE (TASK-0129). * * The Phase-13 exit scenario, driven by a REAL BROWSER against the deployed * stack (web :3000, api :4000): * * 1. A user registers + logs in (the httpOnly `factory_refresh` cookie on * the API origin is the session — exactly what the consent page reads). * 2. An OAuth app is registered through the admin API (`pages:read` + * `pages:write`, a confidential client with a secret). * 3. The browser opens `GET /api/v1/oauth/authorize` (response_type=code, * PKCE) — with the session cookie it renders the CONSENT SCREEN: app * name + the requested scopes' human descriptions. * 4. Click "Allow" → 302 to the registered `redirect_uri` carrying * `?code=oc_…&state=…` (intercepted via page.route — the third-party * callback is a test sink, not a real server). * 5. The code + PKCE verifier are exchanged at `POST /api/v1/oauth/token` * (Basic client auth) → `access_token` (a `type:"oauth"` JWT with * `client_id` + `scope`) + `refresh_token`. * 6. The OAuth token is used: `GET /api/v1/workspaces/:id/pages` with * `Authorization: Bearer ` + `X-Factory-Workspace: ` * → 200 (the §8.2 scope/membership enforcement accepts a `pages:read` * token for a page read). * * A second scenario proves the ANONYMOUS path in the browser: without the * session cookie the authorize URL 302s to the WEB login page * (`/login?next=`), not a relative 404. */ import { createHash, randomBytes } from "node:crypto"; import { test, expect, request as pwRequest } from "@playwright/test"; const API_BASE = process.env.E2E_API_URL ?? "http://localhost:4000"; const WEB_BASE = process.env.E2E_BASE_URL ?? "http://localhost:3000"; const uniqueEmail = () => `e2e-oauth-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@factory.local`; const S256 = (verifier: string): string => createHash("sha256").update(verifier, "ascii").digest("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); test.describe("OAuth2 authorization-code + PKCE — the exit scenario (TASK-0129)", () => { test("browser: consent → code → token → pages read with the OAuth token", async ({ browser }) => { // -- Setup: user + workspace + page + app (admin API). -------------- const email = uniqueEmail(); const password = `e2e-${Date.now().toString(36)}`; const setup = await pwRequest.newContext({ baseURL: API_BASE, timeout: 30_000 }); const reg = await setup.post("/api/v1/auth/register", { data: { email, password, name: "OAuth E2E" } }); expect(reg.status(), await reg.text()).toBe(201); const login = await setup.post("/api/v1/auth/login", { data: { email, password } }); expect(login.status(), await login.text()).toBe(200); const userToken = ((await login.json()) as { accessToken: string }).accessToken; const bearer = await pwRequest.newContext({ baseURL: API_BASE, timeout: 30_000, extraHTTPHeaders: { Authorization: `Bearer ${userToken}` } }); const ws = await bearer.post("/api/v1/workspaces", { data: { name: "OAuth E2E WS" } }); expect(ws.status(), await ws.text()).toBe(201); const wsId = ((await ws.json()) as { id: string }).id; const pageRes = await bearer.post(`/api/v1/workspaces/${wsId}/pages`, { data: { title: "OAuth E2E page" } }); expect(pageRes.status(), await pageRes.text()).toBe(201); const cbUri = "http://localhost:9999/e2e-callback"; const appRes = await bearer.post(`/api/v1/workspaces/${wsId}/oauth/apps`, { data: { name: "Browser Test App", scopes: ["pages:read", "pages:write"], redirectUris: [cbUri] } }); expect(appRes.status(), await appRes.text()).toBe(201); const app = (await appRes.json()) as { clientId: string; clientSecret: string }; expect(app.clientId).toMatch(/^cli_/); expect(app.clientSecret).toMatch(/^cli_sec_/); // -- The browser: session cookie → consent → allow → code. ---------- const context = await browser.newContext({ baseURL: API_BASE }); // Log IN through the API in this context: the Set-Cookie lands in the // context's jar (the consent page reads it — the real browser path). const bLogin = await context.request.post("/api/v1/auth/login", { data: { email, password } }); expect(bLogin.status(), await bLogin.text()).toBe(200); // A test sink for the third-party callback: capture the code+state. let callbackUrl: string | null = null; await context.route(new RegExp(cbUri), async (route) => { callbackUrl = route.request().url(); await route.fulfill({ status: 200, contentType: "text/html", body: "captured" }); }); const verifier = randomBytes(32).toString("base64url"); const challenge = S256(verifier); const auth = new URLSearchParams({ response_type: "code", client_id: app.clientId, redirect_uri: cbUri, scope: "pages:read pages:write", state: "e2e-state-123", code_challenge: challenge, code_challenge_method: "S256" }).toString(); const resp = await context.request.get(`/api/v1/oauth/authorize?${auth}`, { maxRedirects: 0 }); expect(resp.status(), await resp.text()).toBe(200); // the consent page const html = await resp.text(); expect(html).toContain("Browser Test App"); expect(html).toContain("View pages"); // pages:read description expect(html).toContain("Create and edit pages"); // pages:write description expect(html).toContain('name="consent_token"'); // the CSRF token hidden field // Click "Allow" — the form POSTs to /api/v1/oauth/authorize (same // origin, cookie attached). Capture the 302's Location header directly // (it carries code+state); the browser then follows it to the callback // (test-sunk via the :9999 route, so the page never hard-errors). const page = await context.newPage(); await page.goto(`${API_BASE}/api/v1/oauth/authorize?${auth}`); const postRespPromise = page.waitForResponse( (r) => r.request().method() === "POST" && r.url().includes("/api/v1/oauth/authorize"), { timeout: 30_000 } ); await page.getByRole("button", { name: "Allow" }).click(); const postResp = await postRespPromise; expect(postResp.status(), "approve must 302 to the redirect_uri").toBe(302); callbackUrl = postResp.headers()["location"]!; expect(callbackUrl, "the callback must carry ?code=oc_…").toContain("code=oc_"); expect(callbackUrl).toContain("state=e2e-state-123"); const code = new URL(callbackUrl).searchParams.get("code")!; // -- Token exchange (the third-party app's job). --------------------- const tokRes = await setup.post("/api/v1/oauth/token", { data: { grant_type: "authorization_code", code, code_verifier: verifier, redirect_uri: cbUri }, headers: { Authorization: `Basic ${Buffer.from(`${app.clientId}:${app.clientSecret}`).toString("base64")}` } }); expect(tokRes.status(), await tokRes.text()).toBe(200); const tok = (await tokRes.json()) as { access_token: string; refresh_token: string; token_type: string; expires_in: number; scope: string }; expect(tok.token_type).toBe("Bearer"); expect(tok.expires_in).toBe(900); expect(tok.scope).toBe("pages:read pages:write"); expect(tok.refresh_token).toMatch(/^rft_/); const claims = JSON.parse(Buffer.from(tok.access_token.split(".")[1].replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8")); expect(claims.type).toBe("oauth"); expect(claims.client_id).toBe(app.clientId); expect(claims.scope).toBe("pages:read pages:write"); // -- Use the OAuth token: a pages READ (the token holds pages:read). - const use = await setup.get(`/api/v1/workspaces/${wsId}/pages`, { headers: { Authorization: `Bearer ${tok.access_token}`, "X-Factory-Workspace": wsId } }); expect(use.status(), await use.text()).toBe(200); const pages = await use.json(); const list = Array.isArray(pages) ? pages : (pages as { data: unknown[] }).data; expect(list.some((p: { title?: string }) => p?.title === "OAuth E2E page")).toBe(true); // -- A write with the same token is allowed (pages:write was granted). const create = await setup.post(`/api/v1/workspaces/${wsId}/pages`, { data: { title: "Created via OAuth token" }, headers: { Authorization: `Bearer ${tok.access_token}`, "X-Factory-Workspace": wsId } }); expect(create.status(), await create.text()).toBe(201); await context.close(); await setup.dispose(); await bearer.dispose(); }); test("anonymous in the browser → 302 to the WEB /login?next=", async ({ browser }) => { // A clean context: NO session cookie on the API origin. const context = await browser.newContext({ baseURL: API_BASE }); const cbUri = "http://localhost:9999/anon-callback"; const setup = await pwRequest.newContext({ baseURL: API_BASE, timeout: 30_000 }); const email = uniqueEmail(); const password = `e2e-${Date.now().toString(36)}`; expect((await setup.post("/api/v1/auth/register", { data: { email, password, name: "Anon" } })).status()).toBe(201); const login = await setup.post("/api/v1/auth/login", { data: { email, password } }); const userToken = ((await login.json()) as { accessToken: string }).accessToken; const bearer = await pwRequest.newContext({ baseURL: API_BASE, timeout: 30_000, extraHTTPHeaders: { Authorization: `Bearer ${userToken}` } }); const wsId = ((await (await bearer.post("/api/v1/workspaces", { data: { name: "Anon WS" } })).json()) as { id: string }).id; const app = (await ( await bearer.post(`/api/v1/workspaces/${wsId}/oauth/apps`, { data: { name: "Anon App", scopes: ["pages:read"], redirectUris: [cbUri] } }) ).json()) as { clientId: string }; const resp = await context.request.get( `/api/v1/oauth/authorize?${new URLSearchParams({ response_type: "code", client_id: app.clientId, redirect_uri: cbUri, scope: "pages:read" }).toString()}`, { maxRedirects: 0 } ); expect(resp.status()).toBe(302); const location = resp.headers()["location"]!; // The WEB origin (absolute — a relative /login from an API 302 would 404) // with the FULL API authorize URL embedded as `next`. expect(location.startsWith(`${WEB_BASE}/login?next=`), `got: ${location}`).toBe(true); const next = decodeURIComponent(new URL(location, WEB_BASE).searchParams.get("next")!); expect(next).toContain(`${API_BASE}/api/v1/oauth/authorize?`); expect(next).toContain(`client_id=${app.clientId}`); // And the WEB login page is reachable there (200, the sign-in form). const loginPage = await context.request.get(`${WEB_BASE}/login?next=${encodeURIComponent(next)}`); expect(loginPage.status()).toBe(200); expect(await loginPage.text()).toContain("Sign in"); await context.close(); await setup.dispose(); await bearer.dispose(); }); });