Add 6-digit code sign-in as primary, magic link as fast-path fallback

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>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-27 14:16:47 +00:00
parent 9c569f5584
commit d042606955
11 changed files with 10622 additions and 42 deletions

View file

@ -2,16 +2,24 @@ import { NextAuthOptions } from "next-auth";
import GoogleProvider from "next-auth/providers/google";
import AzureADB2CProvider from "next-auth/providers/azure-ad-b2c";
import EmailProvider from "next-auth/providers/email";
import CredentialsProvider from "next-auth/providers/credentials";
import DrizzleEmailAdapter from "./DrizzleEmailAdapter";
import { MagicLinksEmail } from "@/app/email_templates/magic_link";
import {
evaluateCodeAttempt,
generateCode,
hashCode,
} from "@/app/lib/verificationCode";
import { createHash } from "crypto";
import { db } from "@/app/db/db";
import {
user as users,
accounts,
authRateLimits,
verificationTokens,
} from "@/app/db/schema/users";
import { eq, and } from "drizzle-orm";
import { eq, and, ne } from "drizzle-orm";
// ------------------------------------------------------------------
// Environment variables
@ -80,14 +88,111 @@ export const AuthOptions: NextAuthOptions = {
pass: EMAIL_SERVER_PASSWORD,
},
},
from: EMAIL_FROM, // noreply email
maxAge: 60 * 60, // magic link valid for 1 hour
sendVerificationRequest: async ({ identifier, url, provider }) => {
from: EMAIL_FROM,
maxAge: 60 * 10, // code and link share a 10-minute window
sendVerificationRequest: async ({ identifier, url, provider, token }) => {
const secret = process.env.NEXTAUTH_SECRET!;
const hashedToken = createHash("sha256")
.update(`${token}${secret}`)
.digest("hex");
const now = new Date();
const oneHourMs = 60 * 60 * 1000;
const SEND_LIMIT = 5;
// Per-email send rate limit, fixed 1-hour window.
const limited = await db.transaction(async (tx) => {
const [existing] = await tx
.select()
.from(authRateLimits)
.where(
and(
eq(authRateLimits.scope, "send"),
eq(authRateLimits.key, identifier),
),
);
const inWindow =
existing &&
now.getTime() - existing.windowStart.getTime() < oneHourMs;
if (inWindow) {
if (existing.count >= SEND_LIMIT) return true;
await tx
.update(authRateLimits)
.set({ count: existing.count + 1 })
.where(
and(
eq(authRateLimits.scope, "send"),
eq(authRateLimits.key, identifier),
),
);
return false;
}
await tx
.insert(authRateLimits)
.values({
scope: "send",
key: identifier,
count: 1,
windowStart: now,
})
.onConflictDoUpdate({
target: [authRateLimits.scope, authRateLimits.key],
set: { count: 1, windowStart: now },
});
return false;
});
if (limited) {
await db
.delete(verificationTokens)
.where(
and(
eq(verificationTokens.identifier, identifier),
eq(verificationTokens.token, hashedToken),
),
);
console.warn("EMAIL_RATE_LIMIT_EXCEEDED", {
email: identifier,
timestamp: now.toISOString(),
});
throw new Error(
"Too many sign-in attempts. Please wait an hour before requesting another code.",
);
}
// Generate code, attach to the just-created row, replace any older rows
// for this identifier so only the latest send is live.
const code = generateCode();
const codeHash = hashCode(code, secret);
await db.transaction(async (tx) => {
await tx
.update(verificationTokens)
.set({ codeHash, attempts: 0 })
.where(
and(
eq(verificationTokens.identifier, identifier),
eq(verificationTokens.token, hashedToken),
),
);
await tx
.delete(verificationTokens)
.where(
and(
eq(verificationTokens.identifier, identifier),
ne(verificationTokens.token, hashedToken),
),
);
});
try {
const { messageId } = await MagicLinksEmail({
identifier,
url,
provider,
code,
});
console.log("EMAIL_MAGIC_LINK_SUCCESS", {
email: identifier,
@ -104,14 +209,111 @@ export const AuthOptions: NextAuthOptions = {
}
},
}),
// ------------------ Email code (typed at /auth/verify-code) ------------------
CredentialsProvider({
id: "email-code",
name: "Email Code",
credentials: {
email: { label: "Email", type: "email" },
code: { label: "Code", type: "text" },
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.code) return null;
const email = credentials.email.trim().toLowerCase();
const submitted = credentials.code.trim();
if (!/^\d{6}$/.test(submitted)) return null;
const secret = process.env.NEXTAUTH_SECRET!;
const submittedCodeHash = hashCode(submitted, secret);
const now = new Date();
return await db.transaction(async (tx) => {
const [row] = await tx
.select()
.from(verificationTokens)
.where(eq(verificationTokens.identifier, email))
.limit(1);
const outcome = evaluateCodeAttempt({
submittedCodeHash,
row: row
? {
codeHash: row.codeHash,
attempts: row.attempts,
expires: row.expires,
}
: null,
now,
});
if (outcome.outcome === "ok") {
await tx
.delete(verificationTokens)
.where(eq(verificationTokens.identifier, email));
let [dbUser] = await tx
.select()
.from(users)
.where(eq(users.email, email));
if (!dbUser) {
[dbUser] = await tx
.insert(users)
.values({ email, emailVerified: now })
.returning();
} else if (!dbUser.emailVerified) {
await tx
.update(users)
.set({ emailVerified: now })
.where(eq(users.id, dbUser.id));
}
console.log("EMAIL_CODE_SIGN_IN_SUCCESS", {
email,
userId: String(dbUser.id),
timestamp: now.toISOString(),
});
return {
id: String(dbUser.id),
email: dbUser.email,
name: dbUser.firstName ?? null,
dbId: String(dbUser.id),
onboarded: dbUser.onboarded ?? false,
};
}
if (outcome.outcome === "wrong") {
await tx
.update(verificationTokens)
.set({ attempts: outcome.newAttempts })
.where(eq(verificationTokens.identifier, email));
return null;
}
if (
outcome.outcome === "locked-out" ||
outcome.outcome === "expired"
) {
await tx
.delete(verificationTokens)
.where(eq(verificationTokens.identifier, email));
return null;
}
return null;
});
},
}),
],
// ------------------------------------------------------------------
// Pages
// ------------------------------------------------------------------
pages: {
signIn: "/", // your landing/login page
verifyRequest: "/auth/verify-request",
signIn: "/",
verifyRequest: "/auth/verify-code",
error: "/api/auth/error",
},
@ -187,8 +389,9 @@ export const AuthOptions: NextAuthOptions = {
}
}
// Link OAuth ID if missing (helps for older accounts)
if (account && !dbUser.oauthId) {
// Link OAuth ID if missing (helps for older accounts). Only for OAuth
// providers — the email-code credentials flow has no provider identity.
if (account?.type === "oauth" && !dbUser.oauthId) {
console.log("Backfilling OAuth ID:", {
email: normalisedEmail,
provider: account.provider,

View file

@ -0,0 +1,141 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { signIn } from "next-auth/react";
import { useRouter } from "next/navigation";
import { Button } from "@/app/shadcn_components/ui/button";
import { Input } from "@/app/shadcn_components/ui/input";
const RESEND_COOLDOWN_SECONDS = 30;
type Status = "idle" | "verifying" | "resending";
export default function VerifyCodeForm({ email }: { email: string }) {
const router = useRouter();
const [code, setCode] = useState("");
const [status, setStatus] = useState<Status>("idle");
const [error, setError] = useState<string | null>(null);
const [resendCountdown, setResendCountdown] = useState(0);
const [resendNotice, setResendNotice] = useState<string | null>(null);
const inFlightRef = useRef(false);
// Resend cooldown timer
useEffect(() => {
if (resendCountdown <= 0) return;
const id = setTimeout(() => setResendCountdown((s) => s - 1), 1000);
return () => clearTimeout(id);
}, [resendCountdown]);
async function submitCode(value: string) {
if (inFlightRef.current) return;
if (!/^\d{6}$/.test(value)) return;
inFlightRef.current = true;
setStatus("verifying");
setError(null);
const res = await signIn("email-code", {
email,
code: value,
redirect: false,
});
inFlightRef.current = false;
if (res?.ok) {
router.push("/home");
return;
}
setStatus("idle");
setCode("");
setError(
"That code didn't match. Check the latest email — older codes stop working as soon as you request a new one.",
);
}
function handleChange(next: string) {
const digits = next.replace(/\D/g, "").slice(0, 6);
setCode(digits);
if (error) setError(null);
if (digits.length === 6) void submitCode(digits);
}
async function handleResend() {
if (resendCountdown > 0 || status === "resending") return;
if (!email) return;
setStatus("resending");
setError(null);
setResendNotice(null);
const res = await signIn("email", { email, redirect: false });
setStatus("idle");
if (res?.error) {
setError(
"We couldn't send a new code right now. Wait a minute and try again.",
);
return;
}
setResendNotice("A new code is on its way. Older codes stop working.");
setResendCountdown(RESEND_COOLDOWN_SECONDS);
}
return (
<div className="space-y-4 text-left">
<div>
<label
htmlFor="verify-code"
className="block text-xs font-medium text-gray-600 mb-2 text-center"
>
Sign-in code
</label>
<Input
id="verify-code"
inputMode="numeric"
autoComplete="one-time-code"
maxLength={6}
value={code}
onChange={(e) => handleChange(e.target.value)}
placeholder="••••••"
className="h-12 text-center text-2xl tracking-[0.5em] font-mono"
disabled={status === "verifying"}
autoFocus
/>
</div>
<Button
type="button"
onClick={() => submitCode(code)}
disabled={status !== "idle" || code.length !== 6}
className="bg-brandbrown hover:bg-hoverblue w-full text-base py-3"
>
{status === "verifying" ? "Verifying…" : "Sign in"}
</Button>
<div className="text-center text-sm space-y-2">
{error && <p className="text-red-500">{error}</p>}
{resendNotice && !error && (
<p className="text-green-600">{resendNotice}</p>
)}
<button
type="button"
onClick={handleResend}
disabled={
resendCountdown > 0 || status === "resending" || !email
}
className="text-brandblue hover:underline disabled:text-gray-400 disabled:no-underline"
>
{status === "resending"
? "Sending…"
: resendCountdown > 0
? `Resend code in ${resendCountdown}s`
: "Resend code"}
</button>
</div>
<p className="text-xs text-gray-400 text-center">
Or use the one-click link from your email instead.
</p>
</div>
);
}

View file

@ -0,0 +1,60 @@
import { Card } from "@/app/shadcn_components/ui/card";
import { ShieldCheck } from "lucide-react";
import VerifyCodeForm from "./VerifyCodeForm";
export default async function VerifyCodePage({
searchParams,
}: {
searchParams: Promise<{ email?: string }>;
}) {
const { email } = await searchParams;
return (
<div className="relative min-h-screen flex flex-col bg-gradient-to-b from-gray-50 to-white overflow-hidden">
<div className="absolute inset-0 pointer-events-none overflow-hidden">
<div className="absolute -top-24 -left-24 w-[28rem] h-[28rem] bg-brandblue/10 rounded-full blur-3xl" />
<div className="absolute bottom-0 right-0 w-[30rem] h-[30rem] bg-midblue/10 rounded-full blur-3xl" />
</div>
<div className="relative bg-gradient-to-r from-brandblue to-midblue text-white py-16 px-8">
<div className="max-w-5xl mx-auto text-center">
<h1 className="text-4xl font-bold mb-4">Sign in to Ara</h1>
<p className="text-white/90 text-lg max-w-xl mx-auto">
Enter the 6-digit code we just emailed you to continue.
</p>
</div>
</div>
<div className="relative flex-1 flex items-center justify-center px-6">
<div className="w-full max-w-md">
<Card className="p-10 shadow-xl border border-gray-100 backdrop-blur-sm text-center space-y-6">
<div className="flex justify-center">
<div className="bg-brandblue/10 p-3 rounded-full">
<ShieldCheck className="w-7 h-7 text-brandblue" />
</div>
</div>
<h2 className="text-xl font-semibold text-brandblue">
Enter your sign-in code
</h2>
<p className="text-sm text-gray-600 leading-relaxed">
We sent a 6-digit code to{" "}
<span className="font-medium text-brandblue">
{email ?? "your email"}
</span>
. It expires in 10 minutes.
</p>
<VerifyCodeForm email={email ?? ""} />
</Card>
</div>
</div>
<div className="pb-10 text-center text-xs text-gray-400 space-y-1">
<p>Secure authentication powered by Ara</p>
<p>© {new Date().getFullYear()} Domna Homes</p>
</div>
</div>
);
}

View file

@ -2,6 +2,7 @@
import { signIn } from "next-auth/react";
import { useState, useEffect, SetStateAction } from "react";
import { useRouter } from "next/navigation";
import { Input } from "@/app/shadcn_components/ui/input";
import { Button } from "@/app/shadcn_components/ui/button";
import { ChevronRightIcon } from "@heroicons/react/20/solid";
@ -11,28 +12,24 @@ export default function EmailSignInButton({
}: {
error: string | undefined;
}) {
const router = useRouter();
const [email, setEmail] = useState("");
const [error, setError] = useState(initialError);
const [status, setStatus] = useState<"idle" | "sending" | "sent">("idle");
const [status, setStatus] = useState<"idle" | "sending">("idle");
const handleSubmit = async (e: { preventDefault: () => void }) => {
e.preventDefault();
setStatus("sending");
console.log("BEFOERE SIGN IN");
console.log("window.location.origin:", window.location.origin);
const res = await signIn("email", { email, redirect: false });
console.log("AFTER SIGN IN");
if (res?.error) {
setError("You are not a valid user.");
setStatus("idle");
console.log("Error signing in:", res.error);
} else {
console.log("Sign-in link sent to:", email);
setError(undefined);
setStatus("sent");
return;
}
router.push(`/auth/verify-code?email=${encodeURIComponent(email)}`);
};
const handleEmailChange = (e: {
@ -67,13 +64,8 @@ export default function EmailSignInButton({
<div className="min-h-[3rem] text-center">
{error && <p className="text-red-500">{error}</p>}
{status === "sent" && (
<p className="text-green-600">
A login link has been sent to your email.
</p>
)}
{status === "sending" && (
<p className="text-gray-500">Sending login link...</p>
<p className="text-gray-500">Sending sign-in code...</p>
)}
</div>
</form>

View file

@ -0,0 +1,10 @@
CREATE TABLE "authRateLimits" (
"scope" text NOT NULL,
"key" text NOT NULL,
"count" integer DEFAULT 0 NOT NULL,
"window_start" timestamp NOT NULL,
CONSTRAINT "authRateLimits_scope_key_pk" PRIMARY KEY("scope","key")
);
--> statement-breakpoint
ALTER TABLE "verificationToken" ADD COLUMN "code_hash" text;--> statement-breakpoint
ALTER TABLE "verificationToken" ADD COLUMN "attempts" integer DEFAULT 0 NOT NULL;

File diff suppressed because it is too large Load diff

View file

@ -1471,6 +1471,13 @@
"when": 1779877591391,
"tag": "0209_third_klaw",
"breakpoints": true
},
{
"idx": 210,
"version": "7",
"when": 1779889030729,
"tag": "0210_absent_dark_phoenix",
"breakpoints": true
}
]
}

View file

@ -75,10 +75,23 @@ export const verificationTokens = pgTable(
identifier: text("identifier").notNull(),
token: text("token").notNull(),
expires: timestamp("expires", { mode: "date" }).notNull(),
codeHash: text("code_hash"),
attempts: integer("attempts").notNull().default(0),
},
(vt) => [primaryKey({ columns: [vt.identifier, vt.token] })]
);
export const authRateLimits = pgTable(
"authRateLimits",
{
scope: text("scope").notNull(),
key: text("key").notNull(),
count: integer("count").notNull().default(0),
windowStart: timestamp("window_start", { mode: "date" }).notNull(),
},
(rl) => [primaryKey({ columns: [rl.scope, rl.key] })]
);
export const UserType: [string, ...string[]] = [
"private_landlord",
"private_tenant",

View file

@ -1,6 +1,6 @@
// Contains the email template for user sign in via magic links. A user will be asked to
// click a verification email to sign in to the app, should they choose to sign in with magic
// links
// Contains the email template for user sign in. Email contains a 6-digit code
// (primary action — type at /auth/verify-code) and a sign-in link (fast-path
// for users whose corporate gateway doesn't mangle URLs).
import { createTransport } from "nodemailer";
import { buildMailHeaders } from "./buildMailHeaders";
@ -9,10 +9,12 @@ export async function MagicLinksEmail({
identifier,
url,
provider,
code,
}: {
identifier: string;
url: string;
provider: { server: any; from: string };
code: string;
}): Promise<{ messageId: string }> {
const parsed = new URL(url);
const host = parsed.host;
@ -27,22 +29,24 @@ export async function MagicLinksEmail({
throw new Error("Magic link token or email missing");
}
// Create a clean login link instead of the NextAuth callback
// Direct the link at the existing two-step verify page (defends against
// email-scanner pre-fetching), not at the NextAuth callback.
const loginUrl = `${parsed.origin}/verify/${token}`;
const transport = createTransport(provider.server);
const brandColor = "#14163d"; // brand blue
const accentColor = "#2d348f"; // deep blue
const brown = "#c4a47c"; // brand brown
const brandColor = "#14163d";
const accentColor = "#2d348f";
const brown = "#c4a47c";
const background = "#F9F9F9";
const result = await transport.sendMail({
to: identifier,
from: provider.from,
subject: "Sign in to Ara",
text: plainText({ url: loginUrl, host }),
text: plainText({ code, url: loginUrl, host }),
html: domnaHtml({
code,
url: loginUrl,
logoUrl,
host,
@ -65,7 +69,14 @@ export async function MagicLinksEmail({
return { messageId: result.messageId };
}
function formatCodeForDisplay(code: string): string {
// Insert a non-breaking space for readability without breaking copy-paste
// semantics in mail clients that strip the visual grouping.
return `${code.slice(0, 3)} ${code.slice(3)}`;
}
function domnaHtml({
code,
url,
logoUrl,
host,
@ -74,6 +85,7 @@ function domnaHtml({
brown,
background,
}: {
code: string;
url: string;
logoUrl: string;
host: string;
@ -83,14 +95,15 @@ function domnaHtml({
background: string;
}) {
const escapedHost = host.replace(/\./g, "&#8203;.");
const codeDisplay = formatCodeForDisplay(code);
return `
<body style="background: ${background}; font-family: Helvetica, Arial, sans-serif; margin: 0; padding: 0;">
<table width="100%" border="0" cellspacing="0" cellpadding="0"
<table width="100%" border="0" cellspacing="0" cellpadding="0"
style="max-width: 600px; margin: 40px auto; background: #fff; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 12px rgba(0,0,0,0.05);">
<tr>
<td align="center" style="background: linear-gradient(90deg, ${brandColor}, ${accentColor}); padding: 12px 8px;">
<img
<img
src="${logoUrl}"
alt="Domna Logo"
width="120"
@ -100,16 +113,29 @@ function domnaHtml({
</td>
</tr>
<tr>
<td align="center" style="padding: 10px 10px 10px; color: #333;">
<h2 style="color: ${brandColor}; font-size: 22px; margin-bottom: 16px;">Welcome back to Ara by Domna</h2>
<p style="font-size: 16px; line-height: 1.6; color: #444; margin-bottom: 32px;">
Click below to securely sign in to your account and continue your retrofit journey.
<td align="center" style="padding: 28px 24px 12px; color: #333;">
<h2 style="color: ${brandColor}; font-size: 22px; margin: 0 0 8px;">Your sign-in code</h2>
<p style="font-size: 14px; line-height: 1.5; color: #666; margin: 0 0 20px;">
Enter this code at <span style="color: ${accentColor};">${escapedHost}</span> to sign in to Ara.
</p>
<a href="${url}" target="_blank"
style="display: inline-block; padding: 14px 28px; background: ${brown}; color: #fff; text-decoration: none; border-radius: 6px; font-weight: 600; font-size: 16px;">
<div style="font-family: 'Courier New', Courier, monospace; font-size: 36px; font-weight: 700; letter-spacing: 4px; color: ${brandColor}; padding: 18px 24px; background: #f3f4f8; border-radius: 8px; display: inline-block;">
${codeDisplay}
</div>
<p style="font-size: 12px; color: #888; margin: 12px 0 0;">
This code expires in 10 minutes.
</p>
</td>
</tr>
<tr>
<td align="center" style="padding: 4px 24px 24px; color: #333;">
<p style="font-size: 13px; color: #777; margin: 8px 0 12px;">
Or use the one-click link instead:
</p>
<a href="${url}" target="_blank"
style="display: inline-block; padding: 10px 22px; background: ${brown}; color: #fff; text-decoration: none; border-radius: 6px; font-weight: 600; font-size: 14px;">
Sign in to Ara
</a>
<p style="margin-top: 36px; font-size: 13px; color: #777;">
<p style="margin-top: 28px; font-size: 13px; color: #777;">
If you didnt request this email, you can safely ignore it.
</p>
</td>
@ -124,6 +150,24 @@ function domnaHtml({
`;
}
function plainText({ url, host }: { url: string; host: string }) {
return `Sign in to Ara by Domna\n${url}\n\nIf you did not request this email, you can safely ignore it.\n`;
function plainText({
code,
url,
host,
}: {
code: string;
url: string;
host: string;
}) {
return `Sign in to Ara by Domna
Your sign-in code: ${code}
Enter this code at ${host}/auth/verify-code to sign in.
Or use the one-click link instead:
${url}
This code expires in 10 minutes. If you did not request this email, you can safely ignore it.
`;
}

View file

@ -0,0 +1,95 @@
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" });
});
});

View file

@ -0,0 +1,43 @@
import crypto from "crypto";
export function generateCode(): string {
return crypto.randomInt(0, 1_000_000).toString().padStart(6, "0");
}
export function hashCode(code: string, secret: string): string {
return crypto.createHash("sha256").update(code + secret).digest("hex");
}
export type VerificationRowState = {
codeHash: string | null;
attempts: number;
expires: Date;
};
export type CodeAttemptOutcome =
| { outcome: "ok" }
| { outcome: "wrong"; newAttempts: number }
| { outcome: "locked-out" }
| { outcome: "expired" }
| { outcome: "no-such-row" };
const MAX_ATTEMPTS = 5;
export function evaluateCodeAttempt({
submittedCodeHash,
row,
now,
}: {
submittedCodeHash: string;
row: VerificationRowState | null;
now: Date;
}): CodeAttemptOutcome {
if (!row) return { outcome: "no-such-row" };
if (row.expires.getTime() <= now.getTime()) return { outcome: "expired" };
if (row.attempts >= MAX_ATTEMPTS) return { outcome: "locked-out" };
if (row.codeHash === submittedCodeHash) return { outcome: "ok" };
const newAttempts = row.attempts + 1;
if (newAttempts >= MAX_ATTEMPTS) return { outcome: "locked-out" };
return { outcome: "wrong", newAttempts };
}