feat(auth): A3 — Sign in with Google (server)#290
Conversation
Google ID-token verification, and the account rule that keeps one person on one balance no matter which door they come through. - src/web/googleAuth.ts: the sibling of cloudAuth.ts — RS256 over Google's JWKS, zero dependencies, JWKS fetch and clock injected so it tests offline. Both issuer spellings; audience must be a client id we actually run (an empty config rejects rather than vacuously passing); nonce verified when present (Google echoes it raw, unlike Apple's hash). email_verified is REQUIRED — the address is what binds an identity to an existing account, so an unproven one would be a takeover primitive. - accounts.ts: one address, one account. Email and Google accounts both claim their address (EMAIL_OWNER_KINDS), so every lookup spans both kinds: signing in by Google onto an address that already has an email account BINDS to it (same uid, same balance) rather than forking a second one, and a mailed code onto a Google-owned address signs into that same account. Apple is excluded on purpose — private-relay addresses are per-app aliases, not a claim on an inbox. Resolution is sub-first, so changing the address on a Google account keeps the LISA one. - POST /api/auth/google, pre-gate like /api/auth/apple, behind the same per-IP backstop. /api/auth/config now advertises the web client id so the login page knows whether to draw the button. Off unless LISA_GOOGLE_WEB_CLIENT_ID / LISA_GOOGLE_IOS_CLIENT_ID are set. Plan: docs/PLAN_AUTH_OTP_GOOGLE_v1.0.md (A3). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
The verifier is exactly what I'd want: Blocker 1 — pre-hijacking via Blocker 2 — Non-blocking: the |
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>
…auth-google-server
…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>
|
Pre-hijacking + one-address-one-account fixes pushed (
Follow-ups from review (not in this PR): server should require the GIS nonce (currently optional → replay); This PR carries #289's OTP fix as its stacked base; both need reconciling with current |
* feat(auth): A1 — sign in with a mailed one-time code (server) 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> * fix(auth): close account pre-hijacking on OTP adoption (HIGH) 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> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Stacked on #289. Adds Sign in with Google server-side, and the account rule that keeps one person on one balance no matter which door they use.
Pieces
src/web/googleAuth.ts— the sibling ofcloudAuth.ts: RS256 over Google's JWKS, zero dependencies, JWKS fetch and clock injected so it tests offline.accounts.ts— the one-address-one-account rule (see below).POST /api/auth/google— pre-gate like/api/auth/apple, behind the same per-IP backstop./api/auth/confignow advertises the web client id so the login page knows whether to draw the button.Off unless
LISA_GOOGLE_WEB_CLIENT_ID/LISA_GOOGLE_IOS_CLIENT_IDare set.One address, one account
The thing worth reviewing closely. Email and Google accounts both claim their address (
EMAIL_OWNER_KINDS), so every lookup spans both kinds:email_taken.Resolution is sub-first, so someone who changes the address on their Google account keeps their LISA account. Apple is excluded on purpose: private-relay addresses are per-app aliases, not a claim on an inbox.
Security notes for review
email_verifiedis required, not advisory — the address is what binds an identity to an existing account, so an unproven one would be an account-takeover primitive. Both the boolean and the legacy string form are accepted; anything else is rejected.alg: none), unknown kid, foreign issuer, wrong/unconfigured audience, expiry incl. skew allowance.Tests
npm test→ 1172 tests green (+27: 18 verifier/config, 9 account binding). Typecheck clean.🤖 Generated with Claude Code