@roostjs/auth

WorkOS-backed authentication with session management, middleware guards, CSRF protection, RBAC, and multi-tenancy support.

Installation

bun add @roostjs/auth

Configuration

Required environment variables:

WORKOS_API_KEY=sk_test_...
WORKOS_CLIENT_ID=client_...

Required application config:

{
  auth: {
    redirectUrl: 'https://example.com/auth/callback',
  }
}

Register the service provider:

import { AuthServiceProvider } from '@roostjs/auth';
app.register(AuthServiceProvider);

AuthServiceProvider

Registers SessionManager and WorkOSClient in the container. Also registers the /auth/login, /auth/callback, and /auth/logout routes on the application.

WorkOS Clients

RoostWorkOSClient

The real WorkOS client implementation. Wraps the WorkOS SDK and resolves credentials from WORKOS_API_KEY and WORKOS_CLIENT_ID environment variables. Registered automatically by AuthServiceProvider.

FakeWorkOSClient

In-memory WorkOS client for use in tests. Returns synthetic users and tokens without making HTTP calls to WorkOS.

WorkOSClientToken

The DI container token used to resolve the active WorkOSClient implementation.

SessionManager API

Manages session creation, validation, and destruction using Cloudflare KV storage with a sliding TTL.

constructor(kv: KVNamespace, secret: string)

Construct with a KV namespace for session storage and a secret for signing session tokens.

KVSessionStore

Low-level session store that reads and writes session data to Cloudflare KV. Used internally by SessionManager. Resolve from the container when you need direct KV session access without going through SessionManager.

Session Utilities

parseCookie(cookieHeader: string): Record<string, string>

Parse a Cookie request header string into a key-value record.

buildSetCookie(name: string, value: string, options?: CookieOptions): string

Build a Set-Cookie header value string.

parseJwtExpiry(token: string): number | null

Extract the expiration timestamp from a JWT without verifying its signature. Returns the exp claim as a Unix timestamp, or null if absent.

async resolveUser(request: Request): Promise<RoostUser | null>

Parse and validate the session cookie from the request. Returns the associated RoostUser, or null if the session is missing, invalid, or expired.

async createSession(user: WorkOSUser, organizationId?: string): Promise<{ sessionId: string; response: Response }>

Create a new KV session entry for the user. Returns a Response with the Set-Cookie header set and the generated session ID.

async destroySession(sessionId: string): Promise<Response>

Delete the session from KV. Returns a Response with the session cookie cleared.

Built-in Auth Routes

Registered automatically by AuthServiceProvider.

GET /auth/login

Redirects the user to the WorkOS-hosted login page. Accepts optional query parameters: organization_id (string) and return_to (URL).

GET /auth/callback

WorkOS OAuth callback endpoint. Exchanges the authorization code for a session, creates a KV session, and redirects to return_to or /.

GET /auth/logout

Destroys the current session and redirects to /.

Route Handler Functions

These are the underlying handler functions used by the built-in routes. Import them directly when you need to mount auth routes in a custom location.

handleCallback(request: Request, sessionManager: SessionManager, workosClient: WorkOSClient, redirectUrl: string): Promise<Response>

Handle the WorkOS OAuth callback. Exchanges the code for a session and redirects.

handleLogout(request: Request, sessionManager: SessionManager, redirectTo?: string): Promise<Response>

Destroy the current session and redirect. Defaults to redirecting to /auth/login.

createLoginHandler(workosClient: WorkOSClient, redirectUrl: string): (request: Request) => Promise<Response>

Create a login route handler that redirects to the WorkOS authorization URL.

Middleware

AuthMiddleware

Requires the request to have a valid session. Redirects to /auth/login if no valid session is present.

GuestMiddleware

Requires the request to have no valid session. Redirects to / if the user is already authenticated.

RoleMiddleware

Requires the authenticated user to have one of the specified roles in the current organization. Accepts one or more role strings as middleware arguments. Returns 403 Forbidden if the user's role is not in the allowed list.

app.useMiddleware(RoleMiddleware, 'admin', 'owner');

CsrfMiddleware

Double-submit cookie CSRF protection. Validates that the _csrf form field or X-CSRF-Token header matches the CSRF cookie value on state-mutating requests (POST, PUT, PATCH, DELETE).

OrgResolver API

Extracts organization identity from an incoming request using one or more resolution strategies tried in order.

constructor(strategies: OrgResolutionStrategy[])

Construct with an ordered array of strategy names. Available strategies: 'subdomain', 'path-prefix', 'header'.

resolve(request: Request): { slug: string } | null

Attempt each strategy in order. Returns { slug } on first match, or null if no strategy matches.

Resolution Strategies

'subdomain'

Extracts the first subdomain from the request hostname. Ignores www and api. Example: acme.example.comacme.

'path-prefix'

Extracts the organization slug from the second path segment. Example: /org/acme/dashboardacme.

'header'

Reads the X-Org-Slug request header.

TenantScopeMiddleware

TenantScopeMiddleware is exported from @roostjs/orm, not @roostjs/auth, but it is designed to be wired together with OrgResolver from @roostjs/auth. See the full API reference in the @roostjs/orm reference — TenantScopeMiddleware.

OrgResolver satisfies the OrgResolvable interface required by TenantScopeMiddleware:

// OrgResolver.resolve() returns { slug: string } | null
// TenantScopeMiddleware expects OrgResolvable: { resolve(request: Request): { slug: string } | null }
// These are structurally compatible — no adapter needed.

import { OrgResolver } from '@roostjs/auth';
import { TenantScopeMiddleware, TenantContext } from '@roostjs/orm';

const resolver = new OrgResolver(['subdomain']);
const middleware = new TenantScopeMiddleware(
  resolver,
  async (slug) => db.findOrgBySlug(slug),
  ctx,
);

Types

interface RoostUser {
  id: string;
  email: string;
  firstName: string;
  lastName: string;
  organizationId?: string;
  memberships: OrganizationMembership[];
}

interface OrganizationMembership {
  organizationId: string;
  organizationSlug: string;
  role: string;
}

type OrgResolutionStrategy = 'subdomain' | 'path-prefix' | 'header';