docs: add Trellis planning and project specs

This commit is contained in:
2026-07-01 06:27:55 -07:00
parent 7f227c5f2a
commit aafd9caaac
349 changed files with 55801 additions and 0 deletions

View File

@@ -0,0 +1,723 @@
# Authentication Guidelines
This document covers backend authentication integration using better-auth, including session management, protected procedures, and OAuth configuration.
## 1. Overview
### What is better-auth
better-auth is a modern authentication library for TypeScript applications that provides:
- Session-based authentication with secure cookie management
- Multiple authentication methods (email/password, OAuth, magic links, passkeys)
- Built-in support for organizations and multi-tenancy
- Database adapter integration (Drizzle ORM)
- Two-factor authentication (2FA)
- Admin functionality
### Session-based Authentication
The authentication system uses secure, HTTP-only cookies to manage user sessions:
- Sessions are stored in the database and cached in Redis for performance
- Session tokens are automatically validated on each request
- Cookie names are prefixed with `__Secure-` in production (HTTPS)
### Supported Providers
| Provider | Type | Description |
|----------|------|-------------|
| Email/Password | Credential | Traditional email and password authentication |
| Google | OAuth | Social login with Google account |
| GitHub | OAuth | Social login with GitHub account |
| Magic Link | Passwordless | Email-based one-time login links |
| Passkey | Passwordless | WebAuthn/FIDO2 biometric authentication |
## 2. Auth Configuration
### Server-side Auth Setup
The auth configuration is defined in the auth package:
```typescript
// packages/auth/auth.ts
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { db } from "@your-app/database";
import {
admin,
magicLink,
organization,
passkey,
twoFactor,
username,
} from "better-auth/plugins";
export const auth = betterAuth({
baseURL: process.env.APP_URL,
appName: "Your App Name",
// Database adapter
database: drizzleAdapter(db, {
provider: "pg",
}),
// Session configuration
session: {
expiresIn: 60 * 60 * 24 * 7, // 7 days in seconds
freshAge: 0,
},
// Account linking for OAuth providers
account: {
accountLinking: {
enabled: true,
trustedProviders: ["google", "github"],
},
},
// Plugins
plugins: [
username(),
admin(),
passkey(),
magicLink({
sendMagicLink: async ({ email, url }, request) => {
// Send magic link email
await sendEmail({
to: email,
templateId: "magicLink",
context: { url },
});
},
}),
organization({
sendInvitationEmail: async ({ email, id, organization }, request) => {
// Send organization invitation email
},
}),
twoFactor(),
],
});
// Export session type
export type Session = typeof auth.$Infer.Session;
```
### Database Adapter (Drizzle)
better-auth uses Drizzle ORM for database operations. The required tables are automatically created:
- `user` - User accounts
- `session` - Authentication sessions
- `account` - OAuth provider accounts (Google, GitHub, etc.)
- `verification` - Email verification tokens
### Session Configuration
```typescript
session: {
// Session lifetime (default: 7 days)
expiresIn: 60 * 60 * 24 * 7,
// Fresh session age for sensitive operations (0 = always require re-auth)
freshAge: 0,
}
```
## 3. Protected Procedures
### Procedure Types
The API layer provides three procedure types with different authentication levels:
```typescript
// packages/api/orpc/procedures.ts
import { ORPCError, os } from "@orpc/server";
// Public procedure - no authentication required
export const publicProcedure = os
.$context<{ headers: Headers }>()
.use(logIdMiddleware);
// Protected procedure - requires authenticated user
export const protectedProcedure = publicProcedure.use(
async ({ context, next }) => {
const { session } = await getSessionWithCache(context.headers);
if (!session) {
throw new ORPCError("UNAUTHORIZED");
}
return await next({
context: {
session: session.session,
user: session.user,
},
});
},
);
// Admin procedure - requires admin role
export const adminProcedure = protectedProcedure.use(
async ({ context, next }) => {
if (context.user.role !== "admin") {
throw new ORPCError("FORBIDDEN");
}
return await next();
},
);
```
### Using Protected Procedures
**Basic protected endpoint:**
```typescript
// procedures/get-profile.ts
import { protectedProcedure } from "../../../orpc/procedures";
export const getProfile = protectedProcedure
.route({
method: "GET",
path: "/users/profile",
tags: ["Users"],
summary: "Get current user profile",
})
.handler(async ({ context }) => {
// Access authenticated user from context
const { user, session } = context;
return {
success: true,
reason: "Profile retrieved",
user: {
id: user.id,
email: user.email,
name: user.name,
},
};
});
```
**Admin-only endpoint:**
```typescript
// procedures/list-users.ts
import { adminProcedure } from "../../../orpc/procedures";
import { z } from "zod";
export const listUsers = adminProcedure
.route({
method: "GET",
path: "/admin/users",
tags: ["Administration"],
summary: "List all users",
})
.input(
z.object({
limit: z.number().min(1).max(100).default(10),
offset: z.number().min(0).default(0),
}),
)
.handler(async ({ input: { limit, offset } }) => {
const users = await getUsers({ limit, offset });
return { users };
});
```
### Accessing User Session in Context
The protected procedure middleware injects session data into the context:
```typescript
interface ProtectedContext {
session: {
id: string;
userId: string;
expiresAt: Date;
// ... other session fields
};
user: {
id: string;
email: string;
name: string;
role: "user" | "admin";
// ... other user fields
};
}
```
**Accessing context in handlers:**
```typescript
.handler(async ({ context, input }) => {
const { user, session } = context;
// Use user.id for database queries
const userOrders = await getOrdersByUserId(user.id);
// Check user role
if (user.role === "admin") {
// Admin-specific logic
}
return { success: true, reason: "Success", orders: userOrders };
});
```
### Role-based Access Control
**Custom role middleware:**
```typescript
// Create a middleware for specific roles
const organizationAdminProcedure = protectedProcedure.use(
async ({ context, input, next }) => {
const { organizationId } = input as { organizationId: string };
const membership = await getOrganizationMembership(
organizationId,
context.user.id
);
if (!membership || membership.role !== "owner") {
throw new ORPCError("FORBIDDEN", {
message: "Organization admin access required",
});
}
return await next({
context: {
...context,
organization: membership.organization,
},
});
},
);
```
**Verifying organization membership:**
```typescript
// lib/membership.ts
import { getOrganizationMembership } from "@your-app/database";
export async function verifyOrganizationMembership(
organizationId: string,
userId: string,
) {
const membership = await getOrganizationMembership(organizationId, userId);
if (!membership) {
return null;
}
return {
organization: membership.organization,
role: membership.role,
};
}
```
## 4. Session Management
### Session Caching
Sessions are cached in Redis to reduce database load:
```typescript
// lib/session-cache.ts
import { auth } from "@your-app/auth";
import { redis } from "./redis";
const SESSION_CACHE_PREFIX = "session";
const SESSION_TTL = 60 * 60 * 24 * 7; // 7 days
export async function getSessionWithCache(
headers: Headers,
): Promise<{ session: Session | null; fromCache: boolean }> {
const sessionToken = getSessionTokenFromHeaders(headers);
if (!sessionToken) {
const fresh = await fetchSession(headers);
return { session: fresh, fromCache: false };
}
// Try cache first
const cached = await redis.get(`${SESSION_CACHE_PREFIX}:${sessionToken}`);
if (cached) {
return { session: JSON.parse(cached), fromCache: true };
}
// Fetch from database
const fresh = await auth.api.getSession({ headers });
if (fresh) {
// Cache the session
await redis.set(
`${SESSION_CACHE_PREFIX}:${sessionToken}`,
JSON.stringify(fresh),
{ ex: SESSION_TTL }
);
}
return { session: fresh, fromCache: false };
}
```
### Getting Session Token from Headers
```typescript
export function getSessionTokenFromHeaders(headers: Headers): string | null {
// Check Authorization header first
const authHeader = headers.get("Authorization");
if (authHeader?.startsWith("Bearer ")) {
return authHeader.slice("Bearer ".length);
}
// Fall back to cookie
const cookieHeader = headers.get("cookie");
if (!cookieHeader) {
return null;
}
const cookies = parseCookie(cookieHeader);
const cookieName = process.env.NODE_ENV === "production"
? "__Secure-better-auth.session_token"
: "better-auth.session_token";
return cookies[cookieName] ?? null;
}
```
### Session Invalidation
```typescript
// Delete session cache on logout or session change
export async function deleteSessionCache(sessionToken: string): Promise<void> {
await redis.del(`${SESSION_CACHE_PREFIX}:${sessionToken}`);
}
```
## 5. OAuth Integration
### Google OAuth Setup
**Configuration:**
```typescript
// auth.ts
socialProviders: {
google: {
clientId: process.env.GOOGLE_CLIENT_ID as string,
clientSecret: process.env.GOOGLE_CLIENT_SECRET as string,
scope: [
"email",
"profile",
"openid",
// Add additional scopes as needed
// "https://www.googleapis.com/auth/calendar",
],
// Get refresh token for offline access
accessType: "offline",
prompt: "consent",
},
},
```
**Environment variables:**
```bash
# .env
GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-client-secret
```
### GitHub OAuth Setup
```typescript
socialProviders: {
github: {
clientId: process.env.GITHUB_CLIENT_ID as string,
clientSecret: process.env.GITHUB_CLIENT_SECRET as string,
scope: ["user:email"],
},
},
```
### Accessing OAuth Tokens
To access stored OAuth tokens for API calls:
```typescript
import { db } from "@your-app/database";
import { account } from "@your-app/database/drizzle/schema";
import { eq, and } from "drizzle-orm";
export async function getOAuthToken(userId: string, provider: string) {
const accountRecord = await db.query.account.findFirst({
where: and(
eq(account.userId, userId),
eq(account.providerId, provider)
),
});
if (!accountRecord) {
return null;
}
return {
accessToken: accountRecord.accessToken,
refreshToken: accountRecord.refreshToken,
expiresAt: accountRecord.accessTokenExpiresAt,
};
}
```
### Token Refresh
better-auth handles token refresh automatically. For manual refresh:
```typescript
import { auth } from "@your-app/auth";
export async function refreshOAuthToken(userId: string, provider: string) {
// Use auth API to refresh token
const result = await auth.api.refreshAccessToken({
userId,
providerId: provider,
});
return result;
}
```
## 6. Error Handling
### Standard Auth Errors
Use oRPC error codes for authentication failures:
```typescript
import { ORPCError } from "@orpc/server";
// User not authenticated
throw new ORPCError("UNAUTHORIZED");
// User authenticated but lacks permission
throw new ORPCError("FORBIDDEN", {
message: "Admin access required",
});
// Session expired
throw new ORPCError("UNAUTHORIZED", {
message: "Session expired, please login again",
});
```
### Error Response Pattern
```typescript
// Consistent error response structure
export const authErrorSchema = z.object({
success: z.literal(false),
reason: z.string(),
code: z.enum(["UNAUTHORIZED", "FORBIDDEN", "SESSION_EXPIRED"]).optional(),
});
// In handler
if (!hasPermission) {
return {
success: false,
reason: "You do not have permission to perform this action",
code: "FORBIDDEN",
};
}
```
### Handling Session Expiration
```typescript
// Graceful session expiration handling
export async function handleSessionExpiration(sessionToken: string) {
// Clear cache
await deleteSessionCache(sessionToken);
// Log the event
logger.info("Session expired", { sessionToken: sessionToken.slice(0, 10) });
throw new ORPCError("UNAUTHORIZED", {
message: "Your session has expired. Please login again.",
});
}
```
## 7. Best Practices
### Always Validate Session in Protected Routes
```typescript
// GOOD - Use protectedProcedure for authenticated endpoints
export const updateProfile = protectedProcedure
.route({ method: "PATCH", path: "/users/profile" })
.handler(async ({ context }) => {
// context.user is guaranteed to exist
});
// BAD - Manual session check in public procedure
export const updateProfile = publicProcedure
.handler(async ({ context }) => {
const session = await getSession(context.headers);
if (!session) throw new ORPCError("UNAUTHORIZED");
// Error-prone and inconsistent
});
```
### Use Middleware for Reusable Auth Checks
```typescript
// Create reusable middleware for common patterns
const withOrganization = async ({ context, input, next }) => {
const { organizationId } = input;
const membership = await verifyOrganizationMembership(
organizationId,
context.user.id
);
if (!membership) {
throw new ORPCError("FORBIDDEN", {
message: "Not a member of this organization",
});
}
return next({
context: { ...context, organization: membership.organization },
});
};
// Use in procedures
export const getOrganizationData = protectedProcedure
.use(withOrganization)
.handler(async ({ context }) => {
// context.organization is now available
});
```
### Proper Error Responses
```typescript
// Always return meaningful error messages
.handler(async ({ context, input }) => {
try {
const result = await performAction(input);
return { success: true, reason: "Action completed", data: result };
} catch (error) {
if (error instanceof ORPCError) {
throw error; // Re-throw oRPC errors
}
logger.error("Action failed", { error, userId: context.user.id });
return {
success: false,
reason: "An unexpected error occurred",
};
}
});
```
### Secure Session Token Handling
```typescript
// Never log full session tokens
logger.info("Session validated", {
sessionToken: `${token.substring(0, 10)}...`,
userId: session.user.id,
});
// Clear sensitive data from responses
const sanitizedUser = {
id: user.id,
email: user.email,
name: user.name,
// Don't include: passwordHash, sessionTokens, etc.
};
```
### Cache Invalidation on Auth Events
```typescript
// In auth hooks
hooks: {
after: createAuthMiddleware(async (ctx) => {
if (ctx.path.startsWith("/sign-out")) {
const sessionToken = getSessionTokenFromHeaders(ctx.headers);
if (sessionToken) {
await deleteSessionCache(sessionToken);
}
}
}),
}
```
## Client-side Auth Usage
For client-side authentication, use the auth client:
```typescript
// packages/auth/client.ts
import { createAuthClient } from "better-auth/react";
import {
adminClient,
magicLinkClient,
organizationClient,
passkeyClient,
twoFactorClient,
} from "better-auth/client/plugins";
export const authClient = createAuthClient({
plugins: [
magicLinkClient(),
organizationClient(),
adminClient(),
passkeyClient(),
twoFactorClient(),
],
});
```
**Usage in React components:**
```typescript
import { authClient } from "@your-app/auth/client";
// Sign in
await authClient.signIn.email({
email: "user@example.com",
password: "password",
});
// Sign out
await authClient.signOut();
// Get current session
const session = await authClient.getSession();
// Use hooks
const { data: session, isPending } = authClient.useSession();
```
## Quick Reference
| Task | Solution |
|------|----------|
| Require authentication | Use `protectedProcedure` |
| Require admin role | Use `adminProcedure` |
| Get current user | Access `context.user` in handler |
| Get session data | Access `context.session` in handler |
| Check organization membership | Use `verifyOrganizationMembership` helper |
| Throw auth error | `throw new ORPCError("UNAUTHORIZED")` |
| Throw permission error | `throw new ORPCError("FORBIDDEN")` |