1/** 2 * TASK-0134 — the single source of truth for refresh-cookie attributes 3 * (spec: "Cookie flag centralization — Secure/HttpOnly/SameSite/Path in one 4 * place (single source for the cookie-flags test)"). 5 * 6 * The API sets exactly ONE cookie — the rotating opaque refresh token 7 * (`factory_refresh`). Every `Set-Cookie` for it flows through 8 * {@link buildRefreshCookieHeader}, so the cookie-flags suite 9 * (tests/security/auth-paths.test.ts) asserts the flags in exactly one 10 * place, and a future second cookie cannot drift from the policy: 11 * 12 * - `Path=/` — the refresh cookie must be present on /auth/refresh. 13 * - `HttpOnly` — JS never reads it (the access token lives in 14 * memory, not a cookie). 15 * - `SameSite=Lax` — recorded decision (2026-10-03, TASK-0134): Lax for 16 * the app cookie. The ONLY cookie-authenticated 17 * route is `POST /auth/refresh` (the 401-retry); 18 * Lax allows it from a same-site top-level context 19 * while blocking cross-site form posts. `Strict` 20 * was evaluated and rejected: it breaks the 21 * deep-link → /login → app flow where the login 22 * navigation is a cross-site top-level GET that 23 * must NOT carry the (stale) cookie into a 24 * surprise refresh. A separate CSRF-token cookie 25 * is not needed because the web app authenticates 26 * with a Bearer access token (never a cookie) — 27 * recorded in audit-report.md (finding F-107). 28 * - `Secure` — in production only (dev is plain HTTP on :4000; 29 * Secure on plain HTTP would drop the cookie and 30 * break the dev loop). 31 * - `Max-Age` — from `REFRESH_TOKEN_TTL`; 0 clears. 32 */ 33import { REFRESH_COOKIE } from "../constants.js"; 34 35export interface CookieHeaderOptions { 36 /** `null` clears the cookie (empty value + Max-Age=0). */ 37 maxAgeSeconds: number; 38 /** Emit the `Secure` flag (production only). */ 39 secure: boolean; 40} 41 42/** 43 * Build the full `Set-Cookie` header value for the refresh cookie. The flag 44 * ORDER is part of the e2e contract (tests/e2e/global-setup.ts parses this 45 * exact shape) — do not reorder. 46 */ 47export function buildRefreshCookieHeader( 48 token: string | null, 49 opts: CookieHeaderOptions 50): string { 51 const value = token === null ? "" : encodeURIComponent(token); 52 const flags = ["Path=/", "HttpOnly", "SameSite=Lax"]; 53 if (opts.secure) flags.push("Secure"); 54 flags.push(`Max-Age=${token === null ? 0 : Math.max(0, Math.floor(opts.maxAgeSeconds))}`); 55 return `${REFRESH_COOKIE}=${value}; ${flags.join("; ")}`; 56} 57