Snapshot as of 2026-05-19. Records the as-built state of auth after Phase C (integration API redesign) shipped. Not maintained — when the next major auth change lands (Phase D and beyond), write a new dated snapshot rather than editing this one. For the always-current view see
docs/architecture/05-authentication.mdin the repo.Why this exists: the ADR records why we migrated; the changelogs record what landed when; the Phase D backlog records what's next. None of those capture the cross-file picture of the system as it actually stands today, which is the gap this note fills. Pair with
[[Projects/personal-finance-notion/context/audit-2026-05-17-auth|audit 2026-05-17]]to follow the full arc (broken → decided → built).
Auth.js v5 (next-auth@5.0.0-beta) with Credentials + Google OAuth providers, JWT session strategy (sliding 7d / 24h update), custom mongoose adapter that keeps userId canonical, sessionsValidAfter-based JWT revocation on password reset, app-level token models for email-verify (24h) and password-reset (1h), and per-user pfn_live_* API keys for the integration surface. Email transport via Resend with a no-op stub when unconfigured.
Files newly authored or rewritten in the migration. Line refs are as of 2026-05-19 — they will drift, but the structure won't.
| File | Role | Notes |
|---|---|---|
src/auth.config.ts | Edge-safe NextAuthConfig — module augmentation, pages, session (JWT, 7d/24h), jwt/session callbacks for projection only | No DB access; safe to import in middleware |
src/auth.ts | Node entrypoint — providers list, signIn callback (OAuth gate), DB-backed session callback override, CredentialsSignin subclasses, authorize() | Wraps authConfig; reuses its callbacks then layers Node-only behavior |
src/middleware.ts | NextAuth(authConfig).auth(...) wrapper — public-path routing, redirect with callbackUrl, security headers, POST /login 204 short-circuit | Edge runtime |
src/lib/auth.ts | Compat shim — getUserData(), mappedUserData(), UserData (Zod schema) | ~60 lines; replaced the original ~470-line bespoke stack |
src/lib/auth/mongoose-adapter.ts | Custom Auth.js Adapter on top of existing mongoose models | OAuth + verification-token methods only; no database-session methods |
src/lib/auth-routes.ts | PUBLIC_AUTH_PATHS = ["/login", "/signup", "/forgot-password", "/reset-password", "/verify-email"] | Single source of truth for unauthenticated routes |
src/lib/auth-redirect.ts | AUTH_CALLBACK_PARAM constant + helpers | Used by middleware + login page |
src/lib/models/userModel.ts | Extended with emailVerified, image, sessionsValidAfter; password optional (OAuth users) | Mongoose pre("save") hashes password |
src/lib/models/accountModel.ts | Auth.js accounts table — (provider, providerAccountId) unique | Written only by adapter linkAccount |
src/lib/models/verificationTokenModel.ts | Auth.js adapter primitive — plaintext, used by adapter createVerificationToken / useVerificationToken | Not currently consumed by any flow (no magic-link provider); model exists for future use |
src/lib/models/emailVerificationTokenModel.ts | App-level email-verify tokens — sha256-hashed, TTL 24h, single-use | Distinct from above; this is the one the signup flow uses |
src/lib/models/passwordResetTokenModel.ts | Password-reset tokens — sha256-hashed, TTL 1h, single-use | |
src/lib/models/integrationKeyModel.ts | Per-user integration API keys — pfn_live_<random>, sha256-hashed | |
src/lib/actions/auth.ts | signup() (C3 full enumeration-safe), getCurrentUser(), deprecated loginAction/logoutAction stubs | |
src/lib/actions/passwordReset.ts | requestPasswordReset, resetPassword | Bumps sessionsValidAfter on success |
src/lib/actions/emailVerification.ts | issueEmailVerificationToken (internal), requestEmailVerification, verifyEmail | |
src/lib/actions/integrationKeys.ts | createIntegrationKey, listIntegrationKeys, revokeIntegrationKey | Session-scoped |
src/lib/integrations/requireIntegrationUser.ts | Bearer → sha256 → lookup, returns { userId, keyId } | Fire-and-forget lastUsedAt bump |
src/lib/email/sendTransactional.ts | Resend wrapper, stubs when RESEND_API_KEY unset | Swallows send errors (enumeration safety) |
src/components/auth/AuthContext.tsx | <SessionProvider> + useAuth() compat hook over useSession()/signIn()/signOut() | The ~10 consumer components compile unchanged |
src/components/auth/GoogleSignInButton.tsx | Inline G SVG + button that calls signIn("google", { callbackUrl }) | |
src/components/settings/IntegrationKeysSection.tsx | List/create/revoke UI inline on /config/user | One-time reveal of newly created keys |
src/app/api/auth/[...nextauth]/route.ts | Mounts handlers from auth.ts | One-liner |
src/app/(auth)/login/page.tsx | Credentials form + Google button; error banner for OAuthEmailNotVerified and EmailNotVerified | Wrapped in <Suspense> (Phase C build-fix) |
src/app/(auth)/signup/page.tsx | Credentials form + Google button; "Check your inbox" success state | C3 — same UI regardless of email status |
src/app/(auth)/forgot-password/page.tsx | Email form → requestPasswordReset | Generic "check your inbox" |
src/app/(auth)/reset-password/page.tsx | Token-bearing form → resetPassword | Wrapped in <Suspense> |
src/app/(auth)/verify-email/page.tsx | Token-bearing page → verifyEmail on mount | Wrapped in <Suspense> |
Files deleted in the migration (recorded for completeness):
src/lib/auth-cookies.ts, src/lib/auth-edge.ts, src/lib/utils/tokenBlacklist.ts, src/lib/utils/refreshTokenSession.ts, src/lib/models/tokenBlacklistModel.ts, src/lib/models/refreshTokenSessionModel.ts, src/lib/actions/integrationPreview.ts, src/lib/utils/api-client.ts::fetchCurrentUser.
/login form → signIn("credentials", { email, password, redirect: false }) (client).authorize() in src/auth.ts.LoginFormSchema.safeParse → on failure throws InvalidCredentials (code: "credentials").loginRateLimiter.isRateLimited(email) → on hit throws RateLimited (code: "rate_limited").User.findOne({ email }) with +password projection. Missing or !isActive → InvalidCredentials. (No bcrypt.compare in this branch — timing leak; Phase D).lockoutUntil check → AccountLocked if still locked. If expired, reset counters in-memory (saved later).user.comparePassword(password). On miss: increment failedLoginAttempts; if >= 5, set lockoutUntil = now + 30m, save, throw AccountLocked. Else save and throw InvalidCredentials.user.emailVerified check → EmailNotVerified (code: "email_not_verified") if null.newUser flag (no bank, no category, or no currency → true).lastLogin, save.{ id: userId, userId, email, name, role, newUser, currency }. Auth.js mints JWT.callbackUrl (or /).signIn("google", { callbackUrl })./api/auth/callback/google.signIn callback (auth.ts:67-79): checks profile.email_verified === true. If false → redirect to /login?error=OAuthEmailNotVerified. Otherwise return true.getUserByEmail(profile.email) runs (because allowDangerousEmailAccountLinking: true).linkAccount({ userId, provider, providerAccountId, ... }) writes a row in accounts joining the OAuth identity to the existing user. No new user created.createUser({ email, name, emailVerified: <date>, image }) runs. userId is generated via new Types.ObjectId().toHexString(). Then linkAccount runs.image is projected onto the session./forgot-password → requestPasswordReset({ email }).{ success: true } even when:
password-reset-no-account email is sent instead).passwordResetTokenModel with expiresAt = now + 1h, email the raw token to the user as ${baseUrl}/reset-password?token=<raw>./reset-password?token=... form → resetPassword({ token, password }).usedAt already set, or expired.user.password (pre-save hashes); set user.sessionsValidAfter = new Date() (kills JWTs on other devices); reset failedLoginAttempts/lockoutUntil.usedAt = new Date(). User redirected to /login.signup() action calls issueEmailVerificationToken({ userId, email, name }).emailVerificationTokenModel, TTL 24h, single-use.${baseUrl}/verify-email?token=<raw>./verify-email?token=... page runs verifyEmail({ token }) on mount.user.emailVerified = new Date(), token marked usedAt.authorize() no longer throws EmailNotVerified.pfn_live_* key from /config/user → External integrations · API keys (shown once).Authorization: Bearer <key> to /api/integrations/v1/*.requireIntegrationUser hashes the raw key (sha256), looks up IntegrationKey row by hash.revokedAt set, or expiresAt <= now.lastUsedAt bump (errors swallowed — telemetry only).{ userId, keyId } to the route handler; downstream actions use userId as before.The single most subtle piece of the design. Two NextAuth(...) instances coexist:
middleware.ts): NextAuth(authConfig). Uses the session callback in auth.config.ts which only projects token fields onto session.user. No DB access — required for the edge runtime.auth.ts via auth() helper, and signIn()/signOut()): NextAuth({ ...authConfig, callbacks: { ...authConfig.callbacks, session: <override> } }). The override calls the edge callback first to get the projected session, then does a single indexed User.findOne({ userId }, { sessionsValidAfter: 1 }). If jwt.iat * 1000 < cutoff.getTime(), returns { ...base, user: undefined as any } — effectively logged out.Consequence: a revoked session is still accepted by the edge middleware but rejected by every server-side auth() call. The route loads, but getUserData() returns null, which every server action and protected route treats as "not authenticated". A motivated reader can still see public chrome; they can't see data. Acceptable trade-off for the edge constraint.
Each "Scope (in)" item from [[Projects/personal-finance-notion/decisions/adr-2026-05-18-authjs-migration|ADR 2026-05-18]] mapped to where it landed:
| ADR item | Landed in | File(s) |
|---|---|---|
Replace src/lib/auth.ts, auth-cookies.ts, auth-edge.ts, middleware.ts, tokenBlacklist.ts, refreshTokenSession.ts | Phase A d5b94ce | All deleted; src/lib/auth.ts rewritten as 60-line shim |
Rewrite src/lib/actions/auth.ts, AuthContext.tsx, login + signup pages | Phase A + B | Phase A: Credentials wiring. Phase B: Google button + C3 signup + login error banner |
Extend src/lib/models/userModel.ts for Auth.js conventions | Phase B ea61b6a | emailVerified, image, sessionsValidAfter, password made optional |
| Build custom password-reset + email-verify flows | Phase B ea61b6a | Token models, server actions, pages, Resend wrapper, grandfather script |
Build new integration API key system; redesign requireIntegrationUser | Phase C 01d9ac3 | integrationKeyModel, rewritten requireIntegrationUser, settings UI |
Drift: zero on scope. The adapter strategy changed mid-flight from @auth/mongodb-adapter (ADR) to a custom mongoose adapter (revised in Phase B note, reasoned in the Phase B changelog). The cookie scheme inherited from Auth.js (authjs.session-token) matches the ADR's "Auth.js defaults" language.
| Var | Required? | Effect when set | Effect when unset |
|---|---|---|---|
AUTH_SECRET | Yes | JWT signing key | Process throws at startup |
AUTH_URL | Prod only | Used by Auth.js for absolute URLs | Auth.js infers from request — usually fine in dev |
NEXT_PUBLIC_APP_URL | Recommended | Used to build email links (resetUrl, verifyUrl) | Falls back to AUTH_URL, then http://localhost:3000 |
AUTH_GOOGLE_ID + AUTH_GOOGLE_SECRET | Optional (paired) | Google provider registered | Google provider not in providers list. Half-set throws at startup. |
RESEND_API_KEY + MAIL_FROM | Optional (paired) | Emails actually send via Resend | sendTransactional logs and returns { stubbed: true } — flows still succeed |
MONGODB_URI | Yes (inherited) | Mongoose connection | Falls back to mongodb://localhost:27017/personal-finance per CLAUDE.md |
Notable removed env vars from Phase A: JWT_SECRET_KEY, JWT_REFRESH_SECRET_KEY. Notable removed env var from Phase C: INTEGRATION_API_TOKEN_PREFIX.
Behaviors that exist in the code but aren't called out in the ADR or phase changelogs. Recording them here so the next reader doesn't think they're bugs.
Session-callback fail-open on DB outages. auth.ts:106-111 wraps the sessionsValidAfter lookup in try/catch and falls through to return base on any DB error. Mongo blip won't sign everyone out — but it also won't enforce revocation during the outage. Intentional trade-off, but undocumented in vault until now. Pattern: [[Resources/Tech/Auth.js/Auth.js session revocation via sessionsValidAfter]].
Middleware POST /login 204 short-circuit. middleware.ts:12-18 returns 204 on plain POST /login (no next-action header). Origin is unclear — a stray native form submit was hitting the route in a loop pre-Phase-A. May be removable; verify before deleting.
X-XSS-Protection set unconditionally. middleware.ts:36. The header is widely considered deprecated (modern browsers ignore it; recent guidance suggests omitting). Slated for removal alongside Phase D's CSP work.
Google provider half-config guard. auth.ts:28-32 throws if exactly one of AUTH_GOOGLE_ID/AUTH_GOOGLE_SECRET is set. Not mentioned anywhere in vault; positive addition — surfaces misconfig at boot instead of "OAuth just doesn't work".
Adapter updateUser does a blind $set spread. mongoose-adapter.ts:107-119. Whatever Auth.js decides to write lands on the user document. In practice it's emailVerified + OAuth profile fields; there's no allow-list. Low risk; flagging for posterity.
getUserData() returns null silently on Zod parse failure. src/lib/auth.ts:52-60. If the session shape drifts (e.g. Auth.js v6 reshapes the user object), every caller will quietly see "not authenticated" rather than an error. Worth a console.warn in a future cleanup.
AccountLocked and RateLimited share the same code namespace as InvalidCredentials. All three are CredentialsSignin subclasses; both lockout-just-happened and lockout-already-in-place throw AccountLocked with the same code. By design (timing-equal between the two), but worth knowing when debugging.
sendTransactional swallows Resend errors. sendTransactional.ts:133-144 returns success even on Resend failures (enumeration safety — caller can't distinguish "email sent" from "no account exists"). Failures visible only in the logger. Ops monitoring should alert on the error log line; the UI never knows.
emailVerified Google path has a double guard. Provider profile() sets emailVerified: null when email_verified !== true; the signIn callback also refuses the same case. Either alone would suffice; defense in depth is intentional.
userId generation is consistent across paths. Both Credentials createUser (actions/user.ts:127) and adapter createUser (mongoose-adapter.ts:72) use new Types.ObjectId().toHexString(). Only tests/seed-e2e-user.mjs uses sha256 — that's a test-only seed convenience, not a production discrepancy.
Pre-existing TS errors not introduced by migration. src/lib/actions/transaction.ts:1612-1614 (settings on FlattenMaps) and src/app/api/integrations/v1/integrations.routes.test.ts:42 (AbortSignal null) predate Phase A. Scheduled for Phase D cleanup.
Generalisable patterns from this implementation have been written up as evergreen sibling notes under Resources/Tech/Auth.js/:
_idallowDangerousEmailAccountLinking + double-guard patternThe existing [[Resources/Tech/Auth.js/Auth.js Next.js JWT Google MongoDB adapter pattern]] note covers the off-the-shelf @auth/mongodb-adapter path and remains the recommended starting point for projects that don't need a custom adapter.