2026-05-19-authjs-phase-b-features

Tue May 19 2026 07:00:00 GMT+0700 (Western Indonesia Time)type/changelogtopic/authtopic/migrationtopic/email

Auth.js v5 Phase B — Google OAuth, email verify, password reset (ea61b6a)

Phase B of the Auth.js v5 migration. Completes the full user-facing auth surface: Google provider, transactional email via Resend, password reset (was C1), email verification gate (was C2), and enumeration-safe signup (C3 full fix).

What shipped

Google OAuth

  • Google provider registered with allowDangerousEmailAccountLinking: true — verified-email auto-link rather than block.
  • signIn callback guards profile.email_verified === true; unverified Workspace accounts are refused with /login?error=OAuthEmailNotVerified.
  • GoogleSignInButton component (inline SVG, calls signIn("google", { callbackUrl })). Appears on both /login and /signup.

Custom mongoose adapter (src/lib/auth/mongoose-adapter.ts)

  • ~120 lines mapping Auth.js Adapter interface onto existing mongoose models.
  • userId stays canonical — toAdapterUser() returns id: user.userId (not _id).
  • Implements: createUser, getUser, getUserByEmail, getUserByAccount, updateUser, deleteUser, linkAccount, unlinkAccount, createVerificationToken, useVerificationToken.
  • New accounts collection (src/lib/models/accountModel.ts) with unique index on { provider, providerAccountId }.

Email verification gate (was C2)

  • emailVerificationTokenModel.ts — 32-byte random, sha256-hashed, 24h TTL, one-time use.
  • issueEmailVerificationToken() internal helper; verifyEmail(token) server action.
  • Credentials authorize() throws EmailNotVerified (CredentialsSignin subclass, code: "email_not_verified") if user.emailVerified is null.
  • Login page shows friendly banner for email_not_verified error code.
  • /verify-email?token= page fires verifyEmail() on mount; shows pending/success/error states.
  • Google OAuth users get emailVerified set automatically from Google's email_verified claim — no token needed.
  • Grandfather migration (tests/grandfather-email-verified.mjs) — stamped 6 local dev users; run once before or after deploy.

Password reset (was C1)

  • passwordResetTokenModel.ts — sha256-hashed, 1h TTL, usedAt single-use guard.
  • requestPasswordReset(email) — rate-limited 3/h per email, always returns generic success (enumeration-safe).
  • resetPassword(token, newPassword) — validates hash, bcrypt-hashes new password, resets failedLoginAttempts/lockoutUntil, bumps sessionsValidAfter = new Date() to revoke all existing sessions.
  • /forgot-password — email input, "check your inbox" success state.
  • /reset-password?token= — password + confirm form, redirects to /login on success.

Session revocation via sessionsValidAfter

  • New field on userModel.ts (Date | null, default null).
  • Node-side session callback in auth.ts does one indexed findOne per request; if jwt.iat < sessionsValidAfter, returns user: undefined to kill the session.
  • Password reset (and future "log out all devices") bumps this field.

C3 full enumeration-safe signup

  • signup() in src/lib/actions/auth.ts completely rewritten.
  • Always returns { success: true, awaitingVerification: true } for all four paths:
    1. New email → createUser + issueEmailVerificationToken
    2. Existing unverified → re-issue verification token
    3. Existing verified → fire account-exists-already email
    4. Rate-limited → silent success
  • Signup page shows "Check your inbox" state on success instead of auto-redirecting.

Transactional email (src/lib/email/sendTransactional.ts)

  • Resend SDK wrapper. No-op stub when RESEND_API_KEY is unset (dev/test convenience).
  • Templates: email-verify, password-reset, password-reset-no-account, account-exists-already.

Model changes

  • userModel.ts: added emailVerified: Date | null, image: string | null, sessionsValidAfter: Date | null; password made non-required (OAuth users have no password).
  • New models: accountModel.ts, passwordResetTokenModel.ts, emailVerificationTokenModel.ts, verificationTokenModel.ts.

New public routes

  • /forgot-password, /reset-password, /verify-email added to PUBLIC_AUTH_PATHS in src/lib/auth-routes.ts.

Seed + migration scripts

  • tests/seed-e2e-user.mjs — sets emailVerified: new Date() on insert and retroactively patches the E2E user.
  • tests/grandfather-email-verified.mjs — one-shot idempotent migration for existing dev/prod users.

Decisions made during implementation

  • Adapter: custom mongoose adapter (option b) — keeps userId canonical, avoids _id mapping hacks.
  • Session revocation: sessionsValidAfter: Date field (option b from plan) — simpler than tokenVersion, enables "log out all devices" as a one-liner later.
  • Account-link policy: auto-link on verified email match (allowDangerousEmailAccountLinking) — changed from "block" in the plan after confirming safe for personal accounts.
  • Grandfather: stamp emailVerified = now on existing users (option a) — keeps E2E seed and dev accounts working without disruption.

What's next

Phase C: [[Projects/personal-finance-notion/backlog/done/p0-authjs-phase-c-hardening|Phase C · Integration API]]. Phase D: [[Projects/personal-finance-notion/backlog/done/p0-authjs-phase-d-pre-launch-hardening|H1/H2/M2/M5 + E2E]].

As-built reference

  • [[Projects/personal-finance-notion/context/auth-as-built-2026-05-19]] — cross-file picture of the migrated stack as of 2026-05-19, including all Phase B pieces shipped in this commit. The undocumented behaviors flagged in "Quirks + gotchas" there (notably the session-callback fail-open and swallowed Resend errors) are relevant operational context for this work.