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>
265 lines
11 KiB
TypeScript
265 lines
11 KiB
TypeScript
/**
|
|
* Auth Security Suite
|
|
*
|
|
* Covers the rules that keep one person from holding twenty accounts: email
|
|
* alias normalisation, disposable-domain rejection, password hashing, and
|
|
* single-use token handling. Pure logic only — no database required.
|
|
*/
|
|
|
|
import { describe, test, expect } from "./runner";
|
|
import {
|
|
canonicaliseEmail,
|
|
isDisposableEmail,
|
|
normaliseEmail,
|
|
splitEmail,
|
|
} from "../../src/lib/auth/email";
|
|
import { equaliseTiming, hashPassword, verifyPassword } from "../../src/lib/auth/password";
|
|
import { hashIp, hashToken, issueToken, newId } from "../../src/lib/auth/tokens";
|
|
import {
|
|
MIN_PASSWORD_LENGTH,
|
|
scorePassword,
|
|
validateEmail,
|
|
validatePassword,
|
|
} from "../../src/lib/auth/validation";
|
|
import { authErrorMessage, isAuthErrorCode } from "../../src/lib/auth/errors";
|
|
import { PASSWORD_RESET_TTL_MS, VERIFICATION_TTL_MS } from "../../src/lib/auth/config";
|
|
|
|
describe("Auth — email normalisation (one inbox, one account)", () => {
|
|
test("Gmail dots are cosmetic and collapse onto the same key", () => {
|
|
expect(normaliseEmail("t.i.m.o@gmail.com")).toBe("timo@gmail.com");
|
|
expect(normaliseEmail("timo@gmail.com")).toBe("timo@gmail.com");
|
|
});
|
|
|
|
test("plus-tag aliases collapse onto the base address", () => {
|
|
expect(normaliseEmail("timo+spam1@gmail.com")).toBe("timo@gmail.com");
|
|
expect(normaliseEmail("timo+spam2@gmail.com")).toBe("timo@gmail.com");
|
|
});
|
|
|
|
test("googlemail.com is an alias of gmail.com", () => {
|
|
expect(normaliseEmail("timo@googlemail.com")).toBe("timo@gmail.com");
|
|
});
|
|
|
|
test("twenty Gmail variants all reduce to a single uniqueness key", () => {
|
|
const variants = Array.from({ length: 20 }, (_, index) => `t.i.mo+throwaway${index}@gmail.com`);
|
|
const keys = new Set(variants.map((variant) => normaliseEmail(variant)));
|
|
expect(keys.size).toBe(1);
|
|
expect([...keys][0]).toBe("timo@gmail.com");
|
|
});
|
|
|
|
test("plus-tags are stripped on non-Gmail providers too", () => {
|
|
expect(normaliseEmail("bob+newsletter@outlook.com")).toBe("bob@outlook.com");
|
|
});
|
|
|
|
test("dots are preserved outside Gmail — they are significant there", () => {
|
|
expect(normaliseEmail("first.last@company.de")).toBe("first.last@company.de");
|
|
});
|
|
|
|
test("normalisation is case-insensitive", () => {
|
|
expect(normaliseEmail("Timo@Example.COM")).toBe("timo@example.com");
|
|
expect(canonicaliseEmail(" Timo@Example.COM ")).toBe("timo@example.com");
|
|
});
|
|
|
|
test("addresses with multiple @ use the last one as the separator", () => {
|
|
expect(splitEmail("weird\"@\"name@example.com")?.domain).toBe("example.com");
|
|
});
|
|
|
|
test("malformed input yields no key rather than a bogus one", () => {
|
|
expect(normaliseEmail("not-an-email")).toBe(null);
|
|
expect(normaliseEmail("@example.com")).toBe(null);
|
|
expect(normaliseEmail("user@")).toBe(null);
|
|
expect(normaliseEmail("user@localhost")).toBe(null);
|
|
expect(normaliseEmail("+tag@gmail.com")).toBe("+tag@gmail.com");
|
|
expect(normaliseEmail("")).toBe(null);
|
|
});
|
|
});
|
|
|
|
describe("Auth — disposable mailbox rejection", () => {
|
|
test("known throw-away providers are refused", () => {
|
|
expect(isDisposableEmail("abc@mailinator.com")).toBe(true);
|
|
expect(isDisposableEmail("abc@guerrillamail.com")).toBe(true);
|
|
expect(isDisposableEmail("abc@yopmail.com")).toBe(true);
|
|
});
|
|
|
|
test("detection ignores casing and surrounding whitespace", () => {
|
|
expect(isDisposableEmail(" ABC@MAILINATOR.COM ")).toBe(true);
|
|
});
|
|
|
|
test("real providers pass through", () => {
|
|
expect(isDisposableEmail("timo@gmail.com")).toBe(false);
|
|
expect(isDisposableEmail("timo@company.de")).toBe(false);
|
|
});
|
|
|
|
test("malformed addresses are not treated as disposable", () => {
|
|
expect(isDisposableEmail("nonsense")).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("Auth — password hashing", () => {
|
|
test("digest is self-describing and carries its cost parameters", async () => {
|
|
const digest = await hashPassword("Sicher1234!");
|
|
const parts = digest.split("$");
|
|
expect(parts[0]).toBe("scrypt");
|
|
expect(parts.length).toBe(6);
|
|
expect(Number(parts[1]) >= 16384).toBe(true);
|
|
});
|
|
|
|
test("the plaintext never appears in the digest", async () => {
|
|
const digest = await hashPassword("Sicher1234!");
|
|
expect(digest.includes("Sicher1234!")).toBe(false);
|
|
});
|
|
|
|
test("the same password hashes differently every time (unique salt)", async () => {
|
|
const first = await hashPassword("Sicher1234!");
|
|
const second = await hashPassword("Sicher1234!");
|
|
expect(first === second).toBe(false);
|
|
});
|
|
|
|
test("verification accepts the correct password", async () => {
|
|
const digest = await hashPassword("Sicher1234!");
|
|
expect(await verifyPassword("Sicher1234!", digest)).toBe(true);
|
|
});
|
|
|
|
test("verification rejects a wrong password, empty input and a null digest", async () => {
|
|
const digest = await hashPassword("Sicher1234!");
|
|
expect(await verifyPassword("sicher1234!", digest)).toBe(false);
|
|
expect(await verifyPassword("", digest)).toBe(false);
|
|
expect(await verifyPassword("Sicher1234!", null)).toBe(false);
|
|
});
|
|
|
|
test("a tampered or foreign digest is rejected, never crashes", async () => {
|
|
expect(await verifyPassword("Sicher1234!", "garbage")).toBe(false);
|
|
expect(await verifyPassword("Sicher1234!", "scrypt$1$2$3$4$5")).toBe(false);
|
|
expect(await verifyPassword("Sicher1234!", "bcrypt$16384$8$1$c2FsdA==$aGFzaA==")).toBe(false);
|
|
});
|
|
|
|
test("unicode passwords survive normalisation round-trip", async () => {
|
|
const digest = await hashPassword("Pässwörd-Ünïcode1!");
|
|
expect(await verifyPassword("Pässwörd-Ünïcode1!", digest)).toBe(true);
|
|
});
|
|
|
|
test("the timing equaliser resolves without throwing", async () => {
|
|
await equaliseTiming();
|
|
expect(true).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("Auth — tokens and identifiers", () => {
|
|
test("only the digest is meant for storage — it differs from the token", () => {
|
|
const { token, hash } = issueToken();
|
|
expect(token === hash).toBe(false);
|
|
expect(hash.length).toBe(64);
|
|
});
|
|
|
|
test("hashing is deterministic, so a cookie can be looked up", () => {
|
|
const { token, hash } = issueToken();
|
|
expect(hashToken(token)).toBe(hash);
|
|
});
|
|
|
|
test("tokens are unique across many issuances", () => {
|
|
const tokens = new Set(Array.from({ length: 500 }, () => issueToken().token));
|
|
expect(tokens.size).toBe(500);
|
|
});
|
|
|
|
test("IP hashing is stable and returns null for missing addresses", () => {
|
|
expect(hashIp("203.0.113.7")).toBe(hashIp("203.0.113.7"));
|
|
expect(hashIp(null)).toBe(null);
|
|
expect(hashIp(undefined)).toBe(null);
|
|
});
|
|
|
|
test("generated ids stay inside the varchar(64) column", () => {
|
|
const id = newId("usr");
|
|
expect(id.startsWith("usr_")).toBe(true);
|
|
expect(id.length <= 64).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("Auth — credential validation", () => {
|
|
test("signup demands length plus variety", () => {
|
|
expect(validatePassword("short1A", false, { requireStrength: true }) !== null).toBe(true);
|
|
expect(validatePassword("alllowercase", false, { requireStrength: true }) !== null).toBe(true);
|
|
expect(validatePassword("Sicher1234", false, { requireStrength: true })).toBe(null);
|
|
});
|
|
|
|
test("login accepts any non-empty password so old accounts keep working", () => {
|
|
expect(validatePassword("legacy", false)).toBe(null);
|
|
expect(validatePassword("", false) !== null).toBe(true);
|
|
});
|
|
|
|
test("strength scoring gates on length before counting variety", () => {
|
|
expect(scorePassword("Ab1!").score).toBe(1);
|
|
expect(scorePassword("").score).toBe(0);
|
|
expect(scorePassword("abcdefgh").score).toBe(1);
|
|
expect(scorePassword("Abcdefgh").score).toBe(2);
|
|
expect(scorePassword("Abcdefg1").score).toBe(3);
|
|
expect(scorePassword("Abcdefg1!").score).toBe(4);
|
|
});
|
|
|
|
test("the minimum length constant matches the scoring gate", () => {
|
|
expect(scorePassword("A1!".padEnd(MIN_PASSWORD_LENGTH - 1, "x")).checks.length).toBe(false);
|
|
expect(scorePassword("A1!".padEnd(MIN_PASSWORD_LENGTH, "x")).checks.length).toBe(true);
|
|
});
|
|
|
|
test("email validation rejects the usual malformed shapes", () => {
|
|
expect(validateEmail("timo@example.com", false)).toBe(null);
|
|
expect(validateEmail("", false) !== null).toBe(true);
|
|
expect(validateEmail("timo@", false) !== null).toBe(true);
|
|
expect(validateEmail("timo example.com", false) !== null).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("Auth — password reset link shape", () => {
|
|
test("reset links live an hour, confirmation links a day", () => {
|
|
expect(PASSWORD_RESET_TTL_MS).toBe(60 * 60 * 1000);
|
|
expect(VERIFICATION_TTL_MS).toBe(24 * 60 * 60 * 1000);
|
|
// A reset hands over an existing account, so its window must be the shorter one.
|
|
expect(PASSWORD_RESET_TTL_MS < VERIFICATION_TTL_MS).toBe(true);
|
|
});
|
|
|
|
test("a reset token is a fresh secret each time, stored only as a digest", () => {
|
|
const first = issueToken();
|
|
const second = issueToken();
|
|
expect(first.token === second.token).toBe(false);
|
|
expect(first.hash === hashToken(first.token)).toBe(true);
|
|
expect(first.hash.includes(first.token)).toBe(false);
|
|
});
|
|
|
|
test("the new password faces the same strength rules as signup", () => {
|
|
expect(validatePassword("short1A", false, { requireStrength: true }) !== null).toBe(true);
|
|
expect(validatePassword("Sicher1234!", false, { requireStrength: true })).toBe(null);
|
|
});
|
|
|
|
test("link and expiry codes are distinct and both localised", () => {
|
|
expect(isAuthErrorCode("invalid_token")).toBe(true);
|
|
expect(isAuthErrorCode("expired_token")).toBe(true);
|
|
expect(authErrorMessage("invalid_token", true) === authErrorMessage("expired_token", true)).toBe(
|
|
false
|
|
);
|
|
expect(authErrorMessage("expired_token", true) === authErrorMessage("expired_token", false)).toBe(
|
|
false
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("Auth — error vocabulary", () => {
|
|
test("known codes resolve in both languages", () => {
|
|
expect(isAuthErrorCode("email_taken")).toBe(true);
|
|
expect(authErrorMessage("email_taken", true).length > 0).toBe(true);
|
|
expect(authErrorMessage("email_taken", false).length > 0).toBe(true);
|
|
expect(authErrorMessage("email_taken", true) === authErrorMessage("email_taken", false)).toBe(
|
|
false
|
|
);
|
|
});
|
|
|
|
test("unknown codes fall back instead of leaking raw values", () => {
|
|
expect(isAuthErrorCode("wat")).toBe(false);
|
|
expect(authErrorMessage("wat", false)).toBe(authErrorMessage("server_error", false));
|
|
expect(authErrorMessage(null, false)).toBe(authErrorMessage("server_error", false));
|
|
});
|
|
|
|
test("rate limiting reports the wait in whole minutes", () => {
|
|
expect(authErrorMessage("rate_limited", false, 90).includes("2 minutes")).toBe(true);
|
|
expect(authErrorMessage("rate_limited", false, 30).includes("1 minute")).toBe(true);
|
|
expect(authErrorMessage("rate_limited", true, 120).includes("2 Minuten")).toBe(true);
|
|
});
|
|
});
|