Initial commit
This commit is contained in:
12
.claude-plugin/plugin.json
Normal file
12
.claude-plugin/plugin.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "better-auth",
|
||||
"description": "Build authentication systems for TypeScript/Cloudflare Workers with social auth, 2FA, passkeys, organizations, and RBAC. Self-hosted alternative to Clerk/Auth.js. IMPORTANT: Requires Drizzle ORM or Kysely for D1 - no direct D1 adapter. Use when: self-hosting auth on Cloudflare D1, migrating from Clerk, implementing multi-tenant SaaS, or troubleshooting D1 adapter errors, session serialization, OAuth flows.",
|
||||
"version": "1.0.0",
|
||||
"author": {
|
||||
"name": "Jeremy Dawes",
|
||||
"email": "jeremy@jezweb.net"
|
||||
},
|
||||
"skills": [
|
||||
"./"
|
||||
]
|
||||
}
|
||||
3
README.md
Normal file
3
README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# better-auth
|
||||
|
||||
Build authentication systems for TypeScript/Cloudflare Workers with social auth, 2FA, passkeys, organizations, and RBAC. Self-hosted alternative to Clerk/Auth.js. IMPORTANT: Requires Drizzle ORM or Kysely for D1 - no direct D1 adapter. Use when: self-hosting auth on Cloudflare D1, migrating from Clerk, implementing multi-tenant SaaS, or troubleshooting D1 adapter errors, session serialization, OAuth flows.
|
||||
370
assets/auth-flow-diagram.md
Normal file
370
assets/auth-flow-diagram.md
Normal file
@@ -0,0 +1,370 @@
|
||||
# better-auth Authentication Flow Diagrams
|
||||
|
||||
Visual representations of common authentication flows using better-auth.
|
||||
|
||||
---
|
||||
|
||||
## 1. Email/Password Sign-Up Flow
|
||||
|
||||
```
|
||||
┌─────────┐ ┌─────────┐ ┌──────────┐
|
||||
│ Client │ │ Worker │ │ D1 │
|
||||
└────┬────┘ └────┬────┘ └────┬─────┘
|
||||
│ │ │
|
||||
│ POST /api/auth/signup │ │
|
||||
│ { email, password } │ │
|
||||
├──────────────────────────>│ │
|
||||
│ │ Hash password (bcrypt) │
|
||||
│ │ │
|
||||
│ │ INSERT INTO users │
|
||||
│ ├──────────────────────────>│
|
||||
│ │ │
|
||||
│ │ Generate verification │
|
||||
│ │ token │
|
||||
│ │ │
|
||||
│ │ INSERT INTO │
|
||||
│ │ verification_tokens │
|
||||
│ ├──────────────────────────>│
|
||||
│ │ │
|
||||
│ │ Send verification email │
|
||||
│ │ (via email service) │
|
||||
│ │ │
|
||||
│ { success: true } │ │
|
||||
│<──────────────────────────┤ │
|
||||
│ │ │
|
||||
│ │ │
|
||||
│ User clicks email link │ │
|
||||
│ │ │
|
||||
│ GET /api/auth/verify? │ │
|
||||
│ token=xyz │ │
|
||||
├──────────────────────────>│ │
|
||||
│ │ Verify token │
|
||||
│ ├──────────────────────────>│
|
||||
│ │ │
|
||||
│ │ UPDATE users SET │
|
||||
│ │ email_verified = true │
|
||||
│ ├──────────────────────────>│
|
||||
│ │ │
|
||||
│ Redirect to dashboard │ │
|
||||
│<──────────────────────────┤ │
|
||||
│ │ │
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Social Sign-In Flow (Google OAuth)
|
||||
|
||||
```
|
||||
┌─────────┐ ┌─────────┐ ┌──────────┐ ┌─────────┐
|
||||
│ Client │ │ Worker │ │ D1 │ │ Google │
|
||||
└────┬────┘ └────┬────┘ └────┬─────┘ └────┬────┘
|
||||
│ │ │ │
|
||||
│ Click "Sign │ │ │
|
||||
│ in with │ │ │
|
||||
│ Google" │ │ │
|
||||
│ │ │ │
|
||||
│ POST /api/ │ │ │
|
||||
│ auth/signin/ │ │ │
|
||||
│ google │ │ │
|
||||
├────────────────>│ │ │
|
||||
│ │ Generate OAuth │ │
|
||||
│ │ state + PKCE │ │
|
||||
│ │ │ │
|
||||
│ Redirect to │ │ │
|
||||
│ Google OAuth │ │ │
|
||||
│<────────────────┤ │ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
│ User authorizes on Google │ │
|
||||
├───────────────────────────────────────────────────────>│
|
||||
│ │ │ │
|
||||
│ │ │ User approves │
|
||||
│<───────────────────────────────────────────────────────┤
|
||||
│ │ │ │
|
||||
│ Redirect to │ │ │
|
||||
│ callback with │ │ │
|
||||
│ code │ │ │
|
||||
│ │ │ │
|
||||
│ GET /api/auth/ │ │ │
|
||||
│ callback/ │ │ │
|
||||
│ google?code= │ │ │
|
||||
├────────────────>│ │ │
|
||||
│ │ Exchange code │ │
|
||||
│ │ for tokens │ │
|
||||
│ ├─────────────────────────────────────>│
|
||||
│ │ │ │
|
||||
│ │ { access_token, │ │
|
||||
│ │ id_token } │ │
|
||||
│ │<─────────────────────────────────────┤
|
||||
│ │ │ │
|
||||
│ │ Fetch user info │ │
|
||||
│ ├─────────────────────────────────────>│
|
||||
│ │ │ │
|
||||
│ │ { email, name, │ │
|
||||
│ │ picture } │ │
|
||||
│ │<─────────────────────────────────────┤
|
||||
│ │ │ │
|
||||
│ │ Find or create │ │
|
||||
│ │ user │ │
|
||||
│ ├─────────────────>│ │
|
||||
│ │ │ │
|
||||
│ │ Store account │ │
|
||||
│ │ (provider data) │ │
|
||||
│ ├─────────────────>│ │
|
||||
│ │ │ │
|
||||
│ │ Create session │ │
|
||||
│ ├─────────────────>│ │
|
||||
│ │ │ │
|
||||
│ Set session │ │ │
|
||||
│ cookie + │ │ │
|
||||
│ redirect │ │ │
|
||||
│<────────────────┤ │ │
|
||||
│ │ │ │
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Session Verification Flow
|
||||
|
||||
```
|
||||
┌─────────┐ ┌─────────┐ ┌──────────┐
|
||||
│ Client │ │ Worker │ │ KV │
|
||||
└────┬────┘ └────┬────┘ └────┬─────┘
|
||||
│ │ │
|
||||
│ GET /api/protected │ │
|
||||
│ Cookie: session=xyz │ │
|
||||
├───────────────────────>│ │
|
||||
│ │ Extract session ID │
|
||||
│ │ from cookie │
|
||||
│ │ │
|
||||
│ │ GET session from KV │
|
||||
│ ├───────────────────────>│
|
||||
│ │ │
|
||||
│ │ { userId, expiresAt } │
|
||||
│ │<───────────────────────┤
|
||||
│ │ │
|
||||
│ │ Check expiration │
|
||||
│ │ │
|
||||
│ If valid: │ │
|
||||
│ { data: ... } │ │
|
||||
│<───────────────────────┤ │
|
||||
│ │ │
|
||||
│ If invalid: │ │
|
||||
│ 401 Unauthorized │ │
|
||||
│<───────────────────────┤ │
|
||||
│ │ │
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Password Reset Flow
|
||||
|
||||
```
|
||||
┌─────────┐ ┌─────────┐ ┌──────────┐
|
||||
│ Client │ │ Worker │ │ D1 │
|
||||
└────┬────┘ └────┬────┘ └────┬─────┘
|
||||
│ │ │
|
||||
│ POST /api/auth/ │ │
|
||||
│ forgot-password │ │
|
||||
│ { email } │ │
|
||||
├───────────────────────>│ │
|
||||
│ │ Find user by email │
|
||||
│ ├───────────────────────>│
|
||||
│ │ │
|
||||
│ │ Generate reset token │
|
||||
│ │ │
|
||||
│ │ INSERT INTO │
|
||||
│ │ verification_tokens │
|
||||
│ ├───────────────────────>│
|
||||
│ │ │
|
||||
│ │ Send reset email │
|
||||
│ │ │
|
||||
│ { success: true } │ │
|
||||
│<───────────────────────┤ │
|
||||
│ │ │
|
||||
│ │ │
|
||||
│ User clicks email │ │
|
||||
│ link │ │
|
||||
│ │ │
|
||||
│ GET /reset-password? │ │
|
||||
│ token=xyz │ │
|
||||
├───────────────────────>│ │
|
||||
│ │ Verify token │
|
||||
│ ├───────────────────────>│
|
||||
│ │ │
|
||||
│ Show reset form │ │
|
||||
│<───────────────────────┤ │
|
||||
│ │ │
|
||||
│ POST /api/auth/ │ │
|
||||
│ reset-password │ │
|
||||
│ { token, password } │ │
|
||||
├───────────────────────>│ │
|
||||
│ │ Hash new password │
|
||||
│ │ │
|
||||
│ │ UPDATE users │
|
||||
│ ├───────────────────────>│
|
||||
│ │ │
|
||||
│ │ DELETE token │
|
||||
│ ├───────────────────────>│
|
||||
│ │ │
|
||||
│ Redirect to login │ │
|
||||
│<───────────────────────┤ │
|
||||
│ │ │
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Two-Factor Authentication (2FA) Flow
|
||||
|
||||
```
|
||||
┌─────────┐ ┌─────────┐ ┌──────────┐
|
||||
│ Client │ │ Worker │ │ D1 │
|
||||
└────┬────┘ └────┬────┘ └────┬─────┘
|
||||
│ │ │
|
||||
│ POST /api/auth/ │ │
|
||||
│ signin │ │
|
||||
│ { email, password } │ │
|
||||
├───────────────────────>│ │
|
||||
│ │ Verify credentials │
|
||||
│ ├───────────────────────>│
|
||||
│ │ │
|
||||
│ │ Check if 2FA enabled │
|
||||
│ ├───────────────────────>│
|
||||
│ │ │
|
||||
│ { requires2FA: true } │ │
|
||||
│<───────────────────────┤ │
|
||||
│ │ │
|
||||
│ Show 2FA input │ │
|
||||
│ │ │
|
||||
│ POST /api/auth/ │ │
|
||||
│ verify-2fa │ │
|
||||
│ { code: "123456" } │ │
|
||||
├───────────────────────>│ │
|
||||
│ │ Get 2FA secret │
|
||||
│ ├───────────────────────>│
|
||||
│ │ │
|
||||
│ │ Verify TOTP code │
|
||||
│ │ │
|
||||
│ If valid: │ │
|
||||
│ Create session │ │
|
||||
│ + redirect │ │
|
||||
│<───────────────────────┤ │
|
||||
│ │ │
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Organization/Team Flow
|
||||
|
||||
```
|
||||
┌─────────┐ ┌─────────┐ ┌──────────┐
|
||||
│ Client │ │ Worker │ │ D1 │
|
||||
└────┬────┘ └────┬────┘ └────┬─────┘
|
||||
│ │ │
|
||||
│ POST /api/org/create │ │
|
||||
│ { name, slug } │ │
|
||||
├───────────────────────>│ │
|
||||
│ │ Verify session │
|
||||
│ │ │
|
||||
│ │ INSERT INTO orgs │
|
||||
│ ├───────────────────────>│
|
||||
│ │ │
|
||||
│ │ INSERT INTO │
|
||||
│ │ org_members │
|
||||
│ │ (user as owner) │
|
||||
│ ├───────────────────────>│
|
||||
│ │ │
|
||||
│ { org: { ... } } │ │
|
||||
│<───────────────────────┤ │
|
||||
│ │ │
|
||||
│ │ │
|
||||
│ POST /api/org/invite │ │
|
||||
│ { orgId, email, │ │
|
||||
│ role } │ │
|
||||
├───────────────────────>│ │
|
||||
│ │ Check permissions │
|
||||
│ ├───────────────────────>│
|
||||
│ │ │
|
||||
│ │ Generate invite token │
|
||||
│ │ │
|
||||
│ │ INSERT INTO │
|
||||
│ │ org_invitations │
|
||||
│ ├───────────────────────>│
|
||||
│ │ │
|
||||
│ │ Send invite email │
|
||||
│ │ │
|
||||
│ { success: true } │ │
|
||||
│<───────────────────────┤ │
|
||||
│ │ │
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Database Schema Overview
|
||||
|
||||
```
|
||||
┌──────────────────────┐
|
||||
│ users │
|
||||
├──────────────────────┤
|
||||
│ id (PK) │
|
||||
│ email (UNIQUE) │
|
||||
│ email_verified │
|
||||
│ name │
|
||||
│ image │
|
||||
│ role │
|
||||
│ created_at │
|
||||
│ updated_at │
|
||||
└──────────┬───────────┘
|
||||
│
|
||||
│ 1:N
|
||||
│
|
||||
┌──────────┴───────────┐ ┌──────────────────────┐
|
||||
│ sessions │ │ accounts │
|
||||
├──────────────────────┤ ├──────────────────────┤
|
||||
│ id (PK) │ │ id (PK) │
|
||||
│ user_id (FK) │◄───────┤ user_id (FK) │
|
||||
│ expires_at │ │ provider │
|
||||
│ ip_address │ │ provider_account_id │
|
||||
│ user_agent │ │ access_token │
|
||||
│ created_at │ │ refresh_token │
|
||||
└──────────────────────┘ │ expires_at │
|
||||
│ created_at │
|
||||
└──────────────────────┘
|
||||
|
||||
┌──────────────────────┐
|
||||
│ verification_tokens │
|
||||
├──────────────────────┤
|
||||
│ identifier │
|
||||
│ token │
|
||||
│ expires │
|
||||
│ created_at │
|
||||
└──────────────────────┘
|
||||
|
||||
┌──────────────────────┐ ┌──────────────────────┐
|
||||
│ organizations │ │ organization_members │
|
||||
├──────────────────────┤ ├──────────────────────┤
|
||||
│ id (PK) │ │ id (PK) │
|
||||
│ name │ │ organization_id (FK) │◄──┐
|
||||
│ slug (UNIQUE) │◄───────┤ user_id (FK) │ │
|
||||
│ logo │ │ role │ │
|
||||
│ created_at │ │ created_at │ │
|
||||
│ updated_at │ └──────────────────────┘ │
|
||||
└──────────────────────┘ │
|
||||
│
|
||||
┌──────────────────────┐ │
|
||||
│organization_invites │ │
|
||||
├──────────────────────┤ │
|
||||
│ id (PK) │ │
|
||||
│ organization_id (FK) │────────────────────────────────────┘
|
||||
│ email │
|
||||
│ role │
|
||||
│ invited_by (FK) │
|
||||
│ token │
|
||||
│ expires_at │
|
||||
│ created_at │
|
||||
└──────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
These diagrams illustrate the complete authentication flows supported by better-auth. Use them as reference when implementing auth in your application.
|
||||
77
plugin.lock.json
Normal file
77
plugin.lock.json
Normal file
@@ -0,0 +1,77 @@
|
||||
{
|
||||
"$schema": "internal://schemas/plugin.lock.v1.json",
|
||||
"pluginId": "gh:jezweb/claude-skills:skills/better-auth",
|
||||
"normalized": {
|
||||
"repo": null,
|
||||
"ref": "refs/tags/v20251128.0",
|
||||
"commit": "24fa4e88b5217028acd3a4228d614d74979d6d8c",
|
||||
"treeHash": "0108f663286dc7412e736f00d9187d1b66db191540fcae3d2103d68858800b75",
|
||||
"generatedAt": "2025-11-28T10:19:03.307825Z",
|
||||
"toolVersion": "publish_plugins.py@0.2.0"
|
||||
},
|
||||
"origin": {
|
||||
"remote": "git@github.com:zhongweili/42plugin-data.git",
|
||||
"branch": "master",
|
||||
"commit": "aa1497ed0949fd50e99e70d6324a29c5b34f9390",
|
||||
"repoRoot": "/Users/zhongweili/projects/openmind/42plugin-data"
|
||||
},
|
||||
"manifest": {
|
||||
"name": "better-auth",
|
||||
"description": "Build authentication systems for TypeScript/Cloudflare Workers with social auth, 2FA, passkeys, organizations, and RBAC. Self-hosted alternative to Clerk/Auth.js. IMPORTANT: Requires Drizzle ORM or Kysely for D1 - no direct D1 adapter. Use when: self-hosting auth on Cloudflare D1, migrating from Clerk, implementing multi-tenant SaaS, or troubleshooting D1 adapter errors, session serialization, OAuth flows.",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"content": {
|
||||
"files": [
|
||||
{
|
||||
"path": "README.md",
|
||||
"sha256": "5ec2bd2b40c398f91b05ade2579ed0c1ac0668bda3abd49c7ff53130d5308125"
|
||||
},
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"sha256": "2c6cdcfdb09f17fdca05f47f7edc24c836d8518877bb96954883813bb3a7ec22"
|
||||
},
|
||||
{
|
||||
"path": "references/cloudflare-worker-drizzle.ts",
|
||||
"sha256": "a4f6724b67cc0ce0569e21c20d5c59129b7d19955461ef1eed21b78173a89fda"
|
||||
},
|
||||
{
|
||||
"path": "references/cloudflare-worker-kysely.ts",
|
||||
"sha256": "fdbc996a26f3c89f9267de12b189e744e72d30ba2f5547f2047441b06f0439da"
|
||||
},
|
||||
{
|
||||
"path": "references/database-schema.ts",
|
||||
"sha256": "c0632e5b3fce888bec2a038b3b594bb4b99f7f8f4058ff6584c2a527e6d836a2"
|
||||
},
|
||||
{
|
||||
"path": "references/react-client-hooks.tsx",
|
||||
"sha256": "017e3891bae23a114b2ad24cd1fa5596dfa4eb1cdb667fd201eaa00a713c3a2c"
|
||||
},
|
||||
{
|
||||
"path": "references/nextjs/postgres-example.ts",
|
||||
"sha256": "200783bf2a3a892455f93748bca52bb36d9196dff2f1791c0946829e15ea5749"
|
||||
},
|
||||
{
|
||||
"path": "references/nextjs/README.md",
|
||||
"sha256": "1d2d5290502b0fea9cdcff4e50afe5e7ae407a2ecdb9229b138ee527ae9114c0"
|
||||
},
|
||||
{
|
||||
"path": "scripts/setup-d1-drizzle.sh",
|
||||
"sha256": "6a8ac2394c55ef59ae5df66229875a791fa7580d577c635ea289cd7eae0f7af5"
|
||||
},
|
||||
{
|
||||
"path": ".claude-plugin/plugin.json",
|
||||
"sha256": "eb7c8c3da4b78bb758a3bb092adbb63afa47fbd1a2fc058310f4401e03a0177b"
|
||||
},
|
||||
{
|
||||
"path": "assets/auth-flow-diagram.md",
|
||||
"sha256": "8662944d9d3f49b9e83cb511b9234d51cbefcec45233d610e77784e8fac5f9fc"
|
||||
}
|
||||
],
|
||||
"dirSha256": "0108f663286dc7412e736f00d9187d1b66db191540fcae3d2103d68858800b75"
|
||||
},
|
||||
"security": {
|
||||
"scannedAt": null,
|
||||
"scannerVersion": null,
|
||||
"flags": []
|
||||
}
|
||||
}
|
||||
334
references/cloudflare-worker-drizzle.ts
Normal file
334
references/cloudflare-worker-drizzle.ts
Normal file
@@ -0,0 +1,334 @@
|
||||
/**
|
||||
* Complete Cloudflare Worker with better-auth + Drizzle ORM
|
||||
*
|
||||
* This example demonstrates:
|
||||
* - D1 database with Drizzle ORM adapter
|
||||
* - Email/password authentication
|
||||
* - Google and GitHub OAuth
|
||||
* - Protected routes with session verification
|
||||
* - CORS configuration for SPA
|
||||
* - KV storage for sessions (strong consistency)
|
||||
* - Rate limiting with KV
|
||||
*
|
||||
* ⚠️ CRITICAL: better-auth requires Drizzle ORM or Kysely for D1
|
||||
* There is NO direct d1Adapter()!
|
||||
*/
|
||||
|
||||
import { Hono } from "hono";
|
||||
import { cors } from "hono/cors";
|
||||
import { betterAuth } from "better-auth";
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import { drizzle, type DrizzleD1Database } from "drizzle-orm/d1";
|
||||
import { rateLimit } from "better-auth/plugins";
|
||||
import * as schema from "../db/schema"; // Your Drizzle schema
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Environment bindings
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
type Env = {
|
||||
DB: D1Database;
|
||||
SESSIONS_KV: KVNamespace;
|
||||
RATE_LIMIT_KV: KVNamespace;
|
||||
BETTER_AUTH_SECRET: string;
|
||||
BETTER_AUTH_URL: string;
|
||||
GOOGLE_CLIENT_ID: string;
|
||||
GOOGLE_CLIENT_SECRET: string;
|
||||
GITHUB_CLIENT_ID: string;
|
||||
GITHUB_CLIENT_SECRET: string;
|
||||
FRONTEND_URL: string;
|
||||
};
|
||||
|
||||
// Database type
|
||||
export type Database = DrizzleD1Database<typeof schema>;
|
||||
|
||||
const app = new Hono<{ Bindings: Env }>();
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// CORS configuration for SPA
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
app.use("/api/*", async (c, next) => {
|
||||
const corsMiddleware = cors({
|
||||
origin: [c.env.FRONTEND_URL, "http://localhost:3000"],
|
||||
credentials: true,
|
||||
allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
allowHeaders: ["Content-Type", "Authorization"],
|
||||
});
|
||||
return corsMiddleware(c, next);
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Helper: Initialize Drizzle database
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
function createDatabase(d1: D1Database): Database {
|
||||
return drizzle(d1, { schema });
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Helper: Initialize auth (per-request to access env)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
function createAuth(db: Database, env: Env) {
|
||||
return betterAuth({
|
||||
// Base URL for OAuth callbacks
|
||||
baseURL: env.BETTER_AUTH_URL,
|
||||
|
||||
// Secret for signing tokens
|
||||
secret: env.BETTER_AUTH_SECRET,
|
||||
|
||||
// ⚠️ CRITICAL: Use Drizzle adapter with SQLite provider
|
||||
// There is NO direct d1Adapter()!
|
||||
database: drizzleAdapter(db, {
|
||||
provider: "sqlite",
|
||||
}),
|
||||
|
||||
// Email/password authentication
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
requireEmailVerification: true,
|
||||
sendVerificationEmail: async ({ user, url, token }) => {
|
||||
// TODO: Implement email sending
|
||||
// Use Resend, SendGrid, or Cloudflare Email Routing
|
||||
console.log(`Verification email for ${user.email}: ${url}`);
|
||||
console.log(`Verification code: ${token}`);
|
||||
},
|
||||
},
|
||||
|
||||
// Social providers
|
||||
socialProviders: {
|
||||
google: {
|
||||
clientId: env.GOOGLE_CLIENT_ID,
|
||||
clientSecret: env.GOOGLE_CLIENT_SECRET,
|
||||
scope: ["openid", "email", "profile"],
|
||||
},
|
||||
github: {
|
||||
clientId: env.GITHUB_CLIENT_ID,
|
||||
clientSecret: env.GITHUB_CLIENT_SECRET,
|
||||
scope: ["user:email", "read:user"],
|
||||
},
|
||||
},
|
||||
|
||||
// Session configuration
|
||||
session: {
|
||||
expiresIn: 60 * 60 * 24 * 7, // 7 days
|
||||
updateAge: 60 * 60 * 24, // Update every 24 hours
|
||||
|
||||
// Use KV for sessions (strong consistency vs D1 eventual consistency)
|
||||
storage: {
|
||||
get: async (sessionId) => {
|
||||
const session = await env.SESSIONS_KV.get(sessionId);
|
||||
return session ? JSON.parse(session) : null;
|
||||
},
|
||||
set: async (sessionId, session, ttl) => {
|
||||
await env.SESSIONS_KV.put(sessionId, JSON.stringify(session), {
|
||||
expirationTtl: ttl,
|
||||
});
|
||||
},
|
||||
delete: async (sessionId) => {
|
||||
await env.SESSIONS_KV.delete(sessionId);
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// Plugins
|
||||
plugins: [
|
||||
rateLimit({
|
||||
window: 60, // 60 seconds
|
||||
max: 10, // 10 requests per window
|
||||
storage: {
|
||||
get: async (key) => {
|
||||
return await env.RATE_LIMIT_KV.get(key);
|
||||
},
|
||||
set: async (key, value, ttl) => {
|
||||
await env.RATE_LIMIT_KV.put(key, value, {
|
||||
expirationTtl: ttl,
|
||||
});
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Auth routes - handle all better-auth endpoints
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
app.all("/api/auth/*", async (c) => {
|
||||
const db = createDatabase(c.env.DB);
|
||||
const auth = createAuth(db, c.env);
|
||||
return auth.handler(c.req.raw);
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Example: Protected API route
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
app.get("/api/protected", async (c) => {
|
||||
const db = createDatabase(c.env.DB);
|
||||
const auth = createAuth(db, c.env);
|
||||
|
||||
// Verify session
|
||||
const session = await auth.api.getSession({
|
||||
headers: c.req.raw.headers,
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
return c.json({
|
||||
message: "Protected data",
|
||||
user: {
|
||||
id: session.user.id,
|
||||
email: session.user.email,
|
||||
name: session.user.name,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Example: User profile endpoint
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
app.get("/api/user/profile", async (c) => {
|
||||
const db = createDatabase(c.env.DB);
|
||||
const auth = createAuth(db, c.env);
|
||||
|
||||
const session = await auth.api.getSession({
|
||||
headers: c.req.raw.headers,
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
// Fetch additional user data from D1
|
||||
const userProfile = await db.query.user.findFirst({
|
||||
where: (user, { eq }) => eq(user.id, session.user.id),
|
||||
});
|
||||
|
||||
return c.json(userProfile);
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Example: Update user profile
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
app.patch("/api/user/profile", async (c) => {
|
||||
const db = createDatabase(c.env.DB);
|
||||
const auth = createAuth(db, c.env);
|
||||
|
||||
const session = await auth.api.getSession({
|
||||
headers: c.req.raw.headers,
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
const { name } = await c.req.json();
|
||||
|
||||
// Update user in D1 using Drizzle
|
||||
await db
|
||||
.update(schema.user)
|
||||
.set({ name, updatedAt: new Date() })
|
||||
.where(eq(schema.user.id, session.user.id));
|
||||
|
||||
return c.json({ success: true });
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Example: Admin-only endpoint
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
app.get("/api/admin/users", async (c) => {
|
||||
const db = createDatabase(c.env.DB);
|
||||
const auth = createAuth(db, c.env);
|
||||
|
||||
const session = await auth.api.getSession({
|
||||
headers: c.req.raw.headers,
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
// Check admin role (you'd store this in users table)
|
||||
const user = await db.query.user.findFirst({
|
||||
where: (user, { eq }) => eq(user.id, session.user.id),
|
||||
// Add role field to your schema if needed
|
||||
});
|
||||
|
||||
// if (user.role !== 'admin') {
|
||||
// return c.json({ error: 'Forbidden' }, 403)
|
||||
// }
|
||||
|
||||
// Fetch all users
|
||||
const users = await db.query.user.findMany({
|
||||
columns: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
return c.json(users);
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Health check
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
app.get("/health", (c) => {
|
||||
return c.json({
|
||||
status: "ok",
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Export Worker
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
export default app;
|
||||
|
||||
/**
|
||||
* ═══════════════════════════════════════════════════════════════
|
||||
* SETUP CHECKLIST
|
||||
* ═══════════════════════════════════════════════════════════════
|
||||
*
|
||||
* 1. Create D1 database:
|
||||
* wrangler d1 create my-app-db
|
||||
*
|
||||
* 2. Create KV namespaces:
|
||||
* wrangler kv:namespace create SESSIONS_KV
|
||||
* wrangler kv:namespace create RATE_LIMIT_KV
|
||||
*
|
||||
* 3. Add to wrangler.toml:
|
||||
* [[d1_databases]]
|
||||
* binding = "DB"
|
||||
* database_name = "my-app-db"
|
||||
* database_id = "YOUR_ID"
|
||||
*
|
||||
* [[kv_namespaces]]
|
||||
* binding = "SESSIONS_KV"
|
||||
* id = "YOUR_ID"
|
||||
*
|
||||
* [[kv_namespaces]]
|
||||
* binding = "RATE_LIMIT_KV"
|
||||
* id = "YOUR_ID"
|
||||
*
|
||||
* [vars]
|
||||
* BETTER_AUTH_URL = "http://localhost:8787"
|
||||
* FRONTEND_URL = "http://localhost:3000"
|
||||
*
|
||||
* 4. Set secrets:
|
||||
* wrangler secret put BETTER_AUTH_SECRET
|
||||
* wrangler secret put GOOGLE_CLIENT_ID
|
||||
* wrangler secret put GOOGLE_CLIENT_SECRET
|
||||
* wrangler secret put GITHUB_CLIENT_ID
|
||||
* wrangler secret put GITHUB_CLIENT_SECRET
|
||||
*
|
||||
* 5. Generate and apply migrations:
|
||||
* npx drizzle-kit generate
|
||||
* wrangler d1 migrations apply my-app-db --local
|
||||
* wrangler d1 migrations apply my-app-db --remote
|
||||
*
|
||||
* 6. Deploy:
|
||||
* wrangler deploy
|
||||
*
|
||||
* ═══════════════════════════════════════════════════════════════
|
||||
*/
|
||||
240
references/cloudflare-worker-kysely.ts
Normal file
240
references/cloudflare-worker-kysely.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* Complete Cloudflare Worker with better-auth + Kysely
|
||||
*
|
||||
* This example demonstrates:
|
||||
* - D1 database with Kysely adapter
|
||||
* - Email/password authentication
|
||||
* - Google OAuth
|
||||
* - Protected routes with session verification
|
||||
* - CORS configuration for SPA
|
||||
* - CamelCasePlugin for schema conversion
|
||||
*
|
||||
* ⚠️ CRITICAL: better-auth requires Kysely (or Drizzle) for D1
|
||||
* There is NO direct d1Adapter()!
|
||||
*/
|
||||
|
||||
import { Hono } from "hono";
|
||||
import { cors } from "hono/cors";
|
||||
import { betterAuth } from "better-auth";
|
||||
import { Kysely, CamelCasePlugin } from "kysely";
|
||||
import { D1Dialect } from "kysely-d1";
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Environment bindings
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
type Env = {
|
||||
DB: D1Database;
|
||||
BETTER_AUTH_SECRET: string;
|
||||
BETTER_AUTH_URL: string;
|
||||
GOOGLE_CLIENT_ID: string;
|
||||
GOOGLE_CLIENT_SECRET: string;
|
||||
FRONTEND_URL: string;
|
||||
};
|
||||
|
||||
const app = new Hono<{ Bindings: Env }>();
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// CORS configuration for SPA
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
app.use("/api/*", async (c, next) => {
|
||||
const corsMiddleware = cors({
|
||||
origin: [c.env.FRONTEND_URL, "http://localhost:3000"],
|
||||
credentials: true,
|
||||
allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
allowHeaders: ["Content-Type", "Authorization"],
|
||||
});
|
||||
return corsMiddleware(c, next);
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Helper: Initialize auth with Kysely
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
function createAuth(env: Env) {
|
||||
return betterAuth({
|
||||
// Base URL for OAuth callbacks
|
||||
baseURL: env.BETTER_AUTH_URL,
|
||||
|
||||
// Secret for signing tokens
|
||||
secret: env.BETTER_AUTH_SECRET,
|
||||
|
||||
// ⚠️ CRITICAL: Use Kysely with D1Dialect
|
||||
// There is NO direct d1Adapter()!
|
||||
database: {
|
||||
db: new Kysely({
|
||||
dialect: new D1Dialect({
|
||||
database: env.DB,
|
||||
}),
|
||||
plugins: [
|
||||
// CRITICAL: CamelCasePlugin converts between snake_case (DB) and camelCase (better-auth)
|
||||
// Without this, session reads will fail if your schema uses snake_case
|
||||
new CamelCasePlugin(),
|
||||
],
|
||||
}),
|
||||
type: "sqlite",
|
||||
},
|
||||
|
||||
// Email/password authentication
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
requireEmailVerification: true,
|
||||
sendVerificationEmail: async ({ user, url, token }) => {
|
||||
// TODO: Implement email sending
|
||||
console.log(`Verification email for ${user.email}: ${url}`);
|
||||
console.log(`Verification code: ${token}`);
|
||||
},
|
||||
},
|
||||
|
||||
// Social providers
|
||||
socialProviders: {
|
||||
google: {
|
||||
clientId: env.GOOGLE_CLIENT_ID,
|
||||
clientSecret: env.GOOGLE_CLIENT_SECRET,
|
||||
scope: ["openid", "email", "profile"],
|
||||
},
|
||||
},
|
||||
|
||||
// Session configuration
|
||||
session: {
|
||||
expiresIn: 60 * 60 * 24 * 7, // 7 days
|
||||
updateAge: 60 * 60 * 24, // Update every 24 hours
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Auth routes - handle all better-auth endpoints
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
app.all("/api/auth/*", async (c) => {
|
||||
const auth = createAuth(c.env);
|
||||
return auth.handler(c.req.raw);
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Example: Protected API route
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
app.get("/api/protected", async (c) => {
|
||||
const auth = createAuth(c.env);
|
||||
|
||||
// Verify session
|
||||
const session = await auth.api.getSession({
|
||||
headers: c.req.raw.headers,
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
return c.json({
|
||||
message: "Protected data",
|
||||
user: {
|
||||
id: session.user.id,
|
||||
email: session.user.email,
|
||||
name: session.user.name,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Example: User profile endpoint
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
app.get("/api/user/profile", async (c) => {
|
||||
const auth = createAuth(c.env);
|
||||
|
||||
const session = await auth.api.getSession({
|
||||
headers: c.req.raw.headers,
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
// Fetch user data using Kysely
|
||||
const db = new Kysely({
|
||||
dialect: new D1Dialect({ database: c.env.DB }),
|
||||
plugins: [new CamelCasePlugin()],
|
||||
});
|
||||
|
||||
const user = await db
|
||||
.selectFrom("user")
|
||||
.select(["id", "email", "name", "image", "createdAt"])
|
||||
.where("id", "=", session.user.id)
|
||||
.executeTakeFirst();
|
||||
|
||||
return c.json(user);
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Health check
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
app.get("/health", (c) => {
|
||||
return c.json({
|
||||
status: "ok",
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Export Worker
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
export default app;
|
||||
|
||||
/**
|
||||
* ═══════════════════════════════════════════════════════════════
|
||||
* SETUP CHECKLIST
|
||||
* ═══════════════════════════════════════════════════════════════
|
||||
*
|
||||
* 1. Install dependencies:
|
||||
* npm install better-auth kysely kysely-d1 hono
|
||||
*
|
||||
* 2. Create D1 database:
|
||||
* wrangler d1 create my-app-db
|
||||
*
|
||||
* 3. Add to wrangler.toml:
|
||||
* [[d1_databases]]
|
||||
* binding = "DB"
|
||||
* database_name = "my-app-db"
|
||||
* database_id = "YOUR_ID"
|
||||
*
|
||||
* [vars]
|
||||
* BETTER_AUTH_URL = "http://localhost:8787"
|
||||
* FRONTEND_URL = "http://localhost:3000"
|
||||
*
|
||||
* 4. Set secrets:
|
||||
* wrangler secret put BETTER_AUTH_SECRET
|
||||
* wrangler secret put GOOGLE_CLIENT_ID
|
||||
* wrangler secret put GOOGLE_CLIENT_SECRET
|
||||
*
|
||||
* 5. Create database schema manually (Kysely doesn't auto-generate):
|
||||
* wrangler d1 execute my-app-db --local --command "
|
||||
* CREATE TABLE user (
|
||||
* id TEXT PRIMARY KEY,
|
||||
* name TEXT NOT NULL,
|
||||
* email TEXT NOT NULL UNIQUE,
|
||||
* email_verified INTEGER NOT NULL DEFAULT 0,
|
||||
* image TEXT,
|
||||
* created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||
* updated_at INTEGER NOT NULL DEFAULT (unixepoch())
|
||||
* );
|
||||
* CREATE TABLE session (...);
|
||||
* CREATE TABLE account (...);
|
||||
* CREATE TABLE verification (...);
|
||||
* "
|
||||
*
|
||||
* 6. Apply schema to remote:
|
||||
* wrangler d1 execute my-app-db --remote --file schema.sql
|
||||
*
|
||||
* 7. Deploy:
|
||||
* wrangler deploy
|
||||
*
|
||||
* ═══════════════════════════════════════════════════════════════
|
||||
* WHY CamelCasePlugin?
|
||||
* ═══════════════════════════════════════════════════════════════
|
||||
*
|
||||
* If your database schema uses snake_case (email_verified),
|
||||
* but better-auth expects camelCase (emailVerified), the
|
||||
* CamelCasePlugin automatically converts between the two.
|
||||
*
|
||||
* Without it, session reads will fail with missing fields.
|
||||
*
|
||||
* ═══════════════════════════════════════════════════════════════
|
||||
*/
|
||||
315
references/database-schema.ts
Normal file
315
references/database-schema.ts
Normal file
@@ -0,0 +1,315 @@
|
||||
/**
|
||||
* Complete better-auth Database Schema for Drizzle ORM + D1
|
||||
*
|
||||
* This schema includes all tables required by better-auth core.
|
||||
* You can add your own application tables below.
|
||||
*
|
||||
* ═══════════════════════════════════════════════════════════════
|
||||
* CRITICAL NOTES
|
||||
* ═══════════════════════════════════════════════════════════════
|
||||
*
|
||||
* 1. Column names use camelCase (emailVerified, createdAt)
|
||||
* - This matches better-auth expectations
|
||||
* - If you use snake_case, you MUST use CamelCasePlugin with Kysely
|
||||
*
|
||||
* 2. Timestamps use INTEGER with mode: "timestamp"
|
||||
* - D1 (SQLite) doesn't have native timestamp type
|
||||
* - Unix epoch timestamps (seconds since 1970)
|
||||
*
|
||||
* 3. Booleans use INTEGER with mode: "boolean"
|
||||
* - D1 (SQLite) doesn't have native boolean type
|
||||
* - 0 = false, 1 = true
|
||||
*
|
||||
* 4. Foreign keys use onDelete: "cascade"
|
||||
* - Automatically delete related records
|
||||
* - session deleted when user deleted
|
||||
* - account deleted when user deleted
|
||||
*
|
||||
* ═══════════════════════════════════════════════════════════════
|
||||
*/
|
||||
|
||||
import { integer, sqliteTable, text, index } from "drizzle-orm/sqlite-core";
|
||||
import { sql } from "drizzle-orm";
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// better-auth CORE TABLES
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Users table - stores all user accounts
|
||||
*/
|
||||
export const user = sqliteTable(
|
||||
"user",
|
||||
{
|
||||
id: text().primaryKey(),
|
||||
name: text().notNull(),
|
||||
email: text().notNull().unique(),
|
||||
emailVerified: integer({ mode: "boolean" }).notNull().default(false),
|
||||
image: text(), // Profile picture URL
|
||||
createdAt: integer({ mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
updatedAt: integer({ mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
},
|
||||
(table) => ({
|
||||
emailIdx: index("user_email_idx").on(table.email),
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Sessions table - stores active user sessions
|
||||
*
|
||||
* NOTE: Consider using KV storage for sessions instead of D1
|
||||
* to avoid eventual consistency issues
|
||||
*/
|
||||
export const session = sqliteTable(
|
||||
"session",
|
||||
{
|
||||
id: text().primaryKey(),
|
||||
userId: text()
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
token: text().notNull().unique(),
|
||||
expiresAt: integer({ mode: "timestamp" }).notNull(),
|
||||
ipAddress: text(),
|
||||
userAgent: text(),
|
||||
createdAt: integer({ mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
updatedAt: integer({ mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
},
|
||||
(table) => ({
|
||||
userIdIdx: index("session_user_id_idx").on(table.userId),
|
||||
tokenIdx: index("session_token_idx").on(table.token),
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Accounts table - stores OAuth provider accounts and passwords
|
||||
*/
|
||||
export const account = sqliteTable(
|
||||
"account",
|
||||
{
|
||||
id: text().primaryKey(),
|
||||
userId: text()
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
accountId: text().notNull(), // Provider's user ID
|
||||
providerId: text().notNull(), // "google", "github", etc.
|
||||
accessToken: text(),
|
||||
refreshToken: text(),
|
||||
accessTokenExpiresAt: integer({ mode: "timestamp" }),
|
||||
refreshTokenExpiresAt: integer({ mode: "timestamp" }),
|
||||
scope: text(), // OAuth scopes granted
|
||||
idToken: text(), // OpenID Connect ID token
|
||||
password: text(), // Hashed password for email/password auth
|
||||
createdAt: integer({ mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
updatedAt: integer({ mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
},
|
||||
(table) => ({
|
||||
userIdIdx: index("account_user_id_idx").on(table.userId),
|
||||
providerIdx: index("account_provider_idx").on(
|
||||
table.providerId,
|
||||
table.accountId
|
||||
),
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Verification tokens - for email verification, password reset, etc.
|
||||
*/
|
||||
export const verification = sqliteTable(
|
||||
"verification",
|
||||
{
|
||||
id: text().primaryKey(),
|
||||
identifier: text().notNull(), // Email or user ID
|
||||
value: text().notNull(), // Token value
|
||||
expiresAt: integer({ mode: "timestamp" }).notNull(),
|
||||
createdAt: integer({ mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
updatedAt: integer({ mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
},
|
||||
(table) => ({
|
||||
identifierIdx: index("verification_identifier_idx").on(table.identifier),
|
||||
valueIdx: index("verification_value_idx").on(table.value),
|
||||
})
|
||||
);
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// OPTIONAL: Additional tables for better-auth plugins
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Two-Factor Authentication table (if using 2FA plugin)
|
||||
*/
|
||||
export const twoFactor = sqliteTable(
|
||||
"two_factor",
|
||||
{
|
||||
id: text().primaryKey(),
|
||||
userId: text()
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
secret: text().notNull(), // TOTP secret
|
||||
backupCodes: text(), // JSON array of backup codes
|
||||
enabled: integer({ mode: "boolean" }).notNull().default(false),
|
||||
createdAt: integer({ mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
},
|
||||
(table) => ({
|
||||
userIdIdx: index("two_factor_user_id_idx").on(table.userId),
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Organizations table (if using organization plugin)
|
||||
*/
|
||||
export const organization = sqliteTable("organization", {
|
||||
id: text().primaryKey(),
|
||||
name: text().notNull(),
|
||||
slug: text().notNull().unique(),
|
||||
logo: text(),
|
||||
createdAt: integer({ mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
updatedAt: integer({ mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
});
|
||||
|
||||
/**
|
||||
* Organization members table (if using organization plugin)
|
||||
*/
|
||||
export const organizationMember = sqliteTable(
|
||||
"organization_member",
|
||||
{
|
||||
id: text().primaryKey(),
|
||||
organizationId: text()
|
||||
.notNull()
|
||||
.references(() => organization.id, { onDelete: "cascade" }),
|
||||
userId: text()
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
role: text().notNull(), // "owner", "admin", "member"
|
||||
createdAt: integer({ mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
},
|
||||
(table) => ({
|
||||
orgIdIdx: index("org_member_org_id_idx").on(table.organizationId),
|
||||
userIdIdx: index("org_member_user_id_idx").on(table.userId),
|
||||
})
|
||||
);
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// YOUR APPLICATION TABLES
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Example: User profile extension
|
||||
*/
|
||||
export const profile = sqliteTable("profile", {
|
||||
id: text().primaryKey(),
|
||||
userId: text()
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
bio: text(),
|
||||
website: text(),
|
||||
location: text(),
|
||||
phone: text(),
|
||||
createdAt: integer({ mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
updatedAt: integer({ mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
});
|
||||
|
||||
/**
|
||||
* Example: User preferences
|
||||
*/
|
||||
export const userPreferences = sqliteTable("user_preferences", {
|
||||
id: text().primaryKey(),
|
||||
userId: text()
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
theme: text().notNull().default("system"), // "light", "dark", "system"
|
||||
language: text().notNull().default("en"),
|
||||
emailNotifications: integer({ mode: "boolean" }).notNull().default(true),
|
||||
pushNotifications: integer({ mode: "boolean" }).notNull().default(false),
|
||||
createdAt: integer({ mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
updatedAt: integer({ mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Export all schemas for Drizzle
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
export const schema = {
|
||||
user,
|
||||
session,
|
||||
account,
|
||||
verification,
|
||||
twoFactor,
|
||||
organization,
|
||||
organizationMember,
|
||||
profile,
|
||||
userPreferences,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* ═══════════════════════════════════════════════════════════════
|
||||
* USAGE INSTRUCTIONS
|
||||
* ═══════════════════════════════════════════════════════════════
|
||||
*
|
||||
* 1. Save this file as: src/db/schema.ts
|
||||
*
|
||||
* 2. Create drizzle.config.ts:
|
||||
* import type { Config } from "drizzle-kit";
|
||||
*
|
||||
* export default {
|
||||
* out: "./drizzle",
|
||||
* schema: "./src/db/schema.ts",
|
||||
* dialect: "sqlite",
|
||||
* driver: "d1-http",
|
||||
* dbCredentials: {
|
||||
* databaseId: process.env.CLOUDFLARE_DATABASE_ID!,
|
||||
* accountId: process.env.CLOUDFLARE_ACCOUNT_ID!,
|
||||
* token: process.env.CLOUDFLARE_TOKEN!,
|
||||
* },
|
||||
* } satisfies Config;
|
||||
*
|
||||
* 3. Generate migrations:
|
||||
* npx drizzle-kit generate
|
||||
*
|
||||
* 4. Apply migrations to D1:
|
||||
* wrangler d1 migrations apply my-app-db --local
|
||||
* wrangler d1 migrations apply my-app-db --remote
|
||||
*
|
||||
* 5. Use in your Worker:
|
||||
* import { drizzle } from "drizzle-orm/d1";
|
||||
* import * as schema from "./db/schema";
|
||||
*
|
||||
* const db = drizzle(env.DB, { schema });
|
||||
*
|
||||
* 6. Query example:
|
||||
* const users = await db.query.user.findMany({
|
||||
* where: (user, { eq }) => eq(user.emailVerified, true)
|
||||
* });
|
||||
*
|
||||
* ═══════════════════════════════════════════════════════════════
|
||||
*/
|
||||
41
references/nextjs/README.md
Normal file
41
references/nextjs/README.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# Next.js Examples
|
||||
|
||||
This directory contains better-auth examples for **Next.js with PostgreSQL**.
|
||||
|
||||
**Important**: These examples are NOT for Cloudflare D1. They use PostgreSQL via Hyperdrive or direct connection.
|
||||
|
||||
## Files
|
||||
|
||||
### `postgres-example.ts`
|
||||
Complete Next.js API route with better-auth using:
|
||||
- **PostgreSQL** (not D1)
|
||||
- Drizzle ORM with `postgres` driver
|
||||
- Organizations plugin
|
||||
- 2FA plugin
|
||||
- Email verification
|
||||
- Custom error handling
|
||||
|
||||
**Use this example when**:
|
||||
- Building Next.js application (not Cloudflare Workers)
|
||||
- Using PostgreSQL database
|
||||
- Need organizations and 2FA features
|
||||
|
||||
**Installation**:
|
||||
```bash
|
||||
npm install better-auth drizzle-orm postgres
|
||||
```
|
||||
|
||||
**Environment variables**:
|
||||
```env
|
||||
DATABASE_URL=postgresql://user:password@host:5432/database
|
||||
BETTER_AUTH_SECRET=your-secret
|
||||
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||
GOOGLE_CLIENT_ID=your-google-client-id
|
||||
GOOGLE_CLIENT_SECRET=your-google-client-secret
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**For Cloudflare D1 examples**, see the parent `references/` directory:
|
||||
- `cloudflare-worker-drizzle.ts` - Complete Worker with Drizzle + D1
|
||||
- `cloudflare-worker-kysely.ts` - Complete Worker with Kysely + D1
|
||||
147
references/nextjs/postgres-example.ts
Normal file
147
references/nextjs/postgres-example.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Next.js API Route with better-auth
|
||||
*
|
||||
* This example demonstrates:
|
||||
* - PostgreSQL with Drizzle ORM
|
||||
* - Email/password + social auth
|
||||
* - Email verification
|
||||
* - Organizations plugin
|
||||
* - 2FA plugin
|
||||
* - Custom error handling
|
||||
*/
|
||||
|
||||
import { betterAuth } from 'better-auth'
|
||||
import { drizzle } from 'drizzle-orm/postgres-js'
|
||||
import postgres from 'postgres'
|
||||
import { twoFactor, organization } from 'better-auth/plugins'
|
||||
import { sendEmail } from '@/lib/email' // Your email service
|
||||
|
||||
// Database connection
|
||||
const client = postgres(process.env.DATABASE_URL!)
|
||||
const db = drizzle(client)
|
||||
|
||||
// Initialize better-auth
|
||||
export const auth = betterAuth({
|
||||
database: db,
|
||||
|
||||
secret: process.env.BETTER_AUTH_SECRET!,
|
||||
|
||||
baseURL: process.env.NEXT_PUBLIC_APP_URL!,
|
||||
|
||||
// Email/password authentication
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
requireEmailVerification: true,
|
||||
|
||||
// Custom email sending
|
||||
sendVerificationEmail: async ({ user, url, token }) => {
|
||||
await sendEmail({
|
||||
to: user.email,
|
||||
subject: 'Verify your email',
|
||||
html: `
|
||||
<h1>Verify your email</h1>
|
||||
<p>Click the link below to verify your email address:</p>
|
||||
<a href="${url}">Verify Email</a>
|
||||
<p>Or enter this code: <strong>${token}</strong></p>
|
||||
<p>This link expires in 24 hours.</p>
|
||||
`
|
||||
})
|
||||
},
|
||||
|
||||
// Password reset email
|
||||
sendResetPasswordEmail: async ({ user, url, token }) => {
|
||||
await sendEmail({
|
||||
to: user.email,
|
||||
subject: 'Reset your password',
|
||||
html: `
|
||||
<h1>Reset your password</h1>
|
||||
<p>Click the link below to reset your password:</p>
|
||||
<a href="${url}">Reset Password</a>
|
||||
<p>Or enter this code: <strong>${token}</strong></p>
|
||||
<p>This link expires in 1 hour.</p>
|
||||
`
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
// Social providers
|
||||
socialProviders: {
|
||||
google: {
|
||||
clientId: process.env.GOOGLE_CLIENT_ID!,
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
|
||||
scope: ['openid', 'email', 'profile']
|
||||
},
|
||||
github: {
|
||||
clientId: process.env.GITHUB_CLIENT_ID!,
|
||||
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
|
||||
scope: ['user:email', 'read:user']
|
||||
},
|
||||
microsoft: {
|
||||
clientId: process.env.MICROSOFT_CLIENT_ID!,
|
||||
clientSecret: process.env.MICROSOFT_CLIENT_SECRET!,
|
||||
tenantId: process.env.MICROSOFT_TENANT_ID || 'common'
|
||||
}
|
||||
},
|
||||
|
||||
// Session configuration
|
||||
session: {
|
||||
expiresIn: 60 * 60 * 24 * 7, // 7 days
|
||||
updateAge: 60 * 60 * 24, // Update every 24 hours
|
||||
cookieCache: {
|
||||
enabled: true,
|
||||
maxAge: 60 * 5 // 5 minutes
|
||||
}
|
||||
},
|
||||
|
||||
// Advanced features via plugins
|
||||
plugins: [
|
||||
// Two-factor authentication
|
||||
twoFactor({
|
||||
methods: ['totp', 'sms'],
|
||||
issuer: 'MyApp',
|
||||
sendOTP: async ({ user, otp, method }) => {
|
||||
if (method === 'sms') {
|
||||
// Send SMS with OTP (use Twilio, etc.)
|
||||
console.log(`Send SMS to ${user.phone}: ${otp}`)
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
||||
// Organizations and teams
|
||||
organization({
|
||||
roles: ['owner', 'admin', 'member'],
|
||||
permissions: {
|
||||
owner: ['*'], // All permissions
|
||||
admin: ['read', 'write', 'delete', 'invite'],
|
||||
member: ['read']
|
||||
},
|
||||
sendInvitationEmail: async ({ email, organizationName, inviteUrl }) => {
|
||||
await sendEmail({
|
||||
to: email,
|
||||
subject: `You've been invited to ${organizationName}`,
|
||||
html: `
|
||||
<h1>You've been invited!</h1>
|
||||
<p>Click the link below to join ${organizationName}:</p>
|
||||
<a href="${inviteUrl}">Accept Invitation</a>
|
||||
`
|
||||
})
|
||||
}
|
||||
})
|
||||
],
|
||||
|
||||
// Custom error handling
|
||||
onError: (error, req) => {
|
||||
console.error('Auth error:', error)
|
||||
// Log to your error tracking service (Sentry, etc.)
|
||||
},
|
||||
|
||||
// Success callbacks
|
||||
onSuccess: async (user, action) => {
|
||||
console.log(`User ${user.id} performed action: ${action}`)
|
||||
// Log auth events for security monitoring
|
||||
}
|
||||
})
|
||||
|
||||
// Type definitions for TypeScript
|
||||
export type Session = typeof auth.$Infer.Session
|
||||
export type User = typeof auth.$Infer.User
|
||||
470
references/react-client-hooks.tsx
Normal file
470
references/react-client-hooks.tsx
Normal file
@@ -0,0 +1,470 @@
|
||||
/**
|
||||
* React Client Components with better-auth
|
||||
*
|
||||
* This example demonstrates:
|
||||
* - useSession hook
|
||||
* - Sign in/up forms
|
||||
* - Social sign-in buttons
|
||||
* - Protected route component
|
||||
* - User profile component
|
||||
* - Organization switcher
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import { createAuthClient, useSession } from 'better-auth/client'
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
// Initialize auth client
|
||||
export const authClient = createAuthClient({
|
||||
baseURL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Login Form Component
|
||||
// ============================================================================
|
||||
|
||||
export function LoginForm() {
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleEmailSignIn = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const { data, error } = await authClient.signIn.email({
|
||||
email,
|
||||
password
|
||||
})
|
||||
|
||||
if (error) {
|
||||
setError(error.message)
|
||||
return
|
||||
}
|
||||
|
||||
// Redirect on success
|
||||
window.location.href = '/dashboard'
|
||||
} catch (err) {
|
||||
setError('An error occurred. Please try again.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleGoogleSignIn = async () => {
|
||||
setLoading(true)
|
||||
await authClient.signIn.social({
|
||||
provider: 'google',
|
||||
callbackURL: '/dashboard'
|
||||
})
|
||||
}
|
||||
|
||||
const handleGitHubSignIn = async () => {
|
||||
setLoading(true)
|
||||
await authClient.signIn.social({
|
||||
provider: 'github',
|
||||
callbackURL: '/dashboard'
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-md mx-auto p-6 bg-white rounded-lg shadow">
|
||||
<h2 className="text-2xl font-bold mb-6">Sign In</h2>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-100 text-red-700 rounded">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleEmailSignIn} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium mb-1">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium mb-1">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-2 px-4 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Signing in...' : 'Sign In'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-gray-300" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="px-2 bg-white text-gray-500">Or continue with</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid grid-cols-2 gap-3">
|
||||
<button
|
||||
onClick={handleGoogleSignIn}
|
||||
disabled={loading}
|
||||
className="py-2 px-4 border rounded-md hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
Google
|
||||
</button>
|
||||
<button
|
||||
onClick={handleGitHubSignIn}
|
||||
disabled={loading}
|
||||
className="py-2 px-4 border rounded-md hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
GitHub
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-4 text-center text-sm text-gray-600">
|
||||
Don't have an account?{' '}
|
||||
<a href="/signup" className="text-blue-600 hover:underline">
|
||||
Sign up
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Sign Up Form Component
|
||||
// ============================================================================
|
||||
|
||||
export function SignUpForm() {
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [name, setName] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [success, setSuccess] = useState(false)
|
||||
|
||||
const handleSignUp = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const { data, error } = await authClient.signUp.email({
|
||||
email,
|
||||
password,
|
||||
name
|
||||
})
|
||||
|
||||
if (error) {
|
||||
setError(error.message)
|
||||
return
|
||||
}
|
||||
|
||||
setSuccess(true)
|
||||
} catch (err) {
|
||||
setError('An error occurred. Please try again.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<div className="max-w-md mx-auto p-6 bg-white rounded-lg shadow">
|
||||
<h2 className="text-2xl font-bold mb-4">Check your email</h2>
|
||||
<p className="text-gray-600">
|
||||
We've sent a verification link to <strong>{email}</strong>.
|
||||
Click the link to verify your account.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-md mx-auto p-6 bg-white rounded-lg shadow">
|
||||
<h2 className="text-2xl font-bold mb-6">Sign Up</h2>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-100 text-red-700 rounded">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSignUp} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium mb-1">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
placeholder="John Doe"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium mb-1">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium mb-1">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
At least 8 characters
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-2 px-4 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Creating account...' : 'Sign Up'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="mt-4 text-center text-sm text-gray-600">
|
||||
Already have an account?{' '}
|
||||
<a href="/login" className="text-blue-600 hover:underline">
|
||||
Sign in
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// User Profile Component
|
||||
// ============================================================================
|
||||
|
||||
export function UserProfile() {
|
||||
const { data: session, isPending } = useSession()
|
||||
|
||||
if (isPending) {
|
||||
return <div className="p-4">Loading...</div>
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<div className="p-4">
|
||||
<p>Not authenticated</p>
|
||||
<a href="/login" className="text-blue-600 hover:underline">
|
||||
Sign in
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const handleSignOut = async () => {
|
||||
await authClient.signOut()
|
||||
window.location.href = '/login'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 bg-white rounded-lg shadow">
|
||||
<div className="flex items-center gap-4">
|
||||
{session.user.image && (
|
||||
<img
|
||||
src={session.user.image}
|
||||
alt={session.user.name || 'User'}
|
||||
className="w-12 h-12 rounded-full"
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold">{session.user.name}</h3>
|
||||
<p className="text-sm text-gray-600">{session.user.email}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSignOut}
|
||||
className="px-4 py-2 text-sm border rounded-md hover:bg-gray-50"
|
||||
>
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Protected Route Component
|
||||
// ============================================================================
|
||||
|
||||
export function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const { data: session, isPending } = useSession()
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto" />
|
||||
<p className="mt-4 text-gray-600">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
// Redirect to login
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = '/login'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Organization Switcher Component (if using organizations plugin)
|
||||
// ============================================================================
|
||||
|
||||
export function OrganizationSwitcher() {
|
||||
const { data: session } = useSession()
|
||||
const [organizations, setOrganizations] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
// Fetch user's organizations
|
||||
useEffect(() => {
|
||||
async function fetchOrgs() {
|
||||
const orgs = await authClient.organization.listUserOrganizations()
|
||||
setOrganizations(orgs)
|
||||
setLoading(false)
|
||||
}
|
||||
fetchOrgs()
|
||||
}, [])
|
||||
|
||||
const switchOrganization = async (orgId: string) => {
|
||||
await authClient.organization.setActiveOrganization({ organizationId: orgId })
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
if (loading) return <div>Loading organizations...</div>
|
||||
|
||||
return (
|
||||
<select
|
||||
onChange={(e) => switchOrganization(e.target.value)}
|
||||
className="px-3 py-2 border rounded-md"
|
||||
>
|
||||
{organizations.map((org) => (
|
||||
<option key={org.id} value={org.id}>
|
||||
{org.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 2FA Setup Component (if using twoFactor plugin)
|
||||
// ============================================================================
|
||||
|
||||
export function TwoFactorSetup() {
|
||||
const [qrCode, setQrCode] = useState('')
|
||||
const [verifyCode, setVerifyCode] = useState('')
|
||||
const [enabled, setEnabled] = useState(false)
|
||||
|
||||
const enable2FA = async () => {
|
||||
const { data } = await authClient.twoFactor.enable({ method: 'totp' })
|
||||
setQrCode(data.qrCode)
|
||||
}
|
||||
|
||||
const verify2FA = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
const { error } = await authClient.twoFactor.verify({ code: verifyCode })
|
||||
if (!error) {
|
||||
setEnabled(true)
|
||||
}
|
||||
}
|
||||
|
||||
if (enabled) {
|
||||
return <div className="p-4 bg-green-100 rounded">2FA is enabled!</div>
|
||||
}
|
||||
|
||||
if (qrCode) {
|
||||
return (
|
||||
<div className="p-4 bg-white rounded-lg shadow">
|
||||
<h3 className="font-semibold mb-4">Scan QR Code</h3>
|
||||
<img src={qrCode} alt="2FA QR Code" className="mx-auto mb-4" />
|
||||
<form onSubmit={verify2FA} className="space-y-4">
|
||||
<input
|
||||
type="text"
|
||||
value={verifyCode}
|
||||
onChange={(e) => setVerifyCode(e.target.value)}
|
||||
placeholder="Enter 6-digit code"
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
maxLength={6}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full py-2 px-4 bg-blue-600 text-white rounded-md"
|
||||
>
|
||||
Verify & Enable
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={enable2FA}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md"
|
||||
>
|
||||
Enable 2FA
|
||||
</button>
|
||||
)
|
||||
}
|
||||
221
scripts/setup-d1-drizzle.sh
Normal file
221
scripts/setup-d1-drizzle.sh
Normal file
@@ -0,0 +1,221 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# better-auth + D1 + Drizzle ORM Setup Script
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
#
|
||||
# This script automates the setup of better-auth with Cloudflare D1
|
||||
# and Drizzle ORM.
|
||||
#
|
||||
# CRITICAL: better-auth requires Drizzle ORM or Kysely for D1.
|
||||
# There is NO direct d1Adapter()!
|
||||
#
|
||||
# Usage:
|
||||
# chmod +x setup-d1-drizzle.sh
|
||||
# ./setup-d1-drizzle.sh my-app-name
|
||||
#
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
set -e # Exit on error
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Check if app name provided
|
||||
if [ -z "$1" ]; then
|
||||
echo -e "${RED}Error: Please provide an app name${NC}"
|
||||
echo "Usage: ./setup-d1-drizzle.sh my-app-name"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
APP_NAME=$1
|
||||
DB_NAME="${APP_NAME}-db"
|
||||
|
||||
echo -e "${BLUE}═══════════════════════════════════════════════════════════════${NC}"
|
||||
echo -e "${BLUE}better-auth + D1 + Drizzle Setup${NC}"
|
||||
echo -e "${BLUE}═══════════════════════════════════════════════════════════════${NC}"
|
||||
echo ""
|
||||
echo -e "App Name: ${GREEN}$APP_NAME${NC}"
|
||||
echo -e "Database: ${GREEN}$DB_NAME${NC}"
|
||||
echo ""
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Step 1: Install dependencies
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
echo -e "${YELLOW}[1/8]${NC} Installing dependencies..."
|
||||
npm install better-auth drizzle-orm drizzle-kit @cloudflare/workers-types hono
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Step 2: Create D1 database
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
echo -e "${YELLOW}[2/8]${NC} Creating D1 database..."
|
||||
wrangler d1 create $DB_NAME
|
||||
|
||||
echo -e "${GREEN}✓${NC} Database created!"
|
||||
echo -e "${YELLOW}⚠${NC} Copy the database_id from the output above and update wrangler.toml"
|
||||
echo ""
|
||||
read -p "Press Enter after updating wrangler.toml..."
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Step 3: Create directory structure
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
echo -e "${YELLOW}[3/8]${NC} Creating directory structure..."
|
||||
mkdir -p src/db
|
||||
mkdir -p drizzle
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Step 4: Create database schema
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
echo -e "${YELLOW}[4/8]${NC} Creating database schema..."
|
||||
cat > src/db/schema.ts << 'EOF'
|
||||
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||
import { sql } from "drizzle-orm";
|
||||
|
||||
export const user = sqliteTable("user", {
|
||||
id: text().primaryKey(),
|
||||
name: text().notNull(),
|
||||
email: text().notNull().unique(),
|
||||
emailVerified: integer({ mode: "boolean" }).notNull().default(false),
|
||||
image: text(),
|
||||
createdAt: integer({ mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
updatedAt: integer({ mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
});
|
||||
|
||||
export const session = sqliteTable("session", {
|
||||
id: text().primaryKey(),
|
||||
userId: text()
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
token: text().notNull(),
|
||||
expiresAt: integer({ mode: "timestamp" }).notNull(),
|
||||
ipAddress: text(),
|
||||
userAgent: text(),
|
||||
createdAt: integer({ mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
updatedAt: integer({ mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
});
|
||||
|
||||
export const account = sqliteTable("account", {
|
||||
id: text().primaryKey(),
|
||||
userId: text()
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
accountId: text().notNull(),
|
||||
providerId: text().notNull(),
|
||||
accessToken: text(),
|
||||
refreshToken: text(),
|
||||
accessTokenExpiresAt: integer({ mode: "timestamp" }),
|
||||
refreshTokenExpiresAt: integer({ mode: "timestamp" }),
|
||||
scope: text(),
|
||||
idToken: text(),
|
||||
password: text(),
|
||||
createdAt: integer({ mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
updatedAt: integer({ mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
});
|
||||
|
||||
export const verification = sqliteTable("verification", {
|
||||
id: text().primaryKey(),
|
||||
identifier: text().notNull(),
|
||||
value: text().notNull(),
|
||||
expiresAt: integer({ mode: "timestamp" }).notNull(),
|
||||
createdAt: integer({ mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
updatedAt: integer({ mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
});
|
||||
EOF
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Step 5: Create Drizzle config
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
echo -e "${YELLOW}[5/8]${NC} Creating Drizzle config..."
|
||||
cat > drizzle.config.ts << 'EOF'
|
||||
import type { Config } from "drizzle-kit";
|
||||
|
||||
export default {
|
||||
out: "./drizzle",
|
||||
schema: "./src/db/schema.ts",
|
||||
dialect: "sqlite",
|
||||
driver: "d1-http",
|
||||
dbCredentials: {
|
||||
databaseId: process.env.CLOUDFLARE_DATABASE_ID!,
|
||||
accountId: process.env.CLOUDFLARE_ACCOUNT_ID!,
|
||||
token: process.env.CLOUDFLARE_TOKEN!,
|
||||
},
|
||||
} satisfies Config;
|
||||
EOF
|
||||
|
||||
echo -e "${GREEN}✓${NC} Config created"
|
||||
echo ""
|
||||
echo -e "${YELLOW}⚠${NC} Create a .env file with:"
|
||||
echo " CLOUDFLARE_ACCOUNT_ID=your-account-id"
|
||||
echo " CLOUDFLARE_DATABASE_ID=your-database-id"
|
||||
echo " CLOUDFLARE_TOKEN=your-api-token"
|
||||
echo ""
|
||||
read -p "Press Enter after creating .env..."
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Step 6: Generate migrations
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
echo -e "${YELLOW}[6/8]${NC} Generating migrations..."
|
||||
npx drizzle-kit generate
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Step 7: Apply migrations
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
echo -e "${YELLOW}[7/8]${NC} Applying migrations..."
|
||||
echo -e "Applying to ${GREEN}local${NC} D1..."
|
||||
wrangler d1 migrations apply $DB_NAME --local
|
||||
|
||||
read -p "Apply to remote D1 too? (y/n) " -n 1 -r
|
||||
echo
|
||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
echo -e "Applying to ${GREEN}remote${NC} D1..."
|
||||
wrangler d1 migrations apply $DB_NAME --remote
|
||||
fi
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Step 8: Set secrets
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
echo -e "${YELLOW}[8/8]${NC} Setting secrets..."
|
||||
echo ""
|
||||
echo -e "${BLUE}Generating BETTER_AUTH_SECRET...${NC}"
|
||||
SECRET=$(openssl rand -base64 32)
|
||||
echo "$SECRET" | wrangler secret put BETTER_AUTH_SECRET
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}✓ Setup complete!${NC}"
|
||||
echo ""
|
||||
echo -e "${BLUE}═══════════════════════════════════════════════════════════════${NC}"
|
||||
echo -e "${BLUE}Next Steps${NC}"
|
||||
echo -e "${BLUE}═══════════════════════════════════════════════════════════════${NC}"
|
||||
echo ""
|
||||
echo "1. Add OAuth client IDs and secrets (if needed):"
|
||||
echo " wrangler secret put GOOGLE_CLIENT_ID"
|
||||
echo " wrangler secret put GOOGLE_CLIENT_SECRET"
|
||||
echo ""
|
||||
echo "2. Test locally:"
|
||||
echo " npm run dev"
|
||||
echo ""
|
||||
echo "3. Deploy:"
|
||||
echo " wrangler deploy"
|
||||
echo ""
|
||||
echo -e "${YELLOW}⚠ IMPORTANT:${NC} Update your wrangler.toml with the database_id from Step 2"
|
||||
echo ""
|
||||
Reference in New Issue
Block a user