/** * TASK-0134 — auth-path review (ASVS L2; spec: "Auth path review — each item * → written finding + passing test"). * * Every finding group gets ≥1 passing test (spec acceptance criteria): * * 1. **Token handling / alg pinning** — the API's JWT is HS256-only. * `verifyToken` rejects any other `alg` (incl. the classic `alg:none` * forgery) BEFORE signature verification. Proved black-box: a crafted * `alg:none` token (generic base64url — NO app-code import, per the * DoD "no app-code imports from tests/") is rejected 401 on a * bearer-gated route; a real access token carries the minimal claim set. * 2. **Refresh reuse** — rotation + reuse detection. Reusing an * already-rotated token whose successor is dead revokes the whole family * (every live session of the user 401s). Reuse while the successor is * still live is a benign stale-retry 401 (no session loss). Both live. * 3. **Session fixation** — login mints a FRESH session id + FRESH refresh * cookie every time; there is no pre-login session cookie to fixate. * 4. **Cookie flags** — every Set-Cookie in the auth flow carries * `Path=/; HttpOnly; SameSite=Lax` (+ `Secure` in production only) + * `Max-Age`, from the single source `apps/api/src/common/cookies.ts`. * 5. **CSRF route-walk** — the web app authenticates state-changing API * calls with an in-memory Bearer access token (never a cookie); the ONLY * cookie-authenticated route is `POST /auth/refresh` (the 401 retry). * The walk (static source scan + live spot-checks) proves 100% of * state-changing web routes go through the Bearer client. * * The spec assumed a Phase-1/4 double-submit CSRF token; the actual * architecture is Bearer-in-header + SameSite=Lax on the single cookie-auth * route. This is a recorded architecture deviation (audit-report.md finding * F-107, AGENTS rule 29), NOT a gap: a cross-origin page cannot attach the * in-memory Bearer (JS-only, not a cookie) and the SameSite=Lax cookie is * not sent on cross-site POSTs. * * Live tests skip (not fail) when the API is down; SEC_REQUIRE_LIVE=1 (CI) * makes a down API fail the suite. */ import { describe, expect, it } from "vitest"; import { readFileSync } from "node:fs"; import { execSync } from "node:child_process"; import { fileURLToPath } from "node:url"; import { checkLiveApp, apiFetch, webFetch } from "./helpers/app.js"; const REPO_ROOT = fileURLToPath(new URL("../..", import.meta.url)); const PASSWORD = "Str0ngPass!x9"; const REFRESH_COOKIE = "factory_refresh"; // Top-level liveness probe (vitest 2.x passes no test context, so dynamic // skip is `it.skipIf(!live)` — probed once here). const live = await checkLiveApp(); const itLive = it.skipIf(!live); // --------------------------------------------------------------------------- // Helpers (no app-code imports — black-box over HTTP) // --------------------------------------------------------------------------- const b64url = (s: string): string => Buffer.from(s).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); function decodePayload(token: string): Record { const [, bodyB64] = token.split("."); const pad = (s: string) => s.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(s.length / 4) * 4, "="); return JSON.parse(Buffer.from(pad(bodyB64 ?? ""), "base64").toString("utf8")) as Record; } function newEmail(): string { return `secauth_${Date.now()}_${Math.floor(Math.random() * 1e6)}@example.com`; } interface LoginResult { accessToken: string; refreshToken: string; sessionId: string; userId: string; } async function login(email: string, password: string = PASSWORD): Promise { const res = await apiFetch("/auth/login", { method: "POST", body: JSON.stringify({ email, password }) }); if (res.status !== 200) throw new Error(`login failed: ${res.status} ${await res.text()}`); const b = (await res.json()) as LoginResult & { session: { id: string }; user: { id: string } }; return { accessToken: b.accessToken, refreshToken: b.refreshToken, sessionId: b.session.id, userId: b.user.id }; } async function registerAndLogin(email: string): Promise { const reg = await apiFetch("/auth/register", { method: "POST", body: JSON.stringify({ name: "Sec Auth", email, password: PASSWORD }) }); if (reg.status !== 201) throw new Error(`register failed: ${reg.status} ${await reg.text()}`); return login(email); } interface RefreshResult { status: number; body: { refreshToken?: string; accessToken?: string; error?: { code?: string; details?: { reason?: string }[] }; }; } async function refresh(token: string): Promise { const res = await apiFetch("/auth/refresh", { method: "POST", body: JSON.stringify({ refreshToken: token }) }); let body: RefreshResult["body"] = {}; try { body = (await res.json()) as RefreshResult["body"]; } catch { /* non-json */ } return { status: res.status, body }; } interface CookieAttrs { name: string; value: string; path?: string; httpOnly?: boolean; sameSite?: string; secure?: boolean; maxAge?: number; } function parseSetCookie(header: string | null): CookieAttrs[] { if (!header) return []; return header .split(/,(?=[^;]+\=)/) .map((part) => { const [pair, ...attrs] = part.split(";").map((s) => s.trim()); if (pair === undefined) return null; const eq = pair.indexOf("="); const c: CookieAttrs = { name: pair.slice(0, eq).trim(), value: pair.slice(eq + 1).trim() }; for (const a of attrs) { const k = a.split("=")[0]?.trim().toLowerCase(); const v = a.includes("=") ? a.slice(a.indexOf("=") + 1).trim() : undefined; if (k === "path") c.path = v; else if (k === "httponly") c.httpOnly = true; else if (k === "samesite") c.sameSite = v?.toUpperCase(); else if (k === "secure") c.secure = true; else if (k === "max-age") c.maxAge = Number(v); } return c; }) .filter((c): c is CookieAttrs => c !== null); } function refreshCookieOf(res: Response): CookieAttrs[] { return parseSetCookie(res.headers.get("set-cookie")).filter((c) => c.name === REFRESH_COOKIE); } // --------------------------------------------------------------------------- // 1. Token handling: alg pinning + minimal claims // --------------------------------------------------------------------------- describe("auth-paths / token handling (alg pinning, minimal claims)", () => { it("forgery construction: alg:none + wrong-alg tokens are built (no app-code import)", () => { const now = Math.floor(Date.now() / 1000); const payload = b64url(JSON.stringify({ sub: "attacker", iat: now, exp: now + 3600 })); const none = `${b64url(JSON.stringify({ alg: "none", typ: "JWT" }))}.${payload}.`; expect(none).toMatch(/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.$/); const hs512 = `${b64url(JSON.stringify({ alg: "HS512", typ: "JWT" }))}.${payload}.${b64url("x")}`; expect(hs512.split(".")).toHaveLength(3); }); itLive( "forged alg:none / alg:HS512 / alg:RS256 tokens are rejected 401 (alg pinned to HS256)", async () => { const { accessToken, userId } = await registerAndLogin(newEmail()); const control = await apiFetch("/auth/me", { headers: { authorization: `Bearer ${accessToken}` } }); expect(control.status, "control: a real token authenticates").toBe(200); const now = Math.floor(Date.now() / 1000); const payload = b64url(JSON.stringify({ sub: userId, iat: now, exp: now + 3600, sid: "fixated" })); const forgeries: [string, string][] = [ ["none", ""], ["HS512", b64url("forged")], ["RS256", b64url("forged")] ]; for (const [alg, sig] of forgeries) { const token = `${b64url(JSON.stringify({ alg, typ: "JWT" }))}.${payload}.${sig}`; const res = await apiFetch("/auth/me", { headers: { authorization: `Bearer ${token}` } }); expect(res.status, `alg=${alg} must be rejected 401`).toBe(401); const body = (await res.json()) as { error?: { code?: string } }; // The guard maps a TokenError (unsupported alg) to 401 INVALID_TOKEN // (docs §9.1: UNAUTHORIZED | INVALID_TOKEN | TOKEN_EXPIRED). expect(["UNAUTHORIZED", "INVALID_TOKEN"], `alg=${alg} canonical code`).toContain(body.error?.code); } }, { timeout: 90_000 } ); itLive( "a real access token carries the minimal claim set (sub/workspaces/jti/sid/iat/exp only)", async () => { const { accessToken } = await registerAndLogin(newEmail()); const claims = decodePayload(accessToken); const allowed = new Set(["sub", "workspaces", "jti", "sid", "iat", "exp"]); for (const key of Object.keys(claims)) { expect(allowed.has(key), `unexpected claim "${key}" on the access token`).toBe(true); } expect(typeof claims.sub).toBe("string"); const ttl = (claims.exp as number) - (claims.iat as number); expect(ttl).toBeGreaterThan(800); expect(ttl).toBeLessThanOrEqual(905); // 15-minute access TTL (docs §1.1) }, { timeout: 90_000 } ); }); // --------------------------------------------------------------------------- // 2. Refresh reuse → family revocation // --------------------------------------------------------------------------- describe("auth-paths / refresh reuse (rotation + family revocation)", () => { itLive( "reuse of a rotated token whose successor is dead revokes the WHOLE family (all sessions 401)", async () => { const email = newEmail(); const s1 = await registerAndLogin(email); // session A (token T0) const s2 = await login(email); // session B (token T0b) — same user/family // Rotate session A: T0 → T1 (T0 revoked, successor T1 live). const r1 = await refresh(s1.refreshToken); expect(r1.status).toBe(200); const t1 = r1.body.refreshToken!; const t1acc = r1.body.accessToken!; // Revoke session A entirely (its tokens T1 die, session row REVOKED): // DELETE /sessions/:id with session A's current access token. const del = await apiFetch(`/sessions/${s1.sessionId}`, { method: "DELETE", headers: { authorization: `Bearer ${t1acc}` } }); expect([200, 204], "revoke session A").toContain(del.status); // REPLAY T0 (the original session-A login token): it is revoked and its // successor T1 is now DEAD → the engine classifies this as a REUSE // ATTACK and revokes EVERY live token + session of the user. const reuse = await refresh(s1.refreshToken); expect(reuse.status).toBe(401); expect(reuse.body.error?.details?.[0]?.reason, "reuse attack reason").toBe("refresh_token_reused"); // The WHOLE family is dead: session B's token (never touched) can no // longer refresh — this is the family, not the single token, that died. const s2dead = await refresh(s2.refreshToken); expect(s2dead.status, "session B must be dead after the family kill").toBe(401); expect(s2dead.body.error?.details?.[0]?.reason).toBe("refresh_token_reused"); }, { timeout: 120_000 } ); itLive( "reuse while the successor is still LIVE is a benign stale-retry 401 (no session loss)", async () => { const s = await registerAndLogin(newEmail()); const r = await refresh(s.refreshToken); // T0 → T1 (successor live) expect(r.status).toBe(200); const stale = await refresh(s.refreshToken); // replay T0, successor T1 live expect(stale.status).toBe(401); expect(stale.body.error?.details?.[0]?.reason, "stale-retry reason").toBe("refresh_token_stale"); const live = await refresh(r.body.refreshToken!); // T1 still works expect(live.status, "the live successor must still authenticate").toBe(200); }, { timeout: 90_000 } ); }); // --------------------------------------------------------------------------- // 3. Session fixation // --------------------------------------------------------------------------- describe("auth-paths / session fixation", () => { itLive( "login mints a fresh session id + fresh cookie every time; no pre-login session cookie to fixate", async () => { // (a) The pre-login page sets NO session cookie — there is no client // session before login, so a fixated id cannot survive to login. const loginPage = await webFetch("/login"); expect([200, 307]).toContain(loginPage.status); expect( parseSetCookie(loginPage.headers.get("set-cookie")).filter((c) => c.name === REFRESH_COOKIE), "no pre-login refresh cookie" ).toHaveLength(0); // (b) First login → cookie C1 + sid S1. const email = newEmail(); await apiFetch("/auth/register", { method: "POST", body: JSON.stringify({ name: "Sec Auth", email, password: PASSWORD }) }); const l1res = await apiFetch("/auth/login", { method: "POST", body: JSON.stringify({ email, password: PASSWORD }) }); const c1 = refreshCookieOf(l1res); expect(c1).toHaveLength(1); const b1 = (await l1res.json()) as { accessToken: string; session: { id: string } }; const sid1 = decodePayload(b1.accessToken).sid as string; expect(sid1).toBeTruthy(); // (c) Second login → cookie C2 ≠ C1 + sid S2 ≠ S1. A fixation attack // needs a pre-login id to survive into the session — impossible: every // login mints a fresh random session id + fresh random refresh token. const l2res = await apiFetch("/auth/login", { method: "POST", body: JSON.stringify({ email, password: PASSWORD }) }); const c2 = refreshCookieOf(l2res); expect(c2).toHaveLength(1); const b2 = (await l2res.json()) as { accessToken: string; session: { id: string } }; const sid2 = decodePayload(b2.accessToken).sid as string; expect(c1[0]!.value, "fresh cookie each login").not.toBe(c2[0]!.value); expect(sid1, "fresh sid each login").not.toBe(sid2); expect(b1.session.id, "fresh session id each login").not.toBe(b2.session.id); }, { timeout: 90_000 } ); }); // --------------------------------------------------------------------------- // 4. Cookie flags on EVERY Set-Cookie in the auth flow // --------------------------------------------------------------------------- describe("auth-paths / cookie flags (Secure/HttpOnly/SameSite/Path — single source)", () => { const isProd = process.env.NODE_ENV === "production"; function assertFlags(c: CookieAttrs, label: string, cleared: boolean): void { expect(c.path, `${label}: Path=/`).toBe("/"); expect(c.httpOnly, `${label}: HttpOnly`).toBe(true); expect(c.sameSite, `${label}: SameSite=Lax`).toBe("LAX"); if (cleared) { expect(c.value, `${label}: cleared value`).toBe(""); expect(c.maxAge, `${label}: Max-Age=0`).toBe(0); } else { expect((c.maxAge ?? 0) > 0, `${label}: Max-Age>0`).toBe(true); } // Secure is production-only (dev is plain HTTP on :4000). expect(c.secure ?? false, `${label}: Secure (NODE_ENV=${process.env.NODE_ENV})`).toBe(isProd); } itLive("login Set-Cookie carries Path=/; HttpOnly; SameSite=Lax; Max-Age>0", async () => { const email = newEmail(); await apiFetch("/auth/register", { method: "POST", body: JSON.stringify({ name: "Sec Auth", email, password: PASSWORD }) }); const l = await apiFetch("/auth/login", { method: "POST", body: JSON.stringify({ email, password: PASSWORD }) }); const c = refreshCookieOf(l); expect(c).toHaveLength(1); assertFlags(c[0]!, "login", false); }, { timeout: 90_000 }); itLive("rotation (refresh) Set-Cookie carries the same flags", async () => { const s = await registerAndLogin(newEmail()); const res = await apiFetch("/auth/refresh", { method: "POST", body: JSON.stringify({ refreshToken: s.refreshToken }) }); const c = refreshCookieOf(res); expect(c).toHaveLength(1); assertFlags(c[0]!, "rotation", false); }, { timeout: 90_000 }); itLive("clear Set-Cookie (unknown token) carries the flags with Max-Age=0", async () => { const res = await apiFetch("/auth/refresh", { method: "POST", body: JSON.stringify({ refreshToken: "rft_deadbeef000000000000000000000000" }) }); expect(res.status).toBe(401); const c = refreshCookieOf(res); expect(c).toHaveLength(1); assertFlags(c[0]!, "clear", true); }, { timeout: 90_000 }); it("the cookie flags come from a single source (common/cookies.ts)", () => { // The centralization this task added: every Set-Cookie is built by // buildRefreshCookieHeader — no other inline `factory_refresh=…; Path=/` // literal exists in the API (a second cookie cannot drift from the policy). const sites = execSync( `grep -rn "factory_refresh" ${JSON.stringify(REPO_ROOT + "/apps/api/src")} --include=*.ts | grep -iE "Path=/|Set-Cookie" | grep -v "constants.ts" || true`, { encoding: "utf8" } ).trim(); // The inline strings in auth.service are gone (they now call cookies.ts). expect(sites, `unexpected inline cookie-flag sites: ${sites}`).not.toMatch(/auth\.service/); const cookies = readFileSync(REPO_ROOT + "/apps/api/src/common/cookies.ts", "utf8"); expect(cookies).toMatch(/Path=\//); expect(cookies).toMatch(/HttpOnly/); expect(cookies).toMatch(/SameSite=Lax/); expect(cookies).toMatch(/Secure/); }); }); // --------------------------------------------------------------------------- // 5. CSRF route-walk // --------------------------------------------------------------------------- describe("auth-paths / CSRF route-walk (100% of state-changing web routes → Bearer client)", () => { it("the web HTTP layer ALWAYS attaches the Bearer token (noAuth only on the cookie-auth refresh)", () => { const apiLib = readFileSync(REPO_ROOT + "/apps/web/src/lib/api.ts", "utf8"); // doFetch attaches the bearer unless noAuth: expect(apiLib).toMatch(/if \(!init\.noAuth\)/); expect(apiLib).toMatch(/headers\.Authorization\s*=\s*`Bearer \$\{token\}`/); // The ONLY cookie-authenticated fetch is /auth/refresh (credentials // "include" + no bearer): const cookieFetches = apiLib.match(/fetch\(`\$\{API_BASE\}\/([^`]+)`,\s*\{[^}]*?credentials:\s*"include"/gs) ?? []; expect(cookieFetches.length, "a /auth/refresh cookie fetch exists").toBeGreaterThanOrEqual(1); for (const call of cookieFetches) { expect(call, "the only cookie fetch is /auth/refresh").toMatch(/\/auth\/refresh/); } }); it("no feature code bypasses the Bearer client with a cookie fetch (the CSRF surface is empty)", () => { // Raw `credentials: "include"` fetches OUTSIDE lib/api.ts: the Bearer // client is the only path that attaches the access token, so any cookie // fetch in feature code is either (a) an AI call that ALSO sends the // bearer (ai.ts / ai-database.ts — documented) or (b) the auth-store // bootstrap (use-auth.ts — the /auth/refresh call). Anything else is a // CSRF surface and fails the walk. const raw = execSync( `grep -rn 'credentials: "include"' ${JSON.stringify(REPO_ROOT + "/apps/web/src")} --include=*.ts --include=*.tsx | grep -v 'lib/api.ts' || true`, { encoding: "utf8" } ).trim(); const allowed = /ai\.(ts|tsx)|ai-database\.ts|auth-store\.ts|use-auth\.ts/; for (const line of raw.split("\n").filter(Boolean)) { const file = line.split(":")[0] ?? ""; expect(file, `cookie fetch outside the Bearer client (CSRF surface): ${line}`).toMatch(allowed); } console.log("[csrf-walk] raw credential-include sites outside api.ts:", raw.split("\n").filter(Boolean).length); }); itLive( "a state-changing route rejects a cookie-only request (401 without the Bearer token)", async () => { const s = await registerAndLogin(newEmail()); const res = await apiFetch("/workspaces", { method: "POST", headers: { cookie: `${REFRESH_COOKIE}=${s.refreshToken}` }, body: JSON.stringify({ name: "CSRF Attempt" }) }); expect(res.status, "the refresh cookie alone must not authorize a mutation").toBe(401); }, { timeout: 90_000 } ); itLive( "/auth/refresh (the only cookie-auth route) rejects a request with neither cookie nor token", async () => { const res = await apiFetch("/auth/refresh", { method: "POST", body: JSON.stringify({}) }); expect(res.status).toBe(401); }, { timeout: 90_000 } ); it("documents the CSRF exemptions (the walk's exempt set)", () => { const exemptions = [ { route: "POST /auth/refresh", model: "cookie + SameSite=Lax + HttpOnly — the ONLY cookie-auth route" }, { route: "OAuth2 /authorize + /token, SSO /callback", model: "state/nonce (PKCE) — not cookie-authenticated mutations" }, { route: "POST /webhooks/incoming/:id", model: "HMAC signature (X-Factory-Signature) — exempt by credential type" }, { route: "POST /forms/:slug/submit (public)", model: "unauthenticated; content-type guard + honeypot + per-IP rate limit" }, { route: "API-key-gated routes", model: "exempt by credential type (fk_ Bearer key, never a cookie)" } ]; expect(exemptions.length).toBeGreaterThanOrEqual(4); for (const e of exemptions) expect(e.model.length).toBeGreaterThan(5); }); });