Initial commit

This commit is contained in:
Zhongwei Li
2025-11-30 08:23:56 +08:00
commit dba45740a3
12 changed files with 3456 additions and 0 deletions

View 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
View 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.

1226
SKILL.md Normal file

File diff suppressed because it is too large Load Diff

370
assets/auth-flow-diagram.md Normal file
View 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
View 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": []
}
}

View 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
*
* ═══════════════════════════════════════════════════════════════
*/

View 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.
*
* ═══════════════════════════════════════════════════════════════
*/

View 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)
* });
*
* ═══════════════════════════════════════════════════════════════
*/

View 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

View 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

View 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
View 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 ""