feat(auth): A1 — sign in with a mailed one-time code (server)#289
feat(auth): A1 — sign in with a mailed one-time code (server)#289oratis wants to merge 2 commits into
Conversation
Passwordless sign-in for LISA accounts: request a 6-digit code, spend it, and you are in. Reading the mail is the proof of ownership, so one step both registers and authenticates — and the address comes out verified, which is what levels the free window from $1 to $5. - src/web/otp.ts: the code store behind the same file/Firestore seam as accounts.ts. One record per address, split into a challenge (hash, expiry, wrong-guess count) and a send budget (cooldown, daily cap) — the budget deliberately survives a spent code so redeeming one can't reset the mail-bombing cap. Codes are stored only as address-salted SHA-256 and compared in constant time; a code dies at the first of expiry, five wrong guesses, or being spent. - accounts.ts: ensureOtpAccount() — lookup and insert inside one mutation so a Firestore CAS retry can't create an address twice. Password material is now explicitly optional; a code-only account fails the password path against the decoy params like any other bad credential. - mailer.ts: the code mail, sharing one transport with the verification link. Without RESEND_API_KEY it degrades to a server log, so a real key is now a production requirement. - POST /api/auth/otp/request + /verify, pre-gate like the other sign-in surfaces, behind the same per-IP backstop as register/login. The request answer is identical whether or not the address has an account — no membership oracle. Password sign-in is untouched: App Review's demo account keeps working, and a code is an additional way in rather than a replacement. Plan: docs/PLAN_AUTH_OTP_GOOGLE_v1.0.md (A0/A1). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Thorough, well-tested OTP core — CSPRNG Blocker — account pre-hijacking.
The test "the password still works — the code is an additional way in" pins this behavior. On the unverified→verified transition (proof of inbox control), please bump Recommendation (non-blocking) — mail amplification. Minor: |
ensureOtpAccount() adopted a pre-existing email account by setting verified=true but KEEPING its scrypt and NOT bumping sessionVersion. Because /api/auth/register is open+unauthenticated, an attacker could pre-register victim@x.com with a known password; when the victim later signed in by code, the attacker's password still authenticated → shared/hijacked account. Add markVerifiedByOwnershipProof(): on the unverified→verified transition (proof of inbox control), drop any pre-set password and rotate sessionVersion, so a credential set before ownership was proven can no longer authenticate and any session minted from it is invalidated. A password the real owner sets AFTER verifying is unaffected. Updated the test that pinned the vulnerable "password still works" behavior; added an explicit attacker-scenario test. (Google byEmail path fixed on #290; the verify-link path + verifyEmailLogin-allows-unverified are flagged for review.) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…nt (HIGH) upsertGoogleAccount had two flaws: - byEmail bind adopted a pre-existing email account (verified=true) while KEEPING its scrypt and NOT bumping sessionVersion — same account pre-hijacking as the OTP path (#289): an attacker pre-registers victim@x.com/password, the victim signs in with Google, the attacker's password still authenticates. Now routes through markVerifiedByOwnershipProof(): a password set before verification is dropped and sessionVersion rotated. - bySub overwrote acct.email with no uniqueness check, so a Google email change onto an address another local account owns broke one-address-one-account and made getAccountByEmail order-dependent (split balance). Now refuses (email_taken) on a collision. Updated the test that pinned the vulnerable "password still works" bind; added a bySub-collision test. (Merges #289's OTP fix as the base of this stacked PR.) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Account-takeover fix pushed ( Fix: added Behavior change to confirm: a password set before verification is dropped (the owner sets a fresh one after signing in). OTP accounts normally carry no password, so this only affects a register-then-OTP sequence. Related, deliberately NOT changed — your call:
|
First of five stacked PRs adding passwordless email sign-in (OTP) and Google sign-in across web / iOS / Mac / CLI. Plan:
docs/PLAN_AUTH_OTP_GOOGLE_v1.0.md(included here, following the #259 precedent of landing the plan with its first milestone).What this does
Request a 6-digit code, spend it, you're in. Reading the mail is the proof of ownership, so a single step both registers and authenticates — and the address comes out verified, which levels the free window from $1 to $5 (
src/billing/quota.ts).Password sign-in is untouched beside it: App Review's demo account still needs user/pass, and for an existing account a code is an additional way in, not a replacement.
Pieces
src/web/otp.ts— the code store, behind the same file/Firestore seam asaccounts.ts. One record per address, split in two halves:accounts.ts—ensureOtpAccount(); lookup and insert share one mutation so a Firestore CAS retry can't create an address twice. Password material is now explicitly optional.mailer.ts— the code mail, sharing one transport with the verification link.POST /api/auth/otp/request+/verify— pre-gate like the other sign-in surfaces, behind the same per-IP backstop as register/login.Security notes for review
email:code(address-salted, so the same digits for two people don't collide), compared in constant time.verifyEmailLoginruns the decoy scrypt params and fails, same as any bad credential./requestreply is identical whether or not an account exists for the address — no membership oracle.RESEND_API_KEYthe code degrades to a server log. A production instance must have a working key + verified sending domain, or nobody receives a code.Tests
npm test→ 1145 tests green (+22: 14 OTP store, 5 account seam, 3 mailer). Typecheck clean.🤖 Generated with Claude Code