Initial commit
This commit is contained in:
204
skills/better-auth/SKILL.md
Normal file
204
skills/better-auth/SKILL.md
Normal file
@@ -0,0 +1,204 @@
|
||||
---
|
||||
name: better-auth
|
||||
description: Implement authentication and authorization with Better Auth - a framework-agnostic TypeScript authentication framework. Features include email/password authentication with verification, OAuth providers (Google, GitHub, Discord, etc.), two-factor authentication (TOTP, SMS), passkeys/WebAuthn support, session management, role-based access control (RBAC), rate limiting, and database adapters. Use when adding authentication to applications, implementing OAuth flows, setting up 2FA/MFA, managing user sessions, configuring authorization rules, or building secure authentication systems for web applications.
|
||||
license: MIT
|
||||
version: 2.0.0
|
||||
---
|
||||
|
||||
# Better Auth Skill
|
||||
|
||||
Better Auth is comprehensive, framework-agnostic authentication/authorization framework for TypeScript with built-in email/password, social OAuth, and powerful plugin ecosystem for advanced features.
|
||||
|
||||
## When to Use
|
||||
|
||||
- Implementing auth in TypeScript/JavaScript applications
|
||||
- Adding email/password or social OAuth authentication
|
||||
- Setting up 2FA, passkeys, magic links, advanced auth features
|
||||
- Building multi-tenant apps with organization support
|
||||
- Managing sessions and user lifecycle
|
||||
- Working with any framework (Next.js, Nuxt, SvelteKit, Remix, Astro, Hono, Express, etc.)
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
npm install better-auth
|
||||
# or pnpm/yarn/bun add better-auth
|
||||
```
|
||||
|
||||
### Environment Setup
|
||||
|
||||
Create `.env`:
|
||||
```env
|
||||
BETTER_AUTH_SECRET=<generated-secret-32-chars-min>
|
||||
BETTER_AUTH_URL=http://localhost:3000
|
||||
```
|
||||
|
||||
### Basic Server Setup
|
||||
|
||||
Create `auth.ts` (root, lib/, utils/, or under src/app/server/):
|
||||
|
||||
```ts
|
||||
import { betterAuth } from "better-auth";
|
||||
|
||||
export const auth = betterAuth({
|
||||
database: {
|
||||
// See references/database-integration.md
|
||||
},
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
autoSignIn: true
|
||||
},
|
||||
socialProviders: {
|
||||
github: {
|
||||
clientId: process.env.GITHUB_CLIENT_ID!,
|
||||
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Database Schema
|
||||
|
||||
```bash
|
||||
npx @better-auth/cli generate # Generate schema/migrations
|
||||
npx @better-auth/cli migrate # Apply migrations (Kysely only)
|
||||
```
|
||||
|
||||
### Mount API Handler
|
||||
|
||||
**Next.js App Router:**
|
||||
```ts
|
||||
// app/api/auth/[...all]/route.ts
|
||||
import { auth } from "@/lib/auth";
|
||||
import { toNextJsHandler } from "better-auth/next-js";
|
||||
|
||||
export const { POST, GET } = toNextJsHandler(auth);
|
||||
```
|
||||
|
||||
**Other frameworks:** See references/email-password-auth.md#framework-setup
|
||||
|
||||
### Client Setup
|
||||
|
||||
Create `auth-client.ts`:
|
||||
|
||||
```ts
|
||||
import { createAuthClient } from "better-auth/client";
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
baseURL: process.env.NEXT_PUBLIC_BETTER_AUTH_URL || "http://localhost:3000"
|
||||
});
|
||||
```
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```ts
|
||||
// Sign up
|
||||
await authClient.signUp.email({
|
||||
email: "user@example.com",
|
||||
password: "secure123",
|
||||
name: "John Doe"
|
||||
});
|
||||
|
||||
// Sign in
|
||||
await authClient.signIn.email({
|
||||
email: "user@example.com",
|
||||
password: "secure123"
|
||||
});
|
||||
|
||||
// OAuth
|
||||
await authClient.signIn.social({ provider: "github" });
|
||||
|
||||
// Session
|
||||
const { data: session } = authClient.useSession(); // React/Vue/Svelte
|
||||
const { data: session } = await authClient.getSession(); // Vanilla JS
|
||||
```
|
||||
|
||||
## Feature Selection Matrix
|
||||
|
||||
| Feature | Plugin Required | Use Case | Reference |
|
||||
|---------|----------------|----------|-----------|
|
||||
| Email/Password | No (built-in) | Basic auth | [email-password-auth.md](./references/email-password-auth.md) |
|
||||
| OAuth (GitHub, Google, etc.) | No (built-in) | Social login | [oauth-providers.md](./references/oauth-providers.md) |
|
||||
| Email Verification | No (built-in) | Verify email addresses | [email-password-auth.md](./references/email-password-auth.md#email-verification) |
|
||||
| Password Reset | No (built-in) | Forgot password flow | [email-password-auth.md](./references/email-password-auth.md#password-reset) |
|
||||
| Two-Factor Auth (2FA/TOTP) | Yes (`twoFactor`) | Enhanced security | [advanced-features.md](./references/advanced-features.md#two-factor-authentication) |
|
||||
| Passkeys/WebAuthn | Yes (`passkey`) | Passwordless auth | [advanced-features.md](./references/advanced-features.md#passkeys-webauthn) |
|
||||
| Magic Link | Yes (`magicLink`) | Email-based login | [advanced-features.md](./references/advanced-features.md#magic-link) |
|
||||
| Username Auth | Yes (`username`) | Username login | [email-password-auth.md](./references/email-password-auth.md#username-authentication) |
|
||||
| Organizations/Multi-tenant | Yes (`organization`) | Team/org features | [advanced-features.md](./references/advanced-features.md#organizations) |
|
||||
| Rate Limiting | No (built-in) | Prevent abuse | [advanced-features.md](./references/advanced-features.md#rate-limiting) |
|
||||
| Session Management | No (built-in) | User sessions | [advanced-features.md](./references/advanced-features.md#session-management) |
|
||||
|
||||
## Auth Method Selection Guide
|
||||
|
||||
**Choose Email/Password when:**
|
||||
- Building standard web app with traditional auth
|
||||
- Need full control over user credentials
|
||||
- Targeting users who prefer email-based accounts
|
||||
|
||||
**Choose OAuth when:**
|
||||
- Want quick signup with minimal friction
|
||||
- Users already have social accounts
|
||||
- Need access to social profile data
|
||||
|
||||
**Choose Passkeys when:**
|
||||
- Want passwordless experience
|
||||
- Targeting modern browsers/devices
|
||||
- Security is top priority
|
||||
|
||||
**Choose Magic Link when:**
|
||||
- Want passwordless without WebAuthn complexity
|
||||
- Targeting email-first users
|
||||
- Need temporary access links
|
||||
|
||||
**Combine Multiple Methods when:**
|
||||
- Want flexibility for different user preferences
|
||||
- Building enterprise apps with various auth requirements
|
||||
- Need progressive enhancement (start simple, add more options)
|
||||
|
||||
## Core Architecture
|
||||
|
||||
Better Auth uses client-server architecture:
|
||||
1. **Server** (`better-auth`): Handles auth logic, database ops, API routes
|
||||
2. **Client** (`better-auth/client`): Provides hooks/methods for frontend
|
||||
3. **Plugins**: Extend both server/client functionality
|
||||
|
||||
## Implementation Checklist
|
||||
|
||||
- [ ] Install `better-auth` package
|
||||
- [ ] Set environment variables (SECRET, URL)
|
||||
- [ ] Create auth server instance with database config
|
||||
- [ ] Run schema migration (`npx @better-auth/cli generate`)
|
||||
- [ ] Mount API handler in framework
|
||||
- [ ] Create client instance
|
||||
- [ ] Implement sign-up/sign-in UI
|
||||
- [ ] Add session management to components
|
||||
- [ ] Set up protected routes/middleware
|
||||
- [ ] Add plugins as needed (regenerate schema after)
|
||||
- [ ] Test complete auth flow
|
||||
- [ ] Configure email sending (verification/reset)
|
||||
- [ ] Enable rate limiting for production
|
||||
- [ ] Set up error handling
|
||||
|
||||
## Reference Documentation
|
||||
|
||||
### Core Authentication
|
||||
- [Email/Password Authentication](./references/email-password-auth.md) - Email/password setup, verification, password reset, username auth
|
||||
- [OAuth Providers](./references/oauth-providers.md) - Social login setup, provider configuration, token management
|
||||
- [Database Integration](./references/database-integration.md) - Database adapters, schema setup, migrations
|
||||
|
||||
### Advanced Features
|
||||
- [Advanced Features](./references/advanced-features.md) - 2FA/MFA, passkeys, magic links, organizations, rate limiting, session management
|
||||
|
||||
## Scripts
|
||||
|
||||
- `scripts/better_auth_init.py` - Initialize Better Auth configuration with interactive setup
|
||||
|
||||
## Resources
|
||||
|
||||
- Docs: https://www.better-auth.com/docs
|
||||
- GitHub: https://github.com/better-auth/better-auth
|
||||
- Plugins: https://www.better-auth.com/docs/plugins
|
||||
- Examples: https://www.better-auth.com/docs/examples
|
||||
553
skills/better-auth/references/advanced-features.md
Normal file
553
skills/better-auth/references/advanced-features.md
Normal file
@@ -0,0 +1,553 @@
|
||||
# Advanced Features
|
||||
|
||||
Better Auth plugins extend functionality beyond basic authentication.
|
||||
|
||||
## Two-Factor Authentication
|
||||
|
||||
### Server Setup
|
||||
|
||||
```ts
|
||||
import { betterAuth } from "better-auth";
|
||||
import { twoFactor } from "better-auth/plugins";
|
||||
|
||||
export const auth = betterAuth({
|
||||
plugins: [
|
||||
twoFactor({
|
||||
issuer: "YourAppName", // TOTP issuer name
|
||||
otpOptions: {
|
||||
period: 30, // OTP validity period (seconds)
|
||||
digits: 6, // OTP length
|
||||
}
|
||||
})
|
||||
]
|
||||
});
|
||||
```
|
||||
|
||||
### Client Setup
|
||||
|
||||
```ts
|
||||
import { createAuthClient } from "better-auth/client";
|
||||
import { twoFactorClient } from "better-auth/client/plugins";
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
plugins: [
|
||||
twoFactorClient({
|
||||
twoFactorPage: "/two-factor", // Redirect to 2FA verification page
|
||||
redirect: true // Auto-redirect if 2FA required
|
||||
})
|
||||
]
|
||||
});
|
||||
```
|
||||
|
||||
### Enable 2FA for User
|
||||
|
||||
```ts
|
||||
// Enable TOTP
|
||||
const { data } = await authClient.twoFactor.enable({
|
||||
password: "userPassword" // Verify user identity
|
||||
});
|
||||
|
||||
// data contains QR code URI for authenticator app
|
||||
const qrCodeUri = data.totpURI;
|
||||
const backupCodes = data.backupCodes; // Save these securely
|
||||
```
|
||||
|
||||
### Verify TOTP Code
|
||||
|
||||
```ts
|
||||
await authClient.twoFactor.verifyTOTP({
|
||||
code: "123456",
|
||||
trustDevice: true // Skip 2FA on this device for 30 days
|
||||
});
|
||||
```
|
||||
|
||||
### Disable 2FA
|
||||
|
||||
```ts
|
||||
await authClient.twoFactor.disable({
|
||||
password: "userPassword"
|
||||
});
|
||||
```
|
||||
|
||||
### Backup Codes
|
||||
|
||||
```ts
|
||||
// Generate new backup codes
|
||||
const { data } = await authClient.twoFactor.generateBackupCodes({
|
||||
password: "userPassword"
|
||||
});
|
||||
|
||||
// Use backup code instead of TOTP
|
||||
await authClient.twoFactor.verifyBackupCode({
|
||||
code: "backup-code-123"
|
||||
});
|
||||
```
|
||||
|
||||
## Passkeys (WebAuthn)
|
||||
|
||||
### Server Setup
|
||||
|
||||
```ts
|
||||
import { betterAuth } from "better-auth";
|
||||
import { passkey } from "better-auth/plugins";
|
||||
|
||||
export const auth = betterAuth({
|
||||
plugins: [
|
||||
passkey({
|
||||
rpName: "YourApp", // Relying Party name
|
||||
rpID: "yourdomain.com" // Your domain
|
||||
})
|
||||
]
|
||||
});
|
||||
```
|
||||
|
||||
### Client Setup
|
||||
|
||||
```ts
|
||||
import { createAuthClient } from "better-auth/client";
|
||||
import { passkeyClient } from "better-auth/client/plugins";
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
plugins: [passkeyClient()]
|
||||
});
|
||||
```
|
||||
|
||||
### Register Passkey
|
||||
|
||||
```ts
|
||||
// User must be authenticated first
|
||||
await authClient.passkey.register({
|
||||
name: "My Laptop" // Optional: name for this passkey
|
||||
});
|
||||
```
|
||||
|
||||
### Sign In with Passkey
|
||||
|
||||
```ts
|
||||
await authClient.passkey.signIn();
|
||||
```
|
||||
|
||||
### List User Passkeys
|
||||
|
||||
```ts
|
||||
const { data } = await authClient.passkey.list();
|
||||
// data contains array of registered passkeys
|
||||
```
|
||||
|
||||
### Delete Passkey
|
||||
|
||||
```ts
|
||||
await authClient.passkey.delete({
|
||||
id: "passkey-id"
|
||||
});
|
||||
```
|
||||
|
||||
## Magic Link
|
||||
|
||||
### Server Setup
|
||||
|
||||
```ts
|
||||
import { betterAuth } from "better-auth";
|
||||
import { magicLink } from "better-auth/plugins";
|
||||
|
||||
export const auth = betterAuth({
|
||||
plugins: [
|
||||
magicLink({
|
||||
sendMagicLink: async ({ email, url, token }) => {
|
||||
await sendEmail({
|
||||
to: email,
|
||||
subject: "Sign in to YourApp",
|
||||
html: `Click <a href="${url}">here</a> to sign in.`
|
||||
});
|
||||
},
|
||||
expiresIn: 300, // Link expires in 5 minutes (seconds)
|
||||
})
|
||||
]
|
||||
});
|
||||
```
|
||||
|
||||
### Client Setup
|
||||
|
||||
```ts
|
||||
import { createAuthClient } from "better-auth/client";
|
||||
import { magicLinkClient } from "better-auth/client/plugins";
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
plugins: [magicLinkClient()]
|
||||
});
|
||||
```
|
||||
|
||||
### Send Magic Link
|
||||
|
||||
```ts
|
||||
await authClient.magicLink.sendMagicLink({
|
||||
email: "user@example.com",
|
||||
callbackURL: "/dashboard"
|
||||
});
|
||||
```
|
||||
|
||||
### Verify Magic Link
|
||||
|
||||
```ts
|
||||
// Called automatically when user clicks link
|
||||
// Token in URL query params handled by Better Auth
|
||||
await authClient.magicLink.verify({
|
||||
token: "token-from-url"
|
||||
});
|
||||
```
|
||||
|
||||
## Organizations (Multi-Tenancy)
|
||||
|
||||
### Server Setup
|
||||
|
||||
```ts
|
||||
import { betterAuth } from "better-auth";
|
||||
import { organization } from "better-auth/plugins";
|
||||
|
||||
export const auth = betterAuth({
|
||||
plugins: [
|
||||
organization({
|
||||
allowUserToCreateOrganization: true,
|
||||
organizationLimit: 5, // Max orgs per user
|
||||
creatorRole: "owner" // Role for org creator
|
||||
})
|
||||
]
|
||||
});
|
||||
```
|
||||
|
||||
### Client Setup
|
||||
|
||||
```ts
|
||||
import { createAuthClient } from "better-auth/client";
|
||||
import { organizationClient } from "better-auth/client/plugins";
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
plugins: [organizationClient()]
|
||||
});
|
||||
```
|
||||
|
||||
### Create Organization
|
||||
|
||||
```ts
|
||||
await authClient.organization.create({
|
||||
name: "Acme Corp",
|
||||
slug: "acme", // Unique slug
|
||||
metadata: {
|
||||
industry: "Technology"
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Invite Members
|
||||
|
||||
```ts
|
||||
await authClient.organization.inviteMember({
|
||||
organizationId: "org-id",
|
||||
email: "user@example.com",
|
||||
role: "member", // owner, admin, member
|
||||
message: "Join our team!" // Optional
|
||||
});
|
||||
```
|
||||
|
||||
### Accept Invitation
|
||||
|
||||
```ts
|
||||
await authClient.organization.acceptInvitation({
|
||||
invitationId: "invitation-id"
|
||||
});
|
||||
```
|
||||
|
||||
### List Organizations
|
||||
|
||||
```ts
|
||||
const { data } = await authClient.organization.list();
|
||||
// Returns user's organizations
|
||||
```
|
||||
|
||||
### Update Member Role
|
||||
|
||||
```ts
|
||||
await authClient.organization.updateMemberRole({
|
||||
organizationId: "org-id",
|
||||
userId: "user-id",
|
||||
role: "admin"
|
||||
});
|
||||
```
|
||||
|
||||
### Remove Member
|
||||
|
||||
```ts
|
||||
await authClient.organization.removeMember({
|
||||
organizationId: "org-id",
|
||||
userId: "user-id"
|
||||
});
|
||||
```
|
||||
|
||||
### Delete Organization
|
||||
|
||||
```ts
|
||||
await authClient.organization.delete({
|
||||
organizationId: "org-id"
|
||||
});
|
||||
```
|
||||
|
||||
## Session Management
|
||||
|
||||
### Configure Session Expiration
|
||||
|
||||
```ts
|
||||
export const auth = betterAuth({
|
||||
session: {
|
||||
expiresIn: 60 * 60 * 24 * 7, // 7 days (seconds)
|
||||
updateAge: 60 * 60 * 24, // Update session every 24 hours
|
||||
cookieCache: {
|
||||
enabled: true,
|
||||
maxAge: 5 * 60 // Cache for 5 minutes
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Server-Side Session
|
||||
|
||||
```ts
|
||||
// Next.js
|
||||
import { auth } from "@/lib/auth";
|
||||
import { headers } from "next/headers";
|
||||
|
||||
const session = await auth.api.getSession({
|
||||
headers: await headers()
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
// Not authenticated
|
||||
}
|
||||
```
|
||||
|
||||
### Client-Side Session
|
||||
|
||||
```tsx
|
||||
// React
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
|
||||
function UserProfile() {
|
||||
const { data: session, isPending, error } = authClient.useSession();
|
||||
|
||||
if (isPending) return <div>Loading...</div>;
|
||||
if (error) return <div>Error</div>;
|
||||
if (!session) return <div>Not logged in</div>;
|
||||
|
||||
return <div>Hello, {session.user.name}!</div>;
|
||||
}
|
||||
```
|
||||
|
||||
### List Active Sessions
|
||||
|
||||
```ts
|
||||
const { data: sessions } = await authClient.listSessions();
|
||||
// Returns all active sessions for current user
|
||||
```
|
||||
|
||||
### Revoke Session
|
||||
|
||||
```ts
|
||||
await authClient.revokeSession({
|
||||
sessionId: "session-id"
|
||||
});
|
||||
```
|
||||
|
||||
### Revoke All Sessions
|
||||
|
||||
```ts
|
||||
await authClient.revokeAllSessions();
|
||||
```
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
### Server Configuration
|
||||
|
||||
```ts
|
||||
export const auth = betterAuth({
|
||||
rateLimit: {
|
||||
enabled: true,
|
||||
window: 60, // Time window in seconds
|
||||
max: 10, // Max requests per window
|
||||
storage: "memory", // "memory" or "database"
|
||||
customRules: {
|
||||
"/api/auth/sign-in": {
|
||||
window: 60,
|
||||
max: 5 // Stricter limit for sign-in
|
||||
},
|
||||
"/api/auth/sign-up": {
|
||||
window: 3600,
|
||||
max: 3 // 3 signups per hour
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Custom Rate Limiter
|
||||
|
||||
```ts
|
||||
import { betterAuth } from "better-auth";
|
||||
|
||||
export const auth = betterAuth({
|
||||
rateLimit: {
|
||||
enabled: true,
|
||||
customLimiter: async ({ request, limit }) => {
|
||||
// Custom rate limiting logic
|
||||
const ip = request.headers.get("x-forwarded-for");
|
||||
const key = `ratelimit:${ip}`;
|
||||
|
||||
// Use Redis, etc.
|
||||
const count = await redis.incr(key);
|
||||
if (count === 1) {
|
||||
await redis.expire(key, limit.window);
|
||||
}
|
||||
|
||||
if (count > limit.max) {
|
||||
throw new Error("Rate limit exceeded");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Anonymous Sessions
|
||||
|
||||
Track users before they sign up.
|
||||
|
||||
### Server Setup
|
||||
|
||||
```ts
|
||||
import { betterAuth } from "better-auth";
|
||||
import { anonymous } from "better-auth/plugins";
|
||||
|
||||
export const auth = betterAuth({
|
||||
plugins: [anonymous()]
|
||||
});
|
||||
```
|
||||
|
||||
### Client Usage
|
||||
|
||||
```ts
|
||||
// Create anonymous session
|
||||
const { data } = await authClient.signIn.anonymous();
|
||||
|
||||
// Convert to full account
|
||||
await authClient.signUp.email({
|
||||
email: "user@example.com",
|
||||
password: "password123",
|
||||
linkAnonymousSession: true // Link anonymous data
|
||||
});
|
||||
```
|
||||
|
||||
## Email OTP
|
||||
|
||||
One-time password via email (passwordless).
|
||||
|
||||
### Server Setup
|
||||
|
||||
```ts
|
||||
import { betterAuth } from "better-auth";
|
||||
import { emailOTP } from "better-auth/plugins";
|
||||
|
||||
export const auth = betterAuth({
|
||||
plugins: [
|
||||
emailOTP({
|
||||
sendVerificationOTP: async ({ email, otp }) => {
|
||||
await sendEmail({
|
||||
to: email,
|
||||
subject: "Your verification code",
|
||||
text: `Your code is: ${otp}`
|
||||
});
|
||||
},
|
||||
expiresIn: 300, // 5 minutes
|
||||
length: 6 // OTP length
|
||||
})
|
||||
]
|
||||
});
|
||||
```
|
||||
|
||||
### Client Usage
|
||||
|
||||
```ts
|
||||
// Send OTP to email
|
||||
await authClient.emailOTP.sendOTP({
|
||||
email: "user@example.com"
|
||||
});
|
||||
|
||||
// Verify OTP
|
||||
await authClient.emailOTP.verifyOTP({
|
||||
email: "user@example.com",
|
||||
otp: "123456"
|
||||
});
|
||||
```
|
||||
|
||||
## Phone Number Authentication
|
||||
|
||||
Requires phone number plugin.
|
||||
|
||||
### Server Setup
|
||||
|
||||
```ts
|
||||
import { betterAuth } from "better-auth";
|
||||
import { phoneNumber } from "better-auth/plugins";
|
||||
|
||||
export const auth = betterAuth({
|
||||
plugins: [
|
||||
phoneNumber({
|
||||
sendOTP: async ({ phoneNumber, otp }) => {
|
||||
// Use Twilio, AWS SNS, etc.
|
||||
await sendSMS(phoneNumber, `Your code: ${otp}`);
|
||||
}
|
||||
})
|
||||
]
|
||||
});
|
||||
```
|
||||
|
||||
### Client Usage
|
||||
|
||||
```ts
|
||||
// Sign up with phone
|
||||
await authClient.signUp.phoneNumber({
|
||||
phoneNumber: "+1234567890",
|
||||
password: "password123"
|
||||
});
|
||||
|
||||
// Send OTP
|
||||
await authClient.phoneNumber.sendOTP({
|
||||
phoneNumber: "+1234567890"
|
||||
});
|
||||
|
||||
// Verify OTP
|
||||
await authClient.phoneNumber.verifyOTP({
|
||||
phoneNumber: "+1234567890",
|
||||
otp: "123456"
|
||||
});
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **2FA**: Offer 2FA as optional, make mandatory for admin users
|
||||
2. **Passkeys**: Implement as progressive enhancement (fallback to password)
|
||||
3. **Magic Links**: Set short expiration (5-15 minutes)
|
||||
4. **Organizations**: Implement RBAC for org permissions
|
||||
5. **Sessions**: Use short expiration for sensitive apps
|
||||
6. **Rate Limiting**: Enable in production, adjust limits based on usage
|
||||
7. **Anonymous Sessions**: Clean up old anonymous sessions periodically
|
||||
8. **Backup Codes**: Force users to save backup codes before enabling 2FA
|
||||
9. **Multi-Device**: Allow users to manage trusted devices
|
||||
10. **Audit Logs**: Track sensitive operations (role changes, 2FA changes)
|
||||
|
||||
## Regenerate Schema After Plugins
|
||||
|
||||
After adding any plugin:
|
||||
|
||||
```bash
|
||||
npx @better-auth/cli generate
|
||||
npx @better-auth/cli migrate # if using Kysely
|
||||
```
|
||||
|
||||
Or manually apply migrations for your ORM (Drizzle, Prisma).
|
||||
577
skills/better-auth/references/database-integration.md
Normal file
577
skills/better-auth/references/database-integration.md
Normal file
@@ -0,0 +1,577 @@
|
||||
# Database Integration
|
||||
|
||||
Better Auth supports multiple databases and ORMs for flexible data persistence.
|
||||
|
||||
## Supported Databases
|
||||
|
||||
- SQLite
|
||||
- PostgreSQL
|
||||
- MySQL/MariaDB
|
||||
- MongoDB
|
||||
- Any database with adapter support
|
||||
|
||||
## Direct Database Connection
|
||||
|
||||
### SQLite
|
||||
|
||||
```ts
|
||||
import { betterAuth } from "better-auth";
|
||||
import Database from "better-sqlite3";
|
||||
|
||||
export const auth = betterAuth({
|
||||
database: new Database("./sqlite.db"),
|
||||
// or
|
||||
database: new Database(":memory:") // In-memory for testing
|
||||
});
|
||||
```
|
||||
|
||||
### PostgreSQL
|
||||
|
||||
```ts
|
||||
import { betterAuth } from "better-auth";
|
||||
import { Pool } from "pg";
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
// or explicit config
|
||||
host: "localhost",
|
||||
port: 5432,
|
||||
user: "postgres",
|
||||
password: "password",
|
||||
database: "myapp"
|
||||
});
|
||||
|
||||
export const auth = betterAuth({
|
||||
database: pool
|
||||
});
|
||||
```
|
||||
|
||||
### MySQL
|
||||
|
||||
```ts
|
||||
import { betterAuth } from "better-auth";
|
||||
import { createPool } from "mysql2/promise";
|
||||
|
||||
const pool = createPool({
|
||||
host: "localhost",
|
||||
user: "root",
|
||||
password: "password",
|
||||
database: "myapp",
|
||||
waitForConnections: true,
|
||||
connectionLimit: 10
|
||||
});
|
||||
|
||||
export const auth = betterAuth({
|
||||
database: pool
|
||||
});
|
||||
```
|
||||
|
||||
## ORM Adapters
|
||||
|
||||
### Drizzle ORM
|
||||
|
||||
**Install:**
|
||||
```bash
|
||||
npm install drizzle-orm better-auth
|
||||
```
|
||||
|
||||
**Setup:**
|
||||
```ts
|
||||
import { betterAuth } from "better-auth";
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import { drizzle } from "drizzle-orm/node-postgres";
|
||||
import { Pool } from "pg";
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString: process.env.DATABASE_URL
|
||||
});
|
||||
|
||||
const db = drizzle(pool);
|
||||
|
||||
export const auth = betterAuth({
|
||||
database: drizzleAdapter(db, {
|
||||
provider: "pg", // "pg" | "mysql" | "sqlite"
|
||||
schema: {
|
||||
// Optional: custom table names
|
||||
user: "users",
|
||||
session: "sessions",
|
||||
account: "accounts",
|
||||
verification: "verifications"
|
||||
}
|
||||
})
|
||||
});
|
||||
```
|
||||
|
||||
**Generate Schema:**
|
||||
```bash
|
||||
npx @better-auth/cli generate --adapter drizzle
|
||||
```
|
||||
|
||||
### Prisma
|
||||
|
||||
**Install:**
|
||||
```bash
|
||||
npm install @prisma/client better-auth
|
||||
```
|
||||
|
||||
**Setup:**
|
||||
```ts
|
||||
import { betterAuth } from "better-auth";
|
||||
import { prismaAdapter } from "better-auth/adapters/prisma";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export const auth = betterAuth({
|
||||
database: prismaAdapter(prisma, {
|
||||
provider: "postgresql", // "postgresql" | "mysql" | "sqlite"
|
||||
})
|
||||
});
|
||||
```
|
||||
|
||||
**Generate Schema:**
|
||||
```bash
|
||||
npx @better-auth/cli generate --adapter prisma
|
||||
```
|
||||
|
||||
**Apply to Prisma:**
|
||||
```bash
|
||||
# Add generated schema to schema.prisma
|
||||
npx prisma migrate dev --name init
|
||||
npx prisma generate
|
||||
```
|
||||
|
||||
### Kysely
|
||||
|
||||
**Install:**
|
||||
```bash
|
||||
npm install kysely better-auth
|
||||
```
|
||||
|
||||
**Setup:**
|
||||
```ts
|
||||
import { betterAuth } from "better-auth";
|
||||
import { kyselyAdapter } from "better-auth/adapters/kysely";
|
||||
import { Kysely, PostgresDialect } from "kysely";
|
||||
import { Pool } from "pg";
|
||||
|
||||
const db = new Kysely({
|
||||
dialect: new PostgresDialect({
|
||||
pool: new Pool({
|
||||
connectionString: process.env.DATABASE_URL
|
||||
})
|
||||
})
|
||||
});
|
||||
|
||||
export const auth = betterAuth({
|
||||
database: kyselyAdapter(db, {
|
||||
provider: "pg"
|
||||
})
|
||||
});
|
||||
```
|
||||
|
||||
**Auto-migrate with Kysely:**
|
||||
```bash
|
||||
npx @better-auth/cli migrate --adapter kysely
|
||||
```
|
||||
|
||||
### MongoDB
|
||||
|
||||
**Install:**
|
||||
```bash
|
||||
npm install mongodb better-auth
|
||||
```
|
||||
|
||||
**Setup:**
|
||||
```ts
|
||||
import { betterAuth } from "better-auth";
|
||||
import { mongodbAdapter } from "better-auth/adapters/mongodb";
|
||||
import { MongoClient } from "mongodb";
|
||||
|
||||
const client = new MongoClient(process.env.MONGODB_URI!);
|
||||
await client.connect();
|
||||
|
||||
export const auth = betterAuth({
|
||||
database: mongodbAdapter(client, {
|
||||
databaseName: "myapp"
|
||||
})
|
||||
});
|
||||
```
|
||||
|
||||
**Generate Collections:**
|
||||
```bash
|
||||
npx @better-auth/cli generate --adapter mongodb
|
||||
```
|
||||
|
||||
## Core Database Schema
|
||||
|
||||
Better Auth requires these core tables/collections:
|
||||
|
||||
### User Table
|
||||
|
||||
```sql
|
||||
CREATE TABLE user (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
emailVerified BOOLEAN DEFAULT FALSE,
|
||||
name TEXT,
|
||||
image TEXT,
|
||||
createdAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
```
|
||||
|
||||
### Session Table
|
||||
|
||||
```sql
|
||||
CREATE TABLE session (
|
||||
id TEXT PRIMARY KEY,
|
||||
userId TEXT NOT NULL,
|
||||
expiresAt TIMESTAMP NOT NULL,
|
||||
ipAddress TEXT,
|
||||
userAgent TEXT,
|
||||
createdAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (userId) REFERENCES user(id) ON DELETE CASCADE
|
||||
);
|
||||
```
|
||||
|
||||
### Account Table
|
||||
|
||||
```sql
|
||||
CREATE TABLE account (
|
||||
id TEXT PRIMARY KEY,
|
||||
userId TEXT NOT NULL,
|
||||
accountId TEXT NOT NULL,
|
||||
providerId TEXT NOT NULL,
|
||||
accessToken TEXT,
|
||||
refreshToken TEXT,
|
||||
expiresAt TIMESTAMP,
|
||||
scope TEXT,
|
||||
createdAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (userId) REFERENCES user(id) ON DELETE CASCADE,
|
||||
UNIQUE(providerId, accountId)
|
||||
);
|
||||
```
|
||||
|
||||
### Verification Table
|
||||
|
||||
```sql
|
||||
CREATE TABLE verification (
|
||||
id TEXT PRIMARY KEY,
|
||||
identifier TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
expiresAt TIMESTAMP NOT NULL,
|
||||
createdAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
```
|
||||
|
||||
## Schema Generation
|
||||
|
||||
### Using CLI
|
||||
|
||||
```bash
|
||||
# Generate schema files
|
||||
npx @better-auth/cli generate
|
||||
|
||||
# Specify adapter
|
||||
npx @better-auth/cli generate --adapter drizzle
|
||||
npx @better-auth/cli generate --adapter prisma
|
||||
|
||||
# Specify output
|
||||
npx @better-auth/cli generate --output ./db/schema.ts
|
||||
```
|
||||
|
||||
### Auto-migrate (Kysely only)
|
||||
|
||||
```bash
|
||||
npx @better-auth/cli migrate
|
||||
```
|
||||
|
||||
For other ORMs, apply generated schema manually.
|
||||
|
||||
## Custom Fields
|
||||
|
||||
Add custom fields to user table:
|
||||
|
||||
```ts
|
||||
export const auth = betterAuth({
|
||||
user: {
|
||||
additionalFields: {
|
||||
role: {
|
||||
type: "string",
|
||||
required: false,
|
||||
defaultValue: "user"
|
||||
},
|
||||
phoneNumber: {
|
||||
type: "string",
|
||||
required: false
|
||||
},
|
||||
subscriptionTier: {
|
||||
type: "string",
|
||||
required: false
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
After adding fields:
|
||||
```bash
|
||||
npx @better-auth/cli generate
|
||||
```
|
||||
|
||||
Update user with custom fields:
|
||||
```ts
|
||||
await authClient.updateUser({
|
||||
role: "admin",
|
||||
phoneNumber: "+1234567890"
|
||||
});
|
||||
```
|
||||
|
||||
## Plugin Schema Extensions
|
||||
|
||||
Plugins add their own tables/fields. Regenerate schema after adding plugins:
|
||||
|
||||
```bash
|
||||
npx @better-auth/cli generate
|
||||
```
|
||||
|
||||
### Two-Factor Plugin Tables
|
||||
|
||||
- `twoFactor`: Stores TOTP secrets, backup codes
|
||||
|
||||
### Passkey Plugin Tables
|
||||
|
||||
- `passkey`: Stores WebAuthn credentials
|
||||
|
||||
### Organization Plugin Tables
|
||||
|
||||
- `organization`: Organization data
|
||||
- `member`: Organization members
|
||||
- `invitation`: Pending invitations
|
||||
|
||||
## Migration Strategies
|
||||
|
||||
### Development
|
||||
|
||||
```bash
|
||||
# Generate schema
|
||||
npx @better-auth/cli generate
|
||||
|
||||
# Apply migrations (Kysely)
|
||||
npx @better-auth/cli migrate
|
||||
|
||||
# Or manual (Prisma)
|
||||
npx prisma migrate dev
|
||||
|
||||
# Or manual (Drizzle)
|
||||
npx drizzle-kit push
|
||||
```
|
||||
|
||||
### Production
|
||||
|
||||
```bash
|
||||
# Review generated migration
|
||||
npx @better-auth/cli generate
|
||||
|
||||
# Test in staging
|
||||
# Apply to production with your ORM's migration tool
|
||||
|
||||
# Prisma
|
||||
npx prisma migrate deploy
|
||||
|
||||
# Drizzle
|
||||
npx drizzle-kit push
|
||||
|
||||
# Kysely
|
||||
npx @better-auth/cli migrate
|
||||
```
|
||||
|
||||
## Connection Pooling
|
||||
|
||||
### PostgreSQL
|
||||
|
||||
```ts
|
||||
import { Pool } from "pg";
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
max: 20, // Max connections
|
||||
idleTimeoutMillis: 30000,
|
||||
connectionTimeoutMillis: 2000,
|
||||
});
|
||||
```
|
||||
|
||||
### MySQL
|
||||
|
||||
```ts
|
||||
import { createPool } from "mysql2/promise";
|
||||
|
||||
const pool = createPool({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
waitForConnections: true,
|
||||
connectionLimit: 10,
|
||||
queueLimit: 0
|
||||
});
|
||||
```
|
||||
|
||||
## Database URLs
|
||||
|
||||
### PostgreSQL
|
||||
|
||||
```env
|
||||
DATABASE_URL=postgresql://user:password@localhost:5432/dbname
|
||||
# Or with connection params
|
||||
DATABASE_URL=postgresql://user:password@localhost:5432/dbname?schema=public&connection_limit=10
|
||||
```
|
||||
|
||||
### MySQL
|
||||
|
||||
```env
|
||||
DATABASE_URL=mysql://user:password@localhost:3306/dbname
|
||||
```
|
||||
|
||||
### SQLite
|
||||
|
||||
```env
|
||||
DATABASE_URL=file:./dev.db
|
||||
# Or in-memory
|
||||
DATABASE_URL=:memory:
|
||||
```
|
||||
|
||||
### MongoDB
|
||||
|
||||
```env
|
||||
MONGODB_URI=mongodb://localhost:27017/dbname
|
||||
# Or Atlas
|
||||
MONGODB_URI=mongodb+srv://user:password@cluster.mongodb.net/dbname
|
||||
```
|
||||
|
||||
## Performance Optimization
|
||||
|
||||
### Indexes
|
||||
|
||||
Better Auth CLI auto-generates essential indexes:
|
||||
- `user.email` (unique)
|
||||
- `session.userId`
|
||||
- `account.userId`
|
||||
- `account.providerId, accountId` (unique)
|
||||
|
||||
Add custom indexes for performance:
|
||||
```sql
|
||||
CREATE INDEX idx_session_expires ON session(expiresAt);
|
||||
CREATE INDEX idx_user_created ON user(createdAt);
|
||||
```
|
||||
|
||||
### Query Optimization
|
||||
|
||||
```ts
|
||||
// Use connection pooling
|
||||
// Enable query caching where applicable
|
||||
// Monitor slow queries
|
||||
|
||||
export const auth = betterAuth({
|
||||
advanced: {
|
||||
defaultCookieAttributes: {
|
||||
sameSite: "lax",
|
||||
secure: true,
|
||||
httpOnly: true
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Backup Strategies
|
||||
|
||||
### PostgreSQL
|
||||
|
||||
```bash
|
||||
# Backup
|
||||
pg_dump dbname > backup.sql
|
||||
|
||||
# Restore
|
||||
psql dbname < backup.sql
|
||||
```
|
||||
|
||||
### MySQL
|
||||
|
||||
```bash
|
||||
# Backup
|
||||
mysqldump -u root -p dbname > backup.sql
|
||||
|
||||
# Restore
|
||||
mysql -u root -p dbname < backup.sql
|
||||
```
|
||||
|
||||
### SQLite
|
||||
|
||||
```bash
|
||||
# Copy file
|
||||
cp dev.db dev.db.backup
|
||||
|
||||
# Or use backup command
|
||||
sqlite3 dev.db ".backup backup.db"
|
||||
```
|
||||
|
||||
### MongoDB
|
||||
|
||||
```bash
|
||||
# Backup
|
||||
mongodump --db=dbname --out=./backup
|
||||
|
||||
# Restore
|
||||
mongorestore --db=dbname ./backup/dbname
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Environment Variables**: Store credentials in env vars, never commit
|
||||
2. **Connection Pooling**: Use pools for PostgreSQL/MySQL in production
|
||||
3. **Migrations**: Use ORM migration tools, not raw SQL in production
|
||||
4. **Indexes**: Add indexes for frequently queried fields
|
||||
5. **Backups**: Automate daily backups in production
|
||||
6. **SSL**: Use SSL/TLS for database connections in production
|
||||
7. **Schema Sync**: Keep schema in sync across environments
|
||||
8. **Testing**: Use separate database for tests (in-memory SQLite ideal)
|
||||
9. **Monitoring**: Monitor query performance and connection pool usage
|
||||
10. **Cleanup**: Periodically clean expired sessions/verifications
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Connection Errors
|
||||
|
||||
```ts
|
||||
// Add connection timeout
|
||||
const pool = new Pool({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
connectionTimeoutMillis: 5000
|
||||
});
|
||||
```
|
||||
|
||||
### Schema Mismatch
|
||||
|
||||
```bash
|
||||
# Regenerate schema
|
||||
npx @better-auth/cli generate
|
||||
|
||||
# Apply migrations
|
||||
# For Prisma: npx prisma migrate dev
|
||||
# For Drizzle: npx drizzle-kit push
|
||||
```
|
||||
|
||||
### Migration Failures
|
||||
|
||||
- Check database credentials
|
||||
- Verify database server is running
|
||||
- Check for schema conflicts
|
||||
- Review migration SQL manually
|
||||
|
||||
### Performance Issues
|
||||
|
||||
- Add indexes on foreign keys
|
||||
- Enable connection pooling
|
||||
- Monitor slow queries
|
||||
- Consider read replicas for heavy read workloads
|
||||
416
skills/better-auth/references/email-password-auth.md
Normal file
416
skills/better-auth/references/email-password-auth.md
Normal file
@@ -0,0 +1,416 @@
|
||||
# Email/Password Authentication
|
||||
|
||||
Email/password is built-in auth method in Better Auth. No plugins required for basic functionality.
|
||||
|
||||
## Server Configuration
|
||||
|
||||
### Basic Setup
|
||||
|
||||
```ts
|
||||
import { betterAuth } from "better-auth";
|
||||
|
||||
export const auth = betterAuth({
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
autoSignIn: true, // Auto sign-in after signup (default: true)
|
||||
requireEmailVerification: false, // Require email verification before login
|
||||
sendResetPasswordToken: async ({ user, url }) => {
|
||||
// Send password reset email
|
||||
await sendEmail(user.email, url);
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Custom Password Requirements
|
||||
|
||||
```ts
|
||||
export const auth = betterAuth({
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
password: {
|
||||
minLength: 8,
|
||||
requireUppercase: true,
|
||||
requireLowercase: true,
|
||||
requireNumbers: true,
|
||||
requireSpecialChars: true
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Client Usage
|
||||
|
||||
### Sign Up
|
||||
|
||||
```ts
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
|
||||
const { data, error } = await authClient.signUp.email({
|
||||
email: "user@example.com",
|
||||
password: "securePassword123",
|
||||
name: "John Doe",
|
||||
image: "https://example.com/avatar.jpg", // optional
|
||||
callbackURL: "/dashboard" // optional
|
||||
}, {
|
||||
onSuccess: (ctx) => {
|
||||
// ctx.data contains user and session
|
||||
console.log("User created:", ctx.data.user);
|
||||
},
|
||||
onError: (ctx) => {
|
||||
alert(ctx.error.message);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Sign In
|
||||
|
||||
```ts
|
||||
const { data, error } = await authClient.signIn.email({
|
||||
email: "user@example.com",
|
||||
password: "securePassword123",
|
||||
callbackURL: "/dashboard",
|
||||
rememberMe: true // default: true
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
// redirect or update UI
|
||||
},
|
||||
onError: (ctx) => {
|
||||
console.error(ctx.error.message);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Sign Out
|
||||
|
||||
```ts
|
||||
await authClient.signOut({
|
||||
fetchOptions: {
|
||||
onSuccess: () => {
|
||||
router.push("/login");
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Email Verification
|
||||
|
||||
### Server Setup
|
||||
|
||||
```ts
|
||||
export const auth = betterAuth({
|
||||
emailVerification: {
|
||||
sendVerificationEmail: async ({ user, url, token }) => {
|
||||
// Send verification email
|
||||
await sendEmail({
|
||||
to: user.email,
|
||||
subject: "Verify your email",
|
||||
html: `Click <a href="${url}">here</a> to verify your email.`
|
||||
});
|
||||
},
|
||||
sendOnSignUp: true, // Send verification email on signup
|
||||
autoSignInAfterVerification: true // Auto sign-in after verification
|
||||
},
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
requireEmailVerification: true // Require verification before login
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Client Usage
|
||||
|
||||
```ts
|
||||
// Send verification email
|
||||
await authClient.sendVerificationEmail({
|
||||
email: "user@example.com",
|
||||
callbackURL: "/verify-success"
|
||||
});
|
||||
|
||||
// Verify email with token
|
||||
await authClient.verifyEmail({
|
||||
token: "verification-token-from-email"
|
||||
});
|
||||
```
|
||||
|
||||
## Password Reset Flow
|
||||
|
||||
### Server Setup
|
||||
|
||||
```ts
|
||||
export const auth = betterAuth({
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
sendResetPasswordToken: async ({ user, url, token }) => {
|
||||
await sendEmail({
|
||||
to: user.email,
|
||||
subject: "Reset your password",
|
||||
html: `Click <a href="${url}">here</a> to reset your password.`
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Client Flow
|
||||
|
||||
```ts
|
||||
// Step 1: Request password reset
|
||||
await authClient.forgetPassword({
|
||||
email: "user@example.com",
|
||||
redirectTo: "/reset-password"
|
||||
});
|
||||
|
||||
// Step 2: Reset password with token
|
||||
await authClient.resetPassword({
|
||||
token: "reset-token-from-email",
|
||||
password: "newSecurePassword123"
|
||||
});
|
||||
```
|
||||
|
||||
### Change Password (Authenticated)
|
||||
|
||||
```ts
|
||||
await authClient.changePassword({
|
||||
currentPassword: "oldPassword123",
|
||||
newPassword: "newPassword456",
|
||||
revokeOtherSessions: true // Optional: logout other sessions
|
||||
});
|
||||
```
|
||||
|
||||
## Username Authentication
|
||||
|
||||
Requires `username` plugin for username-based auth.
|
||||
|
||||
### Server Setup
|
||||
|
||||
```ts
|
||||
import { betterAuth } from "better-auth";
|
||||
import { username } from "better-auth/plugins";
|
||||
|
||||
export const auth = betterAuth({
|
||||
plugins: [
|
||||
username({
|
||||
// Allow sign in with username or email
|
||||
allowUsernameOrEmail: true
|
||||
})
|
||||
]
|
||||
});
|
||||
```
|
||||
|
||||
### Client Setup
|
||||
|
||||
```ts
|
||||
import { createAuthClient } from "better-auth/client";
|
||||
import { usernameClient } from "better-auth/client/plugins";
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
plugins: [usernameClient()]
|
||||
});
|
||||
```
|
||||
|
||||
### Client Usage
|
||||
|
||||
```ts
|
||||
// Sign up with username
|
||||
await authClient.signUp.username({
|
||||
username: "johndoe",
|
||||
password: "securePassword123",
|
||||
email: "john@example.com", // optional
|
||||
name: "John Doe"
|
||||
});
|
||||
|
||||
// Sign in with username
|
||||
await authClient.signIn.username({
|
||||
username: "johndoe",
|
||||
password: "securePassword123"
|
||||
});
|
||||
|
||||
// Sign in with username or email (if allowUsernameOrEmail: true)
|
||||
await authClient.signIn.username({
|
||||
username: "johndoe", // or "john@example.com"
|
||||
password: "securePassword123"
|
||||
});
|
||||
```
|
||||
|
||||
## Framework Setup
|
||||
|
||||
### Next.js (App Router)
|
||||
|
||||
```ts
|
||||
// app/api/auth/[...all]/route.ts
|
||||
import { auth } from "@/lib/auth";
|
||||
import { toNextJsHandler } from "better-auth/next-js";
|
||||
|
||||
export const { POST, GET } = toNextJsHandler(auth);
|
||||
```
|
||||
|
||||
### Next.js (Pages Router)
|
||||
|
||||
```ts
|
||||
// pages/api/auth/[...all].ts
|
||||
import { auth } from "@/lib/auth";
|
||||
import { toNextJsHandler } from "better-auth/next-js";
|
||||
|
||||
export default toNextJsHandler(auth);
|
||||
```
|
||||
|
||||
### Nuxt
|
||||
|
||||
```ts
|
||||
// server/api/auth/[...all].ts
|
||||
import { auth } from "~/utils/auth";
|
||||
import { toWebRequest } from "better-auth/utils/web";
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
return auth.handler(toWebRequest(event));
|
||||
});
|
||||
```
|
||||
|
||||
### SvelteKit
|
||||
|
||||
```ts
|
||||
// hooks.server.ts
|
||||
import { auth } from "$lib/auth";
|
||||
import { svelteKitHandler } from "better-auth/svelte-kit";
|
||||
|
||||
export async function handle({ event, resolve }) {
|
||||
return svelteKitHandler({ event, resolve, auth });
|
||||
}
|
||||
```
|
||||
|
||||
### Astro
|
||||
|
||||
```ts
|
||||
// pages/api/auth/[...all].ts
|
||||
import { auth } from "@/lib/auth";
|
||||
|
||||
export async function ALL({ request }: { request: Request }) {
|
||||
return auth.handler(request);
|
||||
}
|
||||
```
|
||||
|
||||
### Hono
|
||||
|
||||
```ts
|
||||
import { Hono } from "hono";
|
||||
import { auth } from "./auth";
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
app.on(["POST", "GET"], "/api/auth/*", (c) => {
|
||||
return auth.handler(c.req.raw);
|
||||
});
|
||||
```
|
||||
|
||||
### Express
|
||||
|
||||
```ts
|
||||
import express from "express";
|
||||
import { toNodeHandler } from "better-auth/node";
|
||||
import { auth } from "./auth";
|
||||
|
||||
const app = express();
|
||||
|
||||
app.all("/api/auth/*", toNodeHandler(auth));
|
||||
```
|
||||
|
||||
## Protected Routes
|
||||
|
||||
### Next.js Middleware
|
||||
|
||||
```ts
|
||||
// middleware.ts
|
||||
import { auth } from "@/lib/auth";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
const session = await auth.api.getSession({
|
||||
headers: request.headers
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.redirect(new URL("/login", request.url));
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/dashboard/:path*", "/profile/:path*"]
|
||||
};
|
||||
```
|
||||
|
||||
### SvelteKit Hooks
|
||||
|
||||
```ts
|
||||
// hooks.server.ts
|
||||
import { auth } from "$lib/auth";
|
||||
import { redirect } from "@sveltejs/kit";
|
||||
|
||||
export async function handle({ event, resolve }) {
|
||||
const session = await auth.api.getSession({
|
||||
headers: event.request.headers
|
||||
});
|
||||
|
||||
if (event.url.pathname.startsWith("/dashboard") && !session) {
|
||||
throw redirect(303, "/login");
|
||||
}
|
||||
|
||||
return resolve(event);
|
||||
}
|
||||
```
|
||||
|
||||
### Nuxt Middleware
|
||||
|
||||
```ts
|
||||
// middleware/auth.ts
|
||||
export default defineNuxtRouteMiddleware(async (to) => {
|
||||
const { data: session } = await useAuthSession();
|
||||
|
||||
if (!session.value && to.path.startsWith("/dashboard")) {
|
||||
return navigateTo("/login");
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## User Profile Management
|
||||
|
||||
### Get Current User
|
||||
|
||||
```ts
|
||||
const { data: session } = await authClient.getSession();
|
||||
console.log(session.user);
|
||||
```
|
||||
|
||||
### Update User Profile
|
||||
|
||||
```ts
|
||||
await authClient.updateUser({
|
||||
name: "New Name",
|
||||
image: "https://example.com/new-avatar.jpg",
|
||||
// Custom fields if defined in schema
|
||||
});
|
||||
```
|
||||
|
||||
### Delete User Account
|
||||
|
||||
```ts
|
||||
await authClient.deleteUser({
|
||||
password: "currentPassword", // Required for security
|
||||
callbackURL: "/" // Redirect after deletion
|
||||
});
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Password Security**: Enforce strong password requirements
|
||||
2. **Email Verification**: Enable for production to prevent spam
|
||||
3. **Rate Limiting**: Prevent brute force attacks (see advanced-features.md)
|
||||
4. **HTTPS**: Always use HTTPS in production
|
||||
5. **Error Messages**: Don't reveal if email exists during login
|
||||
6. **Session Security**: Use secure, httpOnly cookies
|
||||
7. **CSRF Protection**: Better Auth handles this automatically
|
||||
8. **Password Reset**: Set short expiration for reset tokens
|
||||
9. **Account Lockout**: Consider implementing after N failed attempts
|
||||
10. **Audit Logs**: Track auth events for security monitoring
|
||||
430
skills/better-auth/references/oauth-providers.md
Normal file
430
skills/better-auth/references/oauth-providers.md
Normal file
@@ -0,0 +1,430 @@
|
||||
# OAuth Providers
|
||||
|
||||
Better Auth provides built-in OAuth 2.0 support for social authentication. No plugins required.
|
||||
|
||||
## Supported Providers
|
||||
|
||||
GitHub, Google, Apple, Discord, Facebook, Microsoft, Twitter/X, Spotify, Twitch, LinkedIn, Dropbox, GitLab, and more.
|
||||
|
||||
## Basic OAuth Setup
|
||||
|
||||
### Server Configuration
|
||||
|
||||
```ts
|
||||
import { betterAuth } from "better-auth";
|
||||
|
||||
export const auth = betterAuth({
|
||||
socialProviders: {
|
||||
github: {
|
||||
clientId: process.env.GITHUB_CLIENT_ID!,
|
||||
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
|
||||
// Optional: custom scopes
|
||||
scope: ["user:email", "read:user"]
|
||||
},
|
||||
google: {
|
||||
clientId: process.env.GOOGLE_CLIENT_ID!,
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
|
||||
scope: ["openid", "email", "profile"]
|
||||
},
|
||||
discord: {
|
||||
clientId: process.env.DISCORD_CLIENT_ID!,
|
||||
clientSecret: process.env.DISCORD_CLIENT_SECRET!,
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Client Usage
|
||||
|
||||
```ts
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
|
||||
// Basic sign in
|
||||
await authClient.signIn.social({
|
||||
provider: "github",
|
||||
callbackURL: "/dashboard"
|
||||
});
|
||||
|
||||
// With callbacks
|
||||
await authClient.signIn.social({
|
||||
provider: "google",
|
||||
callbackURL: "/dashboard",
|
||||
errorCallbackURL: "/error",
|
||||
newUserCallbackURL: "/welcome", // For first-time users
|
||||
});
|
||||
```
|
||||
|
||||
## Provider Configuration
|
||||
|
||||
### GitHub OAuth
|
||||
|
||||
1. Create OAuth App at https://github.com/settings/developers
|
||||
2. Set Authorization callback URL: `http://localhost:3000/api/auth/callback/github`
|
||||
3. Add credentials to `.env`:
|
||||
|
||||
```env
|
||||
GITHUB_CLIENT_ID=your_client_id
|
||||
GITHUB_CLIENT_SECRET=your_client_secret
|
||||
```
|
||||
|
||||
### Google OAuth
|
||||
|
||||
1. Create project at https://console.cloud.google.com
|
||||
2. Enable Google+ API
|
||||
3. Create OAuth 2.0 credentials
|
||||
4. Add authorized redirect URI: `http://localhost:3000/api/auth/callback/google`
|
||||
5. Add credentials to `.env`:
|
||||
|
||||
```env
|
||||
GOOGLE_CLIENT_ID=your_client_id.apps.googleusercontent.com
|
||||
GOOGLE_CLIENT_SECRET=your_client_secret
|
||||
```
|
||||
|
||||
### Discord OAuth
|
||||
|
||||
1. Create application at https://discord.com/developers/applications
|
||||
2. Add OAuth2 redirect: `http://localhost:3000/api/auth/callback/discord`
|
||||
3. Add credentials:
|
||||
|
||||
```env
|
||||
DISCORD_CLIENT_ID=your_client_id
|
||||
DISCORD_CLIENT_SECRET=your_client_secret
|
||||
```
|
||||
|
||||
### Apple Sign In
|
||||
|
||||
```ts
|
||||
export const auth = betterAuth({
|
||||
socialProviders: {
|
||||
apple: {
|
||||
clientId: process.env.APPLE_CLIENT_ID!,
|
||||
clientSecret: process.env.APPLE_CLIENT_SECRET!,
|
||||
teamId: process.env.APPLE_TEAM_ID!,
|
||||
keyId: process.env.APPLE_KEY_ID!,
|
||||
privateKey: process.env.APPLE_PRIVATE_KEY!
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Microsoft/Azure AD
|
||||
|
||||
```ts
|
||||
export const auth = betterAuth({
|
||||
socialProviders: {
|
||||
microsoft: {
|
||||
clientId: process.env.MICROSOFT_CLIENT_ID!,
|
||||
clientSecret: process.env.MICROSOFT_CLIENT_SECRET!,
|
||||
tenantId: process.env.MICROSOFT_TENANT_ID, // Optional: for specific tenant
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Twitter/X OAuth
|
||||
|
||||
```ts
|
||||
export const auth = betterAuth({
|
||||
socialProviders: {
|
||||
twitter: {
|
||||
clientId: process.env.TWITTER_CLIENT_ID!,
|
||||
clientSecret: process.env.TWITTER_CLIENT_SECRET!,
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Custom OAuth Provider
|
||||
|
||||
Add custom OAuth 2.0 provider:
|
||||
|
||||
```ts
|
||||
import { betterAuth } from "better-auth";
|
||||
|
||||
export const auth = betterAuth({
|
||||
socialProviders: {
|
||||
customProvider: {
|
||||
clientId: process.env.CUSTOM_CLIENT_ID!,
|
||||
clientSecret: process.env.CUSTOM_CLIENT_SECRET!,
|
||||
authorizationUrl: "https://provider.com/oauth/authorize",
|
||||
tokenUrl: "https://provider.com/oauth/token",
|
||||
userInfoUrl: "https://provider.com/oauth/userinfo",
|
||||
scope: ["email", "profile"],
|
||||
// Map provider user data to Better Auth user
|
||||
mapProfile: (profile) => ({
|
||||
id: profile.id,
|
||||
email: profile.email,
|
||||
name: profile.name,
|
||||
image: profile.avatar_url
|
||||
})
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Account Linking
|
||||
|
||||
Link multiple OAuth providers to same user account.
|
||||
|
||||
### Server Setup
|
||||
|
||||
```ts
|
||||
export const auth = betterAuth({
|
||||
account: {
|
||||
accountLinking: {
|
||||
enabled: true,
|
||||
trustedProviders: ["google", "github"] // Auto-link these providers
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Client Usage
|
||||
|
||||
```ts
|
||||
// Link new provider to existing account
|
||||
await authClient.linkSocial({
|
||||
provider: "google",
|
||||
callbackURL: "/profile"
|
||||
});
|
||||
|
||||
// List linked accounts
|
||||
const { data: session } = await authClient.getSession();
|
||||
const accounts = session.user.accounts;
|
||||
|
||||
// Unlink account
|
||||
await authClient.unlinkAccount({
|
||||
accountId: "account-id"
|
||||
});
|
||||
```
|
||||
|
||||
## Token Management
|
||||
|
||||
### Access OAuth Tokens
|
||||
|
||||
```ts
|
||||
// Server-side
|
||||
const session = await auth.api.getSession({
|
||||
headers: request.headers
|
||||
});
|
||||
|
||||
const accounts = await auth.api.listAccounts({
|
||||
userId: session.user.id
|
||||
});
|
||||
|
||||
// Get specific provider token
|
||||
const githubAccount = accounts.find(a => a.providerId === "github");
|
||||
const accessToken = githubAccount.accessToken;
|
||||
const refreshToken = githubAccount.refreshToken;
|
||||
```
|
||||
|
||||
### Refresh Tokens
|
||||
|
||||
```ts
|
||||
// Manually refresh OAuth token
|
||||
const newToken = await auth.api.refreshToken({
|
||||
accountId: "account-id"
|
||||
});
|
||||
```
|
||||
|
||||
### Use Provider API
|
||||
|
||||
```ts
|
||||
// Example: Use GitHub token to fetch repos
|
||||
const githubAccount = accounts.find(a => a.providerId === "github");
|
||||
|
||||
const response = await fetch("https://api.github.com/user/repos", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${githubAccount.accessToken}`
|
||||
}
|
||||
});
|
||||
|
||||
const repos = await response.json();
|
||||
```
|
||||
|
||||
## Advanced OAuth Configuration
|
||||
|
||||
### Custom Scopes
|
||||
|
||||
```ts
|
||||
export const auth = betterAuth({
|
||||
socialProviders: {
|
||||
github: {
|
||||
clientId: process.env.GITHUB_CLIENT_ID!,
|
||||
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
|
||||
scope: [
|
||||
"user:email",
|
||||
"read:user",
|
||||
"repo", // Access repositories
|
||||
"gist" // Access gists
|
||||
]
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### State Parameter
|
||||
|
||||
Better Auth automatically handles OAuth state parameter for CSRF protection.
|
||||
|
||||
```ts
|
||||
// Custom state validation
|
||||
export const auth = betterAuth({
|
||||
advanced: {
|
||||
generateState: async () => {
|
||||
// Custom state generation
|
||||
return crypto.randomUUID();
|
||||
},
|
||||
validateState: async (state: string) => {
|
||||
// Custom state validation
|
||||
return true;
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### PKCE Support
|
||||
|
||||
Better Auth automatically uses PKCE (Proof Key for Code Exchange) for supported providers.
|
||||
|
||||
```ts
|
||||
export const auth = betterAuth({
|
||||
socialProviders: {
|
||||
customProvider: {
|
||||
pkce: true, // Enable PKCE
|
||||
// ... other config
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Client-Side
|
||||
|
||||
```ts
|
||||
await authClient.signIn.social({
|
||||
provider: "github",
|
||||
errorCallbackURL: "/auth/error"
|
||||
}, {
|
||||
onError: (ctx) => {
|
||||
console.error("OAuth error:", ctx.error);
|
||||
// Handle specific errors
|
||||
if (ctx.error.code === "OAUTH_ACCOUNT_ALREADY_LINKED") {
|
||||
alert("This account is already linked to another user");
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Server-Side
|
||||
|
||||
```ts
|
||||
export const auth = betterAuth({
|
||||
callbacks: {
|
||||
async onOAuthError({ error, provider }) {
|
||||
console.error(`OAuth error with ${provider}:`, error);
|
||||
// Log to monitoring service
|
||||
await logError(error);
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Callback URLs
|
||||
|
||||
### Development
|
||||
|
||||
```
|
||||
http://localhost:3000/api/auth/callback/{provider}
|
||||
```
|
||||
|
||||
### Production
|
||||
|
||||
```
|
||||
https://yourdomain.com/api/auth/callback/{provider}
|
||||
```
|
||||
|
||||
**Important:** Add all callback URLs to OAuth provider settings.
|
||||
|
||||
## UI Components
|
||||
|
||||
### Sign In Button (React)
|
||||
|
||||
```tsx
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
|
||||
export function SocialSignIn() {
|
||||
const handleOAuth = async (provider: string) => {
|
||||
await authClient.signIn.social({
|
||||
provider,
|
||||
callbackURL: "/dashboard"
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<button onClick={() => handleOAuth("github")}>
|
||||
Sign in with GitHub
|
||||
</button>
|
||||
<button onClick={() => handleOAuth("google")}>
|
||||
Sign in with Google
|
||||
</button>
|
||||
<button onClick={() => handleOAuth("discord")}>
|
||||
Sign in with Discord
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Callback URLs**: Add all environments (dev, staging, prod) to OAuth app
|
||||
2. **Scopes**: Request minimum scopes needed
|
||||
3. **Token Storage**: Better Auth stores tokens securely in database
|
||||
4. **Token Refresh**: Implement automatic token refresh for long-lived sessions
|
||||
5. **Account Linking**: Enable for better UX when user signs in with different providers
|
||||
6. **Error Handling**: Provide clear error messages for OAuth failures
|
||||
7. **Provider Icons**: Use official brand assets for OAuth buttons
|
||||
8. **Mobile Deep Links**: Configure deep links for mobile OAuth flows
|
||||
9. **Email Matching**: Consider auto-linking accounts with same email
|
||||
10. **Privacy**: Inform users what data you access from OAuth providers
|
||||
|
||||
## Common Issues
|
||||
|
||||
### Redirect URI Mismatch
|
||||
|
||||
Ensure callback URL in OAuth app matches exactly:
|
||||
```
|
||||
http://localhost:3000/api/auth/callback/github
|
||||
```
|
||||
|
||||
### Missing Scopes
|
||||
|
||||
Add required scopes for email access:
|
||||
```ts
|
||||
scope: ["user:email"] // GitHub
|
||||
scope: ["email"] // Google
|
||||
```
|
||||
|
||||
### HTTPS Required
|
||||
|
||||
Some providers (Apple, Microsoft) require HTTPS callbacks. Use ngrok for local development:
|
||||
```bash
|
||||
ngrok http 3000
|
||||
```
|
||||
|
||||
### CORS Errors
|
||||
|
||||
Configure CORS if frontend/backend on different domains:
|
||||
```ts
|
||||
export const auth = betterAuth({
|
||||
advanced: {
|
||||
corsOptions: {
|
||||
origin: ["https://yourdomain.com"],
|
||||
credentials: true
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
521
skills/better-auth/scripts/better_auth_init.py
Normal file
521
skills/better-auth/scripts/better_auth_init.py
Normal file
@@ -0,0 +1,521 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Better Auth Initialization Script
|
||||
|
||||
Interactive script to initialize Better Auth configuration.
|
||||
Supports multiple databases, ORMs, and authentication methods.
|
||||
|
||||
.env loading order: process.env > skill/.env > skills/.env > .claude/.env
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, Any, List
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class EnvConfig:
|
||||
"""Environment configuration holder."""
|
||||
secret: str
|
||||
url: str
|
||||
database_url: Optional[str] = None
|
||||
github_client_id: Optional[str] = None
|
||||
github_client_secret: Optional[str] = None
|
||||
google_client_id: Optional[str] = None
|
||||
google_client_secret: Optional[str] = None
|
||||
|
||||
|
||||
class BetterAuthInit:
|
||||
"""Better Auth configuration initializer."""
|
||||
|
||||
def __init__(self, project_root: Optional[Path] = None):
|
||||
"""
|
||||
Initialize the Better Auth configuration tool.
|
||||
|
||||
Args:
|
||||
project_root: Project root directory. Auto-detected if not provided.
|
||||
"""
|
||||
self.project_root = project_root or self._find_project_root()
|
||||
self.env_config: Optional[EnvConfig] = None
|
||||
|
||||
@staticmethod
|
||||
def _find_project_root() -> Path:
|
||||
"""
|
||||
Find project root by looking for package.json.
|
||||
|
||||
Returns:
|
||||
Path to project root.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If project root cannot be found.
|
||||
"""
|
||||
current = Path.cwd()
|
||||
while current != current.parent:
|
||||
if (current / "package.json").exists():
|
||||
return current
|
||||
current = current.parent
|
||||
|
||||
raise RuntimeError("Could not find project root (no package.json found)")
|
||||
|
||||
def _load_env_files(self) -> Dict[str, str]:
|
||||
"""
|
||||
Load environment variables from .env files in order.
|
||||
|
||||
Loading order: process.env > skill/.env > skills/.env > .claude/.env
|
||||
|
||||
Returns:
|
||||
Dictionary of environment variables.
|
||||
"""
|
||||
env_vars = {}
|
||||
|
||||
# Define search paths in reverse priority order
|
||||
skill_dir = Path(__file__).parent.parent
|
||||
env_paths = [
|
||||
self.project_root / ".claude" / ".env",
|
||||
self.project_root / ".claude" / "skills" / ".env",
|
||||
skill_dir / ".env",
|
||||
]
|
||||
|
||||
# Load from files (lowest priority first)
|
||||
for env_path in env_paths:
|
||||
if env_path.exists():
|
||||
env_vars.update(self._parse_env_file(env_path))
|
||||
|
||||
# Override with process environment (highest priority)
|
||||
env_vars.update(os.environ)
|
||||
|
||||
return env_vars
|
||||
|
||||
@staticmethod
|
||||
def _parse_env_file(path: Path) -> Dict[str, str]:
|
||||
"""
|
||||
Parse .env file into dictionary.
|
||||
|
||||
Args:
|
||||
path: Path to .env file.
|
||||
|
||||
Returns:
|
||||
Dictionary of key-value pairs.
|
||||
"""
|
||||
env_vars = {}
|
||||
try:
|
||||
with open(path, "r") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line and not line.startswith("#") and "=" in line:
|
||||
key, value = line.split("=", 1)
|
||||
# Remove quotes if present
|
||||
value = value.strip().strip('"').strip("'")
|
||||
env_vars[key.strip()] = value
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not parse {path}: {e}")
|
||||
|
||||
return env_vars
|
||||
|
||||
@staticmethod
|
||||
def generate_secret(length: int = 32) -> str:
|
||||
"""
|
||||
Generate cryptographically secure random secret.
|
||||
|
||||
Args:
|
||||
length: Length of secret in bytes.
|
||||
|
||||
Returns:
|
||||
Hex-encoded secret string.
|
||||
"""
|
||||
return secrets.token_hex(length)
|
||||
|
||||
def prompt_database(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Prompt user for database configuration.
|
||||
|
||||
Returns:
|
||||
Database configuration dictionary.
|
||||
"""
|
||||
print("\nDatabase Configuration")
|
||||
print("=" * 50)
|
||||
print("1. Direct Connection (PostgreSQL/MySQL/SQLite)")
|
||||
print("2. Drizzle ORM")
|
||||
print("3. Prisma")
|
||||
print("4. Kysely")
|
||||
print("5. MongoDB")
|
||||
|
||||
choice = input("\nSelect database option (1-5): ").strip()
|
||||
|
||||
db_configs = {
|
||||
"1": self._prompt_direct_db,
|
||||
"2": self._prompt_drizzle,
|
||||
"3": self._prompt_prisma,
|
||||
"4": self._prompt_kysely,
|
||||
"5": self._prompt_mongodb,
|
||||
}
|
||||
|
||||
handler = db_configs.get(choice)
|
||||
if not handler:
|
||||
print("Invalid choice. Defaulting to direct PostgreSQL.")
|
||||
return self._prompt_direct_db()
|
||||
|
||||
return handler()
|
||||
|
||||
def _prompt_direct_db(self) -> Dict[str, Any]:
|
||||
"""Prompt for direct database connection."""
|
||||
print("\nDatabase Type:")
|
||||
print("1. PostgreSQL")
|
||||
print("2. MySQL")
|
||||
print("3. SQLite")
|
||||
|
||||
db_type = input("Select (1-3): ").strip()
|
||||
|
||||
if db_type == "3":
|
||||
db_path = input("SQLite file path [./dev.db]: ").strip() or "./dev.db"
|
||||
return {
|
||||
"type": "sqlite",
|
||||
"import": "import Database from 'better-sqlite3';",
|
||||
"config": f'database: new Database("{db_path}")'
|
||||
}
|
||||
elif db_type == "2":
|
||||
db_url = input("MySQL connection string: ").strip()
|
||||
return {
|
||||
"type": "mysql",
|
||||
"import": "import { createPool } from 'mysql2/promise';",
|
||||
"config": f"database: createPool({{ connectionString: process.env.DATABASE_URL }})",
|
||||
"env_var": ("DATABASE_URL", db_url)
|
||||
}
|
||||
else:
|
||||
db_url = input("PostgreSQL connection string: ").strip()
|
||||
return {
|
||||
"type": "postgresql",
|
||||
"import": "import { Pool } from 'pg';",
|
||||
"config": "database: new Pool({ connectionString: process.env.DATABASE_URL })",
|
||||
"env_var": ("DATABASE_URL", db_url)
|
||||
}
|
||||
|
||||
def _prompt_drizzle(self) -> Dict[str, Any]:
|
||||
"""Prompt for Drizzle ORM configuration."""
|
||||
print("\nDrizzle Provider:")
|
||||
print("1. PostgreSQL")
|
||||
print("2. MySQL")
|
||||
print("3. SQLite")
|
||||
|
||||
provider = input("Select (1-3): ").strip()
|
||||
provider_map = {"1": "pg", "2": "mysql", "3": "sqlite"}
|
||||
provider_name = provider_map.get(provider, "pg")
|
||||
|
||||
return {
|
||||
"type": "drizzle",
|
||||
"provider": provider_name,
|
||||
"import": "import { drizzleAdapter } from 'better-auth/adapters/drizzle';\nimport { db } from '@/db';",
|
||||
"config": f"database: drizzleAdapter(db, {{ provider: '{provider_name}' }})"
|
||||
}
|
||||
|
||||
def _prompt_prisma(self) -> Dict[str, Any]:
|
||||
"""Prompt for Prisma configuration."""
|
||||
print("\nPrisma Provider:")
|
||||
print("1. PostgreSQL")
|
||||
print("2. MySQL")
|
||||
print("3. SQLite")
|
||||
|
||||
provider = input("Select (1-3): ").strip()
|
||||
provider_map = {"1": "postgresql", "2": "mysql", "3": "sqlite"}
|
||||
provider_name = provider_map.get(provider, "postgresql")
|
||||
|
||||
return {
|
||||
"type": "prisma",
|
||||
"provider": provider_name,
|
||||
"import": "import { prismaAdapter } from 'better-auth/adapters/prisma';\nimport { PrismaClient } from '@prisma/client';\n\nconst prisma = new PrismaClient();",
|
||||
"config": f"database: prismaAdapter(prisma, {{ provider: '{provider_name}' }})"
|
||||
}
|
||||
|
||||
def _prompt_kysely(self) -> Dict[str, Any]:
|
||||
"""Prompt for Kysely configuration."""
|
||||
return {
|
||||
"type": "kysely",
|
||||
"import": "import { kyselyAdapter } from 'better-auth/adapters/kysely';\nimport { db } from '@/db';",
|
||||
"config": "database: kyselyAdapter(db, { provider: 'pg' })"
|
||||
}
|
||||
|
||||
def _prompt_mongodb(self) -> Dict[str, Any]:
|
||||
"""Prompt for MongoDB configuration."""
|
||||
mongo_uri = input("MongoDB connection string: ").strip()
|
||||
db_name = input("Database name: ").strip()
|
||||
|
||||
return {
|
||||
"type": "mongodb",
|
||||
"import": "import { mongodbAdapter } from 'better-auth/adapters/mongodb';\nimport { client } from '@/db';",
|
||||
"config": f"database: mongodbAdapter(client, {{ databaseName: '{db_name}' }})",
|
||||
"env_var": ("MONGODB_URI", mongo_uri)
|
||||
}
|
||||
|
||||
def prompt_auth_methods(self) -> List[str]:
|
||||
"""
|
||||
Prompt user for authentication methods.
|
||||
|
||||
Returns:
|
||||
List of selected auth method codes.
|
||||
"""
|
||||
print("\nAuthentication Methods")
|
||||
print("=" * 50)
|
||||
print("Select authentication methods (space-separated, e.g., '1 2 3'):")
|
||||
print("1. Email/Password")
|
||||
print("2. GitHub OAuth")
|
||||
print("3. Google OAuth")
|
||||
print("4. Discord OAuth")
|
||||
print("5. Two-Factor Authentication (2FA)")
|
||||
print("6. Passkeys (WebAuthn)")
|
||||
print("7. Magic Link")
|
||||
print("8. Username")
|
||||
|
||||
choices = input("\nYour selection: ").strip().split()
|
||||
return [c for c in choices if c in "12345678"]
|
||||
|
||||
def generate_auth_config(
|
||||
self,
|
||||
db_config: Dict[str, Any],
|
||||
auth_methods: List[str],
|
||||
) -> str:
|
||||
"""
|
||||
Generate auth.ts configuration file content.
|
||||
|
||||
Args:
|
||||
db_config: Database configuration.
|
||||
auth_methods: Selected authentication methods.
|
||||
|
||||
Returns:
|
||||
Generated TypeScript configuration code.
|
||||
"""
|
||||
imports = ["import { betterAuth } from 'better-auth';"]
|
||||
plugins = []
|
||||
plugin_imports = []
|
||||
config_parts = []
|
||||
|
||||
# Database import
|
||||
if db_config.get("import"):
|
||||
imports.append(db_config["import"])
|
||||
|
||||
# Email/Password
|
||||
if "1" in auth_methods:
|
||||
config_parts.append(""" emailAndPassword: {
|
||||
enabled: true,
|
||||
autoSignIn: true
|
||||
}""")
|
||||
|
||||
# OAuth providers
|
||||
social_providers = []
|
||||
if "2" in auth_methods:
|
||||
social_providers.append(""" github: {
|
||||
clientId: process.env.GITHUB_CLIENT_ID!,
|
||||
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
|
||||
}""")
|
||||
|
||||
if "3" in auth_methods:
|
||||
social_providers.append(""" google: {
|
||||
clientId: process.env.GOOGLE_CLIENT_ID!,
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
|
||||
}""")
|
||||
|
||||
if "4" in auth_methods:
|
||||
social_providers.append(""" discord: {
|
||||
clientId: process.env.DISCORD_CLIENT_ID!,
|
||||
clientSecret: process.env.DISCORD_CLIENT_SECRET!,
|
||||
}""")
|
||||
|
||||
if social_providers:
|
||||
config_parts.append(f" socialProviders: {{\n{',\\n'.join(social_providers)}\n }}")
|
||||
|
||||
# Plugins
|
||||
if "5" in auth_methods:
|
||||
plugin_imports.append("import { twoFactor } from 'better-auth/plugins';")
|
||||
plugins.append("twoFactor()")
|
||||
|
||||
if "6" in auth_methods:
|
||||
plugin_imports.append("import { passkey } from 'better-auth/plugins';")
|
||||
plugins.append("passkey()")
|
||||
|
||||
if "7" in auth_methods:
|
||||
plugin_imports.append("import { magicLink } from 'better-auth/plugins';")
|
||||
plugins.append("""magicLink({
|
||||
sendMagicLink: async ({ email, url }) => {
|
||||
// TODO: Implement email sending
|
||||
console.log(`Magic link for ${email}: ${url}`);
|
||||
}
|
||||
})""")
|
||||
|
||||
if "8" in auth_methods:
|
||||
plugin_imports.append("import { username } from 'better-auth/plugins';")
|
||||
plugins.append("username()")
|
||||
|
||||
# Combine all imports
|
||||
all_imports = imports + plugin_imports
|
||||
|
||||
# Build config
|
||||
config_body = ",\n".join(config_parts)
|
||||
|
||||
if plugins:
|
||||
plugins_str = ",\n ".join(plugins)
|
||||
config_body += f",\n plugins: [\n {plugins_str}\n ]"
|
||||
|
||||
# Final output
|
||||
return f"""{chr(10).join(all_imports)}
|
||||
|
||||
export const auth = betterAuth({{
|
||||
{db_config["config"]},
|
||||
{config_body}
|
||||
}});
|
||||
"""
|
||||
|
||||
def generate_env_file(
|
||||
self,
|
||||
db_config: Dict[str, Any],
|
||||
auth_methods: List[str]
|
||||
) -> str:
|
||||
"""
|
||||
Generate .env file content.
|
||||
|
||||
Args:
|
||||
db_config: Database configuration.
|
||||
auth_methods: Selected authentication methods.
|
||||
|
||||
Returns:
|
||||
Generated .env file content.
|
||||
"""
|
||||
env_vars = [
|
||||
f"BETTER_AUTH_SECRET={self.generate_secret()}",
|
||||
"BETTER_AUTH_URL=http://localhost:3000",
|
||||
]
|
||||
|
||||
# Database URL
|
||||
if db_config.get("env_var"):
|
||||
key, value = db_config["env_var"]
|
||||
env_vars.append(f"{key}={value}")
|
||||
|
||||
# OAuth credentials
|
||||
if "2" in auth_methods:
|
||||
env_vars.extend([
|
||||
"GITHUB_CLIENT_ID=your_github_client_id",
|
||||
"GITHUB_CLIENT_SECRET=your_github_client_secret",
|
||||
])
|
||||
|
||||
if "3" in auth_methods:
|
||||
env_vars.extend([
|
||||
"GOOGLE_CLIENT_ID=your_google_client_id",
|
||||
"GOOGLE_CLIENT_SECRET=your_google_client_secret",
|
||||
])
|
||||
|
||||
if "4" in auth_methods:
|
||||
env_vars.extend([
|
||||
"DISCORD_CLIENT_ID=your_discord_client_id",
|
||||
"DISCORD_CLIENT_SECRET=your_discord_client_secret",
|
||||
])
|
||||
|
||||
return "\n".join(env_vars) + "\n"
|
||||
|
||||
def run(self) -> None:
|
||||
"""Run interactive initialization."""
|
||||
print("=" * 50)
|
||||
print("Better Auth Configuration Generator")
|
||||
print("=" * 50)
|
||||
|
||||
# Load existing env
|
||||
env_vars = self._load_env_files()
|
||||
|
||||
# Prompt for configuration
|
||||
db_config = self.prompt_database()
|
||||
auth_methods = self.prompt_auth_methods()
|
||||
|
||||
# Generate files
|
||||
auth_config = self.generate_auth_config(db_config, auth_methods)
|
||||
env_content = self.generate_env_file(db_config, auth_methods)
|
||||
|
||||
# Display output
|
||||
print("\n" + "=" * 50)
|
||||
print("Generated Configuration")
|
||||
print("=" * 50)
|
||||
|
||||
print("\n--- auth.ts ---")
|
||||
print(auth_config)
|
||||
|
||||
print("\n--- .env ---")
|
||||
print(env_content)
|
||||
|
||||
# Offer to save
|
||||
save = input("\nSave configuration files? (y/N): ").strip().lower()
|
||||
if save == "y":
|
||||
self._save_files(auth_config, env_content)
|
||||
else:
|
||||
print("Configuration not saved.")
|
||||
|
||||
def _save_files(self, auth_config: str, env_content: str) -> None:
|
||||
"""
|
||||
Save generated configuration files.
|
||||
|
||||
Args:
|
||||
auth_config: auth.ts content.
|
||||
env_content: .env content.
|
||||
"""
|
||||
# Save auth.ts
|
||||
auth_locations = [
|
||||
self.project_root / "lib" / "auth.ts",
|
||||
self.project_root / "src" / "lib" / "auth.ts",
|
||||
self.project_root / "utils" / "auth.ts",
|
||||
self.project_root / "auth.ts",
|
||||
]
|
||||
|
||||
print("\nWhere to save auth.ts?")
|
||||
for i, loc in enumerate(auth_locations, 1):
|
||||
print(f"{i}. {loc}")
|
||||
print("5. Custom path")
|
||||
|
||||
choice = input("Select (1-5): ").strip()
|
||||
if choice == "5":
|
||||
custom_path = input("Enter path: ").strip()
|
||||
auth_path = Path(custom_path)
|
||||
else:
|
||||
idx = int(choice) - 1 if choice.isdigit() else 0
|
||||
auth_path = auth_locations[idx]
|
||||
|
||||
auth_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
auth_path.write_text(auth_config)
|
||||
print(f"Saved: {auth_path}")
|
||||
|
||||
# Save .env
|
||||
env_path = self.project_root / ".env"
|
||||
if env_path.exists():
|
||||
backup = self.project_root / ".env.backup"
|
||||
env_path.rename(backup)
|
||||
print(f"Backed up existing .env to {backup}")
|
||||
|
||||
env_path.write_text(env_content)
|
||||
print(f"Saved: {env_path}")
|
||||
|
||||
print("\nNext steps:")
|
||||
print("1. Run: npx @better-auth/cli generate")
|
||||
print("2. Apply database migrations")
|
||||
print("3. Mount API handler in your framework")
|
||||
print("4. Create client instance")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""
|
||||
Main entry point.
|
||||
|
||||
Returns:
|
||||
Exit code (0 for success, 1 for error).
|
||||
"""
|
||||
try:
|
||||
initializer = BetterAuthInit()
|
||||
initializer.run()
|
||||
return 0
|
||||
except KeyboardInterrupt:
|
||||
print("\n\nOperation cancelled.")
|
||||
return 1
|
||||
except Exception as e:
|
||||
print(f"\nError: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
15
skills/better-auth/scripts/requirements.txt
Normal file
15
skills/better-auth/scripts/requirements.txt
Normal file
@@ -0,0 +1,15 @@
|
||||
# Better Auth Skill Dependencies
|
||||
# Python 3.10+ required
|
||||
|
||||
# No Python package dependencies - uses only standard library
|
||||
|
||||
# Testing dependencies (dev)
|
||||
pytest>=8.0.0
|
||||
pytest-cov>=4.1.0
|
||||
pytest-mock>=3.12.0
|
||||
|
||||
# Note: This script generates Better Auth configuration
|
||||
# The actual Better Auth library is installed via npm/pnpm/yarn:
|
||||
# npm install better-auth
|
||||
# pnpm add better-auth
|
||||
# yarn add better-auth
|
||||
421
skills/better-auth/scripts/tests/test_better_auth_init.py
Normal file
421
skills/better-auth/scripts/tests/test_better_auth_init.py
Normal file
@@ -0,0 +1,421 @@
|
||||
"""
|
||||
Tests for better_auth_init.py
|
||||
|
||||
Covers main functionality with mocked I/O and file operations.
|
||||
Target: >80% coverage
|
||||
"""
|
||||
|
||||
import sys
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, patch, mock_open, MagicMock
|
||||
from io import StringIO
|
||||
|
||||
# Add parent directory to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from better_auth_init import BetterAuthInit, EnvConfig, main
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_project_root(tmp_path):
|
||||
"""Create mock project root with package.json."""
|
||||
(tmp_path / "package.json").write_text("{}")
|
||||
return tmp_path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_init(mock_project_root):
|
||||
"""Create BetterAuthInit instance with mock project root."""
|
||||
return BetterAuthInit(project_root=mock_project_root)
|
||||
|
||||
|
||||
class TestBetterAuthInit:
|
||||
"""Test BetterAuthInit class."""
|
||||
|
||||
def test_init_with_project_root(self, mock_project_root):
|
||||
"""Test initialization with explicit project root."""
|
||||
init = BetterAuthInit(project_root=mock_project_root)
|
||||
assert init.project_root == mock_project_root
|
||||
assert init.env_config is None
|
||||
|
||||
def test_find_project_root_success(self, mock_project_root, monkeypatch):
|
||||
"""Test finding project root successfully."""
|
||||
monkeypatch.chdir(mock_project_root)
|
||||
init = BetterAuthInit()
|
||||
assert init.project_root == mock_project_root
|
||||
|
||||
def test_find_project_root_failure(self, tmp_path, monkeypatch):
|
||||
"""Test failure to find project root."""
|
||||
# Create path without package.json
|
||||
no_package_dir = tmp_path / "no-package"
|
||||
no_package_dir.mkdir()
|
||||
monkeypatch.chdir(no_package_dir)
|
||||
|
||||
# Mock parent to stop infinite loop
|
||||
with patch.object(Path, "parent", new_callable=lambda: property(lambda self: self)):
|
||||
with pytest.raises(RuntimeError, match="Could not find project root"):
|
||||
BetterAuthInit()
|
||||
|
||||
def test_generate_secret(self):
|
||||
"""Test secret generation."""
|
||||
secret = BetterAuthInit.generate_secret()
|
||||
assert len(secret) == 64 # 32 bytes = 64 hex chars
|
||||
assert all(c in "0123456789abcdef" for c in secret)
|
||||
|
||||
# Test custom length
|
||||
secret = BetterAuthInit.generate_secret(length=16)
|
||||
assert len(secret) == 32 # 16 bytes = 32 hex chars
|
||||
|
||||
def test_parse_env_file(self, tmp_path):
|
||||
"""Test parsing .env file."""
|
||||
env_content = """
|
||||
# Comment
|
||||
KEY1=value1
|
||||
KEY2="value2"
|
||||
KEY3='value3'
|
||||
INVALID LINE
|
||||
KEY4=value=with=equals
|
||||
"""
|
||||
env_file = tmp_path / ".env"
|
||||
env_file.write_text(env_content)
|
||||
|
||||
result = BetterAuthInit._parse_env_file(env_file)
|
||||
|
||||
assert result["KEY1"] == "value1"
|
||||
assert result["KEY2"] == "value2"
|
||||
assert result["KEY3"] == "value3"
|
||||
assert result["KEY4"] == "value=with=equals"
|
||||
assert "INVALID" not in result
|
||||
|
||||
def test_parse_env_file_missing(self, tmp_path):
|
||||
"""Test parsing missing .env file."""
|
||||
result = BetterAuthInit._parse_env_file(tmp_path / "nonexistent.env")
|
||||
assert result == {}
|
||||
|
||||
def test_load_env_files(self, auth_init, mock_project_root):
|
||||
"""Test loading environment variables from multiple files."""
|
||||
# Create .env files
|
||||
claude_env = mock_project_root / ".claude" / ".env"
|
||||
claude_env.parent.mkdir(parents=True, exist_ok=True)
|
||||
claude_env.write_text("BASE_VAR=base\nOVERRIDE=claude")
|
||||
|
||||
skills_env = mock_project_root / ".claude" / "skills" / ".env"
|
||||
skills_env.parent.mkdir(parents=True, exist_ok=True)
|
||||
skills_env.write_text("OVERRIDE=skills\nSKILLS_VAR=skills")
|
||||
|
||||
# Mock process env (highest priority)
|
||||
with patch.dict("os.environ", {"OVERRIDE": "process", "PROCESS_VAR": "process"}):
|
||||
result = auth_init._load_env_files()
|
||||
|
||||
assert result["BASE_VAR"] == "base"
|
||||
assert result["SKILLS_VAR"] == "skills"
|
||||
assert result["OVERRIDE"] == "process" # Process env wins
|
||||
assert result["PROCESS_VAR"] == "process"
|
||||
|
||||
def test_prompt_direct_db_sqlite(self, auth_init):
|
||||
"""Test prompting for SQLite database."""
|
||||
with patch("builtins.input", side_effect=["3", "./test.db"]):
|
||||
config = auth_init._prompt_direct_db()
|
||||
|
||||
assert config["type"] == "sqlite"
|
||||
assert "better-sqlite3" in config["import"]
|
||||
assert "./test.db" in config["config"]
|
||||
|
||||
def test_prompt_direct_db_postgresql(self, auth_init):
|
||||
"""Test prompting for PostgreSQL database."""
|
||||
with patch("builtins.input", side_effect=["1", "postgresql://localhost/test"]):
|
||||
config = auth_init._prompt_direct_db()
|
||||
|
||||
assert config["type"] == "postgresql"
|
||||
assert "pg" in config["import"]
|
||||
assert config["env_var"] == ("DATABASE_URL", "postgresql://localhost/test")
|
||||
|
||||
def test_prompt_direct_db_mysql(self, auth_init):
|
||||
"""Test prompting for MySQL database."""
|
||||
with patch("builtins.input", side_effect=["2", "mysql://localhost/test"]):
|
||||
config = auth_init._prompt_direct_db()
|
||||
|
||||
assert config["type"] == "mysql"
|
||||
assert "mysql2" in config["import"]
|
||||
assert config["env_var"][0] == "DATABASE_URL"
|
||||
|
||||
def test_prompt_drizzle(self, auth_init):
|
||||
"""Test prompting for Drizzle ORM."""
|
||||
with patch("builtins.input", return_value="1"):
|
||||
config = auth_init._prompt_drizzle()
|
||||
|
||||
assert config["type"] == "drizzle"
|
||||
assert config["provider"] == "pg"
|
||||
assert "drizzleAdapter" in config["import"]
|
||||
assert "drizzleAdapter" in config["config"]
|
||||
|
||||
def test_prompt_prisma(self, auth_init):
|
||||
"""Test prompting for Prisma."""
|
||||
with patch("builtins.input", return_value="2"):
|
||||
config = auth_init._prompt_prisma()
|
||||
|
||||
assert config["type"] == "prisma"
|
||||
assert config["provider"] == "mysql"
|
||||
assert "prismaAdapter" in config["import"]
|
||||
assert "PrismaClient" in config["import"]
|
||||
|
||||
def test_prompt_kysely(self, auth_init):
|
||||
"""Test prompting for Kysely."""
|
||||
config = auth_init._prompt_kysely()
|
||||
|
||||
assert config["type"] == "kysely"
|
||||
assert "kyselyAdapter" in config["import"]
|
||||
|
||||
def test_prompt_mongodb(self, auth_init):
|
||||
"""Test prompting for MongoDB."""
|
||||
with patch("builtins.input", side_effect=["mongodb://localhost/test", "mydb"]):
|
||||
config = auth_init._prompt_mongodb()
|
||||
|
||||
assert config["type"] == "mongodb"
|
||||
assert "mongodbAdapter" in config["import"]
|
||||
assert "mydb" in config["config"]
|
||||
assert config["env_var"] == ("MONGODB_URI", "mongodb://localhost/test")
|
||||
|
||||
def test_prompt_database(self, auth_init):
|
||||
"""Test database prompting with different choices."""
|
||||
# Test valid choice
|
||||
with patch("builtins.input", side_effect=["3", "1"]):
|
||||
config = auth_init.prompt_database()
|
||||
assert config["type"] == "prisma"
|
||||
|
||||
# Test invalid choice (defaults to direct DB)
|
||||
with patch("builtins.input", side_effect=["99", "1", "postgresql://localhost/test"]):
|
||||
with patch("builtins.print"):
|
||||
config = auth_init.prompt_database()
|
||||
assert config["type"] == "postgresql"
|
||||
|
||||
def test_prompt_auth_methods(self, auth_init):
|
||||
"""Test prompting for authentication methods."""
|
||||
with patch("builtins.input", return_value="1 2 3 5 8"):
|
||||
with patch("builtins.print"):
|
||||
methods = auth_init.prompt_auth_methods()
|
||||
|
||||
assert methods == ["1", "2", "3", "5", "8"]
|
||||
|
||||
def test_prompt_auth_methods_invalid(self, auth_init):
|
||||
"""Test filtering invalid auth method choices."""
|
||||
with patch("builtins.input", return_value="1 99 abc 3"):
|
||||
with patch("builtins.print"):
|
||||
methods = auth_init.prompt_auth_methods()
|
||||
|
||||
assert methods == ["1", "3"]
|
||||
|
||||
def test_generate_auth_config_basic(self, auth_init):
|
||||
"""Test generating basic auth config."""
|
||||
db_config = {
|
||||
"import": "import Database from 'better-sqlite3';",
|
||||
"config": "database: new Database('./dev.db')"
|
||||
}
|
||||
auth_methods = ["1"] # Email/password only
|
||||
|
||||
config = auth_init.generate_auth_config(db_config, auth_methods)
|
||||
|
||||
assert "import { betterAuth }" in config
|
||||
assert "emailAndPassword" in config
|
||||
assert "enabled: true" in config
|
||||
assert "better-sqlite3" in config
|
||||
|
||||
def test_generate_auth_config_with_oauth(self, auth_init):
|
||||
"""Test generating config with OAuth providers."""
|
||||
db_config = {
|
||||
"import": "import { Pool } from 'pg';",
|
||||
"config": "database: new Pool()"
|
||||
}
|
||||
auth_methods = ["1", "2", "3", "4"] # Email + GitHub + Google + Discord
|
||||
|
||||
config = auth_init.generate_auth_config(db_config, auth_methods)
|
||||
|
||||
assert "socialProviders" in config
|
||||
assert "github:" in config
|
||||
assert "google:" in config
|
||||
assert "discord:" in config
|
||||
assert "GITHUB_CLIENT_ID" in config
|
||||
assert "GOOGLE_CLIENT_ID" in config
|
||||
assert "DISCORD_CLIENT_ID" in config
|
||||
|
||||
def test_generate_auth_config_with_plugins(self, auth_init):
|
||||
"""Test generating config with plugins."""
|
||||
db_config = {"import": "", "config": "database: db"}
|
||||
auth_methods = ["5", "6", "7", "8"] # 2FA, Passkey, Magic Link, Username
|
||||
|
||||
config = auth_init.generate_auth_config(db_config, auth_methods)
|
||||
|
||||
assert "plugins:" in config
|
||||
assert "twoFactor" in config
|
||||
assert "passkey" in config
|
||||
assert "magicLink" in config
|
||||
assert "username" in config
|
||||
assert "from 'better-auth/plugins'" in config
|
||||
|
||||
def test_generate_env_file_basic(self, auth_init):
|
||||
"""Test generating basic .env file."""
|
||||
db_config = {"type": "sqlite"}
|
||||
auth_methods = ["1"]
|
||||
|
||||
env_content = auth_init.generate_env_file(db_config, auth_methods)
|
||||
|
||||
assert "BETTER_AUTH_SECRET=" in env_content
|
||||
assert "BETTER_AUTH_URL=http://localhost:3000" in env_content
|
||||
assert len(env_content.split("\n")) >= 2
|
||||
|
||||
def test_generate_env_file_with_database_url(self, auth_init):
|
||||
"""Test generating .env with database URL."""
|
||||
db_config = {
|
||||
"env_var": ("DATABASE_URL", "postgresql://localhost/test")
|
||||
}
|
||||
auth_methods = []
|
||||
|
||||
env_content = auth_init.generate_env_file(db_config, auth_methods)
|
||||
|
||||
assert "DATABASE_URL=postgresql://localhost/test" in env_content
|
||||
|
||||
def test_generate_env_file_with_oauth(self, auth_init):
|
||||
"""Test generating .env with OAuth credentials."""
|
||||
db_config = {}
|
||||
auth_methods = ["2", "3", "4"] # GitHub, Google, Discord
|
||||
|
||||
env_content = auth_init.generate_env_file(db_config, auth_methods)
|
||||
|
||||
assert "GITHUB_CLIENT_ID=" in env_content
|
||||
assert "GITHUB_CLIENT_SECRET=" in env_content
|
||||
assert "GOOGLE_CLIENT_ID=" in env_content
|
||||
assert "GOOGLE_CLIENT_SECRET=" in env_content
|
||||
assert "DISCORD_CLIENT_ID=" in env_content
|
||||
assert "DISCORD_CLIENT_SECRET=" in env_content
|
||||
|
||||
def test_save_files(self, auth_init, mock_project_root):
|
||||
"""Test saving configuration files."""
|
||||
auth_config = "// auth config"
|
||||
env_content = "SECRET=test"
|
||||
|
||||
with patch("builtins.input", side_effect=["1"]):
|
||||
auth_init._save_files(auth_config, env_content)
|
||||
|
||||
# Check auth.ts was saved
|
||||
auth_path = mock_project_root / "lib" / "auth.ts"
|
||||
assert auth_path.exists()
|
||||
assert auth_path.read_text() == auth_config
|
||||
|
||||
# Check .env was saved
|
||||
env_path = mock_project_root / ".env"
|
||||
assert env_path.exists()
|
||||
assert env_path.read_text() == env_content
|
||||
|
||||
def test_save_files_custom_path(self, auth_init, mock_project_root):
|
||||
"""Test saving with custom path."""
|
||||
auth_config = "// config"
|
||||
env_content = "SECRET=test"
|
||||
|
||||
custom_path = str(mock_project_root / "custom" / "auth.ts")
|
||||
with patch("builtins.input", side_effect=["5", custom_path]):
|
||||
auth_init._save_files(auth_config, env_content)
|
||||
|
||||
assert Path(custom_path).exists()
|
||||
|
||||
def test_save_files_backup_existing_env(self, auth_init, mock_project_root):
|
||||
"""Test backing up existing .env file."""
|
||||
# Create existing .env
|
||||
env_path = mock_project_root / ".env"
|
||||
env_path.write_text("OLD_SECRET=old")
|
||||
|
||||
auth_config = "// config"
|
||||
env_content = "NEW_SECRET=new"
|
||||
|
||||
with patch("builtins.input", return_value="1"):
|
||||
auth_init._save_files(auth_config, env_content)
|
||||
|
||||
# Check backup was created
|
||||
backup_path = mock_project_root / ".env.backup"
|
||||
assert backup_path.exists()
|
||||
assert backup_path.read_text() == "OLD_SECRET=old"
|
||||
|
||||
# Check new .env
|
||||
assert env_path.read_text() == "NEW_SECRET=new"
|
||||
|
||||
def test_run_full_flow(self, auth_init, mock_project_root):
|
||||
"""Test complete run flow."""
|
||||
inputs = [
|
||||
"1", # Direct DB
|
||||
"1", # PostgreSQL
|
||||
"postgresql://localhost/test",
|
||||
"1 2", # Email + GitHub
|
||||
"n" # Don't save
|
||||
]
|
||||
|
||||
with patch("builtins.input", side_effect=inputs):
|
||||
with patch("builtins.print"):
|
||||
auth_init.run()
|
||||
|
||||
# Should complete without errors
|
||||
# Files not saved because user chose 'n'
|
||||
assert not (mock_project_root / "auth.ts").exists()
|
||||
|
||||
def test_run_save_files(self, auth_init, mock_project_root):
|
||||
"""Test run flow with file saving."""
|
||||
inputs = [
|
||||
"1", # Direct DB
|
||||
"3", # SQLite
|
||||
"", # Default path
|
||||
"1", # Email only
|
||||
"y", # Save
|
||||
"1" # Save location
|
||||
]
|
||||
|
||||
with patch("builtins.input", side_effect=inputs):
|
||||
with patch("builtins.print"):
|
||||
auth_init.run()
|
||||
|
||||
# Check files were created
|
||||
assert (mock_project_root / "lib" / "auth.ts").exists()
|
||||
assert (mock_project_root / ".env").exists()
|
||||
|
||||
|
||||
class TestMainFunction:
|
||||
"""Test main entry point."""
|
||||
|
||||
def test_main_success(self, tmp_path, monkeypatch):
|
||||
"""Test successful main execution."""
|
||||
(tmp_path / "package.json").write_text("{}")
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
inputs = ["1", "3", "", "1", "n"]
|
||||
|
||||
with patch("builtins.input", side_effect=inputs):
|
||||
with patch("builtins.print"):
|
||||
exit_code = main()
|
||||
|
||||
assert exit_code == 0
|
||||
|
||||
def test_main_keyboard_interrupt(self, tmp_path, monkeypatch):
|
||||
"""Test main with keyboard interrupt."""
|
||||
(tmp_path / "package.json").write_text("{}")
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
with patch("builtins.input", side_effect=KeyboardInterrupt()):
|
||||
with patch("builtins.print"):
|
||||
exit_code = main()
|
||||
|
||||
assert exit_code == 1
|
||||
|
||||
def test_main_error(self, tmp_path, monkeypatch):
|
||||
"""Test main with error."""
|
||||
# No package.json - should fail
|
||||
no_package = tmp_path / "no-package"
|
||||
no_package.mkdir()
|
||||
monkeypatch.chdir(no_package)
|
||||
|
||||
with patch.object(Path, "parent", new_callable=lambda: property(lambda self: self)):
|
||||
with patch("sys.stderr", new_callable=StringIO):
|
||||
exit_code = main()
|
||||
|
||||
assert exit_code == 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v", "--cov=better_auth_init", "--cov-report=term-missing"])
|
||||
Reference in New Issue
Block a user