Skip to content

feat(auth): email one-time codes — verification, passwordless login, password reset (S2)#299

Closed
oratis wants to merge 1 commit into
feat/s1-google-signinfrom
feat/s2-email-otp
Closed

feat(auth): email one-time codes — verification, passwordless login, password reset (S2)#299
oratis wants to merge 1 commit into
feat/s1-google-signinfrom
feat/s2-email-otp

Conversation

@oratis

@oratis oratis commented Jul 24, 2026

Copy link
Copy Markdown
Owner

Part of PLAN_WEB_SIGNUP_v1.0 — milestone S2. Stacked on #297 (S1); merge that first — GitHub will retarget this to main.

What

One OTP infrastructure, three uses:

  1. Signup email verification by code — the verification mail now leads with a 6-digit code (mobile-friendly; in-app browsers hijack links) and keeps the 24h link as fallback. New session-authed POST /api/auth/verify/confirm.
  2. Passwordless sign-inPOST /api/auth/email/code + POST /api/auth/email/login. Same trust root as an emailed reset link, so no new assumptions (plan §7 D4 verdict).
  3. Password reset — fills a real hole: there was no forgot-password flow at all. POST /api/auth/password/reset consumes a reset code and installs the new scrypt hash atomically, bumps sessionVersion (kills every outstanding session), and marks the account verified (the code just proved ownership).

Hardening

  • Codes hashed at rest (raw never persisted), 10 min TTL, 5-attempt burn (a 1M code space is only safe because guessing is capped), constant-time compare, purpose-scoped so a login code can never reset a password.
  • Send-cooldown (60s) keyed by (purpose, email) and armed uniformly for unknown emails — the 200-vs-429 pattern can't probe which addresses have accounts.
  • All three endpoints share the per-IP auth rate bucket; code-request always answers {ok:true} for existing and unknown emails alike.

Login page

Three-mode form (password / code / reset) with a send-code flow; new modes toggle required flags so hidden inputs never block validity. Covered by the LOGIN_HTML syntax guard from S1.

Tests

npm test: 1305 pass / 0 fail. New: 7 OTP cases (round-trip, purpose enforcement, uniform cooldown, attempt burn, expiry, reset semantics, malformed input) + 2 mail-compose cases.

🤖 Generated with Claude Code

…password reset (S2)

One OTP infra, three uses (PLAN_WEB_SIGNUP S2):

- accounts.ts: 6-digit codes (crypto.randomInt), sha256-at-rest, 10 min TTL,
  5-attempt burn, purpose-scoped (verify|login|reset), 60s send-cooldown
  applied uniformly to unknown emails so nothing enumerates accounts.
- resetPasswordWithOtp fills the missing forgot-password hole: atomic
  code-consume + scrypt install, sessionVersion bump (all old sessions die),
  and the code doubles as ownership proof so the account levels to verified.
- mailer.ts: generic sendMail + otpEmail template; the verification mail now
  leads with the code and keeps the 24h link as fallback.
- server.ts: POST /api/auth/email/code | /api/auth/email/login |
  /api/auth/password/reset (pre-gate, IP rate-bucketed) and session-authed
  POST /api/auth/verify/confirm; register + verify/resend mails carry codes.
- login page: three-mode form (password / code / reset) with send-code flow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@oratis

oratis commented Jul 25, 2026

Copy link
Copy Markdown
Owner Author

Review — approve with one fix ✅⚠️ (adversarial pass)

The one-time-code core is one of the cleaner OTP designs I've reviewed: crypto-random uniform 6-digit (crypto.randomInt, no modulo bias), a real 5-attempt cap that persists on failure (burns the code — the true brute-force guard), crypto.timingSafeEqual on hashes, single-use, 10-min TTL, purpose-scoped (a "verify"/"login" code can't drive "reset"), atomic reset that installs the new hash + bumps sessionVersion in one mutate, and enumeration-resistant (200 {ok:true} for both known/unknown, cooldown armed before the lookup, fire-and-forget send so no timing oracle). Reset/login codes require kind==="email" so a federated account can't be password-injected. All test-covered.

MUST-FIX — login-CSRF (Medium), shared root cause. Same readJsonBody gap as #297: no Content-Type/Origin check, so this PR adds three CSRF-reachable sinks. On /api/auth/email/login an attacker mints a code for their own account and cross-site-POSTs it (text/plain) → the victim's browser stores lisa_token=<attacker session> → session fixation. I'll land the central readJsonBody Content-Type fix (covers this + #297 + register/login/apple at once).

Non-blocking: when RESEND_API_KEY is unset, mailer.ts console.errors the full mail text — now including live login/reset codes — into logs; I'll gate that plaintext to dev/redact the code. Also: per-(purpose,email) single slot lets a "login" request clobber an in-flight "reset" code (self-healing); per-IP throttle is XFF-spoofable (pre-existing).

@oratis

oratis commented Jul 25, 2026

Copy link
Copy Markdown
Owner Author

Superseded by the A-series auth that landed on main via #305 (Land auth OTP + Google + mail A1–A6) — main already has Google sign-in + OTP, so this now conflicts on 7 files with no new content. The two review findings that were LIVE on main (login-CSRF via readJsonBody, and — for #301 — the birth crash-window) are fixed in #306. Closing as superseded per the plan; reopen if any piece here isn't actually covered by the A-series.

@oratis oratis closed this Jul 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant