778 lines
19 KiB
Markdown
778 lines
19 KiB
Markdown
# Authentication Guidelines
|
|
|
|
This document covers backend authentication integration using better-auth, including session management, protected procedures, and OAuth configuration.
|
|
|
|
## 1. Overview
|
|
|
|
### Current Project Auth Contract: Session Organization and Profile APIs
|
|
|
|
#### 1. Scope / Trigger
|
|
- Trigger: app shell and account settings need authenticated current-tenant switching and current-account profile updates.
|
|
- This repository currently uses the custom `teatea_session` HTTP-only cookie plus Drizzle tables (`sessions`, `accounts`, `organizations`, `memberships`, `roles`), not a better-auth runtime.
|
|
|
|
#### 2. Signatures
|
|
- `GET /api/auth/session`: returns current account, active organization, available organization options, membership, and permissions.
|
|
- `POST /api/auth/organization`: switches `sessions.activeOrganizationId` for the current session.
|
|
- `PATCH /api/account/profile`: updates the authenticated account's `name` and `avatarUrl`.
|
|
|
|
#### 3. Contracts
|
|
- `GET /api/auth/session` response payload:
|
|
- `account: PublicAccount | null`
|
|
- `organization: Organization | null`
|
|
- `organizations: AccountOrganizationOption[]`
|
|
- `membership: Membership | null`
|
|
- `permissions: Permission[]`
|
|
- `POST /api/auth/organization` request payload: `{ organizationId: string }`.
|
|
- `PATCH /api/account/profile` request payload: `{ name: string; avatarUrl: string }`.
|
|
- All responses use `{ success, reason, ...payload }` and `Cache-Control: no-store`.
|
|
|
|
#### 4. Validation & Error Matrix
|
|
- Missing/expired session -> `success: false`, `401`, `未登录或会话已过期`.
|
|
- Empty `organizationId` -> `success: false`, `400`, `机构不能为空`.
|
|
- Organization not in authenticated account's available organization list -> `success: false`, `403`, `无权切换到该机构`.
|
|
- Empty profile `name` -> `success: false`, `400`, `用户名称不能为空`.
|
|
|
|
#### 5. Good/Base/Bad Cases
|
|
- Good: platform account with organization-read permission can switch among active organizations.
|
|
- Base: organization user can switch only among active memberships.
|
|
- Bad: never trust a client-provided organization ID without comparing against server-computed organization options.
|
|
|
|
#### 6. Tests Required
|
|
- Session API asserts `organizations` includes `isActive`, `slug`, and `roleLabel`.
|
|
- Organization switch asserts session row changes and forbidden org IDs are rejected.
|
|
- Profile update asserts current account only is updated and audit log is recorded.
|
|
|
|
#### 7. Wrong vs Correct
|
|
|
|
Wrong:
|
|
```typescript
|
|
await database.update(sessions).set({ activeOrganizationId: body.organizationId });
|
|
```
|
|
|
|
Correct:
|
|
```typescript
|
|
const target = context.organizations.find((organization) => organization.id === organizationId);
|
|
if (!target) {
|
|
throw new Error("无权切换到该机构");
|
|
}
|
|
await database.update(sessions).set({ activeOrganizationId: organizationId }).where(eq(sessions.id, context.session.id));
|
|
```
|
|
|
|
### 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")` |
|