p0-authjs-phase-b-features

donetype/backlogpriority/p0topic/authtopic/migrationtopic/email

p0 · Auth.js migration · Phase B · Features

TL;DR

Google OAuth, transactional email, password reset, email verification gate, and C3 enumeration-safe signup shipped (ea61b6a).

Status: done (2026-05-19, commit ea61b6a) · Severity: CRITICAL · Source: [[Projects/personal-finance-notion/decisions/adr-2026-05-18-authjs-migration|ADR 2026-05-18]]

See [[Projects/personal-finance-notion/changelog/2026-05-19-authjs-phase-b-features|changelog entry]] for full shipped summary.

With Auth.js running (Phase A — shipped d5b94ce), add the rest of the user-facing surface: Google OAuth, transactional email, password reset (was C1), email verification (was C2).

Phase A handoff — what's already in place

  • src/auth.config.ts + src/auth.ts are mounted; provider list currently has Credentials only. Adding Google is a one-line addition in auth.config.ts (provider works in edge mode too).
  • auth.config.ts::jwt callback already supports trigger === "update" payload merging — useful for the tokenVersion bump approach to session revocation.
  • auth.config.ts::session already projects userId/role/newUser/currency onto session.user. Add emailVerified to the augmentation + projection when verification lands.
  • Public path routing is currently handled in src/middleware.ts (not in authorized callback). New public routes (/forgot-password, /reset-password, /verify-email) need adding to PUBLIC_AUTH_PATHS in src/lib/auth-routes.ts.
  • src/components/auth/AuthContext.tsx is a thin compat shim over next-auth/react. Adding a "Continue with Google" button on login/signup pages calls signIn("google") directly — no AuthContext changes needed.

Open questions to resolve before coding

  1. MongoDB adapter strategy. Phase A sidestepped this — Credentials doesn't need an adapter. Google OAuth + email verification DO need one (accounts, verificationTokens collections). Two options:

    • (a) Use @auth/mongodb-adapter as-is. It writes to users, accounts, verificationTokens with _id as the user id. Our schema's custom userId field would be unpopulated for OAuth-created users — every model that queries findOne({ userId }) would miss them. Mitigation: in events.createUser, patch user.userId = user._id.toString(). Works but feels hacky.
    • (b) Custom mongoose adapter. ~80 lines. Maps Auth.js's Adapter interface onto mongoose models we control. users collection keeps userId as the canonical app-side id; we generate it in the create hook. Cleaner, more code to maintain.
    • Recommendation: (b). Phase A's user.ts already uses mongoose; consistency wins over adapter convenience.
  2. Session revocation under JWT. Plan calls for option (a) — tokenVersion: number field on user, bumped on password reset, checked in jwt callback. Confirm: do we also want logout-all-devices as a user-facing setting? If yes, the same tokenVersion bump powers it.

  3. Google OAuth profile mapping. Google returns picture (URL). We're adding image: string | null to userModel. Also email_verified (boolean) → maps to emailVerified: Date | null (set to new Date() if true).

  4. Existing dev users. None have emailVerified set. Decision in Phase A: drop existing dev users (no public users yet). Run a one-shot migration: db.users.updateMany({}, { $set: { emailVerified: new Date() } }) to grandfather existing accounts in dev, OR delete and re-create. Recommendation: grandfather — keeps the e2e seed working without touching tests/seed-e2e-user.mjs.

Env vars to provision before Phase B starts

  • AUTH_GOOGLE_ID + AUTH_GOOGLE_SECRET — from Google Cloud Console (create OAuth 2.0 client; authorized origins http://localhost:3000, https://<prod>; redirect URIs <origin>/api/auth/callback/google).
  • RESEND_API_KEY — from Resend dashboard (free tier ~3k/mo is plenty for v1).
  • MAIL_FROM — verified sender on Resend (e.g. noreply@<your-domain>); for dev, Resend allows onboarding@resend.dev.

Steps

  1. Google provider.

    • Google Cloud Console → create OAuth 2.0 client. Authorized JS origins: http://localhost:3000, https://<prod-host>. Redirect URIs: <origin>/api/auth/callback/google.
    • Add AUTH_GOOGLE_ID, AUTH_GOOGLE_SECRET to env + startup check + .env.example.
    • Register provider in auth.config.ts with allowDangerousEmailAccountLinking: true (the "dangerous" qualifier is generic; for Google personal accounts it's safe because Google verifies emails before issuing tokens — see step 2 for the guard).
    • "Continue with Google" button on login + signup pages.
    • Verify the JWT callback also reads emailVerified from the user (Google sets it).
  2. Account-link policy — auto-link on verified-email match (revised 2026-05-18 from earlier "block"; see [[Projects/personal-finance-notion/decisions/adr-2026-05-18-authjs-migration|ADR §Account-link policy]]).

    In the signIn callback:

    // For OAuth (google):
    //   Guard: require profile.email_verified === true. If false, refuse with
    //   /login?error=OAuthEmailNotVerified — friendly message asking the user
    //   to verify the email with the OAuth provider first.
    //   Otherwise, allowDangerousEmailAccountLinking handles the actual link:
    //     - existing Credentials user with same email → new `accounts` row
    //       points to that user's _id; one logical user, two sign-in methods.
    //     - no existing user → adapter creates a new user with emailVerified set.
    //
    // For Credentials:
    //   Always allow (we already gate on user.emailVerified !== null elsewhere).
    //   If the email matches an existing OAuth-only user, the linkAccount call
    //   in the adapter will associate this Credentials login on next signup —
    //   but that path is essentially "user originally signed up via Google,
    //   later sets a password via /forgot-password while signed in" — not a
    //   real signup-time concern.
    

    Implementation specifics for the custom mongoose adapter (decision b):

    • getUserByEmail(email) must do case-insensitive match against the existing users.email field (which is stored lowercase).
    • linkAccount({ userId, provider, providerAccountId, ... }) inserts into a new accounts collection. Index: { provider: 1, providerAccountId: 1 } unique.
    • getUserByAccount({ provider, providerAccountId }) joins accounts → users.

    Login page shows the OAuthEmailNotVerified error inline only — most real users never hit it.

  3. Email transport.

    • npm i resend.
    • Add RESEND_API_KEY, MAIL_FROM to env + startup check + .env.example.
    • New src/lib/email/sendTransactional.ts — thin wrapper: sendTransactional({ template, to, data }).
    • Templates: password-reset, email-verify, account-exists-already.
  4. Password reset flow (was C1, see [[Projects/personal-finance-notion/backlog/done/p0-c1-password-reset|C1 note]] for full design).

    • New src/lib/models/passwordResetTokenModel.ts: { userId, tokenHash (sha256), expiresAt (TTL 1h), usedAt, createdAt } + compound index on userId+createdAt.
    • New src/lib/actions/passwordReset.ts: requestPasswordReset(email) (rate-limited 3/h per email + 5/h per IP, always returns generic success — enumeration-safe), resetPassword(token, newPassword) (single-use; on success, revoke all Auth.js sessions for the user).
    • Auth.js session revocation (decision 2026-05-18: option b — sessionsValidAfter: Date). On password reset (and any future "log out all devices" trigger), set user.sessionsValidAfter = new Date(). In the jwt callback, compare the JWT's iat claim against the user's sessionsValidAfter (one indexed findOne per request). If iat < sessionsValidAfter, return null to kill the session. Same findOne returns tokenVersion-equivalent info for free, so a "Log out all devices" toggle in user settings is a one-line addition later.
    • New routes /forgot-password, /reset-password?token=.... Add to public allowlist in auth.config.ts::authorized.
  5. Email verification flow (was C2, see [[Projects/personal-finance-notion/backlog/done/p0-c2-email-verification|C2 note]] for full design).

    • New src/lib/models/emailVerificationTokenModel.ts: 32-byte random, sha256-hashed, TTL 24h, one-time use.
    • Signup issues token + sends email. Update signIn callback for Credentials to reject if user.emailVerified === null. Show "Please verify your email" on /login?error=EmailNotVerified with a "resend verification" button.
    • For Google OAuth users, Auth.js auto-sets emailVerified from Google's email_verified claim — no custom work.
    • New route /verify-email?token=.... Validates token, sets user.emailVerified = new Date(), signs the user in, redirects to /.
  6. Pair C3 enumeration-safe signup with C2. Once email verify is in place: signup always returns "Check your inbox" regardless of whether the email was new or already registered. Existing-user path silently fires the account-exists-already template. Response shape becomes identical → C3 fully fixed.

Acceptance

  • Google OAuth signin/signup works end-to-end. New Google user lands in / with emailVerified set.
  • Auto-link: an existing Credentials user (e.g. mg@example.com with password) can sign in with Google using the same email and lands in the same account — bank accounts, transactions, settings all intact. The accounts collection gets a new row linking the Google identity to the existing user _id. No data duplication.
  • email_verified === false path refused with OAuthEmailNotVerified (manually verifiable by mocking the Google profile in a unit test or via an actual unverified Workspace account).
  • Credentials signup → verify email arrives → click → can sign in. Unverified credentials user can't sign in.
  • Password reset: unknown email returns 200 with no email sent; known email gets reset link; link single-use; sessionsValidAfter bumped → existing sessions on other devices die on next request; new password works.
  • Signup with existing email returns same UI as a fresh signup; existing user receives the "account already exists" email.
  • Playwright specs added: password-reset.spec.ts, email-verify.spec.ts, account-link.spec.ts.

Related

  • [[Projects/personal-finance-notion/backlog/done/p0-c1-password-reset|C1 design (done)]]
  • [[Projects/personal-finance-notion/backlog/done/p0-c2-email-verification|C2 design (done)]]
  • [[Projects/personal-finance-notion/backlog/done/p0-c3-signup-enumeration|C3 (done)]]
  • [[Projects/personal-finance-notion/backlog/done/p0-authjs-phase-c-hardening|Phase C]]
  • [[Projects/personal-finance-notion/personal-finance-notion|MOC]]