Custom auth stack: bcryptjs password hashing (cost 10, GoTrue-compatible), jose JWT session cookies (edge-safe, 8hr TTL), new API routes for login/logout/reset/change-password, middleware rewritten to JWT-only verification with no DB access. All 38 protected pages and API routes migrated from supabase.auth.getUser() to getSession(). Supabase .from() queries retained for Phase 4. lib/db/index.ts refactored to lazy Proxy singleton to avoid module-level throw during Next.js build. tsc: clean, build: clean, tests: 4/4 passed Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
5.4 KiB
5.4 KiB
Phase 3 Report — Custom Auth (bcryptjs + jose)
Status: COMPLETE
Date: 2026-07-23
Branch: main
Summary
Replaced Supabase GoTrue authentication with a custom bcryptjs + jose stack. All 38 protected pages and API routes migrated. TypeScript clean, build clean, auth unit tests passing.
New Files Created
| File | Purpose |
|---|---|
lib/auth/session.ts |
JWT sign/verify via jose; createSession, verifySession, SessionPayload |
lib/auth/password.ts |
hashPassword / verifyPassword via bcryptjs at cost 10 |
lib/auth/get-session.ts |
Server-only cookie reader; returns SessionPayload | null |
lib/notifications/mailer.ts |
sendPasswordResetEmail via Brevo raw fetch |
app/api/auth/login/route.ts |
POST: bcrypt verify → JWT → set ims_session cookie |
app/api/auth/logout/route.ts |
POST: clear ims_session cookie |
app/api/auth/reset-request/route.ts |
POST: create password_reset_tokens row, send email |
app/api/auth/reset-confirm/route.ts |
POST: validate token hash, update password hash |
app/api/auth/change-password/route.ts |
POST: verify current password, update hash |
supabase/migrations/20260724000001_password_reset_tokens.sql |
password_reset_tokens table |
tests/lib/auth/session.test.ts |
Vitest: JWT create/verify round-trip, invalid token |
tests/lib/auth/password.test.ts |
Vitest: hash/verify, Supabase-style $2a$ hash compat |
Modified Files
Core Auth Infrastructure
lib/auth/require-admin.ts— rewritten: returns{ session: SessionPayload | null }(was{ supabase, user })middleware.ts— rewritten: edge-safe JWT-only verification, no DB importslib/db/index.ts— lazy singleton Proxy pattern to prevent module-level throw during Next.js build static analysis whenDATABASE_URLnot set in build envlib/db/schema.ts— addedpasswordResetTokenstable definition
UI Components
components/auth/login-form.tsx— POST to/api/auth/logininstead of supabase signIncomponents/layout/sidebar.tsx— POST to/api/auth/logoutinstead of supabase signOutapp/(auth)/forgot-password/page.tsx— POST to/api/auth/reset-requestapp/(auth)/reset-password/page.tsx— POST to/api/auth/reset-confirm, readstokenfrom search paramscomponents/account/change-password-form.tsx— POST to/api/auth/change-password
Admin User CRUD
app/api/admin/users/route.ts— POST creates user withhashPassword+ Drizzle insert; DELETE removes row directly; all handlers use{ session }fromrequireAdmin()app/api/admin/sites/route.ts— updated to{ session }pattern + explicitcreateClient()for.from()callsapp/api/admin/trucks/route.ts— same
Storage
lib/supabase/storage.ts— removedsupabase.auth.getUser()call; addeduserId: stringas 5th parameter touploadEvidenceFile
38 Protected Pages and API Routes
Pattern applied to all:
import { getSession } from '@/lib/auth/get-session'const session = await getSession()replacessupabase.auth.getUser()session.subreplacesuser.idsession.rolereplaces profile fetch from DBsession.siteIdreplacesprofile.site_idcreateClient()retained where.from()queries still exist (Phase 4 will remove these)
Issues Fixed During Implementation
app/api/admin/sites/route.tsandapp/api/admin/trucks/route.ts— not in original brief scope but broken byrequireAdminsignature change; fixed.tests/lib/auth/password.test.ts— brief haddescribe(name, fn, options)which is wrong Vitest API; fixed todescribe(name, fn).tests/lib/supabase/storage.test.ts—uploadEvidenceFilesignature addeduserId; all 3 call sites updated; removed now-unusedauth.getUsermock..next/types/validator.ts— stale reference to deleted/api/auth/callback/route.ts; removed the block.- All 5 auth API routes — missing
export const dynamic = 'force-dynamic'; added to prevent Next.js static pre-rendering. lib/db/index.ts— module-levelthrowwhenDATABASE_URLunset failed build's "collect page data" phase even withforce-dynamic; refactored to lazy Proxy singleton.- Test environment — vitest global config uses
jsdom; jose and bcryptjs use nativeUint8Arraywhich failsinstanceofcheck across jsdom/Node realms; fixed with// @vitest-environment nodein both auth test files.
Invariants Preserved
middleware.tsimports onlyjoseandnext/server— nopg,drizzle-orm, or DB connections- All
.from()Supabase queries retained untouched (Phase 4 scope) server-onlyimport inget-session.tsprevents client-side use- Passwords hashed at bcrypt cost 10 — compatible with existing Supabase GoTrue hashes
Verification
npx tsc --noEmit → clean (0 errors)
npm run build → clean (warnings only: img tag, unused UserDb type)
npm test -- tests/lib/auth/ → 4/4 passed
Env Vars Required at Runtime
| Var | Purpose |
|---|---|
JWT_SECRET |
≥32 char random string for HMAC-SHA256 signing |
DATABASE_URL |
app_user pool (RLS enforced) |
DATABASE_URL_ADMIN |
app_admin pool (BYPASSRLS) |
BREVO_API_KEY |
Transactional email for password reset |
BREVO_FROM_EMAIL |
Sender address for password reset emails |
APP_URL |
Base URL for reset link generation (server-side only) |