1
29import { createHash, randomBytes } from "node:crypto";
30import { test, expect, request as pwRequest } from "@playwright/test";
31
32const API_BASE = process.env.E2E_API_URL ?? "http://localhost:4000";
33const WEB_BASE = process.env.E2E_BASE_URL ?? "http://localhost:3000";
34
35const uniqueEmail = () => `e2e-oauth-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@factory.local`;
36
37const S256 = (verifier: string): string =>
38 createHash("sha256").update(verifier, "ascii").digest("base64").replace(/\+/g, "-").replace(/\
39
40test.describe("OAuth2 authorization-code + PKCE — the exit scenario (TASK-0129)", () => {
41 test("browser: consent → code → token → pages read with the OAuth token", async ({ browser }) => {
42
43 const email = uniqueEmail();
44 const password = `e2e-${Date.now().toString(36)}`;
45 const setup = await pwRequest.newContext({ baseURL: API_BASE, timeout: 30_000 });
46 const reg = await setup.post("/api/v1/auth/register", { data: { email, password, name: "OAuth E2E" } });
47 expect(reg.status(), await reg.text()).toBe(201);
48 const login = await setup.post("/api/v1/auth/login", { data: { email, password } });
49 expect(login.status(), await login.text()).toBe(200);
50 const userToken = ((await login.json()) as { accessToken: string }).accessToken;
51
52 const bearer = await pwRequest.newContext({
53 baseURL: API_BASE,
54 timeout: 30_000,
55 extraHTTPHeaders: { Authorization: `Bearer ${userToken}` }
56 });
57 const ws = await bearer.post("/api/v1/workspaces", { data: { name: "OAuth E2E WS" } });
58 expect(ws.status(), await ws.text()).toBe(201);
59 const wsId = ((await ws.json()) as { id: string }).id;
60 const pageRes = await bearer.post(`/api/v1/workspaces/${wsId}/pages`, { data: { title: "OAuth E2E page" } });
61 expect(pageRes.status(), await pageRes.text()).toBe(201);
62
63 const cbUri = "http://localhost:9999/e2e-callback";
64 const appRes = await bearer.post(`/api/v1/workspaces/${wsId}/oauth/apps`, {
65 data: { name: "Browser Test App", scopes: ["pages:read", "pages:write"], redirectUris: [cbUri] }
66 });
67 expect(appRes.status(), await appRes.text()).toBe(201);
68 const app = (await appRes.json()) as { clientId: string; clientSecret: string };
69 expect(app.clientId).toMatch(/^cli_/);
70 expect(app.clientSecret).toMatch(/^cli_sec_/);
71
72
73 const context = await browser.newContext({ baseURL: API_BASE });
74
75
76 const bLogin = await context.request.post("/api/v1/auth/login", { data: { email, password } });
77 expect(bLogin.status(), await bLogin.text()).toBe(200);
78
79
80 let callbackUrl: string | null = null;
81 await context.route(new RegExp(cbUri), async (route) => {
82 callbackUrl = route.request().url();
83 await route.fulfill({ status: 200, contentType: "text/html", body: "<html><body>captured</body></html>" });
84 });
85
86 const verifier = randomBytes(32).toString("base64url");
87 const challenge = S256(verifier);
88 const auth = new URLSearchParams({
89 response_type: "code",
90 client_id: app.clientId,
91 redirect_uri: cbUri,
92 scope: "pages:read pages:write",
93 state: "e2e-state-123",
94 code_challenge: challenge,
95 code_challenge_method: "S256"
96 }).toString();
97 const resp = await context.request.get(`/api/v1/oauth/authorize?${auth}`, { maxRedirects: 0 });
98 expect(resp.status(), await resp.text()).toBe(200);
99 const html = await resp.text();
100 expect(html).toContain("Browser Test App");
101 expect(html).toContain("View pages");
102 expect(html).toContain("Create and edit pages");
103 expect(html).toContain('name="consent_token"');
104
105
106
107
108
109 const page = await context.newPage();
110 await page.goto(`${API_BASE}/api/v1/oauth/authorize?${auth}`);
111 const postRespPromise = page.waitForResponse(
112 (r) => r.request().method() === "POST" && r.url().includes("/api/v1/oauth/authorize"),
113 { timeout: 30_000 }
114 );
115 await page.getByRole("button", { name: "Allow" }).click();
116 const postResp = await postRespPromise;
117 expect(postResp.status(), "approve must 302 to the redirect_uri").toBe(302);
118 callbackUrl = postResp.headers()["location"]!;
119 expect(callbackUrl, "the callback must carry ?code=oc_…").toContain("code=oc_");
120 expect(callbackUrl).toContain("state=e2e-state-123");
121 const code = new URL(callbackUrl).searchParams.get("code")!;
122
123
124 const tokRes = await setup.post("/api/v1/oauth/token", {
125 data: { grant_type: "authorization_code", code, code_verifier: verifier, redirect_uri: cbUri },
126 headers: { Authorization: `Basic ${Buffer.from(`${app.clientId}:${app.clientSecret}`).toString("base64")}` }
127 });
128 expect(tokRes.status(), await tokRes.text()).toBe(200);
129 const tok = (await tokRes.json()) as { access_token: string; refresh_token: string; token_type: string; expires_in: number; scope: string };
130 expect(tok.token_type).toBe("Bearer");
131 expect(tok.expires_in).toBe(900);
132 expect(tok.scope).toBe("pages:read pages:write");
133 expect(tok.refresh_token).toMatch(/^rft_/);
134 const claims = JSON.parse(Buffer.from(tok.access_token.split(".")[1].replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8"));
135 expect(claims.type).toBe("oauth");
136 expect(claims.client_id).toBe(app.clientId);
137 expect(claims.scope).toBe("pages:read pages:write");
138
139
140 const use = await setup.get(`/api/v1/workspaces/${wsId}/pages`, {
141 headers: { Authorization: `Bearer ${tok.access_token}`, "X-Factory-Workspace": wsId }
142 });
143 expect(use.status(), await use.text()).toBe(200);
144 const pages = await use.json();
145 const list = Array.isArray(pages) ? pages : (pages as { data: unknown[] }).data;
146 expect(list.some((p: { title?: string }) => p?.title === "OAuth E2E page")).toBe(true);
147
148
149 const create = await setup.post(`/api/v1/workspaces/${wsId}/pages`, {
150 data: { title: "Created via OAuth token" },
151 headers: { Authorization: `Bearer ${tok.access_token}`, "X-Factory-Workspace": wsId }
152 });
153 expect(create.status(), await create.text()).toBe(201);
154
155 await context.close();
156 await setup.dispose();
157 await bearer.dispose();
158 });
159
160 test("anonymous in the browser → 302 to the WEB /login?next=<absolute authorize>", async ({ browser }) => {
161
162 const context = await browser.newContext({ baseURL: API_BASE });
163 const cbUri = "http://localhost:9999/anon-callback";
164 const setup = await pwRequest.newContext({ baseURL: API_BASE, timeout: 30_000 });
165 const email = uniqueEmail();
166 const password = `e2e-${Date.now().toString(36)}`;
167 expect((await setup.post("/api/v1/auth/register", { data: { email, password, name: "Anon" } })).status()).toBe(201);
168 const login = await setup.post("/api/v1/auth/login", { data: { email, password } });
169 const userToken = ((await login.json()) as { accessToken: string }).accessToken;
170 const bearer = await pwRequest.newContext({
171 baseURL: API_BASE,
172 timeout: 30_000,
173 extraHTTPHeaders: { Authorization: `Bearer ${userToken}` }
174 });
175 const wsId = ((await (await bearer.post("/api/v1/workspaces", { data: { name: "Anon WS" } })).json()) as { id: string }).id;
176 const app = (await (
177 await bearer.post(`/api/v1/workspaces/${wsId}/oauth/apps`, {
178 data: { name: "Anon App", scopes: ["pages:read"], redirectUris: [cbUri] }
179 })
180 ).json()) as { clientId: string };
181
182 const resp = await context.request.get(
183 `/api/v1/oauth/authorize?${new URLSearchParams({
184 response_type: "code",
185 client_id: app.clientId,
186 redirect_uri: cbUri,
187 scope: "pages:read"
188 }).toString()}`,
189 { maxRedirects: 0 }
190 );
191 expect(resp.status()).toBe(302);
192 const location = resp.headers()["location"]!;
193
194
195 expect(location.startsWith(`${WEB_BASE}/login?next=`), `got: ${location}`).toBe(true);
196 const next = decodeURIComponent(new URL(location, WEB_BASE).searchParams.get("next")!);
197 expect(next).toContain(`${API_BASE}/api/v1/oauth/authorize?`);
198 expect(next).toContain(`client_id=${app.clientId}`);
199
200
201 const loginPage = await context.request.get(`${WEB_BASE}/login?next=${encodeURIComponent(next)}`);
202 expect(loginPage.status()).toBe(200);
203 expect(await loginPage.text()).toContain("Sign in");
204
205 await context.close();
206 await setup.dispose();
207 await bearer.dispose();
208 });
209});
210