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>
149 lines
6.4 KiB
TypeScript
149 lines
6.4 KiB
TypeScript
/**
|
|
* 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);
|
|
});
|
|
});
|
|
});
|