mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-08 11:37:25 +00:00
Merge pull request #282 from Hestia-Homes/bug/blocked-magic-links
Bug/blocked magic links
This commit is contained in:
commit
5ea7e00fbe
14 changed files with 11115 additions and 72 deletions
451
docs/wip/auth-email-code-fallback-plan.md
Normal file
451
docs/wip/auth-email-code-fallback-plan.md
Normal file
|
|
@ -0,0 +1,451 @@
|
|||
# Email-auth code-fallback — implementation plan
|
||||
|
||||
**Status:** Ready to implement (2026-05-27)
|
||||
**Author:** Khalim (with Claude, via `/grill-me`)
|
||||
**Trigger:** Multiple corporate-domain users (Atkins, Sustainable Building UK,
|
||||
Arup pre-fix) reporting magic-link emails never arriving. One IT department
|
||||
confirmed direct sender-policy block.
|
||||
|
||||
## Problem
|
||||
|
||||
[Magic-link sign-in](../../src/app/api/auth/[...nextauth]/authOptions.ts#L74-L95)
|
||||
emails are being **silently quarantined** by some corporate email gateways
|
||||
(Microsoft 365 Defender / Mimecast / Proofpoint family). The mail reaches the
|
||||
recipient's MX, is accepted at SMTP, then disappears post-acceptance — never
|
||||
hits the inbox or junk folder. SES sees a clean `Delivery` event; the user
|
||||
sees nothing.
|
||||
|
||||
This is invisible to the sender by design. We confirmed it via the Atkins-
|
||||
adjacent case (Craig Williams, sustainablebuildinguk.com) where the
|
||||
recipient's IT department reached out, confirmed the block, and manually
|
||||
allow-listed `noreply@domna.homes`.
|
||||
|
||||
### What's been ruled out
|
||||
|
||||
- SPF, DKIM are correctly configured (verified via `dig`)
|
||||
- SES suppression list does not contain the failing users
|
||||
- Bounce rate is 0.03%, complaint rate 0.01% — reputation is fine
|
||||
- Link pre-fetching is already defended by
|
||||
[/verify/[token]/page.tsx](../../src/app/verify/[token]/page.tsx) (two-step
|
||||
consent before token consumption)
|
||||
|
||||
### What's contributing but not the root cause
|
||||
|
||||
- `noreply@domna.homes` is not a real mailbox → small reputation hit and
|
||||
broken `List-Unsubscribe` ([magic_link.ts:54](../../src/app/email_templates/magic_link.ts#L54))
|
||||
- DMARC is at `p=none` with no `rua=` reporting — zero visibility into
|
||||
alignment failures
|
||||
- `.homes` is a 2014-era new gTLD with thin aggregate reputation at some
|
||||
receivers (structural, hard to fully mitigate without changing sender domain)
|
||||
- App's `EMAIL_MAGIC_LINK_SENT` log fires *before* the SMTP transaction and
|
||||
isn't wrapped in try/catch, so it can't distinguish "tried" from "sent"
|
||||
([authOptions.ts:86-94](../../src/app/api/auth/[...nextauth]/authOptions.ts#L86-L94))
|
||||
- SES configuration set exists but the app never sets the
|
||||
`X-SES-CONFIGURATION-SET` header, so events don't flow through it
|
||||
([magic_link.ts:53-55](../../src/app/email_templates/magic_link.ts#L53-L55))
|
||||
|
||||
## Decisions (grilled and locked)
|
||||
|
||||
| # | Decision | Rationale |
|
||||
|---|---|---|
|
||||
| D1 | Code-first email auth with link as fast-path fallback (option b2) — both delivered in the same email | Code is more filter-friendly than a long opaque URL; SafeLinks can't break a 6-digit code; cross-device UX is naturally same-device |
|
||||
| D2 | 6-digit numeric code | Industry standard (AWS/Stripe/GitHub/Anthropic Console); easy to read aloud and type on mobile |
|
||||
| D3 | 10-minute expiry for both code and link (single artifact) | Code's lower entropy demands shorter window; unifies behaviour |
|
||||
| D4 | Single DB row per verification — extend `verificationToken` with `code_hash` and `attempts` columns | No new table; both paths consume the same row; deletion on first success enforces single-use |
|
||||
| D5 | Rate limits: 5 attempts/code, 5 codes/email/hour, 20 verify-attempts/IP/10min — stored in Postgres | Postgres avoids Redis dependency; numbers give attacker ~1-in-40k/hour worst case |
|
||||
| D6 | NextAuth v4 `CredentialsProvider` alongside existing `EmailProvider` | Cleanest extension point; works with existing JWT session strategy at [authOptions.ts:269](../../src/app/api/auth/[...nextauth]/authOptions.ts#L269); reuses existing user / `signIn` callback |
|
||||
| D7 | Ship in two PRs: observability first, code-flow second behind feature flag | Lets us measure the improvement; isolates risk |
|
||||
| D8 | Testing: Mailpit + Playwright in CI, mail-tester.com manual on template changes, SES dashboard for prod | Lean — no paid SaaS at current scale |
|
||||
| D9 | Skip the SES event-consumer Lambda for now; ticket on backlog | Volume doesn't justify it; SES dashboard + suppression list + customer-success channel are enough for forensics today |
|
||||
|
||||
## Out of scope / deferred
|
||||
|
||||
- **Microsoft work-account OAuth** (Azure AD multi-tenant) — would skip email
|
||||
entirely for M365 corporate users but locks them to MS SSO; revisit if
|
||||
enterprise customers demand it
|
||||
- **Switching sending domain off `.homes`** — measurable deliverability lift
|
||||
but requires re-warmup and brand thinking; revisit if `domna.homes`
|
||||
reputation stays a bottleneck after the DMARC + MAIL FROM work
|
||||
- **SES event-consumer Lambda** — ticketed for the backlog (see planning
|
||||
board: "Wire up SES event consumer for email auth forensics")
|
||||
- **BIMI / VMC, dedicated SES IP, GlockApps placement testing** — overkill at
|
||||
current scale
|
||||
- **Case-sensitivity bug in portfolio invitations** (separate issue) — user
|
||||
`Craig.Williams@…` invited with mixed case, then signed in lowercase,
|
||||
creating two user records. The lookup at
|
||||
[authOptions.ts:117](../../src/app/api/auth/[...nextauth]/authOptions.ts#L117)
|
||||
normalises but the *invitation* path doesn't. Track separately — not in
|
||||
this plan.
|
||||
|
||||
---
|
||||
|
||||
# PR 1 — Observability + logging fixes
|
||||
|
||||
**Goal:** when the next Atkins-shaped incident happens, we know within minutes
|
||||
whether SES tried, whether SES accepted, and whether the recipient SMTP
|
||||
accepted. Today we know none of these.
|
||||
|
||||
**Risk:** near-zero. Additive only.
|
||||
|
||||
**Effort:** ~half a day.
|
||||
|
||||
### Changes
|
||||
|
||||
1. **Add `X-SES-CONFIGURATION-SET` header** to outbound mail.
|
||||
|
||||
In [magic_link.ts:53-55](../../src/app/email_templates/magic_link.ts#L53-L55):
|
||||
|
||||
```ts
|
||||
headers: {
|
||||
...(process.env.SES_CONFIGURATION_SET && {
|
||||
"X-SES-CONFIGURATION-SET": process.env.SES_CONFIGURATION_SET,
|
||||
}),
|
||||
"List-Unsubscribe": `<mailto:${provider.from}>`,
|
||||
},
|
||||
```
|
||||
|
||||
Conditional so CI (Mailpit) doesn't need the env var.
|
||||
|
||||
2. **Capture and return Nodemailer messageId** from `MagicLinksEmail`.
|
||||
|
||||
In [magic_link.ts:39-62](../../src/app/email_templates/magic_link.ts#L39-L62),
|
||||
change the return type to `Promise<{ messageId: string }>` and return
|
||||
`{ messageId: result.messageId }` after the `result.rejected` check.
|
||||
|
||||
3. **Fix the misleading log** in
|
||||
[authOptions.ts:86-94](../../src/app/api/auth/[...nextauth]/authOptions.ts#L86-L94):
|
||||
|
||||
```ts
|
||||
sendVerificationRequest: async ({ identifier, url, provider }) => {
|
||||
try {
|
||||
const { messageId } = await MagicLinksEmail({ identifier, url, provider });
|
||||
console.log("EMAIL_MAGIC_LINK_SUCCESS", {
|
||||
email: identifier,
|
||||
messageId,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("EMAIL_MAGIC_LINK_FAILURE", {
|
||||
email: identifier,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
```
|
||||
|
||||
Now log presence proves "we tried", `EMAIL_MAGIC_LINK_SUCCESS` proves "SES
|
||||
accepted at SMTP", and `messageId` correlates to SES events.
|
||||
|
||||
4. **Add `SES_CONFIGURATION_SET` env var** to the deployment platform
|
||||
(Vercel / wherever). Value: `dev-ses-config`.
|
||||
|
||||
5. **Terraform — add `renderingFailure` event type and output the cfg-set
|
||||
name.** In the SES module:
|
||||
|
||||
```hcl
|
||||
resource "aws_ses_event_destination" "sns" {
|
||||
# ...
|
||||
matching_types = [
|
||||
"send",
|
||||
"bounce",
|
||||
"reject",
|
||||
"complaint",
|
||||
"delivery",
|
||||
"renderingFailure", # ← add
|
||||
]
|
||||
# ...
|
||||
}
|
||||
|
||||
output "configuration_set_name" {
|
||||
value = aws_ses_configuration_set.this.name
|
||||
}
|
||||
```
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Sending a magic link produces exactly one of: `EMAIL_MAGIC_LINK_SUCCESS`
|
||||
(with messageId) or `EMAIL_MAGIC_LINK_FAILURE` (with error). No bare
|
||||
`EMAIL MAGIC LINK SENT` lines.
|
||||
- The `messageId` in app logs matches the `mail.messageId` SES uses (verify
|
||||
once via SES console for one test send).
|
||||
- Terraform plan is clean after the two SES module changes.
|
||||
|
||||
---
|
||||
|
||||
# IT track (parallel, out-of-band)
|
||||
|
||||
These happen in parallel to PR 1; no engineer dependency.
|
||||
|
||||
1. **Create `noreply@domna.homes` as a real shared mailbox** in M365.
|
||||
Configure an auto-reply pointing to a monitored support address. Set
|
||||
`Reply-To:` on outgoing mail in PR 2 once the mailbox exists, OR change
|
||||
`EMAIL_FROM` to something like `accounts@domna.homes` and make *that*
|
||||
monitored. Update `EMAIL_FROM` env var when ready.
|
||||
|
||||
2. **Update the DMARC TXT record** at `_dmarc.domna.homes` from:
|
||||
|
||||
```
|
||||
v=DMARC1; p=none;
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```
|
||||
v=DMARC1; p=none; rua=mailto:<reporting-address>; fo=1; adkim=r; aspf=r; pct=100;
|
||||
```
|
||||
|
||||
Recommend Postmark DMARC Monitor (free) for the reporting address — they
|
||||
provide a unique mailbox like `<uuid>@inbound-smtp.dmarc.postmarkapp.com`
|
||||
and parse the XML into readable reports.
|
||||
|
||||
3. **Progression** (after ~4 weeks of clean reports): bump to
|
||||
`p=quarantine; pct=10;`, then `p=quarantine; pct=100;`, then
|
||||
`p=reject; pct=100;`. Don't skip the ramp — HubSpot / Outlook sends need
|
||||
to be confirmed aligned first.
|
||||
|
||||
### Nice-to-have (do when convenient, not blocking)
|
||||
|
||||
- **Custom MAIL FROM domain** (`mail.domna.homes`) — adds SPF alignment via
|
||||
*our* domain instead of `*.amazonses.com`. In Terraform:
|
||||
```hcl
|
||||
resource "aws_ses_domain_mail_from" "this" {
|
||||
domain = aws_ses_domain_identity.this.domain
|
||||
mail_from_domain = "mail.${var.domain_name}"
|
||||
}
|
||||
```
|
||||
Plus the corresponding `MX 10 feedback-smtp.eu-west-2.amazonses.com` and
|
||||
`TXT v=spf1 include:amazonses.com -all` at `mail.domna.homes`. Modest but
|
||||
real deliverability win.
|
||||
|
||||
---
|
||||
|
||||
# PR 2 — Code fallback flow (behind feature flag)
|
||||
|
||||
**Goal:** every magic-link email now also contains a 6-digit code. The
|
||||
post-submit UX leads with code entry; the link still works as a fast path.
|
||||
|
||||
**Risk:** medium — changes the user-facing auth flow. Feature flag mitigates.
|
||||
|
||||
**Effort:** ~2-3 days.
|
||||
|
||||
### Schema migration
|
||||
|
||||
Add to the existing `verificationToken` table in
|
||||
[src/app/db/schema/users.ts](../../src/app/db/schema/users.ts):
|
||||
|
||||
```ts
|
||||
export const verificationTokens = pgTable("verificationToken", {
|
||||
identifier: text("identifier").notNull(),
|
||||
token: text("token").notNull(),
|
||||
expires: timestamp("expires", { mode: "date" }).notNull(),
|
||||
codeHash: text("code_hash"), // ← new (nullable)
|
||||
attempts: integer("attempts").notNull().default(0), // ← new
|
||||
});
|
||||
```
|
||||
|
||||
Also new table for rate-limit state:
|
||||
|
||||
```ts
|
||||
export const authRateLimits = pgTable("authRateLimits", {
|
||||
scope: text("scope").notNull(), // "email-send" | "ip-verify"
|
||||
key: text("key").notNull(), // email or IP
|
||||
count: integer("count").notNull().default(0),
|
||||
windowStart: timestamp("window_start").notNull(),
|
||||
}, (t) => ({
|
||||
pk: primaryKey({ columns: [t.scope, t.key] }),
|
||||
}));
|
||||
```
|
||||
|
||||
Drizzle migration is additive — existing rows get `code_hash = NULL`,
|
||||
`attempts = 0`. Pre-deploy magic-link emails (those without codes) continue
|
||||
to work via the link path; users on the code-entry page will fail and need
|
||||
to resend. Acceptable transient.
|
||||
|
||||
### Code utility — `src/lib/auth/code.ts`
|
||||
|
||||
```ts
|
||||
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");
|
||||
}
|
||||
```
|
||||
|
||||
`crypto.randomInt` not `Math.random` — must be CSPRNG.
|
||||
|
||||
### `sendVerificationRequest` update
|
||||
|
||||
In [authOptions.ts:86-94](../../src/app/api/auth/[...nextauth]/authOptions.ts#L86-L94):
|
||||
|
||||
1. Generate code via `generateCode()`
|
||||
2. Hash with `NEXTAUTH_SECRET`
|
||||
3. `UPDATE verificationToken SET code_hash = ?, attempts = 0 WHERE identifier = ? AND token = ?`
|
||||
4. Pass `code` (plaintext) into `MagicLinksEmail`
|
||||
5. Existing logging unchanged
|
||||
|
||||
### Email template update
|
||||
|
||||
[magic_link.ts](../../src/app/email_templates/magic_link.ts) takes a new
|
||||
`code` argument. The HTML and plain-text bodies lead with:
|
||||
|
||||
> **Your sign-in code: 482 911**
|
||||
>
|
||||
> Enter this at `ara.domna.homes/auth/verify-code`
|
||||
>
|
||||
> Or click here to skip the code step: [Sign in to Ara] →
|
||||
|
||||
Code rendered large and monospace; the link is a smaller secondary button.
|
||||
|
||||
### NextAuth providers — add `CredentialsProvider`
|
||||
|
||||
In [authOptions.ts](../../src/app/api/auth/[...nextauth]/authOptions.ts):
|
||||
|
||||
```ts
|
||||
import CredentialsProvider from "next-auth/providers/credentials";
|
||||
|
||||
CredentialsProvider({
|
||||
id: "email-code",
|
||||
name: "Email Code",
|
||||
credentials: {
|
||||
email: { type: "text" },
|
||||
code: { type: "text" },
|
||||
},
|
||||
async authorize(credentials) {
|
||||
if (!credentials?.email || !credentials?.code) return null;
|
||||
const email = credentials.email.toLowerCase();
|
||||
const hashed = hashCode(credentials.code, process.env.NEXTAUTH_SECRET!);
|
||||
|
||||
// Look up the row by (identifier, code_hash, not-expired)
|
||||
// If found: check attempts < 5, delete row, return user
|
||||
// If found but attempts >= 5: delete row, return null
|
||||
// If not found: increment attempts on the row matching identifier (if any), return null
|
||||
// Rate-limit check before any of this; on exceed, return null silently
|
||||
},
|
||||
}),
|
||||
```
|
||||
|
||||
Verify the `signIn` callback at
|
||||
[authOptions.ts:114-206](../../src/app/api/auth/[...nextauth]/authOptions.ts#L114-L206)
|
||||
handles `account.type === "credentials"` cleanly (it should fall through the
|
||||
OAuth branches at lines 142 and 180, but worth a unit test).
|
||||
|
||||
### Drop magic-link maxAge
|
||||
|
||||
Change [authOptions.ts:84](../../src/app/api/auth/[...nextauth]/authOptions.ts#L84)
|
||||
from `60 * 60` to `60 * 10`. Code and link now share the 10-min window.
|
||||
|
||||
### New page — `src/app/auth/verify-code/page.tsx`
|
||||
|
||||
Code-input form. POSTs `{email, code}` to NextAuth credentials endpoint via
|
||||
`signIn("email-code", { email, code, redirect: false })`. Includes a
|
||||
"Resend code" button (rate-limited via the new `auth_rate_limits` table).
|
||||
|
||||
### Redirect change
|
||||
|
||||
[authOptions.ts:103](../../src/app/api/auth/[...nextauth]/authOptions.ts#L103):
|
||||
change `verifyRequest: "/auth/verify-request"` to
|
||||
`verifyRequest: "/auth/verify-code?email=<encoded>"` so post-submit lands on
|
||||
the code-entry page with the email pre-filled.
|
||||
|
||||
### Existing `/verify/[token]` page
|
||||
|
||||
Unchanged behaviour — the link path still works. Optionally add a small
|
||||
"Type the code instead" link to the code-entry page for the rare user who
|
||||
prefers it.
|
||||
|
||||
### Feature flag
|
||||
|
||||
Env var `AUTH_CODE_FALLBACK_ENABLED=true|false`. When `false`, skip the code
|
||||
generation in `sendVerificationRequest` and redirect to the old
|
||||
`/auth/verify-request` page. Lets us canary and roll back via env-var flip.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Submitting email at `/` lands on `/auth/verify-code` (when flag enabled)
|
||||
- The email body contains a clear 6-digit code AND a working link
|
||||
- Typing the code on the verify-code page signs the user in
|
||||
- Clicking the link still signs the user in (single artifact — using the
|
||||
link invalidates the code, and vice versa)
|
||||
- 5 wrong code attempts deletes the row; a 6th attempt with the correct code
|
||||
fails
|
||||
- 6th code request within an hour to the same email is silently no-op'd
|
||||
- After successful sign-in, behaviour identical to today (lands on `/home`,
|
||||
user record + `lastLogin` updated, etc.)
|
||||
|
||||
---
|
||||
|
||||
# Testing strategy
|
||||
|
||||
### CI (every PR)
|
||||
|
||||
- **Vitest** for pure-function unit tests (existing pattern — see
|
||||
[src/app/email_templates/buildMailHeaders.test.ts](../../src/app/email_templates/buildMailHeaders.test.ts)
|
||||
added in PR 1).
|
||||
- **Cypress** for E2E (existing harness at `cypress/e2e/`) covering:
|
||||
- Code path: submit → poll Mailpit → extract code → enter → land on `/home`
|
||||
- Link path: submit → poll Mailpit → extract URL → visit → confirm → land on `/home`
|
||||
- Wrong code: 5 wrong attempts invalidate; 6th with correct code fails
|
||||
- Expired code: code submitted >10 min after generation fails
|
||||
- **Mailpit** as a docker-compose service. SMTP at `:1025`, JSON API at
|
||||
`:8025`. Cypress task helpers (`cy.task`) call the Mailpit JSON API to
|
||||
extract codes/links from captured emails.
|
||||
- **Unit tests** for `generateCode()` distribution, `hashCode()`
|
||||
determinism, rate-limit math (Vitest).
|
||||
|
||||
### Pre-release (manual gate on template changes)
|
||||
|
||||
- Run the template through [mail-tester.com](https://mail-tester.com) before
|
||||
shipping. Target >9/10. Free, 60 seconds.
|
||||
|
||||
### Production
|
||||
|
||||
- Eyeball the SES Account dashboard weekly: bounce rate, complaint rate,
|
||||
reputation status.
|
||||
- One CloudWatch alarm: bounce rate >2% sustained → SNS → email the team.
|
||||
- When investigating an incident, temporarily subscribe an SQS queue or
|
||||
email endpoint to `dev-ses-events` SNS topic to capture events for the
|
||||
duration. Unsubscribe after.
|
||||
|
||||
When volume grows past ~50 sign-ups/day, pick up the backlog ticket for the
|
||||
SES event-consumer Lambda.
|
||||
|
||||
---
|
||||
|
||||
# Files touched (summary)
|
||||
|
||||
| Phase | File | Change |
|
||||
|---|---|---|
|
||||
| PR 1 | [src/app/email_templates/magic_link.ts](../../src/app/email_templates/magic_link.ts) | Add `X-SES-CONFIGURATION-SET` header; return `messageId` |
|
||||
| PR 1 | [src/app/api/auth/[...nextauth]/authOptions.ts](../../src/app/api/auth/[...nextauth]/authOptions.ts) | Replace `sendVerificationRequest` logging with try/catch + messageId |
|
||||
| PR 1 | Terraform `modules/ses/` | Add `renderingFailure` event type + cfg-set name output |
|
||||
| PR 1 | Deployment env vars | Add `SES_CONFIGURATION_SET=dev-ses-config` |
|
||||
| IT | DNS — `_dmarc.domna.homes` | Update DMARC TXT value |
|
||||
| IT | M365 admin | Create `noreply@domna.homes` shared mailbox |
|
||||
| PR 2 | [src/app/db/schema/users.ts](../../src/app/db/schema/users.ts) | Add `code_hash`, `attempts` columns; new `authRateLimits` table |
|
||||
| PR 2 | `src/lib/auth/code.ts` (new) | `generateCode()`, `hashCode()` |
|
||||
| PR 2 | [src/app/email_templates/magic_link.ts](../../src/app/email_templates/magic_link.ts) | Accept `code` arg; render code prominently in HTML + plaintext |
|
||||
| PR 2 | [src/app/api/auth/[...nextauth]/authOptions.ts](../../src/app/api/auth/[...nextauth]/authOptions.ts) | Add `CredentialsProvider`; generate+persist code in `sendVerificationRequest`; drop `maxAge` to 600; change `verifyRequest` redirect |
|
||||
| PR 2 | `src/app/auth/verify-code/page.tsx` (new) | Code-input form + resend button |
|
||||
| PR 2 | `src/lib/auth/rate-limit.ts` (new) | Per-email-send, per-code-attempt, per-IP-verify limiters backed by `authRateLimits` table |
|
||||
| PR 2 | `.github/workflows/test.yml` | Mailpit service + Cypress E2E |
|
||||
| PR 2 | `docker-compose.yml` | Mailpit local-dev service |
|
||||
| PR 2 | Deployment env vars | Add `AUTH_CODE_FALLBACK_ENABLED` flag |
|
||||
|
||||
---
|
||||
|
||||
# Open questions
|
||||
|
||||
None blocking. Possibilities to revisit after PR 2 ships:
|
||||
|
||||
- Do enterprise customers (paid relationships, formal SLAs) want Microsoft
|
||||
work-account OAuth as their primary sign-in?
|
||||
- Does the `.homes` TLD continue to be a deliverability bottleneck after
|
||||
MAIL FROM + DMARC `p=reject` are in place? If yes, evaluate moving sends
|
||||
to a `.com` / `.co.uk` we own.
|
||||
- Does the SES event volume justify the Lambda from the backlog ticket?
|
||||
|
|
@ -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,17 +88,222 @@ export const AuthOptions: NextAuthOptions = {
|
|||
pass: EMAIL_SERVER_PASSWORD,
|
||||
},
|
||||
},
|
||||
from: EMAIL_FROM, // noreply email
|
||||
maxAge: 60 * 60, // magic link valid for 1 hour
|
||||
// Slightly extended magic link email sender, to log sends
|
||||
sendVerificationRequest: async ({ identifier, url, provider }) => {
|
||||
console.log("EMAIL MAGIC LINK SENT:", {
|
||||
email: identifier,
|
||||
url,
|
||||
timestamp: new Date().toISOString(),
|
||||
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;
|
||||
});
|
||||
|
||||
await MagicLinksEmail({ identifier, url, provider });
|
||||
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,
|
||||
messageId,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("EMAIL_MAGIC_LINK_FAILURE", {
|
||||
email: identifier,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
}),
|
||||
|
||||
// ------------------ 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;
|
||||
});
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
|
@ -99,8 +312,8 @@ export const AuthOptions: NextAuthOptions = {
|
|||
// Pages
|
||||
// ------------------------------------------------------------------
|
||||
pages: {
|
||||
signIn: "/", // your landing/login page
|
||||
verifyRequest: "/auth/verify-request",
|
||||
signIn: "/",
|
||||
verifyRequest: "/auth/verify-code",
|
||||
error: "/api/auth/error",
|
||||
},
|
||||
|
||||
|
|
@ -176,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,
|
||||
|
|
|
|||
136
src/app/auth/verify-code/VerifyCodeForm.tsx
Normal file
136
src/app/auth/verify-code/VerifyCodeForm.tsx
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
"use client";
|
||||
|
||||
import { 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_MS = 30_000;
|
||||
|
||||
type VerifyStatus = "idle" | "verifying";
|
||||
type ResendStatus = "idle" | "resending" | "cooldown";
|
||||
|
||||
export default function VerifyCodeForm({ email }: { email: string }) {
|
||||
const router = useRouter();
|
||||
const [code, setCode] = useState("");
|
||||
const [verifyStatus, setVerifyStatus] = useState<VerifyStatus>("idle");
|
||||
const [resendStatus, setResendStatus] = useState<ResendStatus>("idle");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [resendNotice, setResendNotice] = useState<string | null>(null);
|
||||
const inFlightRef = useRef(false);
|
||||
|
||||
const isWorking =
|
||||
verifyStatus === "verifying" || resendStatus === "resending";
|
||||
|
||||
async function submitCode(value: string) {
|
||||
if (inFlightRef.current) return;
|
||||
if (!/^\d{6}$/.test(value)) return;
|
||||
inFlightRef.current = true;
|
||||
setVerifyStatus("verifying");
|
||||
setError(null);
|
||||
|
||||
const res = await signIn("email-code", {
|
||||
email,
|
||||
code: value,
|
||||
redirect: false,
|
||||
});
|
||||
|
||||
inFlightRef.current = false;
|
||||
|
||||
if (res?.ok) {
|
||||
router.push("/home");
|
||||
return;
|
||||
}
|
||||
|
||||
setVerifyStatus("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 (resendNotice) setResendNotice(null);
|
||||
if (digits.length === 6) void submitCode(digits);
|
||||
}
|
||||
|
||||
async function handleResend() {
|
||||
if (isWorking || resendStatus === "cooldown" || !email) return;
|
||||
setResendStatus("resending");
|
||||
setError(null);
|
||||
setResendNotice(null);
|
||||
|
||||
const res = await signIn("email", { email, redirect: false });
|
||||
|
||||
if (res?.error) {
|
||||
setResendStatus("idle");
|
||||
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.");
|
||||
setResendStatus("cooldown");
|
||||
setTimeout(() => setResendStatus("idle"), RESEND_COOLDOWN_MS);
|
||||
}
|
||||
|
||||
const resendLabel =
|
||||
resendStatus === "resending"
|
||||
? "Sending…"
|
||||
: resendStatus === "cooldown"
|
||||
? "Code sent — wait a moment"
|
||||
: "Resend code";
|
||||
|
||||
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"
|
||||
value={code}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
placeholder="••••••"
|
||||
className="h-12 text-center text-2xl tracking-[0.5em] font-mono"
|
||||
disabled={verifyStatus === "verifying"}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => submitCode(code)}
|
||||
disabled={isWorking || code.length !== 6}
|
||||
className="bg-brandbrown hover:bg-hoverblue w-full text-base py-3"
|
||||
>
|
||||
{verifyStatus === "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={isWorking || resendStatus === "cooldown" || !email}
|
||||
className="text-brandblue hover:underline disabled:text-gray-400 disabled:no-underline"
|
||||
>
|
||||
{resendLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
60
src/app/auth/verify-code/page.tsx
Normal file
60
src/app/auth/verify-code/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
10
src/app/db/migrations/0210_absent_dark_phoenix.sql
Normal file
10
src/app/db/migrations/0210_absent_dark_phoenix.sql
Normal 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;
|
||||
9972
src/app/db/migrations/meta/0210_snapshot.json
Normal file
9972
src/app/db/migrations/meta/0210_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
31
src/app/email_templates/buildMailHeaders.test.ts
Normal file
31
src/app/email_templates/buildMailHeaders.test.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { buildMailHeaders } from "./buildMailHeaders";
|
||||
|
||||
describe("buildMailHeaders", () => {
|
||||
it("includes X-SES-CONFIGURATION-SET when a configuration set is provided", () => {
|
||||
const headers = buildMailHeaders({
|
||||
fromAddress: "test-sender@example.test",
|
||||
sesConfigurationSet: "test-config-set",
|
||||
});
|
||||
|
||||
expect(headers["X-SES-CONFIGURATION-SET"]).toBe("test-config-set");
|
||||
});
|
||||
|
||||
it("omits X-SES-CONFIGURATION-SET entirely when no configuration set is provided", () => {
|
||||
const headers = buildMailHeaders({
|
||||
fromAddress: "test-sender@example.test",
|
||||
sesConfigurationSet: undefined,
|
||||
});
|
||||
|
||||
expect(headers).not.toHaveProperty("X-SES-CONFIGURATION-SET");
|
||||
});
|
||||
|
||||
it("always includes a List-Unsubscribe mailto pointing at the from address", () => {
|
||||
const headers = buildMailHeaders({
|
||||
fromAddress: "test-sender@example.test",
|
||||
sesConfigurationSet: undefined,
|
||||
});
|
||||
|
||||
expect(headers["List-Unsubscribe"]).toBe("<mailto:test-sender@example.test>");
|
||||
});
|
||||
});
|
||||
12
src/app/email_templates/buildMailHeaders.ts
Normal file
12
src/app/email_templates/buildMailHeaders.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
export function buildMailHeaders(opts: {
|
||||
fromAddress: string;
|
||||
sesConfigurationSet: string | undefined;
|
||||
}): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
"List-Unsubscribe": `<mailto:${opts.fromAddress}>`,
|
||||
};
|
||||
if (opts.sesConfigurationSet) {
|
||||
headers["X-SES-CONFIGURATION-SET"] = opts.sesConfigurationSet;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
|
@ -1,92 +1,90 @@
|
|||
// 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
|
||||
// Email template for user sign-in. Body contains a 6-digit code only — the
|
||||
// magic-link path is still wired (see /verify/[token] and the EmailProvider
|
||||
// callback) but the URL is intentionally omitted from the email to reduce
|
||||
// the content-scanner surface area for corporate email gateways.
|
||||
|
||||
import { createTransport } from "nodemailer";
|
||||
import { buildMailHeaders } from "./buildMailHeaders";
|
||||
|
||||
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;
|
||||
|
||||
const baseUrl = parsed.origin;
|
||||
const logoUrl = `${baseUrl}/domna-email-logo.png`;
|
||||
|
||||
const token = parsed.searchParams.get("token");
|
||||
const email = parsed.searchParams.get("email");
|
||||
|
||||
if (!token || !email) {
|
||||
throw new Error("Magic link token or email missing");
|
||||
}
|
||||
|
||||
// Create a clean login link instead of the NextAuth callback
|
||||
const loginUrl = `${parsed.origin}/verify/${token}`;
|
||||
const logoUrl = `${parsed.origin}/domna-email-logo.png`;
|
||||
|
||||
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 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, host }),
|
||||
html: domnaHtml({
|
||||
url: loginUrl,
|
||||
code,
|
||||
logoUrl,
|
||||
host,
|
||||
brandColor,
|
||||
accentColor,
|
||||
brown,
|
||||
background,
|
||||
}),
|
||||
headers: {
|
||||
"List-Unsubscribe": `<mailto:${provider.from}>`,
|
||||
},
|
||||
headers: buildMailHeaders({
|
||||
fromAddress: provider.from,
|
||||
sesConfigurationSet: process.env.SES_CONFIGURATION_SET,
|
||||
}),
|
||||
});
|
||||
|
||||
const failed = result.rejected.filter(Boolean);
|
||||
if (failed.length) {
|
||||
throw new Error(`Email(s) (${failed.join(", ")}) could not be sent`);
|
||||
}
|
||||
|
||||
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({
|
||||
url,
|
||||
code,
|
||||
logoUrl,
|
||||
host,
|
||||
brandColor,
|
||||
accentColor,
|
||||
brown,
|
||||
background,
|
||||
}: {
|
||||
url: string;
|
||||
code: string;
|
||||
logoUrl: string;
|
||||
host: string;
|
||||
brandColor: string;
|
||||
accentColor: string;
|
||||
brown: string;
|
||||
background: string;
|
||||
}) {
|
||||
const escapedHost = host.replace(/\./g, "​.");
|
||||
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"
|
||||
|
|
@ -96,16 +94,18 @@ 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 28px; 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;">
|
||||
Sign in to Ara
|
||||
</a>
|
||||
<p style="margin-top: 36px; font-size: 13px; color: #777;">
|
||||
<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>
|
||||
<p style="margin-top: 28px; font-size: 13px; color: #777;">
|
||||
If you didn’t request this email, you can safely ignore it.
|
||||
</p>
|
||||
</td>
|
||||
|
|
@ -120,6 +120,13 @@ 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, host }: { code: 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.
|
||||
|
||||
This code expires in 10 minutes. If you did not request this email, you can safely ignore it.
|
||||
`;
|
||||
}
|
||||
|
|
|
|||
95
src/app/lib/verificationCode.test.ts
Normal file
95
src/app/lib/verificationCode.test.ts
Normal 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" });
|
||||
});
|
||||
});
|
||||
43
src/app/lib/verificationCode.ts
Normal file
43
src/app/lib/verificationCode.ts
Normal 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 };
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue