/** * User-Enumeration Hardening Suite * * The auth endpoints must not reveal whether an email address is registered. * Signup answers the same 200 `verification_sent` for every outcome, login * folds Google-only accounts into plain `invalid_credentials`, and the * revealing error codes (`email_taken`, `email_taken_google`, `use_google`) are * never emitted by any auth route. * * Pure decision logic plus static source assertions — no database required. */ import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { describe, test, expect } from "./runner"; import { DISCONTINUED_ENUMERATION_CODES, loginDecision, signupDecision, signupResponseBody, type SignupAccountState, } from "../../src/lib/auth/neutral"; import { isAuthErrorCode } from "../../src/lib/auth/errors"; /** * Root of the workspace. The runner is always invoked from the workspace root * (`npx tsx tests/e2e/runner.ts …`), so the current working directory is the * anchor — this also keeps the static source assertions working when the suite * runs from a compiled copy elsewhere. */ const WORKSPACE_ROOT = process.cwd(); function readAuthRouteSource(route: string): string { return readFileSync(resolve(WORKSPACE_ROOT, "src", "app", "api", "auth", route), "utf8"); } describe("UserEnumeration", () => { describe("signup answers are identical for every account state", () => { const ALL_STATES: SignupAccountState[] = ["new", "unverified", "verified", "google_only"]; test("every account state maps to the same verification_sent status", () => { for (const state of ALL_STATES) { expect(signupDecision(state).status).toBe("verification_sent"); } }); test("only new and never-confirmed accounts get a real confirmation mail", () => { expect(signupDecision("new").sendMail).toBe(true); expect(signupDecision("unverified").sendMail).toBe(true); expect(signupDecision("verified").sendMail).toBe(false); expect(signupDecision("google_only").sendMail).toBe(false); }); test("the wire body is identical for new, existing and Google-only accounts", () => { const bodies = ALL_STATES.map((state) => signupResponseBody(signupDecision(state), undefined)); for (const body of bodies) { expect(body).toEqual({ status: "verification_sent" }); } // Every pair is byte-for-byte the same shape. expect(bodies[0]).toEqual(bodies[1]); expect(bodies[1]).toEqual(bodies[2]); expect(bodies[2]).toEqual(bodies[3]); }); test("devLink appears only in the dev fallback and only when a mail was produced", () => { // Production / no dev fallback: never present, for any state. for (const state of ALL_STATES) { expect(signupResponseBody(signupDecision(state), undefined)).toEqual({ status: "verification_sent", }); } // Dev fallback with a produced mail: unverified accounts get the link… expect(signupResponseBody(signupDecision("unverified"), "http://localhost/dev-link")).toEqual( { status: "verification_sent", devLink: "http://localhost/dev-link" } ); expect(signupResponseBody(signupDecision("new"), "http://localhost/dev-link")).toEqual( { status: "verification_sent", devLink: "http://localhost/dev-link" } ); // …but verified / Google-only accounts never get a mail, so no devLink either. expect(signupResponseBody(signupDecision("verified"), "http://localhost/dev-link")).toEqual( { status: "verification_sent" } ); expect(signupResponseBody(signupDecision("google_only"), "http://localhost/dev-link")).toEqual( { status: "verification_sent" } ); }); }); describe("login folds Google-only accounts into invalid_credentials", () => { test("a Google-only account is indistinguishable from a missing one", () => { for (const passwordMatches of [true, false]) { expect(loginDecision("google_only", passwordMatches)).toEqual({ kind: "invalid_credentials" }); expect(loginDecision("none", passwordMatches)).toEqual({ kind: "invalid_credentials" }); } }); test("a wrong password stays invalid_credentials for local accounts", () => { expect(loginDecision("local", false)).toEqual({ kind: "invalid_credentials" }); expect(loginDecision("unverified_local", false)).toEqual({ kind: "invalid_credentials" }); }); test("email_not_verified is reserved for correct-password unverified accounts", () => { expect(loginDecision("unverified_local", true)).toEqual({ kind: "email_not_verified" }); // Never reachable for a missing or Google-only account, even with a "match": // an attacker without the password can never get this code. expect(loginDecision("google_only", true)).toEqual({ kind: "invalid_credentials" }); expect(loginDecision("none", true)).toEqual({ kind: "invalid_credentials" }); }); test("only a verified local account with the right password signs in", () => { expect(loginDecision("local", true)).toEqual({ kind: "sign_in" }); expect(loginDecision("local", false)).toEqual({ kind: "invalid_credentials" }); }); }); describe("revealing codes are never emitted by auth routes", () => { test("the discontinued codes still exist in the vocabulary for compatibility", () => { for (const code of DISCONTINUED_ENUMERATION_CODES) { expect(isAuthErrorCode(code)).toBe(true); } }); test("no auth route source contains any discontinued enumeration code", () => { const routes = [ "signup/route.ts", "login/route.ts", "forgot-password/route.ts", "reset-password/route.ts", "resend-verification/route.ts", ]; for (const route of routes) { const source = readAuthRouteSource(route); for (const code of DISCONTINUED_ENUMERATION_CODES) { expect(source.includes(code)).toBe(false); } } }); test("the login 403 carries no extra fields that would set it apart", () => { // The `email_not_verified` failure body must be a plain `{ error }` — // no `email` field — so its shape matches every other failure. // (Guard: the login route must not pass an `email` extra into authError.) const loginSource = readAuthRouteSource("login/route.ts"); expect(loginSource.includes('email_not_verified", 403, { email')).toBe(false); }); }); });