#!/usr/bin/env node /** * Verification that every auth cookie the app issues carries the hardened * attributes: HttpOnly + SameSite=Lax (+ Secure in production). * * Runs under plain Node — no build step: * node scripts/verify_cookies.mjs * * Three layers: * 1. SOURCE assertion on the single source of truth — `cookieSecurityOptions()` * in src/lib/auth/config.ts must declare httpOnly / sameSite / secure / * path. Every cookie writer below is checked only for *delegating* to * this helper (or to sessionCookieOptions, which itself delegates to * it), not for re-declaring the attributes inline — the attributes only * live in one place, so re-checking them at each call site just goes * stale the next time a call site is refactored to delegate instead of * inlining. * 2. DELEGATION assertions — session.ts, guest.ts and the Google OAuth * handshake/callback routes must each go through the shared helper, and * no cookie writer in the auth stack may set attributes without it. * 3. SERIALIZATION assertion — feed the exact attribute values the helper * produces through Next's cookie serializer (NextResponse) and confirm * the resulting Set-Cookie string contains `HttpOnly` and `SameSite=Lax`. */ import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; const root = join(dirname(fileURLToPath(import.meta.url)), ".."); let failures = 0; let checks = 0; function check(name, condition, detail = "") { checks += 1; if (condition) { console.log(` PASS ${name}`); } else { failures += 1; console.error(` FAIL ${name}${detail ? ` — ${detail}` : ""}`); } } const read = (rel) => readFileSync(join(root, rel), "utf8"); // --------------------------------------------------------------------------- // 1. Source-level attribute assertion on the single shared helper // --------------------------------------------------------------------------- console.log("\n[1] Core helper — src/lib/auth/config.ts (cookieSecurityOptions)"); { const src = read("src/lib/auth/config.ts"); const fn = src.slice(src.indexOf("export function cookieSecurityOptions")); check("cookieSecurityOptions declares httpOnly: true", /\bhttpOnly:\s*true\b/.test(fn)); check("cookieSecurityOptions declares sameSite: \"lax\"", /sameSite:\s*"lax"/.test(fn)); check("cookieSecurityOptions declares secure: isProduction", /\bsecure:\s*isProduction\b/.test(fn)); check("cookieSecurityOptions declares path: \"/\"", /path:\s*"\/"/.test(fn)); } console.log("\n[2] Session cookie — src/lib/auth/session.ts (sessionCookieOptions)"); { const src = read("src/lib/auth/session.ts"); check("sessionCookieOptions delegates to cookieSecurityOptions", /export function sessionCookieOptions[\s\S]*?cookieSecurityOptions\(/.test(src)); check("session cookie is set through sessionCookieOptions (single source)", /cookieStore\.set\(SESSION_COOKIE,\s*token,\s*sessionCookieOptions\(expiresAt\)\)/.test(src)); check("no other raw session-cookie set without the helper", (src.match(/cookieStore\.set\(/g) || []).length === 1); } console.log("\n[3] Guest cookie — src/lib/auth/guest.ts (applyGuestCookie)"); { const src = read("src/lib/auth/guest.ts"); const block = src.slice(src.indexOf("response.cookies.set")); check("guest cookie is set via cookieSecurityOptions", /response\.cookies\.set\(\s*GUEST_COOKIE,\s*ctx\.cookieValue,\s*cookieSecurityOptions\(/.test(block)); } console.log("\n[4] OAuth handshake cookies — src/app/api/auth/google/route.ts"); { const src = read("src/app/api/auth/google/route.ts"); check("oauth state/verifier cookies are set via cookieSecurityOptions", /cookieSecurityOptions\(\{[^}]*\}\)/.test(src)); check("both handshake cookies reuse the same options object", (src.match(/response\.cookies\.set\((OAUTH_STATE_COOKIE|OAUTH_VERIFIER_COOKIE),\s*\w+,\s*cookieOptions\)/g) || []).length === 2); } console.log("\n[5] OAuth session cookie — src/app/api/auth/google/callback/route.ts"); { const src = read("src/app/api/auth/google/callback/route.ts"); // The callback sets the session cookie through the same shared helper, so the // attribute source is config.ts (asserted above). Confirm the delegation. check( "callback sets the session cookie via sessionCookieOptions", /response\.cookies\.set\(SESSION_COOKIE,\s*token,\s*sessionCookieOptions\(expiresAt\)\)/.test(src) ); } console.log("\n[6] No cookie writer in the auth stack bypasses the shared helper"); { // Any `.cookies.set(` in the auth stack must be immediately followed by a // call to cookieSecurityOptions(...) or sessionCookieOptions(...) as its // options argument — never an inline object literal. const files = [ "src/lib/auth/session.ts", "src/lib/auth/guest.ts", "src/app/api/auth/google/route.ts", "src/app/api/auth/google/callback/route.ts", ]; for (const rel of files) { const src = read(rel); const setCalls = src.match(/\.cookies\.set\([^;]*?\)(?=;|\n)/gs) || []; const bypassing = setCalls.filter((c) => !/(cookieSecurityOptions|sessionCookieOptions|cookieOptions)\s*\(/.test(c) && !c.includes("cookieOptions)")); check(`${rel}: every .cookies.set() options arg comes from the shared helper`, bypassing.length === 0, bypassing.join(" | ")); } } // --------------------------------------------------------------------------- // 3. Serialization assertion (NextResponse + the exact attribute values) // --------------------------------------------------------------------------- console.log("\n[7] Serialized Set-Cookie contains HttpOnly and SameSite=Lax"); { // Next ships its server entry as CJS; createRequire gives us the same // resolution plain `require('next/server')` uses. const { createRequire } = await import("node:module"); const require = createRequire(import.meta.url); const { NextResponse } = require("next/server"); const res = NextResponse.json({ ok: true }); res.cookies.set("sr_session", "test-token", { httpOnly: true, sameSite: "lax", secure: false, // dev — production flips via isProduction path: "/", expires: new Date(Date.now() + 3_600_000), }); const header = res.headers.get("set-cookie") || ""; check("Set-Cookie header present", header.length > 0); check("contains HttpOnly", /\bHttpOnly\b/i.test(header), header); check("contains SameSite (case-insensitive)", /SameSite=Lax/i.test(header), header); check("contains Path=/", /Path=\//i.test(header), header); check("session cookie is not visible to JS", !/\bHttpOnly=false\b/i.test(header)); } // --------------------------------------------------------------------------- console.log(`\n${checks} checks, ${failures} failure(s)`); if (failures > 0) { console.error("COOKIE VERIFICATION FAILED"); process.exit(1); } console.log("COOKIE VERIFICATION PASSED");