Files
teatea-pension/.trellis/spec/backend/authentication.md

20 KiB

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:

await database.update(sessions).set({ activeOrganizationId: body.organizationId });

Correct:

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:

// 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

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:

// 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:

// 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:

// 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:

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:

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

// 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:

// 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:

// 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

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

// 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:

// 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:

# .env
GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-client-secret

GitHub OAuth Setup

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:

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:

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:

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

// 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

// 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

// 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

// 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

// 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

// 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

// 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:

// 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:

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