Brings the working codebase (Next.js app, auth system, Stripe billing, Docker/deploy config, tests, docs) into version control on top of the placeholder initial commit, and adds account self-deletion (Danger Zone in Settings, password + typed-email confirmation, cascading DB cleanup, Stripe cancellation) per GDPR right-to-erasure. Excludes local build caches, node_modules, and internal agent scratch files; .gitignore hardened to keep those out going forward. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
132 lines
5.5 KiB
TypeScript
132 lines
5.5 KiB
TypeScript
/**
|
|
* Cookie Flags Suite
|
|
*
|
|
* Every auth cookie must be hardened the same way: httpOnly, a deliberate
|
|
* SameSite policy, Secure whenever the app serves TLS, path "/", and a sane
|
|
* lifetime. All writers (session, guest, OAuth handshake, CSRF) go through the
|
|
* single `cookieSecurityOptions` builder, so one test of the builder plus the
|
|
* public option shapes covers every cookie the auth system can write. The
|
|
* CSRF cookie is the sole deliberate exception: it overrides httpOnly to
|
|
* false so client JS can read it, everything else stays shared.
|
|
* Pure logic only — no server, no database.
|
|
*
|
|
* Run standalone: npx tsx tests/e2e/cookie_flags.test.ts
|
|
*/
|
|
|
|
import { describe, test, expect, runAllTests } from "./runner";
|
|
import { cookieSecurityOptions } from "../../src/lib/auth/config";
|
|
import { sessionCookieOptions } from "../../src/lib/auth/session";
|
|
import { applyGuestCookie, GUEST_COOKIE } from "../../src/lib/auth/guest";
|
|
import { csrfCookieOptions } from "../../src/lib/auth/csrf";
|
|
import { NextResponse } from "next/server";
|
|
|
|
/** Must stay in lockstep with the writers: 10 min for the OAuth handshake, 1y for guests. */
|
|
const HANDSHAKE_MAX_AGE_SECONDS = 600;
|
|
const GUEST_MAX_AGE_SECONDS = 60 * 60 * 24 * 365;
|
|
const CSRF_MAX_AGE_SECONDS = 86400;
|
|
|
|
describe("Cookie flags — central builder", () => {
|
|
test("always pins httpOnly, path / and a deliberate SameSite", () => {
|
|
const base = cookieSecurityOptions();
|
|
expect(base.httpOnly).toBe(true);
|
|
expect(base.path).toBe("/");
|
|
expect(base.sameSite).toBe("lax");
|
|
});
|
|
|
|
test("the flags survive an override — extras cannot drop the defaults", () => {
|
|
const withMaxAge = cookieSecurityOptions({ maxAge: HANDSHAKE_MAX_AGE_SECONDS });
|
|
expect(withMaxAge.httpOnly).toBe(true);
|
|
expect(withMaxAge.path).toBe("/");
|
|
expect(withMaxAge.sameSite).toBe("lax");
|
|
expect(withMaxAge.maxAge).toBe(HANDSHAKE_MAX_AGE_SECONDS);
|
|
});
|
|
|
|
test("secure follows the environment — production flips it on", () => {
|
|
const options = cookieSecurityOptions();
|
|
// `isProduction` is read from NODE_ENV at module load; Secure must be on
|
|
// exactly when the app runs in production (TLS) and off in local dev.
|
|
expect(options.secure).toBe(process.env.NODE_ENV === "production");
|
|
});
|
|
|
|
test("a sameSite override is honoured — lax stays the default", () => {
|
|
expect(cookieSecurityOptions().sameSite).toBe("lax");
|
|
expect(cookieSecurityOptions({ sameSite: "strict" }).sameSite).toBe("strict");
|
|
expect(cookieSecurityOptions({ sameSite: "none" }).sameSite).toBe("none");
|
|
});
|
|
});
|
|
|
|
describe("Cookie flags — session cookie", () => {
|
|
test("sessionCookieOptions carries the hardening set plus the expiry", () => {
|
|
const expiresAt = new Date(Date.now() + 60 * 60 * 1000);
|
|
const options = sessionCookieOptions(expiresAt);
|
|
expect(options.httpOnly).toBe(true);
|
|
expect(options.sameSite).toBe("lax");
|
|
expect(options.path).toBe("/");
|
|
expect(options.secure).toBe(process.env.NODE_ENV === "production");
|
|
expect(options.expires).toBe(expiresAt);
|
|
});
|
|
});
|
|
|
|
describe("Cookie flags — OAuth handshake", () => {
|
|
test("the oauth options include the maxAge when one is passed", () => {
|
|
const options = cookieSecurityOptions({ maxAge: HANDSHAKE_MAX_AGE_SECONDS });
|
|
expect(options.maxAge).toBe(HANDSHAKE_MAX_AGE_SECONDS);
|
|
expect(options.httpOnly).toBe(true);
|
|
expect(options.path).toBe("/");
|
|
expect(options.sameSite).toBe("lax");
|
|
});
|
|
});
|
|
|
|
describe("Cookie flags — guest cookie", () => {
|
|
test("applyGuestCookie writes a fully hardened one-year cookie", () => {
|
|
const response = NextResponse.json({});
|
|
applyGuestCookie(response, { bucket: "guest_test", cookieValue: "guest_test" });
|
|
|
|
const cookie = response.cookies.get(GUEST_COOKIE);
|
|
expect(cookie?.httpOnly).toBe(true);
|
|
expect(cookie?.sameSite).toBe("lax");
|
|
expect(cookie?.path).toBe("/");
|
|
expect(cookie?.maxAge).toBe(GUEST_MAX_AGE_SECONDS);
|
|
expect(cookie?.secure).toBe(process.env.NODE_ENV === "production");
|
|
});
|
|
|
|
test("applyGuestCookie leaves the response untouched when there is nothing to persist", () => {
|
|
const response = NextResponse.json({});
|
|
const out = applyGuestCookie(response, { bucket: "guest_test", cookieValue: null });
|
|
expect(out.cookies.get(GUEST_COOKIE)).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("Cookie flags — CSRF double-submit cookie", () => {
|
|
test("csrfCookieOptions goes through the shared builder, with httpOnly deliberately off", () => {
|
|
const options = csrfCookieOptions();
|
|
// httpOnly must be false here — client JS has to read this one to echo it
|
|
// as a header — everything else still comes from cookieSecurityOptions.
|
|
expect(options.httpOnly).toBe(false);
|
|
expect(options.sameSite).toBe("lax");
|
|
expect(options.path).toBe("/");
|
|
expect(options.maxAge).toBe(CSRF_MAX_AGE_SECONDS);
|
|
expect(options.secure).toBe(process.env.NODE_ENV === "production");
|
|
});
|
|
|
|
test("only httpOnly diverges from the shared builder's defaults", () => {
|
|
const shared = cookieSecurityOptions({ maxAge: CSRF_MAX_AGE_SECONDS });
|
|
const csrf = csrfCookieOptions();
|
|
expect(csrf.sameSite).toBe(shared.sameSite);
|
|
expect(csrf.path).toBe(shared.path);
|
|
expect(csrf.secure).toBe(shared.secure);
|
|
expect(csrf.maxAge).toBe(shared.maxAge);
|
|
expect(csrf.httpOnly).not.toBe(shared.httpOnly);
|
|
});
|
|
});
|
|
|
|
async function main() {
|
|
const passed = await runAllTests();
|
|
if (!passed) process.exit(1);
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error("Cookie flags suite crashed:", error);
|
|
process.exit(1);
|
|
});
|