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 |
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