mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-07-22 08:48:34 +00:00
Same email now contains a 6-digit code and a magic link, both backed by a
single verificationToken row. After submitting their email, the user
lands on /auth/verify-code with a single-input form (inputmode=numeric,
autocomplete=one-time-code, auto-submit on 6 digits or paste) and can
either type the code or use the link from the email. Either path
consumes the same row — single-use, replace-on-resend.
This is the structural fix for the silent-quarantine pattern observed
with Atkins and Sustainable Building UK: corporate gateways are happier
with short transactional content than long opaque token URLs, and a
code can't be broken by SafeLinks-style URL rewriting. The link path is
preserved so users whose email gets through unmangled keep one-click UX.
Security:
- Codes are 6-digit, crypto.randomInt-generated, stored as sha256
hashed against NEXTAUTH_SECRET on the same row as the link token
- 5-attempt lockout per code (attempts column); 6th attempt with the
correct code still fails
- Per-email send rate limit: 5/hour fixed window (authRateLimits
table); 6th send returns an error
- Code + link share a 10-minute window (maxAge dropped from 1h)
- Resending replaces any prior token rows for the identifier so only
the latest send is ever live
Implementation:
- verificationCode.ts holds generateCode + hashCode + the pure
evaluateCodeAttempt decision tree; 9 unit tests cover every branch
of the verification outcome (no-such-row, expired, locked-out, ok,
wrong-with-newAttempts, locked-out-still-rejects-correct-code)
- sendVerificationRequest now hashes the URL token the same way
/verify/[token]/page.tsx does, applies the rate limit + records the
code + replaces older rows in two transactions
- CredentialsProvider (id: "email-code") calls evaluateCodeAttempt
inside a transaction, handles all 5 outcomes, creates the user on
first successful code (parity with the magic-link callback path)
- oauthId backfill in the signIn callback is now guarded on
account.type === "oauth" so the credentials flow doesn't pollute
oauthProvider with "email-code"
- Migration is additive: code_hash nullable, attempts default 0; new
authRateLimits table is independent. In-flight tokens at deploy time
keep working via the link path.
Vercel preview deployment is the test surface; a Mailpit + Cypress E2E
loop is intentionally deferred per the lean-setup plan in docs/wip/
auth-email-code-fallback-plan.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
95 lines
2.6 KiB
TypeScript
95 lines
2.6 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import {
|
|
evaluateCodeAttempt,
|
|
generateCode,
|
|
hashCode,
|
|
} from "./verificationCode";
|
|
|
|
const FUTURE = new Date("2030-01-01T00:00:00Z");
|
|
const NOW = new Date("2026-05-27T12:00:00Z");
|
|
const CORRECT_HASH = "correct-hash";
|
|
const WRONG_HASH = "wrong-hash";
|
|
|
|
describe("generateCode", () => {
|
|
it("returns a 6-digit numeric string", () => {
|
|
const code = generateCode();
|
|
expect(code).toMatch(/^\d{6}$/);
|
|
});
|
|
});
|
|
|
|
describe("hashCode", () => {
|
|
it("produces the same hash for the same code+secret", () => {
|
|
const a = hashCode("482911", "secret-x");
|
|
const b = hashCode("482911", "secret-x");
|
|
expect(a).toBe(b);
|
|
});
|
|
|
|
it("produces different hashes when the secret differs", () => {
|
|
const a = hashCode("482911", "secret-x");
|
|
const b = hashCode("482911", "secret-y");
|
|
expect(a).not.toBe(b);
|
|
});
|
|
});
|
|
|
|
describe("evaluateCodeAttempt", () => {
|
|
it("rejects a wrong code and increments attempts", () => {
|
|
const result = evaluateCodeAttempt({
|
|
submittedCodeHash: WRONG_HASH,
|
|
row: { codeHash: CORRECT_HASH, attempts: 0, expires: FUTURE },
|
|
now: NOW,
|
|
});
|
|
|
|
expect(result).toEqual({ outcome: "wrong", newAttempts: 1 });
|
|
});
|
|
|
|
it("accepts a correct code", () => {
|
|
const result = evaluateCodeAttempt({
|
|
submittedCodeHash: CORRECT_HASH,
|
|
row: { codeHash: CORRECT_HASH, attempts: 0, expires: FUTURE },
|
|
now: NOW,
|
|
});
|
|
|
|
expect(result).toEqual({ outcome: "ok" });
|
|
});
|
|
|
|
it("locks out on the 5th consecutive wrong attempt", () => {
|
|
const result = evaluateCodeAttempt({
|
|
submittedCodeHash: WRONG_HASH,
|
|
row: { codeHash: CORRECT_HASH, attempts: 4, expires: FUTURE },
|
|
now: NOW,
|
|
});
|
|
|
|
expect(result).toEqual({ outcome: "locked-out" });
|
|
});
|
|
|
|
it("still rejects a correct code submitted after lock-out", () => {
|
|
const result = evaluateCodeAttempt({
|
|
submittedCodeHash: CORRECT_HASH,
|
|
row: { codeHash: CORRECT_HASH, attempts: 5, expires: FUTURE },
|
|
now: NOW,
|
|
});
|
|
|
|
expect(result).toEqual({ outcome: "locked-out" });
|
|
});
|
|
|
|
it("reports no-such-row when there's no row for the email", () => {
|
|
const result = evaluateCodeAttempt({
|
|
submittedCodeHash: CORRECT_HASH,
|
|
row: null,
|
|
now: NOW,
|
|
});
|
|
|
|
expect(result).toEqual({ outcome: "no-such-row" });
|
|
});
|
|
|
|
it("reports expired when the row's expiry is in the past", () => {
|
|
const expired = new Date(NOW.getTime() - 1000);
|
|
const result = evaluateCodeAttempt({
|
|
submittedCodeHash: CORRECT_HASH,
|
|
row: { codeHash: CORRECT_HASH, attempts: 0, expires: expired },
|
|
now: NOW,
|
|
});
|
|
|
|
expect(result).toEqual({ outcome: "expired" });
|
|
});
|
|
});
|