# 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 }`.
- `POST /api/auth/organization` success response payload includes:
  - `organization: Organization | null`
  - `organizations: AccountOrganizationOption[]`
  - `permissions: Permission[]`
- `PATCH /api/account/profile` request payload: `{ name: string; avatarUrl: string }`.
- All responses use `{ success, reason, ...payload }` and `Cache-Control: no-store`.
- `Organization.slug` is the canonical tenant segment for authenticated workspace URLs.
  Organization create/update and setup slug generation must reject or rewrite reserved
  workspace segments from `modules/shared/lib/workspace-routing.ts` so `/app/settings` and
  similar product routes cannot be interpreted as tenants.
- Switching organizations should return the selected organization and available organization
  options so the client can redirect to the same workspace section under the new slug.

#### 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")` |
