p0-authjs-phase-a-foundation

donetype/backlogpriority/p0topic/authtopic/migration

p0 · Auth.js migration · Phase A · Foundation

TL;DR

Auth.js v5 foundation shipped: Credentials provider, middleware, AuthContext shim, legacy JWT stack removed (d5b94ce).

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

2026-05-18 update — shipped same day, faster than the W21 estimate. Adapter decision: kept the custom userId field (option b sidestepped — Phase A is Credentials-only, no adapter needed). Bespoke JWT stack collapsed from ~470 lines to a 60-line shim; getUserData() and useAuth() keep their public shape so the ~50 consumer call sites compile unchanged. Six legacy auth modules deleted. Smoke-verified via curl (unauth redirect, credentials sign-in, augmented session JSON, protected-route access, security headers). E2E tests/e2e/auth.spec.ts is black-box and works as-is — no rewrite needed.

Stand up Auth.js v5 with the Credentials provider against the existing user store. Replace middleware + AuthContext + every server consumer of getUserData(). Google OAuth is added in Phase B; password reset + email verify also Phase B.

Steps

  1. Install deps. npm i next-auth@5 @auth/mongodb-adapter. Add AUTH_SECRET (openssl rand -base64 32) to .env.local + .env.example. Add a startup check in src/lib/env*.ts.
  2. Extend userModel. Add emailVerified: Date | null (default null for credentials signups; set to new Date() on verify), image: string | null (Google avatar). Keep password (select: false), role, newUser, settings, failedLoginAttempts, lockoutUntil, userId.
  3. Confirm adapter compatibility. @auth/mongodb-adapter expects _id as ObjectId. Decide: (a) drop custom userId field and use _id.toHexString() everywhere, or (b) write a thin custom adapter that maps userId ↔ _id. Option (a) is cleaner long-term; (b) avoids touching every model that references userId. Test on a scratch branch first before committing.
  4. auth.config.ts (edge-safe). Providers list (Credentials only in Phase A), pages config (signIn: "/login", signOut: "/login", error: "/login"), authorized callback for middleware (returns boolean given auth + request). No DB imports here — edge runtime.
  5. auth.ts (Node). Re-export auth, signIn, signOut, handlers from next-auth. Add MongoDB adapter. Add jwt + session callbacks to map our UserData shape (currency, newUser, role, userId) into the JWT and session.
  6. Credentials authorize(). Port src/lib/actions/user.ts:153-251:
    • Pull user with select("+password").
    • Check isActive, lockoutUntil.
    • bcrypt.compare (apply H2 dummy-bcrypt in Phase C).
    • Increment failedLoginAttempts; lock at 5; reset on success.
    • Update lastLogin.
    • Wire loginRateLimiter from src/lib/utils/rateLimiter.ts.
    • Return user object (mapped to Auth.js's User shape) or null.
  7. Replace src/middleware.ts. Use Auth.js's auth helper directly: export { auth as middleware } from "@/auth". Migrate public-paths list from src/lib/auth-routes.ts into auth.config.ts::authorized callback. Keep the existing security headers block (HSTS, X-Frame-Options, etc.).
  8. Replace AuthContext. src/components/auth/AuthContext.tsx → thin wrapper around <SessionProvider> from next-auth/react. Expose a useAuth() shim that maps useSession() to the existing user, login, logout, refreshUserData API for minimum consumer churn. Or migrate consumers to useSession() directly.
  9. Migrate getUserData() consumers to auth() server helper from Auth.js:
    • src/lib/actions/auth.ts:164, 207 (getCurrentUser, refreshAuth)
    • src/lib/actions/user.ts:25
    • src/lib/actions/transactionCategory.ts (6 calls)
    • src/lib/actions/transactionTemplate.ts (3 calls)
    • src/app/api/import/normalize/route.ts
  10. Rewrite src/app/(auth)/login/page.tsx. Call signIn("credentials", { email, password, redirect: false }). Show errors from the result. Preserve "remember me" UI (Auth.js sliding session handles this implicitly).
  11. Rewrite src/app/(auth)/signup/page.tsx. Still calls createUser(name, email, password) (which becomes a thin wrapper that does the bcrypt + insert), then signIn("credentials", ...) to set the session.
  12. Delete dead code post-migration:
    • src/lib/auth.ts, src/lib/auth-cookies.ts, src/lib/auth-edge.ts
    • src/lib/utils/tokenBlacklist.ts, src/lib/utils/refreshTokenSession.ts
    • Defer until Phase A is fully green — these are still referenced by logoutAction, etc.

Acceptance

  • Login + signup work end-to-end via Auth.js Credentials.
  • auth() server helper returns the same UserData shape callers expect.
  • Account lockout (5 fails → 30min) still triggers.
  • Per-email rate limit still enforced on login.
  • Playwright tests/e2e/auth.spec.ts rewritten and green.
  • npx tsc --noEmit && npx next lint clean for touched files.

Open

  • Adapter strategy (drop userId vs custom adapter) — decide on day 1.
  • Sliding session vs refresh-family — accepting the trade per [[Projects/personal-finance-notion/decisions/adr-2026-05-18-authjs-migration|ADR]].
  • Migrate existing dev users (drop or script) — recommend drop, none are public.

Related

  • [[Projects/personal-finance-notion/decisions/adr-2026-05-18-authjs-migration|ADR 2026-05-18]]
  • [[Projects/personal-finance-notion/backlog/done/p0-authjs-phase-b-features|Phase B]]
  • [[Projects/personal-finance-notion/personal-finance-notion|MOC]]