brackt/BETTERAUTH_MIGRATION.md
Chris Parsons ba9bf64e37
Migrate authentication from Clerk to BetterAuth (#324)
* Migrate authentication from Clerk to BetterAuth (#322)

Replaces @clerk/react-router with self-hosted better-auth to eliminate
the external Clerk dependency and keep all user/session data in our own
PostgreSQL database.

**What changed**
- New: auth.server.ts (BetterAuth config w/ Drizzle adapter, bcrypt, Resend), auth-client.ts, api.auth.$.ts handler
- New: /login and /register pages with email+password and Google/Discord OAuth; open-redirect guard on redirectTo param
- New: UserMenu component replacing Clerk's UserButton
- Schema: sessions, accounts, verifications tables; emailVerified column; clerkId made nullable
- Migrations 0081 (BetterAuth tables) and 0082 (accounts extra columns for v1.6.9)
- All ~30 route files: getAuth → auth.api.getSession, isUserAdminByClerkId → isUserAdmin
- root.tsx: isAdmin read directly from session.user.isAdmin (no extra DB query)
- useDraftAuthRecovery: removed Clerk JWT refresh logic; replaced with cookie-session check
- models/user.ts: removed findUserByClerkId, findOrCreateUser, updateUserByClerkId (webhook pattern)
- Deleted: app/routes/api/webhooks/clerk.ts; uninstalled @clerk/react-router, @clerk/themes, svix
- scripts/migrate.mjs: extended with idempotent Clerk → BetterAuth data migration (FK conversion, email_verified, OAuth accounts)
- scripts/migrate-clerk-passwords.mjs: one-time script to import bcrypt hashes from Clerk CSV export
- BETTERAUTH_MIGRATION.md: dev and production runbooks
- All test mocks updated: vi.mock('~/lib/auth.server') instead of @clerk/react-router/server
- Test fixtures: added emailVerified field

**Follow-up (post-stable)**
- Rename actor_clerk_id column → actor_user_id in commissioner_audit_log
- Drop clerk_id column from users once migration confirmed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Add .npmrc with legacy-peer-deps for better-auth/drizzle peer dep conflict

better-auth@1.6.9 declares peerOptional deps on drizzle-orm ^0.45.2 and
drizzle-kit >=0.31.4, but we run drizzle-orm ~0.36.3 / drizzle-kit ~0.28.1.
The adapter works correctly at runtime with our versions — the peer dep is
only for stricter type checking. This unblocks npm ci in CI without a risky
drizzle major-version upgrade.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 22:00:49 -07:00

183 lines
7 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# BetterAuth Migration Checklist
This document covers the manual steps required to complete the Clerk → BetterAuth migration in both development and production.
**What's automated** (runs via `scripts/migrate.mjs` on every deploy, idempotent):
- Schema migrations (sessions, accounts, verifications tables; nullable clerk_id; email_verified column)
- Convert `teams.owner_id` and `commissioners.user_id` from Clerk IDs → `users.id` UUIDs
- Mark all Clerk users as `email_verified = true`
- Create `accounts` records for Google and Discord OAuth users (via Clerk API)
**What's manual** (one-time steps you do by hand):
- Export user CSV from Clerk (for password hash migration)
- Run the password migration script
- Update OAuth redirect URIs in Google/Discord consoles
- Set env vars in production
---
## Development
### Step 1 — Add environment variables
Add these to your `.env` file:
```
BETTER_AUTH_SECRET="<random string — run: openssl rand -base64 32>"
BETTER_AUTH_URL="http://localhost:5173"
GOOGLE_CLIENT_ID="<from Clerk dashboard → Social Connections → Google>"
GOOGLE_CLIENT_SECRET="<from Clerk dashboard → Social Connections → Google>"
DISCORD_CLIENT_ID="<from Clerk dashboard → Social Connections → Discord>"
DISCORD_CLIENT_SECRET="<from Clerk dashboard → Social Connections → Discord>"
# Keep temporarily for the automated OAuth migration (remove after confirmed working)
CLERK_SECRET_KEY="<your existing value>"
```
Remove once confirmed working: `VITE_CLERK_PUBLISHABLE_KEY`, `CLERK_WEBHOOK_SECRET`, `DEV_ADMIN_CLERK_ID`
### Step 2 — Apply the database schema migration
```bash
npm run db:migrate
```
Adds the `sessions`, `accounts`, and `verifications` tables; makes `clerk_id` nullable; adds `email_verified`.
### Step 3 — Add OAuth redirect URIs for localhost
In **Google Cloud Console** (APIs & Services → Credentials → your OAuth client):
- Add `http://localhost:5173/api/auth/callback/google` to Authorized redirect URIs
In **Discord Developer Portal** (your app → OAuth2):
- Add `http://localhost:5173/api/auth/callback/discord` to Redirects
### Step 4 — Run the automated data migration
```bash
node scripts/migrate.mjs
```
This converts owner IDs, creates Google/Discord OAuth accounts, and marks users as email-verified. It reads from the Clerk API using your `CLERK_SECRET_KEY`.
### Step 5 — Migrate email/password users
This requires exporting password hashes from Clerk (they're only in the CSV export, not the API).
1. Go to **Clerk dashboard → Users → Export** and download the CSV
2. Save it as `exported_users.csv` in the project root (it's gitignored)
3. Run:
```bash
node scripts/migrate-clerk-passwords.mjs
```
4. **Delete `exported_users.csv` immediately after** — it contains bcrypt password hashes
### Step 6 — Set yourself as admin
`DEV_ADMIN_CLERK_ID` no longer works. Set admin access directly in the database:
```sql
UPDATE users SET is_admin = true WHERE email = 'your@email.com';
```
### Step 7 — Verify sign-in works
- **Google / Discord**: sign in immediately — OAuth accounts were migrated automatically
- **Email/password**: sign in with the same password as before — bcrypt hashes were migrated
- Check that league ownership and commissioner access still works
After verifying, remove `CLERK_SECRET_KEY` from `.env`.
---
## Production
> Do Steps 13 before merging to main. The data migration (Step 4) and OAuth account creation run automatically on deploy.
### Step 1 — Add production environment variables
Add to your production environment (server docker-compose / secrets manager):
```
BETTER_AUTH_SECRET="<strong random string — openssl rand -base64 32>"
BETTER_AUTH_URL="https://brackt.com"
GOOGLE_CLIENT_ID="<same as Clerk dashboard>"
GOOGLE_CLIENT_SECRET="<same as Clerk dashboard>"
DISCORD_CLIENT_ID="<same as Clerk dashboard>"
DISCORD_CLIENT_SECRET="<same as Clerk dashboard>"
# Keep temporarily — needed for the automated OAuth migration on first deploy
CLERK_SECRET_KEY="<your existing production value>"
```
### Step 2 — Add OAuth redirect URIs for production
In **Google Cloud Console**:
- Add `https://brackt.com/api/auth/callback/google` to Authorized redirect URIs
In **Discord Developer Portal**:
- Add `https://brackt.com/api/auth/callback/discord` to Redirects
### Step 3 — Migrate email/password users (before deploying)
The password migration runs against the production database directly — it does NOT go through Docker.
1. Go to **Clerk dashboard → Users → Export** and download the CSV for production users
2. Save as `exported_users.csv` in the project root (gitignored)
3. Run against the production database:
```bash
DATABASE_DIRECT_URL="<prod connection string>" CLERK_SECRET_KEY="<prod key>" node scripts/migrate-clerk-passwords.mjs
```
4. **Delete `exported_users.csv` immediately**
### Step 4 — Deploy
Merge to `main`. The GitHub Actions deploy will:
1. Build and push the Docker image
2. SSH to the server and run `docker compose up --no-deps migrate`
3. `scripts/migrate.mjs` runs — applies schema migrations, then (because `CLERK_SECRET_KEY` is set):
- Converts `teams.owner_id` and `commissioners.user_id` to `users.id` UUIDs
- Marks users as `email_verified`
- Creates `accounts` records for Google and Discord OAuth users
4. The app container starts with the new BetterAuth code
### Step 5 — Verify production sign-in
- Google and Discord users: sign in immediately
- Email/password users: sign in with the same password as before
- Spot-check that league ownership and commissioner access are intact
### Step 6 — Remove Clerk credentials
Once everything is confirmed, remove from the production environment:
```
CLERK_SECRET_KEY
VITE_CLERK_PUBLISHABLE_KEY
CLERK_WEBHOOK_SECRET
DEV_ADMIN_CLERK_ID
```
Delete the Clerk webhook endpoint from the Clerk dashboard (it no longer exists in the codebase).
On the next deploy, `scripts/migrate.mjs` will log `"CLERK_SECRET_KEY not set — skipping Clerk account migration"` and skip gracefully.
---
## Notes
### Why email/password migration is a separate manual step
Clerk's REST API does not expose password hashes — they're only available in the CSV dashboard export. The automated deploy script (`scripts/migrate.mjs`) handles OAuth accounts via the API, but password hashes must be exported manually and migrated with `scripts/migrate-clerk-passwords.mjs`.
The bcrypt hashes from Clerk are directly compatible with BetterAuth — no re-hashing is needed. Users sign in with the same password as before.
### What if a user signed up with both Google and email/password?
Both an OAuth account and a credential account will be created for them. BetterAuth handles this correctly — the user can sign in with either method.
### Future cleanup
After the migration is stable (a week or two after production deploy), open a follow-up PR to:
1. Remove the `clerk_id` column from `database/schema.ts` and run `npm run db:generate`
2. Remove `CLERK_SECRET_KEY` handling from `scripts/migrate.mjs`
3. Delete `scripts/migrate-clerk-passwords.mjs`