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
userIdfield (option b sidestepped — Phase A is Credentials-only, no adapter needed). Bespoke JWT stack collapsed from ~470 lines to a 60-line shim;getUserData()anduseAuth()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). E2Etests/e2e/auth.spec.tsis 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.
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.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.@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.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.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.authorize(). Port src/lib/actions/user.ts:153-251:
select("+password").isActive, lockoutUntil.bcrypt.compare (apply H2 dummy-bcrypt in Phase C).failedLoginAttempts; lock at 5; reset on success.lastLogin.loginRateLimiter from src/lib/utils/rateLimiter.ts.User shape) or null.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.).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.getUserData() consumers to auth() server helper from Auth.js:
src/lib/actions/auth.ts:164, 207 (getCurrentUser, refreshAuth)src/lib/actions/user.ts:25src/lib/actions/transactionCategory.ts (6 calls)src/lib/actions/transactionTemplate.ts (3 calls)src/app/api/import/normalize/route.tssrc/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).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.src/lib/auth.ts, src/lib/auth-cookies.ts, src/lib/auth-edge.tssrc/lib/utils/tokenBlacklist.ts, src/lib/utils/refreshTokenSession.tslogoutAction, etc.auth() server helper returns the same UserData shape callers expect.tests/e2e/auth.spec.ts rewritten and green.npx tsc --noEmit && npx next lint clean for touched files.userId vs custom adapter) — decide on day 1.