1
38import { describe, expect, it } from "vitest";
39import { readFileSync } from "node:fs";
40import { execSync } from "node:child_process";
41import { fileURLToPath } from "node:url";
42import { checkLiveApp, apiFetch, webFetch } from "./helpers/app.js";
43
44const REPO_ROOT = fileURLToPath(new URL("../..", import.meta.url));
45const PASSWORD = "Str0ngPass!x9";
46const REFRESH_COOKIE = "factory_refresh";
47
48
49
50const live = await checkLiveApp();
51const itLive = it.skipIf(!live);
52
53
54
55
56const b64url = (s: string): string =>
57 Buffer.from(s).toString("base64").replace(/\+/g, "-").replace(/\
58
59function decodePayload(token: string): Record<string, unknown> {
60 const [, bodyB64] = token.split(".");
61 const pad = (s: string) => s.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(s.length / 4) * 4, "=");
62 return JSON.parse(Buffer.from(pad(bodyB64 ?? ""), "base64").toString("utf8")) as Record<string, unknown>;
63}
64
65function newEmail(): string {
66 return `secauth_${Date.now()}_${Math.floor(Math.random() * 1e6)}@example.com`;
67}
68
69interface LoginResult {
70 accessToken: string;
71 refreshToken: string;
72 sessionId: string;
73 userId: string;
74}
75
76async function login(email: string, password: string = PASSWORD): Promise<LoginResult> {
77 const res = await apiFetch("/auth/login", {
78 method: "POST",
79 body: JSON.stringify({ email, password })
80 });
81 if (res.status !== 200) throw new Error(`login failed: ${res.status} ${await res.text()}`);
82 const b = (await res.json()) as LoginResult & { session: { id: string }; user: { id: string } };
83 return { accessToken: b.accessToken, refreshToken: b.refreshToken, sessionId: b.session.id, userId: b.user.id };
84}
85
86async function registerAndLogin(email: string): Promise<LoginResult> {
87 const reg = await apiFetch("/auth/register", {
88 method: "POST",
89 body: JSON.stringify({ name: "Sec Auth", email, password: PASSWORD })
90 });
91 if (reg.status !== 201) throw new Error(`register failed: ${reg.status} ${await reg.text()}`);
92 return login(email);
93}
94
95interface RefreshResult {
96 status: number;
97 body: {
98 refreshToken?: string;
99 accessToken?: string;
100 error?: { code?: string; details?: { reason?: string }[] };
101 };
102}
103async function refresh(token: string): Promise<RefreshResult> {
104 const res = await apiFetch("/auth/refresh", { method: "POST", body: JSON.stringify({ refreshToken: token }) });
105 let body: RefreshResult["body"] = {};
106 try {
107 body = (await res.json()) as RefreshResult["body"];
108 } catch {
109
110 }
111 return { status: res.status, body };
112}
113
114interface CookieAttrs {
115 name: string;
116 value: string;
117 path?: string;
118 httpOnly?: boolean;
119 sameSite?: string;
120 secure?: boolean;
121 maxAge?: number;
122}
123function parseSetCookie(header: string | null): CookieAttrs[] {
124 if (!header) return [];
125 return header
126 .split(/,(?=[^;]+\=)/)
127 .map((part) => {
128 const [pair, ...attrs] = part.split(";").map((s) => s.trim());
129 if (pair === undefined) return null;
130 const eq = pair.indexOf("=");
131 const c: CookieAttrs = { name: pair.slice(0, eq).trim(), value: pair.slice(eq + 1).trim() };
132 for (const a of attrs) {
133 const k = a.split("=")[0]?.trim().toLowerCase();
134 const v = a.includes("=") ? a.slice(a.indexOf("=") + 1).trim() : undefined;
135 if (k === "path") c.path = v;
136 else if (k === "httponly") c.httpOnly = true;
137 else if (k === "samesite") c.sameSite = v?.toUpperCase();
138 else if (k === "secure") c.secure = true;
139 else if (k === "max-age") c.maxAge = Number(v);
140 }
141 return c;
142 })
143 .filter((c): c is CookieAttrs => c !== null);
144}
145function refreshCookieOf(res: Response): CookieAttrs[] {
146 return parseSetCookie(res.headers.get("set-cookie")).filter((c) => c.name === REFRESH_COOKIE);
147}
148
149
150
151
152describe("auth-paths / token handling (alg pinning, minimal claims)", () => {
153 it("forgery construction: alg:none + wrong-alg tokens are built (no app-code import)", () => {
154 const now = Math.floor(Date.now() / 1000);
155 const payload = b64url(JSON.stringify({ sub: "attacker", iat: now, exp: now + 3600 }));
156 const none = `${b64url(JSON.stringify({ alg: "none", typ: "JWT" }))}.${payload}.`;
157 expect(none).toMatch(/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.$/);
158 const hs512 = `${b64url(JSON.stringify({ alg: "HS512", typ: "JWT" }))}.${payload}.${b64url("x")}`;
159 expect(hs512.split(".")).toHaveLength(3);
160 });
161
162 itLive(
163 "forged alg:none / alg:HS512 / alg:RS256 tokens are rejected 401 (alg pinned to HS256)",
164 async () => {
165 const { accessToken, userId } = await registerAndLogin(newEmail());
166 const control = await apiFetch("/auth/me", { headers: { authorization: `Bearer ${accessToken}` } });
167 expect(control.status, "control: a real token authenticates").toBe(200);
168
169 const now = Math.floor(Date.now() / 1000);
170 const payload = b64url(JSON.stringify({ sub: userId, iat: now, exp: now + 3600, sid: "fixated" }));
171 const forgeries: [string, string][] = [
172 ["none", ""],
173 ["HS512", b64url("forged")],
174 ["RS256", b64url("forged")]
175 ];
176 for (const [alg, sig] of forgeries) {
177 const token = `${b64url(JSON.stringify({ alg, typ: "JWT" }))}.${payload}.${sig}`;
178 const res = await apiFetch("/auth/me", { headers: { authorization: `Bearer ${token}` } });
179 expect(res.status, `alg=${alg} must be rejected 401`).toBe(401);
180 const body = (await res.json()) as { error?: { code?: string } };
181
182
183 expect(["UNAUTHORIZED", "INVALID_TOKEN"], `alg=${alg} canonical code`).toContain(body.error?.code);
184 }
185 },
186 { timeout: 90_000 }
187 );
188
189 itLive(
190 "a real access token carries the minimal claim set (sub/workspaces/jti/sid/iat/exp only)",
191 async () => {
192 const { accessToken } = await registerAndLogin(newEmail());
193 const claims = decodePayload(accessToken);
194 const allowed = new Set(["sub", "workspaces", "jti", "sid", "iat", "exp"]);
195 for (const key of Object.keys(claims)) {
196 expect(allowed.has(key), `unexpected claim "${key}" on the access token`).toBe(true);
197 }
198 expect(typeof claims.sub).toBe("string");
199 const ttl = (claims.exp as number) - (claims.iat as number);
200 expect(ttl).toBeGreaterThan(800);
201 expect(ttl).toBeLessThanOrEqual(905);
202 },
203 { timeout: 90_000 }
204 );
205});
206
207
208
209
210describe("auth-paths / refresh reuse (rotation + family revocation)", () => {
211 itLive(
212 "reuse of a rotated token whose successor is dead revokes the WHOLE family (all sessions 401)",
213 async () => {
214 const email = newEmail();
215 const s1 = await registerAndLogin(email);
216 const s2 = await login(email);
217
218
219 const r1 = await refresh(s1.refreshToken);
220 expect(r1.status).toBe(200);
221 const t1 = r1.body.refreshToken!;
222 const t1acc = r1.body.accessToken!;
223
224
225
226 const del = await apiFetch(`/sessions/${s1.sessionId}`, {
227 method: "DELETE",
228 headers: { authorization: `Bearer ${t1acc}` }
229 });
230 expect([200, 204], "revoke session A").toContain(del.status);
231
232
233
234
235 const reuse = await refresh(s1.refreshToken);
236 expect(reuse.status).toBe(401);
237 expect(reuse.body.error?.details?.[0]?.reason, "reuse attack reason").toBe("refresh_token_reused");
238
239
240
241 const s2dead = await refresh(s2.refreshToken);
242 expect(s2dead.status, "session B must be dead after the family kill").toBe(401);
243 expect(s2dead.body.error?.details?.[0]?.reason).toBe("refresh_token_reused");
244 },
245 { timeout: 120_000 }
246 );
247
248 itLive(
249 "reuse while the successor is still LIVE is a benign stale-retry 401 (no session loss)",
250 async () => {
251 const s = await registerAndLogin(newEmail());
252 const r = await refresh(s.refreshToken);
253 expect(r.status).toBe(200);
254 const stale = await refresh(s.refreshToken);
255 expect(stale.status).toBe(401);
256 expect(stale.body.error?.details?.[0]?.reason, "stale-retry reason").toBe("refresh_token_stale");
257 const live = await refresh(r.body.refreshToken!);
258 expect(live.status, "the live successor must still authenticate").toBe(200);
259 },
260 { timeout: 90_000 }
261 );
262});
263
264
265
266
267describe("auth-paths / session fixation", () => {
268 itLive(
269 "login mints a fresh session id + fresh cookie every time; no pre-login session cookie to fixate",
270 async () => {
271
272
273 const loginPage = await webFetch("/login");
274 expect([200, 307]).toContain(loginPage.status);
275 expect(
276 parseSetCookie(loginPage.headers.get("set-cookie")).filter((c) => c.name === REFRESH_COOKIE),
277 "no pre-login refresh cookie"
278 ).toHaveLength(0);
279
280
281 const email = newEmail();
282 await apiFetch("/auth/register", {
283 method: "POST",
284 body: JSON.stringify({ name: "Sec Auth", email, password: PASSWORD })
285 });
286 const l1res = await apiFetch("/auth/login", {
287 method: "POST",
288 body: JSON.stringify({ email, password: PASSWORD })
289 });
290 const c1 = refreshCookieOf(l1res);
291 expect(c1).toHaveLength(1);
292 const b1 = (await l1res.json()) as { accessToken: string; session: { id: string } };
293 const sid1 = decodePayload(b1.accessToken).sid as string;
294 expect(sid1).toBeTruthy();
295
296
297
298
299 const l2res = await apiFetch("/auth/login", {
300 method: "POST",
301 body: JSON.stringify({ email, password: PASSWORD })
302 });
303 const c2 = refreshCookieOf(l2res);
304 expect(c2).toHaveLength(1);
305 const b2 = (await l2res.json()) as { accessToken: string; session: { id: string } };
306 const sid2 = decodePayload(b2.accessToken).sid as string;
307 expect(c1[0]!.value, "fresh cookie each login").not.toBe(c2[0]!.value);
308 expect(sid1, "fresh sid each login").not.toBe(sid2);
309 expect(b1.session.id, "fresh session id each login").not.toBe(b2.session.id);
310 },
311 { timeout: 90_000 }
312 );
313});
314
315
316
317
318describe("auth-paths / cookie flags (Secure/HttpOnly/SameSite/Path — single source)", () => {
319 const isProd = process.env.NODE_ENV === "production";
320
321 function assertFlags(c: CookieAttrs, label: string, cleared: boolean): void {
322 expect(c.path, `${label}: Path=/`).toBe("/");
323 expect(c.httpOnly, `${label}: HttpOnly`).toBe(true);
324 expect(c.sameSite, `${label}: SameSite=Lax`).toBe("LAX");
325 if (cleared) {
326 expect(c.value, `${label}: cleared value`).toBe("");
327 expect(c.maxAge, `${label}: Max-Age=0`).toBe(0);
328 } else {
329 expect((c.maxAge ?? 0) > 0, `${label}: Max-Age>0`).toBe(true);
330 }
331
332 expect(c.secure ?? false, `${label}: Secure (NODE_ENV=${process.env.NODE_ENV})`).toBe(isProd);
333 }
334
335 itLive("login Set-Cookie carries Path=/; HttpOnly; SameSite=Lax; Max-Age>0", async () => {
336 const email = newEmail();
337 await apiFetch("/auth/register", {
338 method: "POST",
339 body: JSON.stringify({ name: "Sec Auth", email, password: PASSWORD })
340 });
341 const l = await apiFetch("/auth/login", {
342 method: "POST",
343 body: JSON.stringify({ email, password: PASSWORD })
344 });
345 const c = refreshCookieOf(l);
346 expect(c).toHaveLength(1);
347 assertFlags(c[0]!, "login", false);
348 }, { timeout: 90_000 });
349
350 itLive("rotation (refresh) Set-Cookie carries the same flags", async () => {
351 const s = await registerAndLogin(newEmail());
352 const res = await apiFetch("/auth/refresh", {
353 method: "POST",
354 body: JSON.stringify({ refreshToken: s.refreshToken })
355 });
356 const c = refreshCookieOf(res);
357 expect(c).toHaveLength(1);
358 assertFlags(c[0]!, "rotation", false);
359 }, { timeout: 90_000 });
360
361 itLive("clear Set-Cookie (unknown token) carries the flags with Max-Age=0", async () => {
362 const res = await apiFetch("/auth/refresh", {
363 method: "POST",
364 body: JSON.stringify({ refreshToken: "rft_deadbeef000000000000000000000000" })
365 });
366 expect(res.status).toBe(401);
367 const c = refreshCookieOf(res);
368 expect(c).toHaveLength(1);
369 assertFlags(c[0]!, "clear", true);
370 }, { timeout: 90_000 });
371
372 it("the cookie flags come from a single source (common/cookies.ts)", () => {
373
374
375
376 const sites = execSync(
377 `grep -rn "factory_refresh" ${JSON.stringify(REPO_ROOT + "/apps/api/src")} --include=*.ts | grep -iE "Path=/|Set-Cookie" | grep -v "constants.ts" || true`,
378 { encoding: "utf8" }
379 ).trim();
380
381 expect(sites, `unexpected inline cookie-flag sites: ${sites}`).not.toMatch(/auth\.service/);
382 const cookies = readFileSync(REPO_ROOT + "/apps/api/src/common/cookies.ts", "utf8");
383 expect(cookies).toMatch(/Path=\
384 expect(cookies).toMatch(/HttpOnly/);
385 expect(cookies).toMatch(/SameSite=Lax/);
386 expect(cookies).toMatch(/Secure/);
387 });
388});
389
390
391
392
393describe("auth-paths / CSRF route-walk (100% of state-changing web routes → Bearer client)", () => {
394 it("the web HTTP layer ALWAYS attaches the Bearer token (noAuth only on the cookie-auth refresh)", () => {
395 const apiLib = readFileSync(REPO_ROOT + "/apps/web/src/lib/api.ts", "utf8");
396
397 expect(apiLib).toMatch(/if \(!init\.noAuth\)/);
398 expect(apiLib).toMatch(/headers\.Authorization\s*=\s*`Bearer \$\{token\}`/);
399
400
401 const cookieFetches = apiLib.match(/fetch\(`\$\{API_BASE\}\/([^`]+)`,\s*\{[^}]*?credentials:\s*"include"/gs) ?? [];
402 expect(cookieFetches.length, "a /auth/refresh cookie fetch exists").toBeGreaterThanOrEqual(1);
403 for (const call of cookieFetches) {
404 expect(call, "the only cookie fetch is /auth/refresh").toMatch(/\/auth\/refresh/);
405 }
406 });
407
408 it("no feature code bypasses the Bearer client with a cookie fetch (the CSRF surface is empty)", () => {
409 // Raw `credentials: "include"` fetches OUTSIDE lib/api.ts: the Bearer
410 // client is the only path that attaches the access token, so any cookie
411 // fetch in feature code is either (a) an AI call that ALSO sends the
412 // bearer (ai.ts / ai-database.ts — documented) or (b) the auth-store
413 // bootstrap (use-auth.ts — the /auth/refresh call). Anything else is a
414 // CSRF surface and fails the walk.
415 const raw = execSync(
416 `grep -rn 'credentials: "include"' ${JSON.stringify(REPO_ROOT + "/apps/web/src")} --include=*.ts --include=*.tsx | grep -v 'lib/api.ts' || true`,
417 { encoding: "utf8" }
418 ).trim();
419 const allowed = /ai\.(ts|tsx)|ai-database\.ts|auth-store\.ts|use-auth\.ts/;
420 for (const line of raw.split("\n").filter(Boolean)) {
421 const file = line.split(":")[0] ?? "";
422 expect(file, `cookie fetch outside the Bearer client (CSRF surface): ${line}`).toMatch(allowed);
423 }
424 console.log("[csrf-walk] raw credential-include sites outside api.ts:", raw.split("\n").filter(Boolean).length);
425 });
426
427 itLive(
428 "a state-changing route rejects a cookie-only request (401 without the Bearer token)",
429 async () => {
430 const s = await registerAndLogin(newEmail());
431 const res = await apiFetch("/workspaces", {
432 method: "POST",
433 headers: { cookie: `${REFRESH_COOKIE}=${s.refreshToken}` },
434 body: JSON.stringify({ name: "CSRF Attempt" })
435 });
436 expect(res.status, "the refresh cookie alone must not authorize a mutation").toBe(401);
437 },
438 { timeout: 90_000 }
439 );
440
441 itLive(
442 "/auth/refresh (the only cookie-auth route) rejects a request with neither cookie nor token",
443 async () => {
444 const res = await apiFetch("/auth/refresh", { method: "POST", body: JSON.stringify({}) });
445 expect(res.status).toBe(401);
446 },
447 { timeout: 90_000 }
448 );
449
450 it("documents the CSRF exemptions (the walk's exempt set)", () => {
451 const exemptions = [
452 { route: "POST /auth/refresh", model: "cookie + SameSite=Lax + HttpOnly — the ONLY cookie-auth route" },
453 { route: "OAuth2 /authorize + /token, SSO /callback", model: "state/nonce (PKCE) — not cookie-authenticated mutations" },
454 { route: "POST /webhooks/incoming/:id", model: "HMAC signature (X-Factory-Signature) — exempt by credential type" },
455 { route: "POST /forms/:slug/submit (public)", model: "unauthenticated; content-type guard + honeypot + per-IP rate limit" },
456 { route: "API-key-gated routes", model: "exempt by credential type (fk_ Bearer key, never a cookie)" }
457 ];
458 expect(exemptions.length).toBeGreaterThanOrEqual(4);
459 for (const e of exemptions) expect(e.model.length).toBeGreaterThan(5);
460 });
461});
462