Authentication & Sessions

This document describes the authentication implementation in the AgentPress monorepo.

Overview

AgentPress uses session-based authentication with Lucia Auth:

  • Sessions stored in PostgreSQL
  • HTTP-only cookies for token storage
  • OAuth support (GitHub, Google)
  • External provider integration
  • Role-based access control (Admin, Guest)

Key Libraries

LibraryVersionPurpose
lucia3.2.2Session management
@lucia-auth/adapter-drizzle1.1.0Database adapter
arctic3.6.0OAuth provider management
bcrypt6.0.0Password hashing

Session Configuration

File: packages/api/src/auth/auth.ts

export const lucia = new Lucia(adapter, {
  sessionCookie: {
    expires: true,
    attributes: {
      secure: true, // HTTPS only in production
      sameSite: process.env.NODE_ENV === "production" ? "none" : "lax",
    },
  },
  getUserAttributes: (attributes) => ({
    email: attributes.email,
    username: attributes.username,
    firstName: attributes.firstName,
    lastName: attributes.lastName,
    githubId: attributes.githubId,
    googleId: attributes.googleId,
    role: attributes.role,
    provider: attributes.provider,
  }),
});

Session Duration

const MAX_AGE = 60 * 60 * 24 * 30; // 30 days

Authentication Flows

Credentials Login

Endpoint: POST /auth/login/credentials

// Request
{ email: string, password: string }

// Flow
1. Validate input with Zod schema
2. Rate limit check (10 requests/60 seconds)
3. Find user by email
4. Verify password: bcrypt.compare(password, user.password)
5. Create session: lucia.createSession(user.id, {})
6. Set HTTP-only cookie
7. Return user object

GitHub OAuth

Step 1: Initiate

GET /auth/github
├─ Generate random state
├─ Store state in cookie (httpOnly)
└─ Redirect to GitHub authorization URL

Step 2: Callback

GET /auth/github/callback?code=CODE&state=STATE
├─ Validate state matches cookie
├─ Exchange code for access token
├─ Fetch user from GitHub API
├─ Create/update user
├─ Create session
└─ Redirect to APP_HOST

Google OAuth

Similar to GitHub with PKCE:

GET /auth/google
├─ Generate state + code verifier
├─ Store both in cookies
└─ Redirect to Google authorization

GET /auth/google/callback
├─ Validate state
├─ Exchange code + verifier for token
├─ Fetch user from Google OpenID
└─ Create session

External Provider

Endpoint: POST /auth/external

// Request
{ authToken: string, providerName?: string }

// Flow
1. Get provider: getExternalAuthProvider(providerName)
2. Validate token: provider.validateToken(authToken)
3. Find/create user by externalUserId
4. Encrypt and store authToken
5. Create session

Logout

Endpoint: POST /auth/logout

1. Get session ID from cookie
2. Invalidate: lucia.invalidateSession(sessionId)
3. Clear session cookie (maxAge: 0)

Password Reset

Request Reset:

POST /auth/forgot-password { email }
├─ Find user by email
├─ Create reset token with expiration
├─ Send email with reset link
└─ Return generic success (don't reveal user existence)

Complete Reset:

POST /auth/reset-password { token, newPassword }
├─ Validate token not expired/used
├─ Hash new password (bcrypt, 12 rounds)
├─ Update user password
└─ Mark token as used

Authentication Middleware

File: packages/api/src/middleware/auth.ts

sessionMiddleware

Default middleware for authenticated routes:

export const sessionMiddleware = (routeRole: EUserRole = EUserRole.GUEST) => {
  return async (req: Request, res: Response, next: NextFunction) => {
    const sessionToken = req.cookies?.session;

    if (!sessionToken) {
      return res.status(401).json({ error: "Unauthorized: No session found" });
    }

    const { session, user } = await lucia.validateSession(sessionToken);

    if (!user || !session) {
      return res.status(401).json({ error: "Unauthorized: Invalid session" });
    }

    // Role-based access control
    if (routeRole === EUserRole.ADMIN && user.role !== EUserRole.ADMIN) {
      return res.status(403).json({ error: "Forbidden: Requires Admin role" });
    }

    req.user = user;
    req.session = session;
    next();
  };
};

flexibleSessionMiddleware

Accepts token from headers OR cookies:

export const flexibleSessionMiddleware = (requiredRole = EUserRole.ADMIN) => {
  const cookieToken = req.cookies?.session;
  const headerToken =
    req.headers.authorization?.replace("Bearer ", "") || req.headers.session;

  const sessionToken = headerToken || cookieToken;
  // ... validation
};

optionalSessionMiddleware

Extracts session if present, doesn't fail if missing:

export const optionalSessionMiddleware = async (req, res, next) => {
  const sessionToken = req.cookies?.session;

  if (sessionToken) {
    try {
      const { session, user } = await lucia.validateSession(sessionToken);
      if (user && session) {
        req.user = user;
        req.session = session;
      }
    } catch {
      /* ignore */
    }
  }
  next();
};

Security Features

Password Hashing

// On registration/reset
const hashedPassword = await bcrypt.hash(newPassword, 12);

// On login
const isValid = await bcrypt.compare(password, user.password);

Token Encryption

External provider tokens are encrypted using AES-256-GCM:

File: packages/api/src/utils/tokenEncryption.ts

class EncryptionService {
  private algorithm = "aes-256-gcm";

  async encrypt(text: string): Promise<string> {
    const salt = randomBytes(32);
    const iv = randomBytes(16);
    const key = await this.deriveKey(masterKey, salt);
    const cipher = createCipheriv("aes-256-gcm", key, iv);
    // ... encryption logic
  }
}

Required: ENCRYPTION_MASTER_KEY environment variable

res.cookie("session", sessionId, {
  secure: true, // HTTPS only
  httpOnly: true, // No JS access
  sameSite: process.env.NODE_ENV === "production" ? "none" : "lax",
  maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days
});

Rate Limiting

const authLimiter = rateLimit({
  windowMs: 60000, // 1 minute
  max: 10, // 10 requests
});

// Applied to OAuth and auth endpoints
router.use("/github", authLimiter);
router.use("/google", authLimiter);

External Auth Provider Interface

File: packages/api/src/auth/externalAuthProvider.ts

export interface IExternalAuthProvider {
  name: string;

  validateToken(authToken: string): Promise<{
    isValid: boolean;
    userData?: {
      externalUserId: string;
      email: string;
      firstName?: string;
      lastName?: string;
      isAdmin?: boolean;
    };
    error?: string;
  }>;

  getUserLimits?(
    externalUserId: string,
    authToken: string,
  ): Promise<{
    maxMessages?: number;
    resetDate?: Date;
  }>;
}

// Registry functions
export function registerExternalAuthProvider(
  provider: IExternalAuthProvider,
): void;
export function getExternalAuthProvider(name: string): IExternalAuthProvider;

Environment Variables

Required

ENCRYPTION_MASTER_KEY=<required>
APP_HOST=http://localhost:3000
API_HOST=http://localhost:3001

OAuth (Optional)

GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=

Usage Examples

Route Protection

// Require any authenticated user (guest or admin)
router.use(sessionMiddleware(EUserRole.GUEST));

// Require admin role
router.use(sessionMiddleware(EUserRole.ADMIN));

// Optional authentication
router.use(optionalSessionMiddleware);

Accessing User in Routes

router.get("/profile", async (req: Request, res: Response) => {
  const { id, email, role } = req.user;
  res.json({ id, email, role });
});

Frontend Token Usage

// Token is automatically sent via cookies
const response = await fetch("/api/threads", {
  credentials: "include",
});

// Or via header for cross-origin
const response = await fetch("/api/threads", {
  headers: {
    Authorization: `Bearer ${sessionToken}`,
  },
});

Key Files

FilePurpose
packages/api/src/auth/auth.tsLucia configuration
packages/api/src/auth/externalAuthProvider.tsExternal provider interface
packages/api/src/routes/auth.tsAll auth endpoints
packages/api/src/middleware/auth.tsSession middleware
packages/api/src/db/schema/sessions.tsSession table
packages/api/src/db/schema/users.tsUser table

On this page