Skip to content

Commit 6c94fc6

Browse files
authored
feat(auth): migrate from hand-rolled JWT to BetterAuth (#126) (#127)
## Summary Replace HashHive's hand-rolled JWT authentication system (jose + bcrypt + custom middleware) with [BetterAuth](https://www.better-auth.com/) database-backed sessions. This eliminates the JWT claim type coercion bug (#126), adds automatic session refresh, enables immediate session revocation, and reduces auth maintenance surface by ~60%. **40 files changed** (+1,143 / -1,157 net) across backend, frontend, shared schema, tests, and documentation. ## What Changed ### Auth Infrastructure - **BetterAuth instance** (`packages/backend/src/lib/auth.ts`) -- Drizzle adapter, custom bcrypt hash/verify via `Bun.password`, 8h session with hourly sliding refresh, self-registration disabled, air-gapped config (no email verification) - **Schema** (`packages/shared/src/db/schema.ts`) -- 3 new tables: `ba_sessions`, `ba_accounts`, `ba_verifications` with `ba_` prefix, cascade deletes, unique constraint on `(userId, providerId)` - **Data migration** (`packages/backend/src/scripts/migrate-auth-accounts.ts`) -- Transaction-wrapped, idempotent script copies bcrypt hashes from `users.password_hash` to `ba_accounts.password` ### Backend - **`requireSession`** middleware rewritten -- calls `auth.api.getSession()` instead of jose JWT validation, validates `X-Project-Id` header (positive integer only), logs failures via pino - **RBAC** unchanged -- still validates project membership per-request via `findProjectMembership()` - **Auth routes** simplified to `/me` only -- login/logout handled by BetterAuth at `/api/auth/*` - **WebSocket auth** uses BetterAuth session cookie only -- removed `?token=` query param fallback (security improvement) - **`/projects/select` removed** -- project selection is now client-side - **`jose` dependency removed** -- all JWT functions (`createToken`, `validateToken`, `hashPassword`, etc.) deleted ### Frontend - **Auth store** calls BetterAuth endpoints (`/api/auth/sign-in/email`, `/api/auth/sign-out`) - **`syncSelectedProject()`** helper -- auto-selects single project, clears stale selections on membership change, used in both `login()` and `fetchUser()` - **API client** injects `X-Project-Id` header from Zustand `useUiStore.selectedProjectId` - **Logout** is resilient to server failures -- clears local state regardless - **Login page** navigates explicitly after auto-select (no React re-render race) ### Documentation - **`ARCHITECTURE.md`** (new) -- system design, tech stack, schema flow, API surfaces, data model - **`docs/development.md`** (new) -- setup, commands, env vars, infrastructure - **`docs/testing.md`** (new) -- test strategy, bun:test patterns, BetterAuth mock patterns - **`AGENTS.md`** rewritten as thin index pointing to real docs - **`CONTRIBUTING.md`** enhanced with doc links and issue tracking section - **`GOTCHAS.md`** updated for BetterAuth (resolved JWT gotcha, added new ones) ### Cleanup - Local tooling configs (`.claude/`, `.cursor/`, `.codex/`, `.impeccable.md`) untracked from git - `.gitignore` updated with consolidated AI tool section ## Key Design Decisions | Decision | Rationale | |----------|-----------| | Project selection stays client-side (`X-Project-Id` header) | Multiple tabs share one session cookie -- server-side `activeProjectId` would cause tab A's switch to affect tab B | | No `cookieCache` | Immediate session revocation is more important than saving a DB lookup for 1-3 users | | `disableSignUp: true` | Air-gapped lab environment; users created by admin seed only. BetterAuth's default sign-up would fail on `users.password_hash` NOT NULL constraint | | Cookie name `hh.session_token` | Prevents session fixation during migration from old `session` cookie | | Custom `Bun.password.hash/verify` | BetterAuth defaults to scrypt; existing bcrypt `$2b$` hashes must be readable without migration | | `generateId: false` for user model | Existing `users.id` is `serial` (integer); BetterAuth defaults to text UUIDs | ## API Changes | Endpoint | Before | After | |----------|--------|-------| | Login | `POST /api/v1/dashboard/auth/login` | `POST /api/auth/sign-in/email` (BetterAuth) | | Logout | `POST /api/v1/dashboard/auth/logout` | `POST /api/auth/sign-out` (BetterAuth) | | Get session | `GET /api/v1/dashboard/auth/me` | Same (reads BetterAuth session) | | Select project | `POST /api/v1/dashboard/projects/select` | **Removed** -- client-side only | | Agent auth | `Authorization: Bearer <token>` | **No change** | ## Security Improvements - **Immediate session revocation** -- deleting DB row invalidates instantly (vs 24h JWT validity window) - **Cookie name change** prevents session fixation during migration - **`?token=` WebSocket fallback removed** -- credentials no longer appear in logs/history/referrers - **`BETTER_AUTH_SECRET`** requires 32+ chars (vs 16 for old `JWT_SECRET`) - **`X-Project-Id` validated** as positive integer -- rejects 0, negative, float, non-numeric - **BetterAuth handler wrapped** in try/catch with error normalization - **Self-registration disabled** -- prevents unauthorized account creation on lab network ## Test Coverage **213 tests passing** (94 backend + 119 frontend), 0 failures. | Area | Tests | What's Covered | |------|-------|----------------| | Auth middleware | 9 | Valid session, null session, getSession throws, X-Project-Id (valid, missing, malformed x5) | | Agent token | 5 | No header, non-Bearer, valid, unknown, error state | | WebSocket auth | 4 | Valid cookie, no auth (4001), no projectIds (4002), unauthorized projects (4003) | | Dashboard API guards | 5 | 401 on all protected routes without session | | Hash type detection | 2 | Valid MD5, missing hashValue | | Password hashing | 3 | Hash+verify, reject wrong, bcrypt format check | | Frontend login | 5 | Form render, invalid credentials, multi-project redirect, auto-select, already-authed redirect | | Frontend select-project | 4 | Render, redirect when selected, select and redirect, empty state | ## How to Test ```bash # Run all checks (matches CI) just ci-check # Run data migration (after seeding) bun packages/backend/src/scripts/migrate-auth-accounts.ts # Manual login flow # 1. Start services: bun dev # 2. Navigate to http://localhost:3000/login # 3. Login with admin@hashhive.local / changeme123 # 4. Verify project auto-select -> dashboard # 5. Verify sidebar project switch works per-tab # 6. Verify logout clears session ``` ## Post-Deploy Checklist - [ ] Set `BETTER_AUTH_SECRET` (generate with `openssl rand -base64 32`) - [ ] Run `bun packages/backend/src/scripts/migrate-auth-accounts.ts` to copy credentials to `ba_accounts` - [ ] Verify login works for all existing users - [ ] Verify agent heartbeats are unaffected - [ ] Monitor for `AUTH_TOKEN_INVALID` errors in logs (expected for old cookie cleanup, should stop after first request per user) - [ ] After 30 days: drop `users.password_hash` column, remove legacy cookie cleanup code ## Risk Assessment | Risk | Level | Mitigation | |------|-------|------------| | Users locked out after migration | Low | Migration copies existing bcrypt hashes; custom hash/verify ensures compatibility | | Multi-tab project corruption | Eliminated | Project selection is client-side, not in session | | Agent API regression | None | `requireAgentToken` middleware completely unchanged | | Session fixation during transition | Mitigated | Cookie name changed; legacy cookies auto-cleaned | | E2E tests break | Fixed | Global setup runs `migrate-auth-accounts.ts` after seeding | Closes #126 --- [![Compound Engineered](https://img.shields.io/badge/Compound-Engineered-6366f1)](https://github.com/EveryInc/compound-engineering-plugin) --------- Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
1 parent 79227a5 commit 6c94fc6

113 files changed

Lines changed: 1862 additions & 13816 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.claude/hookify.block-banned-packages.local.md

Lines changed: 0 additions & 22 deletions
This file was deleted.

.claude/hookify.block-npm-yarn-pnpm.local.md

Lines changed: 0 additions & 19 deletions
This file was deleted.

.claude/hookify.block-wrong-test-runners.local.md

Lines changed: 0 additions & 19 deletions
This file was deleted.

.claude/hookify.warn-bun-add.local.md

Lines changed: 0 additions & 13 deletions
This file was deleted.

.claude/instincts/biome-useLiteralKeys.md

Lines changed: 0 additions & 27 deletions
This file was deleted.

.claude/instincts/httpexception-in-onerror.md

Lines changed: 0 additions & 31 deletions
This file was deleted.

.claude/instincts/schema-flow.md

Lines changed: 0 additions & 36 deletions
This file was deleted.

.claude/rules/agents.md

Lines changed: 0 additions & 55 deletions
This file was deleted.

0 commit comments

Comments
 (0)