feat(auth): phase 3 — replace Supabase GoTrue with bcryptjs+jose

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>
This commit is contained in:
2026-07-23 16:20:04 +08:00
co-authored by Claude Sonnet 4.6
parent a95273b182
commit d18d29168a
67 changed files with 966 additions and 591 deletions
+108
View File
@@ -0,0 +1,108 @@
# 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 imports
- `lib/db/index.ts` — lazy singleton Proxy pattern to prevent module-level throw during Next.js build static analysis when `DATABASE_URL` not set in build env
- `lib/db/schema.ts` — added `passwordResetTokens` table definition
### UI Components
- `components/auth/login-form.tsx` — POST to `/api/auth/login` instead of supabase signIn
- `components/layout/sidebar.tsx` — POST to `/api/auth/logout` instead of supabase signOut
- `app/(auth)/forgot-password/page.tsx` — POST to `/api/auth/reset-request`
- `app/(auth)/reset-password/page.tsx` — POST to `/api/auth/reset-confirm`, reads `token` from search params
- `components/account/change-password-form.tsx` — POST to `/api/auth/change-password`
### Admin User CRUD
- `app/api/admin/users/route.ts` — POST creates user with `hashPassword` + Drizzle insert; DELETE removes row directly; all handlers use `{ session }` from `requireAdmin()`
- `app/api/admin/sites/route.ts` — updated to `{ session }` pattern + explicit `createClient()` for `.from()` calls
- `app/api/admin/trucks/route.ts` — same
### Storage
- `lib/supabase/storage.ts` — removed `supabase.auth.getUser()` call; added `userId: string` as 5th parameter to `uploadEvidenceFile`
### 38 Protected Pages and API Routes
Pattern applied to all:
- `import { getSession } from '@/lib/auth/get-session'`
- `const session = await getSession()` replaces `supabase.auth.getUser()`
- `session.sub` replaces `user.id`
- `session.role` replaces profile fetch from DB
- `session.siteId` replaces `profile.site_id`
- `createClient()` retained where `.from()` queries still exist (Phase 4 will remove these)
---
## Issues Fixed During Implementation
1. **`app/api/admin/sites/route.ts` and `app/api/admin/trucks/route.ts`** — not in original brief scope but broken by `requireAdmin` signature change; fixed.
2. **`tests/lib/auth/password.test.ts`** — brief had `describe(name, fn, options)` which is wrong Vitest API; fixed to `describe(name, fn)`.
3. **`tests/lib/supabase/storage.test.ts`** — `uploadEvidenceFile` signature added `userId`; all 3 call sites updated; removed now-unused `auth.getUser` mock.
4. **`.next/types/validator.ts`** — stale reference to deleted `/api/auth/callback/route.ts`; removed the block.
5. **All 5 auth API routes** — missing `export const dynamic = 'force-dynamic'`; added to prevent Next.js static pre-rendering.
6. **`lib/db/index.ts`** — module-level `throw` when `DATABASE_URL` unset failed build's "collect page data" phase even with `force-dynamic`; refactored to lazy Proxy singleton.
7. **Test environment** — vitest global config uses `jsdom`; jose and bcryptjs use native `Uint8Array` which fails `instanceof` check across jsdom/Node realms; fixed with `// @vitest-environment node` in both auth test files.
---
## Invariants Preserved
- `middleware.ts` imports only `jose` and `next/server` — no `pg`, `drizzle-orm`, or DB connections
- All `.from()` Supabase queries retained untouched (Phase 4 scope)
- `server-only` import in `get-session.ts` prevents 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) |