auth-as-built-2026-05-19

Tue May 19 2026 07:00:00 GMT+0700 (Western Indonesia Time)snapshottype/contexttopic/authtopic/snapshot

Auth as-built — 2026-05-19

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.md in 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).

TL;DR

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.

File wiring map

Files newly authored or rewritten in the migration. Line refs are as of 2026-05-19 — they will drift, but the structure won't.

FileRoleNotes
src/auth.config.tsEdge-safe NextAuthConfig — module augmentation, pages, session (JWT, 7d/24h), jwt/session callbacks for projection onlyNo DB access; safe to import in middleware
src/auth.tsNode 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.tsNextAuth(authConfig).auth(...) wrapper — public-path routing, redirect with callbackUrl, security headers, POST /login 204 short-circuitEdge runtime
src/lib/auth.tsCompat shim — getUserData(), mappedUserData(), UserData (Zod schema)~60 lines; replaced the original ~470-line bespoke stack
src/lib/auth/mongoose-adapter.tsCustom Auth.js Adapter on top of existing mongoose modelsOAuth + verification-token methods only; no database-session methods
src/lib/auth-routes.tsPUBLIC_AUTH_PATHS = ["/login", "/signup", "/forgot-password", "/reset-password", "/verify-email"]Single source of truth for unauthenticated routes
src/lib/auth-redirect.tsAUTH_CALLBACK_PARAM constant + helpersUsed by middleware + login page
src/lib/models/userModel.tsExtended with emailVerified, image, sessionsValidAfter; password optional (OAuth users)Mongoose pre("save") hashes password
src/lib/models/accountModel.tsAuth.js accounts table — (provider, providerAccountId) uniqueWritten only by adapter linkAccount
src/lib/models/verificationTokenModel.tsAuth.js adapter primitive — plaintext, used by adapter createVerificationToken / useVerificationTokenNot currently consumed by any flow (no magic-link provider); model exists for future use
src/lib/models/emailVerificationTokenModel.tsApp-level email-verify tokens — sha256-hashed, TTL 24h, single-useDistinct from above; this is the one the signup flow uses
src/lib/models/passwordResetTokenModel.tsPassword-reset tokens — sha256-hashed, TTL 1h, single-use
src/lib/models/integrationKeyModel.tsPer-user integration API keys — pfn_live_<random>, sha256-hashed
src/lib/actions/auth.tssignup() (C3 full enumeration-safe), getCurrentUser(), deprecated loginAction/logoutAction stubs
src/lib/actions/passwordReset.tsrequestPasswordReset, resetPasswordBumps sessionsValidAfter on success
src/lib/actions/emailVerification.tsissueEmailVerificationToken (internal), requestEmailVerification, verifyEmail
src/lib/actions/integrationKeys.tscreateIntegrationKey, listIntegrationKeys, revokeIntegrationKeySession-scoped
src/lib/integrations/requireIntegrationUser.tsBearer → sha256 → lookup, returns { userId, keyId }Fire-and-forget lastUsedAt bump
src/lib/email/sendTransactional.tsResend wrapper, stubs when RESEND_API_KEY unsetSwallows 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.tsxInline G SVG + button that calls signIn("google", { callbackUrl })
src/components/settings/IntegrationKeysSection.tsxList/create/revoke UI inline on /config/userOne-time reveal of newly created keys
src/app/api/auth/[...nextauth]/route.tsMounts handlers from auth.tsOne-liner
src/app/(auth)/login/page.tsxCredentials form + Google button; error banner for OAuthEmailNotVerified and EmailNotVerifiedWrapped in <Suspense> (Phase C build-fix)
src/app/(auth)/signup/page.tsxCredentials form + Google button; "Check your inbox" success stateC3 — same UI regardless of email status
src/app/(auth)/forgot-password/page.tsxEmail form → requestPasswordResetGeneric "check your inbox"
src/app/(auth)/reset-password/page.tsxToken-bearing form → resetPasswordWrapped in <Suspense>
src/app/(auth)/verify-email/page.tsxToken-bearing page → verifyEmail on mountWrapped 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.

Sequences

Credentials sign-in

  1. User submits /login form → signIn("credentials", { email, password, redirect: false }) (client).
  2. Auth.js calls authorize() in src/auth.ts.
  3. LoginFormSchema.safeParse → on failure throws InvalidCredentials (code: "credentials").
  4. loginRateLimiter.isRateLimited(email) → on hit throws RateLimited (code: "rate_limited").
  5. User.findOne({ email }) with +password projection. Missing or !isActiveInvalidCredentials. (No bcrypt.compare in this branch — timing leak; Phase D).
  6. lockoutUntil check → AccountLocked if still locked. If expired, reset counters in-memory (saved later).
  7. user.comparePassword(password). On miss: increment failedLoginAttempts; if >= 5, set lockoutUntil = now + 30m, save, throw AccountLocked. Else save and throw InvalidCredentials.
  8. user.emailVerified check → EmailNotVerified (code: "email_not_verified") if null.
  9. Determine newUser flag (no bank, no category, or no currency → true).
  10. Reset counters, set lastLogin, save.
  11. Return user object: { id: userId, userId, email, name, role, newUser, currency }. Auth.js mints JWT.
  12. Client redirects to callbackUrl (or /).

Google OAuth sign-in

  1. User clicks "Continue with Google" → signIn("google", { callbackUrl }).
  2. Auth.js redirects to Google; user consents; Google redirects back to /api/auth/callback/google.
  3. signIn callback (auth.ts:67-79): checks profile.email_verified === true. If false → redirect to /login?error=OAuthEmailNotVerified. Otherwise return true.
  4. Adapter's getUserByEmail(profile.email) runs (because allowDangerousEmailAccountLinking: true).
  5. If an existing user matches the email: adapter's linkAccount({ userId, provider, providerAccountId, ... }) writes a row in accounts joining the OAuth identity to the existing user. No new user created.
  6. If no match: adapter's createUser({ email, name, emailVerified: <date>, image }) runs. userId is generated via new Types.ObjectId().toHexString(). Then linkAccount runs.
  7. JWT minted with the user's claims; image is projected onto the session.

Password reset

  1. /forgot-passwordrequestPasswordReset({ email }).
  2. Always returns { success: true } even when:
    • The email is unknown (a password-reset-no-account email is sent instead).
    • Rate-limited (3/hr per-email key namespace).
    • Validation fails or an internal error occurs.
  3. On the known-email happy path: mint 32-byte random token, store sha256 hash in passwordResetTokenModel with expiresAt = now + 1h, email the raw token to the user as ${baseUrl}/reset-password?token=<raw>.
  4. User clicks → /reset-password?token=... form → resetPassword({ token, password }).
  5. Hash token, lookup. Reject if not found, usedAt already set, or expired.
  6. Update user.password (pre-save hashes); set user.sessionsValidAfter = new Date() (kills JWTs on other devices); reset failedLoginAttempts/lockoutUntil.
  7. Mark token usedAt = new Date(). User redirected to /login.

Email verification

  1. New signup → signup() action calls issueEmailVerificationToken({ userId, email, name }).
  2. 32-byte random token, sha256 stored in emailVerificationTokenModel, TTL 24h, single-use.
  3. Email sent with link ${baseUrl}/verify-email?token=<raw>.
  4. User clicks → /verify-email?token=... page runs verifyEmail({ token }) on mount.
  5. Token validated; on success user.emailVerified = new Date(), token marked usedAt.
  6. User clicks "Sign in" — Credentials authorize() no longer throws EmailNotVerified.

Integration API request

  1. Operator obtains a pfn_live_* key from /config/user → External integrations · API keys (shown once).
  2. Agent sends Authorization: Bearer <key> to /api/integrations/v1/*.
  3. requireIntegrationUser hashes the raw key (sha256), looks up IntegrationKey row by hash.
  4. Reject 401 if missing, revokedAt set, or expiresAt <= now.
  5. Fire-and-forget lastUsedAt bump (errors swallowed — telemetry only).
  6. Return { userId, keyId } to the route handler; downstream actions use userId as before.

Session callback split (edge vs Node)

The single most subtle piece of the design. Two NextAuth(...) instances coexist:

  • Edge (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.
  • Node (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.

ADR scope reconciliation

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 itemLanded inFile(s)
Replace src/lib/auth.ts, auth-cookies.ts, auth-edge.ts, middleware.ts, tokenBlacklist.ts, refreshTokenSession.tsPhase A d5b94ceAll deleted; src/lib/auth.ts rewritten as 60-line shim
Rewrite src/lib/actions/auth.ts, AuthContext.tsx, login + signup pagesPhase A + BPhase A: Credentials wiring. Phase B: Google button + C3 signup + login error banner
Extend src/lib/models/userModel.ts for Auth.js conventionsPhase B ea61b6aemailVerified, image, sessionsValidAfter, password made optional
Build custom password-reset + email-verify flowsPhase B ea61b6aToken models, server actions, pages, Resend wrapper, grandfather script
Build new integration API key system; redesign requireIntegrationUserPhase C 01d9ac3integrationKeyModel, 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.

Env vars (with effects)

VarRequired?Effect when setEffect when unset
AUTH_SECRETYesJWT signing keyProcess throws at startup
AUTH_URLProd onlyUsed by Auth.js for absolute URLsAuth.js infers from request — usually fine in dev
NEXT_PUBLIC_APP_URLRecommendedUsed to build email links (resetUrl, verifyUrl)Falls back to AUTH_URL, then http://localhost:3000
AUTH_GOOGLE_ID + AUTH_GOOGLE_SECRETOptional (paired)Google provider registeredGoogle provider not in providers list. Half-set throws at startup.
RESEND_API_KEY + MAIL_FROMOptional (paired)Emails actually send via ResendsendTransactional logs and returns { stubbed: true } — flows still succeed
MONGODB_URIYes (inherited)Mongoose connectionFalls 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.

🟡 Quirks + gotchas

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.

  1. 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]].

  2. 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.

  3. 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.

  4. 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".

  5. 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.

  6. 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.

  7. 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.

  8. 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.

  9. 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.

  10. 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.

  11. 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.

Pattern extractions

Generalisable patterns from this implementation have been written up as evergreen sibling notes under Resources/Tech/Auth.js/:

  • [[Resources/Tech/Auth.js/Auth.js custom mongoose adapter pattern]] — when your canonical user id is not Mongo _id
  • [[Resources/Tech/Auth.js/Auth.js session revocation via sessionsValidAfter]] — JWT revocation without an extra DB-session table
  • [[Resources/Tech/Auth.js/Auth.js auto-link OAuth on verified email]] — allowDangerousEmailAccountLinking + double-guard pattern

The 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.

Related

  • [[Projects/personal-finance-notion/decisions/adr-2026-05-18-authjs-migration]] — the decision this realises
  • [[Projects/personal-finance-notion/context/audit-2026-05-17-auth]] — what was broken before
  • [[Projects/personal-finance-notion/backlog/done/p0-authjs-phase-d-pre-launch-hardening]] — Phase D done (2026-05-24); ops: [[Projects/personal-finance-notion/backlog/p2-csp-enforce-production|CSP enforce]]
  • [[Projects/personal-finance-notion/changelog/2026-05-18-authjs-phase-a-foundation]]
  • [[Projects/personal-finance-notion/changelog/2026-05-19-authjs-phase-b-features]]
  • [[Projects/personal-finance-notion/changelog/2026-05-19-integration-api-keys]]
  • [[Projects/personal-finance-notion/personal-finance-notion|MOC]]