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).
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.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.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:
@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.users collection keeps userId as the canonical app-side id; we generate it in the create hook. Cleaner, more code to maintain.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.
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).
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.
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.Google provider.
http://localhost:3000, https://<prod-host>. Redirect URIs: <origin>/api/auth/callback/google.AUTH_GOOGLE_ID, AUTH_GOOGLE_SECRET to env + startup check + .env.example.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).emailVerified from the user (Google sets it).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.
Email transport.
npm i resend.RESEND_API_KEY, MAIL_FROM to env + startup check + .env.example.src/lib/email/sendTransactional.ts — thin wrapper: sendTransactional({ template, to, data }).password-reset, email-verify, account-exists-already.Password reset flow (was C1, see [[Projects/personal-finance-notion/backlog/done/p0-c1-password-reset|C1 note]] for full design).
src/lib/models/passwordResetTokenModel.ts: { userId, tokenHash (sha256), expiresAt (TTL 1h), usedAt, createdAt } + compound index on userId+createdAt.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).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./forgot-password, /reset-password?token=.... Add to public allowlist in auth.config.ts::authorized.Email verification flow (was C2, see [[Projects/personal-finance-notion/backlog/done/p0-c2-email-verification|C2 note]] for full design).
src/lib/models/emailVerificationTokenModel.ts: 32-byte random, sha256-hashed, TTL 24h, one-time use.signIn callback for Credentials to reject if user.emailVerified === null. Show "Please verify your email" on /login?error=EmailNotVerified with a "resend verification" button.emailVerified from Google's email_verified claim — no custom work./verify-email?token=.... Validates token, sets user.emailVerified = new Date(), signs the user in, redirects to /.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.
/ with emailVerified set.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).sessionsValidAfter bumped → existing sessions on other devices die on next request; new password works.password-reset.spec.ts, email-verify.spec.ts, account-link.spec.ts.