# theAuth documentation # theAuth Source: https://docs.theauth.dev/ **Auth for AI agents and for the humans behind them.** theAuth gives every agent its own identity, checks permissions at call time and writes an audit row for every decision. It also ships human auth: 14 methods (email and password, magic link, passkeys, TOTP and more) and 17 OAuth providers. Use either half alone, or both. Runs on Node, edge, Workers, Deno and Bun. Building in Go? See the [theAuth Go library docs](/go/getting-started/overview). ## Pick your track Give agents an identity, scoped permissions, delegation and an audit trail. About five minutes. Sign-up, sign-in, verification and reset with email and password, then add passkeys, OAuth and more. About five minutes. ### Agent auth in one screen ```ts import { createTheAuth, users } from '@glinr/theauth'; const theauth = await createTheAuth({ database: { provider: 'sqlite', url: ':memory:' }, }); // The owner must exist in theauth_users (human auth creates these for you). theauth.db.insert(users).values({ id: 'user-123', email: 'owner@example.com', name: 'Owner', createdAt: new Date(), updatedAt: new Date(), }).run(); const agent = await theauth.agent.create({ ownerId: 'user-123', name: 'code-reviewer', type: 'autonomous', permissions: [{ resource: 'mcp:github:*', actions: ['read'] }], }); const { allowed, auditId } = await theauth.authorize(agent.id, { action: 'read', resource: 'mcp:github:repos', }); ``` ### Human auth in one screen ```ts import { createTheAuth } from '@glinr/theauth'; import { emailPassword } from '@glinr/theauth-email'; const theauth = await createTheAuth({ database: { provider: 'sqlite', url: ':memory:' }, plugins: [ emailPassword({ appUrl: 'http://localhost:3000', sendVerificationEmail: async (email, _token, url) => console.log(email, url), sendResetEmail: async (email, _token, url) => console.log(email, url), }), ], }); // POST /auth/sign-up, /auth/sign-in, /auth/verify-email, /auth/request-reset ... const res = await theauth.plugins.handleRequest(request); ``` ## What's in the box
**Agent identity** as a first-class entity, not an extension of a user.
**Resource wildcards** with rate limits, time windows, and IP allowlists.
**Delegation chains** with depth, expiry, and cascading revocation.
**Append-only audit** with JSON and CSV export.
**MCP OAuth 2.1** authorization server, PKCE and DCR built in.
**Trust scoring** per agent from audit history, plus budget policies.
**Ten adapters** for Node, edge, Workers, Deno, Bun.
**Four databases**: SQLite, Postgres, MySQL, Cloudflare D1.
**Web Crypto only** in core, no Node-specific APIs.
## How it fits with your stack ```mermaid flowchart LR User([Human user]) -->|signs in| HumanAuth[theAuth human auth, or Clerk / Auth.js / better-auth] HumanAuth -->|user ID| TheAuth[TheAuth] TheAuth -->|agent.create| Agents[(Agents)] TheAuth -->|authorize| Decision{allowed?} Decision -->|yes| Tools[MCP servers, APIs, databases] Decision -.->|logged either way| Audit[(Audit trail)] ``` theAuth includes human auth, but you do not have to use it. If you already run Clerk, Auth.js, better-auth or your own login, keep it and pass the user ID as the agent's owner. theAuth's own methods (see [Authentication](/auth)) cover sign-in forms, password reset and social OAuth when you want one library for both. ## Pick your framework App Router and Pages. Workers, Deno, Bun. Classic Node handlers. Plugins and decorators. Guards and decorators. Server routes. Hooks and endpoints. Server islands. ## The six primitives Bearer tokens (`kv_...`, a prefix kept from before the rename), rotation, expiry. SHA-256 hashed at rest. Resource wildcards, rate limits, time windows, IP allowlists, approval gates. Orchestrator delegates a subset to a sub-agent with depth and expiry. Revocation cascades. Every `authorize()` writes agent, user, resource, action, result, duration. Spec-compliant AS with PKCE S256, RFC 9728, RFC 7591. Score per agent computed from audit history. Budget policies you check and record yourself. ## Switching from another auth library Concepts map, code diffs, data migration SQL. Hooks, middleware, Clerk data export, rollout plan. New releases land on [GitHub](https://github.com/glincker/theauth/releases) every week or two. Watch the repo or follow [@thegdsks](https://x.com/thegdsks) for the highlights. ## Related The mental model for agents, permissions, delegation, and audit. Spec-compliant authorization server for Model Context Protocol tool servers. SQLite, Postgres, MySQL, and Cloudflare D1 configuration. IETF draft claims theAuth tracks and emits on agent JWTs. --- # Core concepts Source: https://docs.theauth.dev/concepts A theAuth app has one loop: a user signs in, creates agents, agents call `authorize()` before acting, and every decision lands in the audit trail. Everything else is detail on top of that loop. ```mermaid flowchart LR U([User]) -->|creates| A[(Agent)] A -->|token| C[Agent call] C -->|authorize| K[TheAuth] K -->|yes / no| Out{Decision} K -.->|logs| Log[(Audit)] K -.->|adjusts| T[Trust score] ``` ## User vs agent A **user** is a human. They have email, password, sessions, OAuth accounts. theAuth can run human auth for you (see [Authentication](/auth)) or plug into Clerk, Auth.js, better-auth. An **agent** is a program acting on a user's behalf. One human can own many agents. Agents do not sign in. They authenticate with a bearer token (`kv_...`) that is issued once and hashed at rest. No password, no session, no OAuth. If you reach for password reset, email verification, or social sign-in on an agent, you want a user, not an agent. Agents are non-interactive by design. ## Permission model Permissions are strings that describe what the agent may do, scoped to resources it may touch. ```ts permissions: [ { resource: 'mcp:github:*', // wildcard on tool namespaces actions: ['read'], constraints: { maxCallsPerHour: 100, // rate limit requireApproval: true, // authorize() denies until approval is wired up ipAllowlist: ['10.0.0.0/8'], // where the agent may run from timeWindow: { start: '09:00', end: '18:00' }, // server local time }, }, ] ``` Three shapes carry most of the real work: the **resource string** (free-form, convention-driven, usually `kind:namespace:id`), the **action list** (`read`, `write`, `execute`, domain-specific verbs), and **constraints** (rate, approval, IP, time, argument patterns). `authorize()` evaluates all three. `allowedArgPatterns` are regular expressions, and every string argument must match every pattern. Resource strings are conventions, not enforced syntax. Pick `mcp:github:*`, `db:users:write`, `s3:bucket:objects`, whatever reads in logs. Consistency matters more than syntax. ## Delegation An agent can hand a subset of its permissions to a sub-agent. Every hop carries a depth counter, an expiry, and can be revoked independently. Revoking a delegation cascades: every delegation made onward from that link is revoked too, so those agents lose the delegated permissions the next time they call `authorize()`. ```ts await theauth.delegate({ fromAgent: parent.id, toAgent: child.id, permissions: [{ resource: 'mcp:github:issues', actions: ['read'] }], expiresAt: new Date(Date.now() + 3_600_000), maxDepth: 2, }); ``` An agent cannot delegate permissions it does not hold. Attempts to escalate are rejected at delegation time, not at the next `authorize()` call. `maxDepth` limits chain length: the new link's depth must be less than or equal to it (default 3). ## Audit trail When `agents.auditAll` is on (the default), every `authorize()` and `authorizeByToken()` call against a known agent writes one row: | Field | Meaning | |---|---| | `agentId` | Which agent asked | | `userId` | The human the agent belongs to | | `action`, `resource` | What they tried to do | | `parameters` | The `arguments` passed to `authorize()` | | `result` | `allowed` or `denied` | | `reason` | Why, if denied (free text) | | `durationMs` | Evaluation latency in ms | The IP address and user agent are stored too, when the request carried them. Nothing in the API edits entries; `theauth.audit.cleanup({ retentionDays })` deletes entries older than the retention window. Export as JSON or CSV for compliance. ## Agent types Acts on its own. No human approval unless a permission constraint says so. Background jobs, cron, assistants that run unattended. Gets permissions from a parent agent via delegation. Use for ephemeral sub-agents created to finish one task, then discarded. Long-lived infrastructure identity. MCP servers, internal microservices, anything service-account-shaped. ## Trust scoring Every agent can have a score from 0 to 100, computed from its audit log when you call `theauth.trust.computeScore(agentId)` (it is not recomputed on each `authorize()` call). The score starts at 50 and moves with successful calls (up), denials (down), flagged denials (down more), and agent age (up). The score maps to a level: `untrusted` (below 40), `limited` (40-59), `standard` (60-79), `trusted` (80-94), `elevated` (95+). See [Trust scoring](/trust) for the full formula and [Policy templates](/policies/templates) for policies you can pair with the level. ## MCP OAuth theAuth ships an OAuth 2.1 authorization server for Model Context Protocol tool servers. Your MCP servers register as OAuth clients, and they get scoped JWT access tokens. The OAuth server comes from `createMcpModule` in `@glinr/theauth/mcp`; token validation is separate from agent `authorize()` and does not write audit entries. Three relevant standards: **PKCE S256** (RFC 7636), **Protected Resource Metadata** (RFC 9728), **Dynamic Client Registration** (RFC 7591). Full details in [MCP OAuth 2.1](/mcp). ## What to read next Five minutes to a working agent. Drop theAuth next to Clerk, Auth.js, or better-auth. Resource strings, constraints, approval gates. Chains, depth limits, revocation. --- # Quickstart Source: https://docs.theauth.dev/quickstart Building in Go? See the [theAuth Go library docs](/go/getting-started/overview). theAuth does two jobs, and you can use either one alone or both together. Pick the track that matches what you are building. Each takes under five minutes and the code runs as pasted (SQLite, no other services). Identity, scoped permissions, delegation and an audit row for every AI agent. Sign-up, sign-in, email verification and password reset for your users. Want a full app instead? `pnpm create @glinr/theauth-app` scaffolds a Next.js SaaS with theAuth wired up (also `npm create`, `yarn create`, `bun create`). The CLI asks for a directory, a template, a package manager and a database driver; the Next.js SaaS and Hono MCP templates are available. ## Track A: Add agent auth ### Install ```bash title="terminal" pnpm add @glinr/theauth better-sqlite3 ``` `npm install` and `yarn add` work the same way. The Node SQLite driver is `better-sqlite3`; on Bun, Deno and Workers use the matching [database provider](/database). ### Create an instance and an owner Every agent has an owner: a user ID that exists as a row in `theauth_users` (the `owner_id` column is a foreign key, so creating an agent for an unknown ID fails). Human auth in the next track creates these rows for you; if you already have users in another system, insert a row for each one you want to own agents. ```ts title="agent.ts" import { createTheAuth, users } from '@glinr/theauth'; const theauth = await createTheAuth({ database: { provider: 'sqlite', url: ':memory:' }, // use a file path to keep data agents: { enabled: true, maxPerUser: 10, auditAll: true, tokenExpiry: '24h' }, }); // Owner row. Skip this if the user came from the human auth track. theauth.db.insert(users).values({ id: 'user-123', email: 'owner@example.com', name: 'Owner', createdAt: new Date(), updatedAt: new Date(), }).run(); ``` ### Create an agent ```ts title="agent.ts" const agent = await theauth.agent.create({ ownerId: 'user-123', name: 'github-reader', type: 'autonomous', permissions: [ { resource: 'mcp:github:*', actions: ['read'] }, { resource: 'mcp:deploy:production', actions: ['execute'], constraints: { requireApproval: true, // authorize() denies until approval is wired up maxCallsPerHour: 5, }, }, ], }); // agent.token is the bearer token. Agent tokens start with "kv_". console.log(agent.token); ``` The token is shown once, at creation. Store it in your secrets manager or hand it straight to the agent. It cannot be recovered, only rotated. The `kv_` prefix is intentional: it predates the rename from Kavach to theAuth and was kept so tokens already issued keep working (see [RENAME-MAP.md](https://github.com/glincker/theauth/blob/main/RENAME-MAP.md)). There are three agent types: | Type | When to use | |---|---| | `autonomous` | Runs without human involvement. Default for most agents. | | `delegated` | Receives permissions from another agent via a delegation chain. | | `service` | Long-lived service account identity. | ### Authorize an action and read the audit trail `authorize` returns `{ allowed, reason?, auditId }`. `reason` explains a denial. Budget policies are not checked here; call `theauth.policies.checkBudget()` yourself if you use them. ```ts title="agent.ts" const result = await theauth.authorize(agent.id, { action: 'read', resource: 'mcp:github:repos', }); if (!result.allowed) throw new Error(`Denied: ${result.reason}`); // With only the raw bearer token (from an incoming HTTP request): const byToken = await theauth.authorizeByToken(agent.token, { action: 'read', resource: 'mcp:github:repos', }); // Every decision is logged while auditAll is on (the default). const logs = await theauth.audit.query({ agentId: agent.id }); const denied = await theauth.audit.query({ agentId: agent.id, result: 'denied' }); const csv = await theauth.audit.export({ format: 'csv' }); ``` Run it with `npx tsx agent.ts`. ## Track B: Add human auth theAuth ships 14 human auth methods and 17 OAuth providers as plugins and modules; this track uses email and password, the most common starting point. See [Authentication](/auth) for the rest. ### Install ```bash title="terminal" pnpm add @glinr/theauth @glinr/theauth-email better-sqlite3 ``` ### Add the plugin `emailPassword()` registers `POST /auth/sign-up`, `/auth/sign-in`, `/auth/verify-email`, `/auth/request-reset`, `/auth/reset-password` and `/auth/change-password`. You supply the email transport; the callbacks below just log. Passwords are hashed before storage. ```ts title="human.ts" import { createTheAuth } from '@glinr/theauth'; import { emailPassword } from '@glinr/theauth-email'; const theauth = await createTheAuth({ database: { provider: 'sqlite', url: ':memory:' }, plugins: [ emailPassword({ appUrl: 'http://localhost:3000', requireVerification: false, // development only, default is true sendVerificationEmail: async (email, _token, url) => console.log('verify', email, url), sendResetEmail: async (email, _token, url) => console.log('reset', email, url), }), ], }); ``` ### Sign up and sign in The plugin endpoints take standard `Request` objects, so you can call them directly or mount them with a [framework adapter](/adapters). ```ts title="human.ts" const post = (path: string, body: unknown) => theauth.plugins.handleRequest( new Request(`http://localhost${path}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), }), ); const signUp = await post('/auth/sign-up', { email: 'ada@example.com', password: 'correct horse battery', name: 'Ada' }); console.log(signUp?.status); // 201 const signIn = await post('/auth/sign-in', { email: 'ada@example.com', password: 'correct horse battery' }); console.log(await signIn?.json()); // { user, session: { token, expiresAt } } ``` Run it with `npx tsx human.ts`. With `requireVerification` left at its default of `true`, sign-in fails with `EMAIL_NOT_VERIFIED` until the user posts the token from the verification email to `/auth/verify-email`. ### Give the user an agent The sign-up response contains `user.id`. Pass it as `ownerId` in Track A and the foreign key is satisfied with no manual seeding. ```ts title="human.ts" const { user } = await signUp!.json(); const agent = await theauth.agent.create({ ownerId: user.id, name: 'assistant', type: 'autonomous', permissions: [{ resource: 'mcp:github:*', actions: ['read'] }], }); ``` Prefer to keep Clerk, Auth.js, better-auth or your own login? Skip Track B and pass your user ID as `ownerId`, or wrap your resolver with `customAuth` ([details](/auth/email-password)). ## Delegation An orchestrator agent can delegate a subset of its permissions to a sub-agent. The delegation has its own expiry and a `maxDepth` (default 3) to prevent unbounded chains: the new link's depth must be less than or equal to `maxDepth`. ```ts const sub = await theauth.agent.create({ ownerId: 'user-123', name: 'sub-reader', type: 'delegated', permissions: [], // starts empty; receives permissions via delegation }); await theauth.delegate({ fromAgent: agent.id, toAgent: sub.id, permissions: [{ resource: 'mcp:github:issues', actions: ['read'] }], expiresAt: new Date(Date.now() + 3_600_000), // 1 hour maxDepth: 2, }); // Permissions this agent currently holds through active delegations const perms = await theauth.delegation.getEffectivePermissions(sub.id); ``` An agent cannot delegate permissions it does not hold itself. Attempts to escalate are rejected at the point of delegation, not at authorization time. ## Full working example Covers agent creation, authorization by ID and token, token rotation, delegation, and audit export. ```ts import { createTheAuth, users } from '@glinr/theauth'; const theauth = await createTheAuth({ database: { provider: 'sqlite', url: ':memory:' }, agents: { enabled: true, maxPerUser: 10, auditAll: true, tokenExpiry: '24h' }, }); // The owner must exist in theauth_users (see Track A, step 2) theauth.db.insert(users).values({ id: 'user-123', email: 'owner@example.com', name: 'Owner', createdAt: new Date(), updatedAt: new Date(), }).run(); // Create an agent with wildcard read on github resources const agent = await theauth.agent.create({ ownerId: 'user-123', name: 'github-reader', type: 'autonomous', permissions: [ { resource: 'mcp:github:*', actions: ['read'] }, { resource: 'mcp:github:issues', actions: ['read', 'comment'] }, ], }); // Allowed: mcp:github:repos matches mcp:github:* const r1 = await theauth.authorize(agent.id, { action: 'read', resource: 'mcp:github:repos', }); console.log(r1.allowed); // true // Denied: no write permission const r2 = await theauth.authorize(agent.id, { action: 'write', resource: 'mcp:github:repos', }); console.log(r2.allowed); // false // Authorize using the raw bearer token const r3 = await theauth.authorizeByToken(agent.token, { action: 'read', resource: 'mcp:github:issues', }); console.log(r3.allowed); // true // Rotate: old token is immediately invalid const rotated = await theauth.agent.rotate(agent.id); console.log(rotated.token); // new kv_... token // Delegate a subset to a sub-agent const sub = await theauth.agent.create({ ownerId: 'user-123', name: 'sub-reader', type: 'delegated', permissions: [], }); await theauth.delegate({ fromAgent: agent.id, toAgent: sub.id, permissions: [{ resource: 'mcp:github:issues', actions: ['read'] }], expiresAt: new Date(Date.now() + 3_600_000), maxDepth: 2, }); const perms = await theauth.delegation.getEffectivePermissions(sub.id); console.log(perms); // [{ resource: 'mcp:github:issues', actions: ['read'] }] (delegated permissions only) // Export the audit trail (up to the 10,000 most recent entries) const csv = await theauth.audit.export({ format: 'csv' }); ``` ## Cloudflare Workers theAuth runs on Workers with no changes. Pass a D1 binding as the database and use the Hono adapter. ```typescript import { createTheAuth } from '@glinr/theauth'; import { Hono } from 'hono'; type Env = { THEAUTH_DB: D1Database }; const app = new Hono<{ Bindings: Env }>(); app.get('/health', async (c) => { const theauth = await createTheAuth({ database: { provider: 'd1', binding: c.env.THEAUTH_DB }, agents: { enabled: true }, }); const agent = await theauth.agent.create({ ownerId: 'user-1', name: 'my-agent', type: 'autonomous', permissions: [{ resource: 'mcp:github:*', actions: ['read'] }], }); return c.json({ agent }); }); export default app; ``` Bind a D1 database in your `wrangler.toml`: ```toml [[d1_databases]] binding = "THEAUTH_DB" database_name = "theauth" database_id = "" ``` Run `npx wrangler d1 execute theauth --file=./theauth-schema.sql` to apply the schema yourself (and set `skipMigrations: true` inside `database`), or leave it at its default of `false` and theAuth creates the tables with `CREATE TABLE IF NOT EXISTS` on startup. `createTheAuth` is always async, not only with D1. Workers and Deno both support top-level await, so you can also initialize outside the handler if you use a module worker. ## Troubleshooting ### "Invalid email or password" after sign-up With the `@glinr/theauth-email` plugin, sign-in requires email verification by default. Either: 1. Verify the email using the token from the sign-up response 2. Set `requireVerification: false` in the `emailPassword()` config (development only) ### "FOREIGN KEY constraint failed" when creating agents The `ownerId` must match a row in `theauth_users`. Sign the user up with the email plugin (Track B), or insert a row as shown in step 2 of Track A. ### Session not persisting after page reload The React hooks store sessions in `localStorage`. Make sure your app is wrapped in ``. If using SSR (Next.js), wrap the provider in a `"use client"` component. ## Next steps Wildcards, rate limits, time windows, IP allowlists, approval gates. Sub-agent delegation with depth limits and cascading revocation. Set up the authorization server for MCP tool servers. Drop-in middleware for ten frameworks. Migration guides from better-auth and Clerk. All `createTheAuth()` options and environment patterns. --- # Add to an existing app Source: https://docs.theauth.dev/add-to-existing-app You already have users. You just added agents. This is the ten-minute recipe. theAuth does not replace your human auth. It runs alongside it. This guide assumes you already have a way to get a stable `userId` for every request. ## What you need before you start Your existing auth must expose a stable string ID, anything from Clerk's `userId`, Auth.js's `session.user.id`, or a row id from your own users table. theAuth needs a matching row in its `theauth_users` table (see step 4). Postgres, MySQL, SQLite, or Cloudflare D1. theAuth creates its own tables, prefixed `theauth_`, alongside yours. ## Steps ```bash pnpm add @glinr/theauth @glinr/theauth-nextjs ``` Substitute the adapter for your framework: `@glinr/theauth-hono`, `@glinr/theauth-express`, `@glinr/theauth-fastify`, and so on. See [Framework adapters](/adapters). ```ts title="lib/theauth.ts" import { createTheAuth } from '@glinr/theauth'; let instance: Awaited> | null = null; export async function getTheAuth() { if (!instance) { instance = await createTheAuth({ database: { provider: 'postgres', url: process.env.DATABASE_URL!, }, agents: { enabled: true }, secret: process.env.THEAUTH_SECRET!, }); } return instance; } ``` The singleton makes sure the instance (and its database connection) is created once per process. Passing `agents` is what makes theAuth create the agent, permission, delegation, and audit tables; without it those tables do not exist and `agent.create` fails. ```ts title="app/api/theauth/[...theauth]/route.ts" import { theAuthNextjs } from '@glinr/theauth-nextjs'; import { getTheAuth } from '@/lib/theauth'; const theauth = await getTheAuth(); export const { GET, POST, PATCH, DELETE, OPTIONS } = theAuthNextjs(theauth, { authenticate }); ``` `theAuthNextjs` takes the resolved instance (not a function) and returns one handler per HTTP method. Export all five: the REST API uses `PATCH` and `DELETE` as well. Its default `basePath` is `/api/theauth`; pass `{ basePath }` if you mount it elsewhere. The route handler only exposes the theAuth endpoints and does not add authentication, so protect it with your own middleware (see the [REST API](/api)). Pick a path that does not collide with your existing auth. `/api/theauth/*` lives next to `/api/auth/*` (Clerk / Auth.js) without stepping on it. Hook into your existing post-sign-in flow. For Clerk, that is a webhook or a server action. For Auth.js, the `signIn` event. For better-auth, the `onSignIn` hook. `theauth_agents.owner_id` is a foreign key to `theauth_users.id`, and `agent.create` does not create the user for you. Before the first agent, make sure a `theauth_users` row exists whose `id` is your provider's user ID (`id`, `email`, `createdAt`, and `updatedAt` are required). Otherwise the insert fails with a foreign key error. ```ts title="wherever you create user-scoped resources" import { getTheAuth } from '@/lib/theauth'; export async function createDefaultAgent(userId: string) { const theauth = await getTheAuth(); const agent = await theauth.agent.create({ ownerId: userId, // stable ID from your auth provider name: 'default', type: 'autonomous', permissions: [ { resource: 'app:read:*', actions: ['read'] }, ], }); // Persist agent.token somewhere the user can access, encrypted at rest. // It is returned once and cannot be recovered. return { agentId: agent.id, token: agent.token }; } ``` The token is shown once. Store it in your secrets store or hand it directly to the agent process. If you lose it, rotate with `theauth.agent.rotate(agentId)` to issue a new one. Agents also expire after `agents.tokenExpiry` (default `24h`) unless you pass `expiresAt` to `agent.create`. Anywhere your agent code runs, check authorization before the call. ```ts title="app/api/agent-action/route.ts" import { getTheAuth } from '@/lib/theauth'; export async function POST(req: Request) { const token = req.headers.get('Authorization')?.replace('Bearer ', ''); if (!token) return new Response('Missing token', { status: 401 }); const theauth = await getTheAuth(); const { allowed, reason, auditId } = await theauth.authorizeByToken(token, { action: 'read', resource: 'mcp:github:repos', }); if (!allowed) { return Response.json({ error: reason, auditId }, { status: 403 }); } // allowed. Do the work. const repos = await fetchGithubRepos(); return Response.json({ repos, auditId }); } ``` ## Fitting in with Clerk, Auth.js, and better-auth ```ts import { auth } from '@clerk/nextjs/server'; import { getTheAuth } from '@/lib/theauth'; export async function POST(req: Request) { const { userId } = await auth(); if (!userId) return new Response('Unauthorized', { status: 401 }); const theauth = await getTheAuth(); const agent = await theauth.agent.create({ ownerId: userId, name: 'default', type: 'autonomous', permissions: [{ resource: 'app:read:*', actions: ['read'] }], }); return Response.json({ agentId: agent.id, token: agent.token }); } ``` ```ts import { auth } from '@/auth'; import { getTheAuth } from '@/lib/theauth'; export async function POST() { const session = await auth(); if (!session?.user?.id) return new Response('Unauthorized', { status: 401 }); const theauth = await getTheAuth(); const agent = await theauth.agent.create({ ownerId: session.user.id, name: 'default', type: 'autonomous', permissions: [{ resource: 'app:read:*', actions: ['read'] }], }); return Response.json({ agentId: agent.id, token: agent.token }); } ``` ```ts import { auth } from '@/lib/auth'; import { getTheAuth } from '@/lib/theauth'; import { headers } from 'next/headers'; export async function POST() { const session = await auth.api.getSession({ headers: await headers() }); if (!session?.user?.id) return new Response('Unauthorized', { status: 401 }); const theauth = await getTheAuth(); const agent = await theauth.agent.create({ ownerId: session.user.id, name: 'default', type: 'autonomous', permissions: [{ resource: 'app:read:*', actions: ['read'] }], }); return Response.json({ agentId: agent.id, token: agent.token }); } ``` ## Common first-day questions No by default. theAuth runs its own migrations on first boot. Set `skipMigrations: true` inside the `database` config if you want to run them yourself with `npx theauth migrate` (from `@glinr/theauth-cli`). Not directly. theAuth issues its own cookie for sessions created via its plugins. If you never use theAuth's human-auth plugins (because you have Clerk / Auth.js / better-auth), you never see that cookie and there is nothing to share. theAuth stores `ownerId` as a string and never interprets it, but `theauth_agents.owner_id` references `theauth_users.id`. Migrating from numeric IDs to UUIDs later means updating `theauth_users.id` and every referencing `owner_id` together, in one transaction. Yes, via [event streaming](/event-streaming) and [webhooks](/webhooks). Wire them to your analytics or to Stripe / PostHog / Slack. ## Next steps Define what agents can and cannot do. Run your own authorization server for MCP tools. Let agents spawn sub-agents with scoped permissions. Query and export every authorization decision. --- # Configuration Source: https://docs.theauth.dev/configuration `createTheAuth()` accepts a `TheAuthConfig` object. The only required field is `database`. Everything else is optional and enables features incrementally. ```typescript import { createTheAuth } from '@glinr/theauth'; const theauth = await createTheAuth({ database: { provider: 'sqlite', url: 'theauth.db' }, }); ``` ## Common development setup A minimal config for local development with username and password sign-in, theAuth-managed sessions, and no email sending: ```typescript import { createTheAuth } from '@glinr/theauth'; const theauth = await createTheAuth({ database: { provider: 'sqlite', url: './theauth.db' }, agents: { enabled: true }, auth: { session: { secret: process.env.SESSION_SECRET! }, // at least 32 characters }, username: {}, // enables theauth.username (signUp, signIn, ...) }); ``` ## Top-level options Database connection config. Required. Agent identity settings. Human auth adapter and session config. CIBA async approval flow config. See [Approval flows](/approval). Lifecycle hooks for sandboxing and custom validation. See [Hooks](/hooks). Unified policy engine cache, combine strategy, and audit sampling. See [Policy engine](/policy-engine). did:web domain and path. See [DIDs](/did). Plugin factories such as `gdpr()`, `rateLimit()`, `twoFactor()`. See [Plugins](#plugins). Base URL for the auth server (e.g. https://auth.example.com). Read by plugins that build callback URLs or decide on secure cookies (OAuth, passkey, OAuth proxy). See [Session freshness config](#session-freshness-config). Webhook endpoints to notify on auth events. See [Webhooks](/webhooks). Password reset flow. See [Password reset config](#password-reset-config). Other optional top-level modules, each of which creates the matching `theauth.` instance property when provided: `magicLink`, `emailOtp`, `emailVerification`, `totp`, `passkey`, `org`, `sso`, `admin`, `apiKeys`, `username`, `phone`, `captcha`, and `redirects`. Several of these require `auth.session` as noted in their own pages. Options that `TheAuthConfig` declares but `createTheAuth()` does not act on: - `mcp`: `createTheAuth()` does not read it. The OAuth 2.1 authorization server is built with `createMcpModule` from `@glinr/theauth/mcp`, which takes a `McpConfig` (see [MCP config](#mcp-config)). `theauth.mcp` on the instance is only the MCP tool server registry. - `secret`: not read by `createTheAuth()`. Set the session secret in `auth.session.secret` and the MCP signing secret in `McpConfig.signingSecret`. - `emitAgenticJwtClaims`: not read by `createTheAuth()`. See [Standards](/standards) for the places it does work. There is no `trust` or `telemetry` option. The trust module is always created with its defaults. ## Database config Database driver to use. File path for SQLite, connection string for Postgres/MySQL. Required for every provider except D1. D1Database binding from the Worker environment. Required when provider is "d1". Skip automatic CREATE TABLE IF NOT EXISTS on init. Use when you manage migrations externally (Flyway, drizzle-kit push, etc.). Defaults to false. ```typescript // Cloudflare D1 (edge) const theauth = await createTheAuth({ database: { provider: 'd1', binding: env.THEAUTH_DB, // D1Database from Worker env }, }); // SQLite (Node.js) const theauth = await createTheAuth({ database: { provider: 'sqlite', url: './theauth.db' }, }); // PostgreSQL const theauth = await createTheAuth({ database: { provider: 'postgres', url: process.env.DATABASE_URL }, }); // MySQL database: { provider: 'mysql', url: process.env.DATABASE_URL } // In-memory SQLite (tests) database: { provider: 'sqlite', url: ':memory:' } ``` ## Agent config Controls the agent identity lifecycle. Required by the type, but not read: agent management is always available. Set it to `true`. Maximum number of active agents per owner. `agent.create()` throws when the owner is at the limit. Accepted but not applied: new agents get only the permissions you pass to `agent.create()`. Write every `authorize()` outcome (allowed and denied) to the audit log. When `false`, no rows are written by the permission engine at all. Default agent lifetime when `agent.create()` is called without `expiresAt`. Format is a number followed by `s`, `m`, `h`, or `d` (for example `"30m"`, `"24h"`, `"7d"`); anything else makes `create()` throw. Every agent therefore has an expiry unless you pass `expiresAt`. ```typescript agents: { enabled: true, maxPerUser: 10, defaultPermissions: [], auditAll: true, tokenExpiry: '30d', }, ``` ## MCP config Options for the OAuth 2.1 authorization server for MCP-compliant tool access. These are the fields of `McpConfig`, passed to `createMcpModule` from `@glinr/theauth/mcp` (see [MCP](/mcp)). Passing them as the `mcp` option of `createTheAuth()` has no effect today. Enable the MCP authorization server. Token issuer URL. Appears as the iss claim in JWTs. Base path for MCP endpoints. Secret used to sign JWTs. At least 32 characters; `createMcpModule` throws without it. It does not fall back to any other secret. Access token lifetime in seconds. Defaults to 3600 (1 hour). Refresh token lifetime in seconds. Defaults to 604800 (7 days). Authorization code lifetime in seconds. Defaults to 600 (10 minutes). Reject all MCP requests without a valid Bearer token. Custom OAuth scopes supported by this server. Allowed resource URIs for RFC 8707 resource indicators. URL of your login page. Users are redirected here when unauthenticated. URL of your consent page. Users are redirected here to approve scopes. `}>OAuth clients registered at startup (first-party apps, CLIs, test fixtures). Promise>`}>Async function to add custom claims to issued tokens. Emit `agent_id`, `agent_type`, and `trust_tier` on access tokens, using `getAgenticContext`. See [Standards](/standards). Supplies the values for those claims. ```typescript const mcpConfig = { enabled: true, issuer: 'https://auth.example.com', baseUrl: 'https://auth.example.com', signingSecret: process.env.THEAUTH_SIGNING_SECRET!, accessTokenTtl: 3600, refreshTokenTtl: 604800, enforceAuth: true, loginPage: 'https://example.com/login', consentPage: 'https://example.com/consent', }; ``` ## Auth config Connects theAuth to your existing auth provider so it can resolve the human user behind incoming requests. ```typescript auth: { adapter: betterAuthAdapter(auth), // resolves user from request session: { // optional: TheAuth-managed sessions secret: process.env.SESSION_SECRET, maxAge: 60 * 60 * 24 * 30, // 30 days in seconds }, }, ``` When `auth` is omitted, `theauth.auth.resolveUser()` always returns `null` (manual user management mode). Built-in adapters exported from `@glinr/theauth/auth` include `betterAuthAdapter`, `authJsAdapter`, `clerkAdapter`, `bearerAuth`, `cookieAuth`, `headerAuth`, `apiKeyAdapter`, and `customAuth`. ## Session config Signing secret for session JWTs. Min 32 characters. Session lifetime in seconds. Name of the session cookie. ## Password reset config A top-level `passwordReset` option (not nested under `auth`). It requires `auth.session`, and its flow relies on the `username` module, so configure `username` too. If `auth.session` is missing, `theauth.passwordReset` is `null`. The caller provides an email-sending callback. Promise`} required>Callback to deliver the reset email. Receives email, raw token, and constructed URL. Base URL for the reset page. Token is appended as ?token=... Reset token lifetime in seconds. Revoke all sessions when the password is successfully reset. Minimum new password length. Maximum new password length. Always use an HTTPS URL for `resetUrl` in production. Reset tokens in plain HTTP links can be intercepted in transit or leaked via `Referer` headers. ```typescript auth: { adapter: betterAuthAdapter(auth), session: { secret: process.env.SESSION_SECRET!, maxAge: 60 * 60 * 24 * 7, // 7 days }, }, username: {}, passwordReset: { resetUrl: 'https://example.com/reset-password', tokenTtlSeconds: 3600, revokeSessionsOnReset: true, minPasswordLength: 10, sendResetEmail: async (email, token, url) => { await mailer.send({ to: email, subject: 'Reset your password', html: `Reset password`, }); }, }, ``` ## Session freshness config Controls when sessions are considered "fresh" for sensitive operations. Pass it as the top-level `sessionFreshness` option; it backs `theauth.sessionFreshness`. Maximum session age in seconds to be considered fresh. ## Plugins Plugins are factories passed in the `plugins` array. They register HTTP endpoints and lifecycle hooks. All of these are exported from `@glinr/theauth/auth`: | Plugin | What it does | |--------|--------------| | `passkey(config)` | WebAuthn/FIDO2 endpoints (requires a `PasskeyConfig`) | | `magicLink(config)` | Passwordless email links | | `emailOtp(config)` | One-time password codes via email | | `twoFactor(config?)` | TOTP two-factor endpoints | | `organization(config?)` | Organization endpoints with RBAC | | `apiKeys(config?)` | Static API key endpoints | | `admin(config?)` | User management, banning, impersonation | | `stripe(config)` | Stripe billing integration | | `polar(config)` | Polar payment integration | | `gdpr()` | Data export, delete, and anonymize endpoints | | `rateLimit(config?)` | Per-IP limits on `/auth/*` endpoints | | `oauth(config)` | Social OAuth sign-in endpoints | There is no `emailPassword` plugin. Username and password sign-in is the top-level `username` option (with `passwordReset` and `emailVerification` alongside it). Multi-session support lives in the session module, see [Multi-session](/multi-session). ## Environment variables pattern Never hardcode secrets in config. Pass them through environment variables: ```typescript const theauth = await createTheAuth({ database: { provider: 'postgres', url: process.env.DATABASE_URL!, }, secret: process.env.THEAUTH_SECRET!, auth: { session: { secret: process.env.THEAUTH_SESSION_SECRET! }, }, }); ``` ## Dev vs production example ```typescript // config/theauth.ts const isDev = process.env.NODE_ENV !== 'production'; export const theauth = await createTheAuth({ database: isDev ? { provider: 'sqlite', url: './theauth-dev.db' } : { provider: 'postgres', url: process.env.DATABASE_URL! }, baseUrl: process.env.THEAUTH_BASE_URL ?? 'http://localhost:3000', auth: { session: { secret: process.env.THEAUTH_SESSION_SECRET! }, }, agents: { enabled: true, maxPerUser: isDev ? 100 : 25, auditAll: true, tokenExpiry: '30d', }, }); ``` `auth.session.secret` and the MCP `signingSecret` must be at least 32 characters. In production, generate them with `openssl rand -base64 32`. ## Next steps Switch from better-auth, Clerk, or other providers. SQLite, Postgres, or MySQL configuration. Mount theAuth on your framework. --- # Database setup Source: https://docs.theauth.dev/database theAuth uses [Drizzle ORM](https://orm.drizzle.team) under the hood. You pick a provider and pass the connection URL; theAuth handles the rest. ## Choosing a provider | Provider | Best for | |----------|----------| | `sqlite` | Local dev and small single-process deploys. Runs on `sql.js` (SQLite compiled to WebAssembly), no native build step | | `sqlite-native` | Node.js servers that want the fastest SQLite. Uses `better-sqlite3`, which needs a native build | | `postgres` | Production, high-concurrency, multi-tenant | | `mysql` | Existing MySQL infrastructure | | `d1` | Cloudflare Workers, using a D1 binding | ## Setup The `sqlite` provider uses `sql.js` (SQLite compiled to WebAssembly), which ships with `@glinr/theauth`. It needs no native build and runs on Node.js, Bun, Deno, and edge runtimes. ```typescript import { createTheAuth } from '@glinr/theauth'; const theauth = await createTheAuth({ database: { provider: 'sqlite', url: './theauth.db', }, }); ``` For in-memory SQLite (tests and CI), use `:memory:` as the URL: ```typescript const theauth = await createTheAuth({ database: { provider: 'sqlite', url: ':memory:', }, }); ``` `sql.js` keeps the database in memory. For a file path, theAuth loads the file at startup if it exists and rewrites the whole file after each write statement. That is fine for development and small apps, but it is a single-process setup: do not point several processes at the same file. theAuth turns on foreign keys (`PRAGMA foreign_keys = ON`). It does not enable WAL mode for this provider. The `sqlite-native` provider uses `better-sqlite3`. Install it yourself, it is an optional peer dependency and needs C++ build tools: ```bash npm install better-sqlite3 ``` ```typescript import { createTheAuth } from '@glinr/theauth'; const theauth = await createTheAuth({ database: { provider: 'sqlite-native', url: './theauth.db', }, }); ``` theAuth enables WAL mode and foreign keys automatically for this provider: ```sql PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON; ``` Install the `pg` peer dependency: ```bash npm install pg npm install --save-dev @types/pg ``` ```typescript import { createTheAuth } from '@glinr/theauth'; const theauth = await createTheAuth({ database: { provider: 'postgres', url: process.env.DATABASE_URL!, // postgresql://user:password@host:5432/dbname }, }); ``` theAuth uses `drizzle-orm/node-postgres` with a connection pool via `pg.Pool`. The `pg` package is loaded with a dynamic import, so it stays an optional peer dep. Install the `mysql2` peer dependency: ```bash npm install mysql2 ``` ```typescript import { createTheAuth } from '@glinr/theauth'; const theauth = await createTheAuth({ database: { provider: 'mysql', url: process.env.DATABASE_URL!, // mysql://user:password@host:3306/dbname }, }); ``` theAuth uses `drizzle-orm/mysql2` with a connection pool. `mysql2` is loaded via dynamic import and stays an optional peer dep. Pass the D1 binding from your Worker environment. The `drizzle-orm/d1` driver is loaded with a dynamic import. ```typescript import { createTheAuth } from '@glinr/theauth'; export default { async fetch(request: Request, env: { DB: D1Database }) { const theauth = await createTheAuth({ database: { provider: 'd1', binding: env.DB, }, }); // ... }, }; ``` The D1 provider takes a `binding` instead of a `url`. Set `skipMigrations: true` if you create the tables yourself. ## Auto-migration By default, theAuth calls `CREATE TABLE IF NOT EXISTS` for all its tables on startup. This is safe to run on every start, but it only creates missing tables, it does not alter existing ones. This means your database is always ready to use without any manual migration step. To disable this (e.g. when you manage migrations externally with Flyway, Liquibase, or `drizzle-kit push`), set `skipMigrations: true`: ```typescript database: { provider: 'postgres', url: process.env.DATABASE_URL!, skipMigrations: true, }, ``` When `skipMigrations: true`, you are responsible for keeping the schema in sync. theAuth will fail at runtime if expected tables or columns are missing. ## Schema overview theAuth creates the following tables in your database: | Table | Purpose | |-------|---------| | `theauth_users` | Human user identities (also holds ban, Stripe and Polar billing fields) | | `theauth_tenants` | Multi-tenant isolation | | `theauth_agents` | AI agent identities (the core entity) | | `theauth_permissions` | Per-agent resource+action permissions with constraints | | `theauth_delegation_chains` | Agent-to-agent delegation records | | `theauth_audit_logs` | Log of every agent action | | `theauth_rate_limits` | Per-agent call-rate counters | | `theauth_mcp_servers` | Registered MCP servers | | `theauth_sessions` | theAuth-managed human user sessions | | `theauth_oauth_clients`, `theauth_oauth_access_tokens`, `theauth_oauth_authorization_codes` | OAuth 2.1 client registrations, issued tokens, and short-lived PKCE codes | | `theauth_agent_cards` | A2A capability discovery cards | | `theauth_approval_requests` | CIBA async approval flow records | | `theauth_trust_scores` | Graduated autonomy trust scores per agent | | `theauth_budget_policies` | Token and call budget caps per agent/user/tenant | | `theauth_magic_links`, `theauth_email_otps`, `theauth_phone_verifications`, `theauth_totp` | Magic link, email OTP, phone OTP, and TOTP records | | `theauth_username_accounts`, `theauth_passkey_credentials`, `theauth_passkey_challenges` | Username/password and passkey records | | `theauth_organizations`, `theauth_org_members`, `theauth_org_invitations`, `theauth_org_roles`, `theauth_sso_connections`, `theauth_api_keys` | Organizations, SSO connections, and API keys | | `theauth_trusted_devices`, `theauth_one_time_tokens`, `theauth_login_history`, `theauth_jwt_refresh_tokens`, `theauth_refresh_tokens`, `theauth_refresh_token_families` | Trusted devices, one-time tokens, login history, and refresh tokens | | `theauth_ephemeral_sessions`, `theauth_agent_dids`, `theauth_cost_events`, `theauth_stream_events` | Ephemeral sessions, agent DIDs, cost attribution, and event streaming | | `theauth_oidc_clients`, `theauth_oidc_auth_codes`, `theauth_oidc_refresh_tokens` | OIDC provider records | | `theauth_rebac_resources`, `theauth_rebac_relationships` | ReBAC graph | | `theauth_federation_instances`, `theauth_federation_tokens` | Federation records | All table and column names use `snake_case`. IDs are `text`. In the SQLite schema, timestamps are stored as integers. ## Peer dependencies | Provider | Required package | |----------|-----------------| | `sqlite` | `sql.js` (installed with core) | | `sqlite-native` | `better-sqlite3` | | `postgres` | `pg` | | `mysql` | `mysql2` | | `d1` | `drizzle-orm/d1` (a Cloudflare Workers binding, no extra install) | theAuth uses dynamic imports for the SQLite, Postgres, and MySQL drivers so they remain optional. You will get a clear error message at startup if the required package is missing, for example: ``` TheAuth: provider "postgres" requires the "pg" package. Install it with: npm install pg ``` ## Testing with in-memory SQLite Use `:memory:` for fast, isolated tests that need no setup or teardown: ```typescript import { createTheAuth } from '@glinr/theauth'; import { describe, beforeEach, it } from 'vitest'; let theauth: Awaited>; beforeEach(async () => { theauth = await createTheAuth({ database: { provider: 'sqlite', url: ':memory:' }, agents: { enabled: true }, }); }); it('creates an agent', async () => { const agent = await theauth.agent.create({ ownerId: 'user_1', name: 'Test Agent', type: 'autonomous', permissions: [], }); expect(agent.id).toBeDefined(); }); ``` Each `createTheAuth()` call with `:memory:` gets a completely isolated database, so tests never share state. ## Related Read theAuth tables through an existing PrismaClient. Full createTheAuth() options including database and secrets. In-memory mock server and factories for auth-dependent tests. Provision agents and permissions as infrastructure-as-code. --- # AI coding assistants Source: https://docs.theauth.dev/ai-assistants If an AI assistant writes your auth code, give it the real docs and the real API. theAuth ships three things for that: an agent skill, a stdio MCP server in the CLI, and `llms.txt` files. ## One command setup Run this in your project root: ```bash title="terminal" npx @glinr/theauth-cli init --agent ``` It writes, for each assistant: | Assistant | Files | |---|---| | Claude Code | `.claude/skills/theauth/SKILL.md` and `.mcp.json` | | Cursor | `.cursor/rules/theauth.mdc` and `.cursor/mcp.json` | | VS Code | `.vscode/mcp.json` | Options: - `--target claude,cursor,vscode` limits the set. The default is all three. - `--dry-run` prints what would be written and writes nothing. - `--force` replaces files and `theauth` MCP entries that already exist. Existing files are left alone. Other servers in your MCP config are kept. A config file that is not plain JSON (for example one with comments) is skipped and reported, so add the entry by hand: ```json title=".mcp.json" { "mcpServers": { "theauth": { "command": "npx", "args": ["-y", "@glinr/theauth-cli", "mcp"] } } } ``` VS Code uses `servers` with `"type": "stdio"` instead of `mcpServers`. ## The MCP server `theauth mcp` starts a stdio MCP server. It speaks newline-delimited JSON-RPC on stdout and writes nothing else there. All tools are read-only. | Tool | What it does | |---|---| | `search_docs` | Ranked search over these docs, with a snippet and URL per hit. | | `get_doc` | Full text of one page by slug, for example `quickstart`. | | `add_plugin` | Install command, import and `plugins` entry for a plugin such as `magic-link`, `passkey` or `agent-registration`. It does not edit your files. | | `generate_schema` | The SQL tables theAuth creates for a feature set, built in memory as SQLite DDL. Needs `better-sqlite3`. Tables are created on startup either way. | | `inspect` | Summary of your local setup: installed `@glinr/theauth*` packages, database provider, plugins, whether agents and MCP are configured, and env var names. | `inspect` reads source files as text and never runs them. It reports env var names from `.env` files, never their values, and never echoes string literals from your config. ## The skill The skill lives in the repository at [`skills/theauth/SKILL.md`](https://github.com/glincker/theauth/blob/main/skills/theauth/SKILL.md) and covers install, adapter choice, plugins, agent identity, delegation and MCP auth. Any tool that reads the `SKILL.md` format can use it directly. `init --agent` copies it into your project. ## llms.txt For assistants that fetch context from URLs: - [`/llms.txt`](https://docs.theauth.dev/llms.txt): one line per page. - [`/llms-full.txt`](https://docs.theauth.dev/llms-full.txt): the full text of every page. Maintainers regenerate both from `docs/*.mdx` and `docs/docs.json` with `node scripts/generate-llms.mjs`. Add `--check` to fail when they are stale. The Go docs are not included. --- # Session model Source: https://docs.theauth.dev/sessions theAuth has three session types: - **Cookie sessions** for human users in browsers. A signed JWT sits in an `httpOnly` cookie. The session record lives in your database so you can revoke it instantly. - **JWT sessions** for SPAs, mobile apps, and server-to-server flows. Stateless access tokens paired with rotating refresh tokens. - **Ephemeral agent sessions** for AI agents (Claude, GPT-with-browsing, operator loops). Short-lived, budget-bounded credentials that expire by time or action count, whichever comes first. This page covers cookie sessions and JWT sessions. For ephemeral agent sessions see the [agents guide](/agents). --- ## Cookie sessions ### Configure the session manager Pass `createCookieSessionManager` a config object and your `theauth.db` instance. The manager handles creation, validation, refresh, and revocation. ```typescript title="lib/theauth.ts" import { createTheAuth } from '@glinr/theauth'; import { createCookieSessionManager } from '@glinr/theauth'; // [!code highlight] export const theauth = await createTheAuth({ database: { provider: 'postgres', url: process.env.DATABASE_URL! }, secret: process.env.THEAUTH_SECRET!, baseUrl: 'https://auth.example.com', }); export const sessions = createCookieSessionManager({ // [!code highlight] secret: process.env.SESSION_SECRET!, // at least 32 characters // [!code highlight] sessionName: 'theauth_session', // [!code highlight] maxAge: 7 * 24 * 60 * 60, // 7 days in seconds // [!code highlight] autoRefresh: true, // [!code highlight] cookieOptions: { httpOnly: true, // [!code highlight] secure: true, // [!code highlight] sameSite: 'lax', // [!code highlight] path: '/', }, }, theauth.db); ``` ### Create a session after sign-in Call `createSession` with a user ID after your authentication logic succeeds. `createSession` returns the session record and a ready-made `Set-Cookie` header value to send back to the browser. It does not return a `Result`: it resolves with the data or throws. ```typescript title="routes/sign-in.ts" import { sessions } from '@/lib/theauth'; export async function POST(req: Request): Promise { // ... validate credentials, look up user // The second argument is the session metadata object const { setCookieHeader } = await sessions.createSession(user.id, { // [!code highlight] ipAddress: req.headers.get('x-forwarded-for') ?? 'unknown', userAgent: req.headers.get('user-agent') ?? 'unknown', }); return Response.json( { user: { id: user.id, email: user.email } }, { status: 200, headers: { 'Set-Cookie': setCookieHeader }, // [!code highlight] }, ); } ``` ### Validate on each request Pass the raw `Cookie` request header to `validateSession`. It returns `{ session, refreshCookieHeader }`. `session` is `null` when the cookie is missing, invalid, expired, or revoked. ```typescript title="middleware.ts" import { sessions } from '@/lib/theauth'; export async function middleware(req: Request): Promise { const cookieHeader = req.headers.get('cookie') ?? ''; const { session } = await sessions.validateSession(cookieHeader); // [!code highlight] if (!session) { return Response.json({ error: 'Not authenticated' }, { status: 401 }); } // session.userId, session.createdAt, session.expiresAt, session.metadata return null; // continue } ``` When `autoRefresh` is on (the default), every successful `validateSession` call extends the session. Internally the old session row is deleted and a new one is created, so the session ID changes on each refresh. `validateSession` returns the new cookie as `refreshCookieHeader`. Forward it in your response. ```typescript title="middleware.ts" const { session, refreshCookieHeader } = await sessions.validateSession(cookieHeader); if (session && refreshCookieHeader) { response.headers.set('Set-Cookie', refreshCookieHeader); // [!code highlight] } ``` ### Revoke on sign-out Revoking a session removes it from the database immediately. Any subsequent validation attempt returns `session: null`. ```typescript title="routes/sign-out.ts" import { sessions } from '@/lib/theauth'; export async function POST(req: Request): Promise { const cookieHeader = req.headers.get('cookie') ?? ''; const { session } = await sessions.validateSession(cookieHeader); if (session) { await sessions.revokeSession(session.id); // [!code highlight] } return new Response(null, { status: 204, headers: { 'Set-Cookie': sessions.buildLogoutCookie() }, // [!code highlight] }); } ``` --- ## JWT sessions Use JWT sessions when cookies don't work: SPAs calling a separate API origin, mobile apps, server-to-server flows. Access tokens are short-lived and stateless. Refresh tokens are opaque random strings stored as SHA-256 hashes, and they rotate on every use. ```typescript title="lib/theauth.ts" import { createJwtSessionModule } from '@glinr/theauth/auth'; // [!code highlight] export const jwtSessions = createJwtSessionModule({ secret: process.env.SESSION_SECRET!, // HS256 by default // [!code highlight] issuer: 'https://auth.example.com', // [!code highlight] audience: 'https://app.example.com', accessTokenTtl: 900, // 15 minutes // [!code highlight] refreshTokenTtl: 604_800, // 7 days // [!code highlight] customClaims: (user) => ({ email: user.email }), // [!code highlight] }, theauth.db); ``` `customClaims` receives only `{ id, email?, name? }`. On refresh the module re-issues tokens from `{ id }` alone, so claims that depend on `email` or `name` are not present on tokens minted by `refreshSession`. ### Create a session ```typescript title="routes/sign-in.ts" const result = await jwtSessions.createSession({ id: user.id, email: user.email, name: user.name, }); if (!result.success) { return Response.json({ error: result.error }, { status: 500 }); } const { accessToken, refreshToken, expiresIn } = result.data; // [!code highlight] // Store refreshToken in an httpOnly cookie or secure storage // Send accessToken to the client for use in Authorization headers ``` ### Verify on each request Access token verification is stateless. No database round-trip. ```typescript title="middleware.ts" const authHeader = req.headers.get('authorization') ?? ''; const token = authHeader.replace('Bearer ', ''); const result = await jwtSessions.verifySession(token); // [!code highlight] if (!result.success) { return Response.json({ error: result.error }, { status: 401 }); } const { userId, email, claims } = result.data; // [!code highlight] ``` ### Refresh Calling `refreshSession` marks the old refresh token as used and issues a new access + refresh pair. Each refresh rotates the token, so a stolen token is invalidated the moment the legitimate client refreshes. ```typescript title="routes/refresh.ts" const result = await jwtSessions.refreshSession(refreshToken); // [!code highlight] if (!result.success) { // REFRESH_TOKEN_EXPIRED, REFRESH_TOKEN_USED, REFRESH_TOKEN_NOT_FOUND return Response.json({ error: result.error }, { status: 401 }); } const { accessToken, refreshToken: newRefreshToken, expiresIn } = result.data; // Return newRefreshToken to the client to replace the old one ``` If `refreshSession` returns `REFRESH_TOKEN_USED`, a previously-used token was replayed. This is a strong signal that the refresh token was stolen. The module only rejects the replayed token: it does not revoke the newer token it issued. Revoke the user's other refresh tokens with `revokeSession` and force re-authentication. --- ## Session freshness Some operations should require that the user authenticated recently, not just that they hold any valid session. Changing a password, registering a passkey, or modifying billing details are examples where a session from 6 days ago is not good enough even though it is technically valid. `createSessionFreshnessModule` wraps this check. When a session is older than `freshAge` seconds, it returns a `403` response with code `SESSION_NOT_FRESH`. Your handler returns it directly. ```typescript title="lib/freshness.ts" import { createSessionFreshnessModule } from '@glinr/theauth'; // [!code highlight] export const freshness = createSessionFreshnessModule({ freshAge: 300, // 5 minutes // [!code highlight] }); ``` Use it in any endpoint that needs re-authentication assurance: ```typescript title="routes/change-password.ts" import { sessions } from '@/lib/theauth'; import { freshness } from '@/lib/freshness'; export async function POST(req: Request): Promise { const cookieHeader = req.headers.get('cookie') ?? ''; const { session } = await sessions.validateSession(cookieHeader); if (!session) { return Response.json({ error: 'Not authenticated' }, { status: 401 }); } const staleResponse = freshness.guard(session); // [!code highlight] if (staleResponse) return staleResponse; // 403 SESSION_NOT_FRESH if too old // [!code highlight] // Session is fresh, safe to proceed with the sensitive operation // ... } ``` When the client receives `SESSION_NOT_FRESH`, redirect them to a lightweight re-authentication page (password confirmation, passkey prompt, or similar) rather than a full sign-out. After re-auth succeeds, refresh the session's `createdAt` timestamp by issuing a new session, then retry the original operation. The `freshAge` threshold is separate from `maxAge`. A session can be well within its 7-day lifetime but still be considered stale for sensitive operations. Keep `freshAge` short. 5 to 15 minutes is typical. --- ## CSRF protection Cookie-based sessions are vulnerable to cross-site request forgery unless you add a second layer. theAuth uses the double-submit cookie pattern: a random token is set in a separate readable cookie and must also appear in the request body or header. An attacker's page can trigger the cookie but cannot read it to reproduce the header value. ```typescript title="lib/csrf.ts" import { generateCsrfToken, parseCookies, validateCsrfToken, validateOrigin } from '@glinr/theauth'; // [!code highlight] // On page load or sign-in response, set a CSRF cookie const csrfToken = generateCsrfToken(); // [!code highlight] // Return it in a Set-Cookie header: SameSite=Strict, NOT httpOnly // Also send it in the response body so the client can store it // On each mutating request (POST, PUT, DELETE, PATCH) const headerToken = req.headers.get('x-csrf-token') ?? ''; const cookieToken = parseCookies(req.headers.get('cookie') ?? '')['theauth_csrf'] ?? ''; // Both validators return { valid: boolean; reason?: string }, not a boolean const csrf = validateCsrfToken(headerToken, cookieToken); // [!code highlight] if (!csrf.valid) { return Response.json( { error: { code: 'CSRF_INVALID', message: 'CSRF token mismatch' } }, { status: 403 }, ); } // Also validate the Origin header to defend against subdomain takeover const origin = validateOrigin(req, ['https://app.example.com']); // [!code highlight] if (!origin.valid) { return Response.json( { error: { code: 'ORIGIN_MISMATCH', message: 'Request origin not allowed' } }, { status: 403 }, ); } ``` The CSRF cookie must **not** be `httpOnly`. It needs to be readable by JavaScript so the client can include its value in the `x-csrf-token` request header. Set it as `SameSite=Strict` instead. --- ## Revocation patterns ```typescript title="Revoke one session" // Revoke by session ID. Use this for "sign out this device" await sessions.revokeSession(sessionId); // [!code highlight] ``` ```typescript title="Revoke all sessions for a user" // Full sign-out. Use this after a password reset or account compromise await sessions.revokeAllSessions(userId); // [!code highlight] ``` Call this yourself after a password change or when handling a security event (suspicious login, compromised credential detected, etc.). ```typescript title="Revoke all other sessions" // There is no revokeAllSessionsExcept. List the sessions and revoke the others. const { session } = await sessions.validateSession(cookieHeader); const all = await sessions.listSessions(userId); // [!code highlight] for (const other of all) { if (session && other.id !== session.id) await sessions.revokeSession(other.id); } ``` This is the "sign out everywhere else" pattern, common in account security settings. Because `autoRefresh` replaces the session row on each validation, read the current session ID from the same `validateSession` call you use for the comparison. --- ## Session metadata Store arbitrary data on a session at creation time. Useful for tracking the device, IP address, or a custom attribute your app needs. The object you pass as the second argument of `createSession` is stored as the session metadata. There is no separate call to update it later. ```typescript title="Creating a session with metadata" const { setCookieHeader } = await sessions.createSession(userId, { ipAddress: req.headers.get('x-forwarded-for') ?? 'unknown', // [!code highlight] userAgent: req.headers.get('user-agent') ?? 'unknown', // [!code highlight] appVersion: req.headers.get('x-app-version'), // [!code highlight] }); ``` Read it back when you validate: ```typescript title="Reading session metadata" const { session } = await sessions.validateSession(cookieHeader); if (session) { console.log(session.metadata?.userAgent, session.metadata?.ipAddress); } ``` Metadata is stored as a JSON column. It is not indexed, so avoid querying sessions by metadata fields. If you need to look up sessions by device or IP, store those in a separate indexed column via a custom schema extension. --- ## Human sessions vs agent tokens | | Human sessions | Agent tokens | |---|---|---| | Format | Signed JWT in `httpOnly` cookie | `kv_...` bearer token | | Lifetime | 7 days (configurable) | 24 hours (configurable) | | Storage | Database row | SHA-256 hash in database | | Revocation | Per-session or per-user bulk | Per-agent | | CSRF | Required (cookie-based) | Not needed (header-based) | | Freshness guard | Yes, for sensitive operations | N/A | | Action budget | No | Yes (ephemeral sessions) | Agent tokens use a `kv_` prefix and are stored only as a SHA-256 hash. The raw token is shown once at creation and cannot be recovered. If a token is lost, issue a new one. --- ## Error codes The cookie session manager does not return error codes: `validateSession` returns `session: null` for a missing, invalid, expired, or revoked cookie. The JWT session module and the helpers return the following: | Code | Status | Meaning | |------|--------|---------| | `SESSION_NOT_FRESH` | 403 | Session is valid but older than `freshAge` (from `freshness.guard`) | | `INVALID_TOKEN` | 401 | JWT signature is invalid or the token has no `sub` claim | | `TOKEN_EXPIRED` | 401 | Access token has expired | | `ISSUER_MISMATCH` | 401 | Access token `iss` does not match the configured issuer | | `AUDIENCE_MISMATCH` | 401 | Access token `aud` does not match the configured audience | | `REFRESH_TOKEN_NOT_FOUND` | 401 | Refresh token does not exist in the database | | `REFRESH_TOKEN_USED` | 401 | Refresh token was already exchanged (possible replay) | | `REFRESH_TOKEN_EXPIRED` | 401 | Refresh token lifetime has elapsed | | `INVALID_INPUT` | 400 | An empty token or user ID was passed | `validateCsrfToken` and `validateOrigin` return `{ valid: false, reason }` rather than a code. The status codes in the table are what the modules suggest, not something the library sets on a response (except the `403` from `freshness.guard`). ------|--------|---------| | `SESSION_NOT_FOUND` | 401 | No session record matches the provided token | | `SESSION_EXPIRED` | 401 | Session exceeded its `maxAge` | | `SESSION_REVOKED` | 401 | Session was explicitly revoked | | `SESSION_STALE` | 403 | Session is valid but older than `freshAge` | | `CSRF_INVALID` | 403 | CSRF token in header does not match cookie value | | `ORIGIN_MISMATCH` | 403 | Request `Origin` header not in the allowed list | | `REFRESH_TOKEN_NOT_FOUND` | 401 | Refresh token does not exist in the database | | `REFRESH_TOKEN_USED` | 401 | Refresh token was already exchanged (possible replay) | | `REFRESH_TOKEN_EXPIRED` | 401 | Refresh token lifetime has elapsed | | `CREATE_SESSION_FAILED` | 500 | Database error during session creation | --- ## Configuration reference ### CookieSessionConfig Signing secret for the session token, at least 32 characters. Name of the session cookie. Session lifetime in seconds. After this period the session is considered expired even if it has been used recently. When true, every successful `validateSession` extends the session by replacing the session row (the session ID changes) and returns a new cookie as `refreshCookieHeader`. Prevents client-side JavaScript from accessing the cookie. Always set this to true for session cookies. Only send the cookie over HTTPS. Set it to true explicitly if your deployment does not set NODE_ENV=production. Controls cross-site cookie behavior. 'lax' works for most apps. 'strict' adds more protection. 'none' is only for cross-origin cookie transport (requires secure: true). Restricts the cookie to a URL path prefix. Set a cookie domain to share sessions across subdomains. Omit for single-domain apps. ### JwtSessionConfig Signing secret. A string uses HMAC-SHA256 and must be at least 32 characters. Pass a CryptoKey or JsonWebKey for asymmetric algorithms (RS256, ES256). JWT signing algorithm. Inferred from the secret type when not set: 'HS256' for strings, 'RS256' for RSA keys, 'ES256' for EC keys. Access token lifetime in seconds. Keep this short. Access tokens are stateless and cannot be revoked before expiry. Refresh token lifetime in seconds. Refresh tokens are stored hashed and can be revoked immediately. Value for the JWT 'iss' claim. Validated on every verify call. Value for the JWT 'aud' claim. Validated on every verify call. Record`} default="undefined">Function called at token creation to attach extra claims to the access token payload. ### SessionFreshnessConfig Maximum session age in seconds before a sensitive operation requires re-authentication. The freshness guard returns 403 SESSION_NOT_FRESH when the session is older than this. --- ## Security best practices Always keep `cookieOptions.httpOnly: true` and set `cookieOptions.secure: true` in production. Without `httpOnly`, a single XSS vulnerability lets an attacker read the session cookie directly. Without `secure`, the cookie travels over plain HTTP and can be intercepted on any network path. Rotate `THEAUTH_SECRET` periodically. All existing sessions signed with the old secret become invalid when you rotate. Plan for a brief period where some users need to sign in again, or run with two active secrets during the transition window. Set `cookieOptions.sameSite: 'lax'` for most apps. `'lax'` allows the cookie to be sent on top-level navigations (clicking a link) but blocks it on cross-origin subresource requests, which stops most CSRF attacks without breaking OAuth redirect flows. Only use `'strict'` if you have no external links that expect to land in an authenticated state. Short access token TTLs (`accessTokenTtl`) reduce the window of exposure for a stolen token, but they increase refresh traffic. 15 minutes is a reasonable default. If you need to revoke access instantly (account ban, credential compromise), route your API through the session validation middleware instead of relying solely on stateless JWT verification. ## Related Cookie attributes, cross-subdomain setup, and rolling vs absolute expiry. Stateless access tokens with rotating refresh tokens. Cap concurrent sessions and build an active devices settings page. Short-lived, budget-bounded agent credentials for single tasks. --- # JWT sessions Source: https://docs.theauth.dev/jwt-sessions The JWT session module issues short-lived access tokens (JWTs) and long-lived refresh tokens. Access tokens are stateless (no DB lookup on every request). Refresh tokens are opaque random strings stored hashed in the database and rotate on every use. It is a module you create yourself with `createJwtSessionModule`. There is no `jwtSession()` plugin, no `theauth.jwt` property, and no `/auth/jwt/*` routes. You call the module from your own handlers. ## Setup ```ts import { createTheAuth } from '@glinr/theauth'; import { createJwtSessionModule } from '@glinr/theauth/auth'; const theauth = await createTheAuth({ database: { provider: 'sqlite', url: 'theauth.db' }, }); export const jwtSessions = createJwtSessionModule({ secret: process.env.JWT_SECRET!, // min 32 chars for HS256 accessTokenTtl: 900, // 15 minutes refreshTokenTtl: 604800, // 7 days issuer: 'https://myapp.com', audience: 'https://api.myapp.com', customClaims: (user) => ({ role: 'admin' }), // optional extra claims }, theauth.db); ``` ## Usage Every method returns a `Result`: `{ success: true, data }` or `{ success: false, error: { code, message } }`. They do not throw for normal failures. ### Create a session ```ts const result = await jwtSessions.createSession({ id: user.id, email: user.email, name: user.name, }); if (result.success) { const { accessToken, refreshToken, expiresIn } = result.data; } ``` Custom claims come from the `customClaims` function in the config, which receives `{ id, email?, name? }`. `createSession` does not take a `claims` argument. ### Verify an access token ```ts const result = await jwtSessions.verifySession(accessToken); if (result.success) { const { userId, email, name, claims } = result.data; // claims includes custom claims plus iss, aud, iat, exp } ``` Verification does not touch the database. It fails on a bad signature, expiry, or issuer or audience mismatch (error codes include `TOKEN_EXPIRED`, `INVALID_TOKEN`, `ISSUER_MISMATCH`, `AUDIENCE_MISMATCH`). ### Refresh ```ts const result = await jwtSessions.refreshSession(refreshToken); if (result.success) { const { accessToken: newAccess, refreshToken: newRefresh } = result.data; } // The old refresh token is marked used (rotation) ``` A refresh issues a new pair for the same user ID. The new access token carries `sub` and your `customClaims` output, but not the `email` or `name` from the original `createSession` call. Errors are `REFRESH_TOKEN_NOT_FOUND`, `REFRESH_TOKEN_USED`, and `REFRESH_TOKEN_EXPIRED`. Presenting an already-used refresh token fails but does not revoke the user's other refresh tokens. ### Revoke ```ts await jwtSessions.revokeSession(refreshToken); ``` `revokeSession` marks one refresh token as used. Revoking an already-revoked or unknown token is a no-op. There is no revoke-all method on this module. ## Algorithms | Algorithm | Config | Notes | |-----------|--------|-------| | HS256 | `secret: string` (at least 32 characters) | Symmetric, simplest | | RS256 / ES256 | `secret: CryptoKey` or `JsonWebKey` | Asymmetric. The algorithm is inferred from the key type, or set `algorithm` explicitly | The module does not publish a JWKS endpoint. If other services verify your tokens with a public key, serve that key yourself. ## HTTP endpoints The module ships no HTTP handler. Expose the methods through routes you write, for example `POST /auth/token` calling `createSession` and `POST /auth/refresh` calling `refreshSession`. Access tokens are stateless. Revocation only affects refresh tokens. For immediate access token invalidation, use short TTLs (5-15 minutes). Set `emitAgenticJwtClaims: true` to add `agent_id`, `agent_type`, and `trust_tier` claims when `createSession` receives an `agenticContext` on the user. See [Standards alignment](/standards). ## Related Cookie sessions, JWT tokens, and the full session lifecycle. Cookie attributes for same-domain browser setups. OAuth 2.1 token issuance for MCP tool server authorization. IETF draft claims added to agent JWTs. --- # Cookie options Source: https://docs.theauth.dev/cookies theAuth only sets cookies when you use its human-auth session features. If Clerk, Auth.js, or better-auth runs your sign-in, theAuth is cookie-free, it authenticates agents with bearer tokens instead. Cookie handling is configured on the cookie session manager (`createCookieSessionManager`, exported from `@glinr/theauth`), not through a top-level `cookies` option on `createTheAuth`. See [Sessions](/sessions) for the full manager API. ## Defaults | Attribute | Value | |---|---| | Name | `theauth_session` (`sessionName`) | | `HttpOnly` | `true` | | `Secure` | `true` when `NODE_ENV` is `production`, otherwise `false` | | `SameSite` | `Lax` | | `Path` | `/` | | `Domain` | unset (host-only) | | `Max-Age` | 7 days (`maxAge`), sliding by default (`autoRefresh`) | The `Secure` default for the cookie session manager comes from `NODE_ENV`, not from your URL. If your deployment does not set `NODE_ENV=production`, set `cookieOptions.secure: true` yourself. Cookies that the plugins set directly (for example the passkey sign-in response) mark the cookie `Secure` when `baseUrl` starts with `https://`. ## Overriding ```ts import { createTheAuth, createCookieSessionManager } from '@glinr/theauth'; const theauth = await createTheAuth({ database: { provider: 'postgres', url: process.env.DATABASE_URL! }, }); const sessions = createCookieSessionManager({ secret: process.env.SESSION_SECRET!, // at least 32 characters sessionName: 'myapp_session', maxAge: 60 * 60 * 24 * 7, // seconds; seven days cookieOptions: { secure: true, sameSite: 'strict', domain: '.example.com', }, }, theauth.db); ``` `cookieOptions` accepts `httpOnly`, `secure`, `sameSite`, `path`, `domain`, `expires` and `partitioned`. `maxAge` comes from the top-level `maxAge` option. Changing the cookie name on a live app signs every user out on the next request. Do it during a planned migration window or ship a middleware that reads both names for a transition period. ## Cross-subdomain Share sessions across `app.example.com` and `auth.example.com` by setting a leading-dot domain. ```ts cookieOptions: { domain: '.example.com', sameSite: 'lax', // required for top-level nav; 'strict' would break redirects } ``` A leading dot is the shape browsers accept even if the spec no longer requires it. It is still the interoperable choice across every browser that matters. ## Cross-origin (different registrable domains) If your auth server and your app live on different registrable domains (`auth.example.com` and `app.example.org`), cookies are not enough. Use the [JWT session](/jwt-sessions) path instead. Cookies do not cross registrable domains under any `SameSite` mode that browsers accept in 2026. ## Sliding vs absolute expiry By default (`autoRefresh: true`), every successful `validateSession` replaces the session row and returns a new cookie in `refreshCookieHeader` with a fresh `Max-Age`. A user signed in thirty days ago but still active stays signed in. Turn `autoRefresh` off to expire the session `maxAge` seconds after sign-in regardless of activity: ```ts const sessions = createCookieSessionManager({ secret: process.env.SESSION_SECRET!, maxAge: 60 * 60 * 24 * 7, autoRefresh: false, // expire seven days after sign-in, regardless of activity }, theauth.db); ``` For sensitive actions you can additionally require a recently authenticated session, see session freshness in [Sessions](/sessions). ## Reading the cookie yourself Pass the raw `Cookie` header to `validateSession`. It returns `{ session, refreshCookieHeader }`, where `session` is `null` when the cookie is missing, invalid, expired, or revoked. ```ts export async function GET(req: Request) { const cookieHeader = req.headers.get('cookie') ?? ''; const { session, refreshCookieHeader } = await sessions.validateSession(cookieHeader); if (!session) { return new Response('Unauthorized', { status: 401 }); } const headers = new Headers({ 'Content-Type': 'application/json' }); if (refreshCookieHeader) headers.set('Set-Cookie', refreshCookieHeader); return new Response(JSON.stringify({ userId: session.userId }), { headers }); } ``` ## Troubleshooting The cookie is probably being sent without `Secure` over HTTPS, or `Secure` is set but the request is plain HTTP. The cookie session manager sets `Secure` only when `NODE_ENV=production` (or when you pass `cookieOptions.secure`), and browsers drop `Secure` cookies sent over plain HTTP. Make sure production runs over HTTPS and set `cookieOptions.secure: true` explicitly. Default cookies are host-only. Set `cookieOptions.domain: '.your-domain.com'` (with the leading dot) and re-sign the user in, or issue the cookie from the top-level domain. You are probably changing the session `secret`. Session tokens are signed with it, and a new secret invalidates existing cookies. The `secret` option takes a single string, so keep it stable across deploys. theAuth does not support a list of secrets for rotation. Some older mobile webviews treat `Lax` inconsistently across top-level POSTs. Set `cookieOptions: { sameSite: 'none', secure: true }` and make sure you are on HTTPS. ## Related Cookie and JWT session management with lifecycle details. Stateless access tokens for cross-origin and mobile setups. List and revoke a user's sessions. Store application-specific data inside every session. --- # One-time tokens Source: https://docs.theauth.dev/one-time-tokens One-time tokens are short-lived, single-use strings for flows like email verification, password resets, and invitations. The raw token is handed to the caller once and never stored, only a SHA-256 hash lives in the database. On first use (or expiry), the token is gone. ## Setup The module is part of theAuth core. No extra plugin needed. ```typescript title="lib/theauth.ts" import { createTheAuth } from '@glinr/theauth'; const theauth = await createTheAuth({ database: { provider: 'postgres', url: process.env.DATABASE_URL! }, }); // Access the module const tokens = theauth.oneTimeTokens; ``` `theauth.oneTimeTokens` is always available and uses the default config (one hour TTL). To change the default TTL, create your own instance with `createOneTimeTokenModule`: ```typescript import { createOneTimeTokenModule } from '@glinr/theauth/auth'; const customTokens = createOneTimeTokenModule({ defaultTtlSeconds: 1800 }, theauth.db); ``` ## Token purposes Each token has a `purpose` that scopes its validity. Validation fails if the purpose at creation does not match the purpose at consumption. | Purpose | Use | |---------|-----| | `email-verify` | Confirm a new email address | | `password-reset` | Authenticate a password-reset request | | `invitation` | Invite a user to an org or workspace | | `custom` | Any application-specific flow | ## Creating a token `createToken` returns the raw token exactly once. Put it in a link or hand it to your mailer, there is no way to recover it from the database later. ```typescript const result = await tokens.createToken({ purpose: 'password-reset', identifier: 'alice@example.com', // email, user ID, or any scoping key ttlSeconds: 1800, // 30 minutes }); if (result.success) { const { token, expiresAt } = result.data; await mailer.send({ to: 'alice@example.com', subject: 'Reset your password', html: `Reset password`, }); } ``` ```typescript const result = await tokens.createToken({ purpose: 'email-verify', identifier: user.id, ttlSeconds: 86400, // 24 hours }); if (result.success) { await sendVerificationEmail(user.email, result.data.token); } ``` ```typescript const result = await tokens.createToken({ purpose: 'invitation', identifier: 'invited@example.com', ttlSeconds: 604800, // 7 days metadata: { orgId: 'org_abc123', role: 'member' }, // [!code highlight] }); ``` The default TTL is **3600 seconds** (1 hour). Override it per call with `ttlSeconds`, or set `defaultTtlSeconds` on a module you create with `createOneTimeTokenModule` to change the default for all tokens. The raw token is 64 hex characters (32 random bytes). ## Validating a token Call `validateToken` when the user lands on your reset or verification page. On success, the token is consumed immediately, a second call with the same token always fails. ```typescript const result = await tokens.validateToken( incomingToken, // from the URL query param 'password-reset', ); if (!result.success) { // result.error.code is one of: // TOKEN_NOT_FOUND | TOKEN_ALREADY_USED | TOKEN_EXPIRED | TOKEN_PURPOSE_MISMATCH // (also INVALID_INPUT and CONSUME_TOKEN_FAILED) return { error: result.error.message }; } const { identifier, metadata } = result.data; // identifier === 'alice@example.com' // Proceed with the reset ``` `validateToken` marks the token as used before it returns. Even if your handler crashes after this call, the token cannot be reused. Handle the downstream action (password update, email confirmation) in the same request. ## Revoking tokens Revoke all active tokens for an identifier when a user takes an action that makes them obsolete, for example, invalidating outstanding reset links when a user changes their password through a different flow. ```typescript // Revoke all active password-reset tokens for this user const result = await tokens.revokeTokens('alice@example.com', 'password-reset'); if (result.success) { console.log(`Revoked ${result.data.count} token(s)`); } // Revoke everything for this identifier (all purposes) await tokens.revokeTokens(user.id); ``` Revocation is a soft operation, tokens are marked as used, not deleted. Only active (unused, unexpired) tokens are counted. The `purpose` argument must be one of the four purpose values. ## Attaching metadata Pass a `metadata` object to store arbitrary data alongside the token. It is returned on successful validation. ```typescript const result = await tokens.createToken({ purpose: 'invitation', identifier: 'bob@example.com', metadata: { orgId: 'org_xyz', role: 'admin', invitedBy: 'alice' }, }); // On validation: const validation = await tokens.validateToken(token, 'invitation'); if (validation.success) { const { orgId, role } = validation.data.metadata as { orgId: string; role: string }; await addUserToOrg(user.id, orgId, role); } ``` ## Error codes | Code | Cause | |------|-------| | `TOKEN_NOT_FOUND` | Token does not exist or was already deleted | | `TOKEN_ALREADY_USED` | Token was consumed by a previous call | | `TOKEN_EXPIRED` | Token's `expiresAt` is in the past | | `TOKEN_PURPOSE_MISMATCH` | Purpose at validation does not match purpose at creation | | `INVALID_INPUT` | Empty token, purpose or identifier, or an unknown purpose value | | `CREATE_TOKEN_FAILED` | Database write failed | | `CONSUME_TOKEN_FAILED` | Database update failed while marking the token used | | `REVOKE_TOKENS_FAILED` | Database update failed during revocation | ## Security notes **Tokens are hashed at rest.** Only a SHA-256 hash is stored. A database dump does not expose usable tokens. **Single-use marking.** The module reads the token, then marks it used with a conditional `WHERE used = false` update before returning. It does not check how many rows the update changed, so two truly simultaneous requests with the same token could both succeed. If that matters for your flow, make the downstream action idempotent. A purpose mismatch returns `TOKEN_PURPOSE_MISMATCH` without consuming the token. **Purpose binding prevents cross-flow reuse.** A password-reset token cannot be submitted to an email-verify endpoint. ## Related Cookie and JWT sessions you can issue after a token validates. Email templates for reset and verification messages. Lifecycle hooks you can attach to your instance. theAuth error codes with their HTTP status and meaning. --- # Secondary storage Source: https://docs.theauth.dev/secondary-storage ## Overview Some state is small, short-lived and written constantly: rate limit counters, device login codes, nonces, caches. TheAuth keeps it in a **secondary storage**, a tiny key/value interface with expiry. By default that is process memory, which is fine for local development and a single server. Once you run more than one instance, or on serverless, point it somewhere shared. ```ts import { createTheAuth, redisStorage } from "@glinr/theauth"; import Redis from "ioredis"; const theauth = await createTheAuth({ database: { provider: "postgres", url: process.env.DATABASE_URL }, secondaryStorage: redisStorage(new Redis(process.env.REDIS_URL)), }); ``` ## Choosing a backend | Backend | Atomic counters | Shared across instances | Notes | | --- | --- | --- | --- | | `"memory"` (default) | yes | no | Lost on restart. Dev and single process. | | `"database"` | yes | yes | Uses the TheAuth database (SQLite, Postgres, MySQL, D1). No new infrastructure. | | `redisStorage(client)` | yes | yes | Redis, Valkey, Upstash, Dragonfly. Best throughput. | | `cloudflareKvStorage(kv)` | **no** | eventually | See the warning below. | | `defineSecondaryStorage({...})` | only if you supply `incr` | depends | Bring your own. | Cloudflare KV is eventually consistent and has no atomic increment. Two requests hitting different locations at the same moment can read the same count, and a write can take up to a minute to be visible elsewhere. Rate limits on KV are soft: they stop casual abuse, not a determined burst. For strict counters on Workers use `"database"` with D1, a Durable Object, or Upstash Redis. Plain values such as device codes work on KV if you accept the propagation delay. ## Per-feature overrides Pass one backend for everything, or an object. Each feature falls back to `default`, then to memory. ```ts const theauth = await createTheAuth({ database: { provider: "d1", binding: env.DB }, secondaryStorage: { default: "database", rateLimit: cloudflareKvStorage(env.RATE_KV), // soft limits are fine here deviceCodes: "database", // must be reliable }, }); ``` | Key | Used for | | --- | --- | | `rateLimit` | The `rateLimit()` plugin counters. | | `deviceCodes` | Device authorization grants (`deviceAuth()`). | | `oneTimeTokens`, `nonces`, `sessionsCache` | Reserved names. `theauth.secondaryStorage.for(name)` returns a namespaced store for your own code; core modules do not read them yet. | Keys are prefixed `theauth::` automatically, so features never collide in a shared Redis or table. A misspelled feature name throws at startup. ## Database adapter `"database"` creates a `theauth_secondary_storage` table (it is added to the automatic migrations). `incr` is a single `UPDATE counter = counter + 1` guarded by the key, so it is atomic on every supported database. Expired rows are removed lazily; for large keyspaces call `databaseStorage(db).purgeExpired()` from a cron. ## Redis adapter `redisStorage` accepts any client with `get`, `del`, `incr`, `pexpire` and `pttl` (node-redis spells them `pExpire` and `pTTL`, and that works too). It has no Redis dependency, so ioredis, node-redis and `@upstash/redis` all fit. ```ts import { createClient } from "redis"; const client = createClient({ url: process.env.REDIS_URL }); await client.connect(); const storage = redisStorage(client); ``` If the process dies between `INCR` and `PEXPIRE`, the next caller notices the missing TTL and repairs it, so a counter can never become permanent. ## Custom store ```ts import { defineSecondaryStorage } from "@glinr/theauth"; const storage = defineSecondaryStorage({ get: (key) => myStore.get(key), set: (key, value, ttlSeconds) => myStore.set(key, value, { ttl: ttlSeconds }), delete: (key) => myStore.del(key), // Provide incr if your store can do it atomically. incr: async (key, ttlSeconds) => { /* return { count, expiresAt } */ }, }); ``` Without `incr`, the helper builds one from `get` and `set`. That is not atomic and the result reports `atomicIncr: false`. ## The interface ```ts interface SecondaryStorage { get(key: string): Promise; set(key: string, value: string, ttlSeconds?: number): Promise; delete(key: string): Promise; incr(key: string, ttlSeconds: number): Promise<{ count: number; expiresAt: number }>; list?(prefix: string): Promise; readonly atomicIncr?: boolean; } ``` `incr` uses a fixed window: a missing or expired key starts at 1 with the given TTL, and later calls keep the original expiry. ## Backward compatibility `kvStore(namespace)`, `KVStore`, `MemoryStore` and `RateLimitStore` keep working. `KVStore` now delegates to the new adapter, and it also accepts any `SecondaryStorage`, which gives you atomic counting through the old API. `rateLimit({ store })` accepts either kind. If you set no `store`, the plugin follows `secondaryStorage.rateLimit`. --- # Authentication Source: https://docs.theauth.dev/auth/index
theAuth runs the sign-in for the humans who own agents. Each method is either a plugin or a config key on `createTheAuth` that you opt into, use only what your app needs. Methods that issue sessions (magic link, email OTP, username, phone, OAuth) need `auth.session` configured. If you already run Clerk, Auth.js, or better-auth, keep them and skip the plugins. [Plug into an existing provider.](/migrate)
```ts import { createTheAuth } from '@glinr/theauth'; import { oauth, magicLink, createGoogleProvider, createGithubProvider, } from '@glinr/theauth/auth'; const theauth = await createTheAuth({ database: { provider: 'postgres', url: process.env.DATABASE_URL! }, baseUrl: 'https://auth.example.com', auth: { session: { secret: process.env.SESSION_SECRET! } }, plugins: [ oauth({ providers: { google: createGoogleProvider({ clientId: process.env.GOOGLE_CLIENT_ID!, clientSecret: process.env.GOOGLE_CLIENT_SECRET!, }), github: createGithubProvider({ clientId: process.env.GITHUB_CLIENT_ID!, clientSecret: process.env.GITHUB_CLIENT_SECRET!, }), }, }), magicLink({ appUrl: 'https://auth.example.com/api/theauth', sendMagicLink: async (email, _token, url) => { await sendEmail(email, `Sign in: ${url}`); }, }), ], }); ```
## Sign-in methods Why there is no email plus password plugin, and the options. Built-in password auth with PBKDF2-SHA256 hashing. Single-use link in an email, no password. Six-digit code via email. SMS code, you supply the delivery function. WebAuthn / FIDO2 biometrics and security keys. EIP-4361 wallet-based sign-in. For TVs and CLIs that can't take a password. TOTP with backup codes. Turnstile, hCaptcha, reCAPTCHA token verification. Throwaway sessions, upgrade on sign-up. Google's one-tap sign-in widget. Reverse-proxy mode for trusted ingress. ## OAuth providers Thirty-eight first-class providers, plus a generic factory for anything with a standard authorization code flow. Don't see your provider? The [generic OAuth factory](/auth/oauth) wires any authorization-code provider in about ten lines of config. ## How plugins fit Plugins register routes at `createTheAuth()` time, and the instance serves them through `theauth.plugins.handleRequest(request)` (framework adapters do this for you, mounted under `/api/theauth` by default). Several methods are also available as config keys that expose a module on the instance, for example `username`, `phone`, `magicLink`, `emailOtp`, `totp`, `passkey`, `org`, `sso`, `admin`, `apiKeys` and `captcha`. Those properties are `null` unless the matching config key is passed. To identify the signed-in human, configure an `auth.adapter` (for example `bearerAuth`) or `auth.session`, then resolve the user from a request: ```ts title="resolving a user from a request" const user = await theauth.auth.resolveUser(request); if (!user) { return new Response('Unauthorized', { status: 401 }); } // user.id is the stable owner ID for creating agents ``` `theauth.auth.resolveUser` uses the configured `auth.adapter`. It returns `null` when no adapter is set, even if `auth.session` is configured. Once the user is resolved, theAuth is done with human auth. The rest of the stack (agents, permissions, audit) hangs off `user.id`. ## Enterprise identity Multi-user accounts, roles, invitations. SAML 2.0 and OIDC SSO. Automated provisioning from your IdP. Ban, impersonate with TTL, audit. For machine-to-machine callers. Turn your theAuth instance into an IdP for other apps. ## Related The agent layer that sits on top of human authentication. How theAuth manages session lifetime and cookie settings. Get a working theAuth instance running in minutes. Mount auth routes on Hono, Next.js, Express, and more. --- # Last login tracking Source: https://docs.theauth.dev/last-login The last login module records every successful authentication event per user. You can show users when and how they last signed in on a security page, or build your own checks on the login history. theAuth's sign-in modules do not call this module for you, so you call `recordLogin` yourself after each successful sign-in. ## Setup ```ts import { createTheAuth } from '@glinr/theauth'; import { createLastLoginModule } from '@glinr/theauth/auth'; const theauth = await createTheAuth({ /* ... */ }); const loginHistory = createLastLoginModule( { maxHistoryPerUser: 20 }, theauth.db, ); ``` `createLastLoginModule` is stateless and safe to call multiple times against the same database instance. ## Recording a login Call `recordLogin` after any successful authentication. It validates its input (the `userId` must be non-empty and the `method` must be one of the values below) and returns a `Result` with the stored event: ```ts await loginHistory.recordLogin({ userId: 'usr_123', method: 'magic-link', ip: request.headers.get('x-forwarded-for') ?? undefined, userAgent: request.headers.get('user-agent') ?? undefined, }); ``` Older entries beyond `maxHistoryPerUser` (default: `10`) are pruned automatically on every write. ## Reading login data ### Last login ```ts const result = await loginHistory.getLastLogin('usr_123'); if (result.success && result.data) { console.log(result.data.method); // 'magic-link' console.log(result.data.timestamp); // Date console.log(result.data.ip); // string | null } ``` Returns `null` in `data` when no history exists for the user. ### Full history ```ts const result = await loginHistory.getLoginHistory('usr_123', 5); if (result.success) { for (const event of result.data) { console.log(event.method, event.timestamp); } } ``` Events are returned newest first. The optional `limit` parameter overrides the module's `maxHistoryPerUser`. ## Supported methods | Value | Description | |-------|-------------| | `email-password` | Email and password sign-in | | `magic-link` | Passwordless email link | | `email-otp` | One-time code via email | | `passkey` | WebAuthn / FIDO2 | | `username-password` | Username and password | | `phone-sms` | SMS one-time code | | `siwe` | Sign-in with Ethereum | | `device-auth` | OAuth 2.0 device flow | | `anonymous` | Anonymous session | | `api-key` | API key authentication | | `oauth:{provider}` | Any OAuth provider, e.g. `oauth:github` | ## Result type All methods return `Result`, either `{ success: true; data: T }` or `{ success: false; error: TheAuthError }`. Invalid input and database failures come back as `success: false` rather than thrown errors. ```ts const result = await loginHistory.getLastLogin(userId); if (!result.success) { console.error(result.error.code, result.error.message); } ``` IP addresses are stored as-is. Normalise or hash them before passing if your privacy policy requires it. ## Related All supported auth methods that generate login events. What exists today for unusual behavior (theAuth has no login anomaly detector). Skip 2FA on devices you have marked as trusted. Full agent-level action log for deeper activity analysis. --- # Email and password Source: https://docs.theauth.dev/auth/email-password ## Option 1: The email plugin (`@glinr/theauth-email`) `emailPassword()` is a full email-and-password flow: sign-up, sign-in, email verification, password reset and password change, with per-endpoint rate limits. You provide the email transport. ```bash pnpm add @glinr/theauth @glinr/theauth-email ``` ```typescript title="lib/theauth.ts" import { createTheAuth } from '@glinr/theauth'; import { emailPassword } from '@glinr/theauth-email'; const theauth = await createTheAuth({ database: { provider: 'sqlite', url: 'theauth.db' }, plugins: [ emailPassword({ appUrl: 'https://app.example.com', sendVerificationEmail: async (email, token, url) => { /* send url */ }, sendResetEmail: async (email, token, url) => { /* send url */ }, password: { minLength: 10 }, // requireVerification defaults to true }), ], }); ``` | Endpoint | Purpose | |---|---| | `POST /auth/sign-up` | Register (5 per minute) | | `POST /auth/sign-in` | Sign in (10 per minute) | | `POST /auth/verify-email` | Confirm the emailed token | | `POST /auth/request-reset`, `/auth/reset-password` | Reset flow (3 per minute for requests) | | `POST /auth/change-password` | Change password while signed in | Route requests with `theauth.plugins.handleRequest(request)` or a [framework adapter](/adapters). The [quickstart](/quickstart#track-b-add-human-auth) has a runnable version. Rate limit windows are per client IP. ## Option 1b: Username as the identifier The [username module](/auth/username) hashes a password against a username string. It does not send verification or reset emails or track `emailVerified`; those live in separate modules (see below). ```typescript title="lib/theauth.ts" import { createTheAuth } from '@glinr/theauth'; const theauth = await createTheAuth({ database: { provider: 'postgres', url: process.env.DATABASE_URL! }, auth: { session: { secret: process.env.SESSION_SECRET! } }, username: { password: { minLength: 8 }, }, }); ``` The username module stores a placeholder (`{username}@username.local`) in `users.email`, so do not rely on that column for delivery. ## Option 2: Passwordless If you would rather not store passwords at all, theAuth's passwordless methods authenticate against a real email address. They authenticate against a real email address and don't require you to build password reset UX at all: - [Magic link](/auth/magic-link), a single-use link emailed to the user, no password. - [Email OTP](/auth/email-otp), a numeric code emailed to the user, no password. Both can be added as a plugin (`magicLink()`, `emailOtp()`) or as a top-level config key on `createTheAuth()`, which exposes a module with a `handleRequest` you forward matching requests to. Both require `auth.session`. ## Option 3: Bring your own (better-auth, Auth.js, Clerk, custom) If you already run email+password auth in an existing system, don't migrate it, adapt it. `customAuth` wraps an arbitrary resolver function as an `AuthAdapter` so theAuth's agent layer can resolve the human user from an inbound request without owning the credential flow: ```typescript import { createTheAuth } from '@glinr/theauth'; import { customAuth } from '@glinr/theauth/auth'; const adapter = customAuth(async (request) => { const session = await existingAuth.api.getSession({ headers: request.headers }); if (!session?.user) return null; return { id: session.user.id, email: session.user.email, name: session.user.name }; }); const theauth = await createTheAuth({ database: { provider: 'postgres', url: process.env.DATABASE_URL! }, auth: { adapter }, }); ``` See [Concepts](/concepts) for how theAuth's agent layer composes with an external human-auth provider, and the [migration guides](/migrate) for better-auth, Auth0, and Clerk specifically. ## Supporting modules (compose with any of the above) These are independent, headless modules, use them regardless of which sign-in method you pick: `createEmailVerificationModule` (`@glinr/theauth/auth`), or `theauth.emailVerification` when you pass the `emailVerification` config key. Sends and confirms email verification tokens. `createPasswordResetModule` (`@glinr/theauth/auth`), or `theauth.passwordReset` when you pass the `passwordReset` config key. Token-based reset flow that revokes existing sessions on success by default (`revokeSessionsOnReset`). Reject passwords found in known breaches via HaveIBeenPwned. Add TOTP as a second factor. ## Related theAuth's actual built-in password module. Passwordless alternative using a single-use email link. Numeric code via email, no password required. All sign-in methods available in theAuth. --- # Username and password Source: https://docs.theauth.dev/auth/username theAuth's built-in password auth is username-based: users register with a username (not an email address) and a password. Passwords are hashed with PBKDF2 before storage so a database breach does not expose credentials. It is a headless module, you mount it yourself by forwarding requests to `theauth.username.handleRequest`. There is no separate email-based password plugin. For email-first sign-in, pair this module with the email verification module and [email OTP](/auth/email-otp), or use [magic link](/auth/magic-link) for a fully passwordless flow. ## Setup ```typescript title="lib/theauth.ts" import { createTheAuth } from '@glinr/theauth'; const theauth = await createTheAuth({ database: { provider: 'postgres', url: process.env.DATABASE_URL! }, auth: { session: { secret: process.env.SESSION_SECRET! } }, // required, signUp/signIn issue sessions username: { minUsernameLength: 3, maxUsernameLength: 32, allowedPattern: /^[a-z0-9_-]+$/i, caseSensitive: false, password: { minLength: 8, maxLength: 128 }, }, }); ``` Forward matching requests to the module from your framework adapter (or call it directly from a catch-all route): ```typescript title="Mount the handler" async function handleAuth(request: Request): Promise { const response = await theauth.username?.handleRequest(request); if (response) return response; return new Response('Not Found', { status: 404 }); } ``` `handleRequest` matches the literal pathnames below (`/auth/username/sign-up` and so on) and only for `POST`. If you mount it under a prefix such as `/api/theauth`, strip the prefix from the URL before calling it. `theauth.username` is `null` unless the `username` key is passed and `auth.session` is configured. ## Sign up `POST /auth/username/sign-up` ```typescript title="Sign up (client)" const res = await fetch('/auth/username/sign-up', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: 'ada_lovelace', // [!code highlight] password: 'correct horse battery', name: 'Ada Lovelace', // optional display name }), }); // 201: { user: { id, username, name }, session: { token, expiresAt } } // 400: { error: "Username already taken" | "Username must be at least N characters" | ... } const { user, session } = await res.json(); ``` Sign-up throws (returned as a `400` with a plain-text `error` message, not a machine-readable code) when the username fails validation or is already taken, or when the password fails length validation. ## Sign in `POST /auth/username/sign-in` ```typescript title="Sign in (client)" const res = await fetch('/auth/username/sign-in', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: 'ada_lovelace', // [!code highlight] password: 'correct horse battery', // [!code highlight] }), }); if (res.status === 403) { const { error } = await res.json(); // error.code === 'PASSWORD_RESET_REQUIRED' } else if (!res.ok) { // 401: { error: "Invalid username or password" } (generic, does not reveal which field was wrong) } else { const { user, session } = await res.json(); } ``` ## Change password `POST /auth/username/change-password` Requires the caller to know the current password. The module does not read the session for you, pass the authenticated `userId` from your own session lookup. The HTTP handler takes `userId` from the request body and does not check the caller's session. Do not expose `change-password` or `change-username` to browsers as-is. Resolve the session on your server and call `theauth.username.changePassword(userId, current, newPassword)` and `theauth.username.changeUsername(userId, newUsername)` instead. ```typescript title="Change password (client)" const res = await fetch('/auth/username/change-password', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userId: user.id, // [!code highlight] current: 'correct horse battery', // [!code highlight] newPassword: 'new-stronger-password', // [!code highlight] }), }); ``` ## Change username `POST /auth/username/change-username` ```typescript title="Change username (client)" const res = await fetch('/auth/username/change-username', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userId: user.id, // [!code highlight] newUsername: 'ada_v2', // [!code highlight] }), }); ``` Usernames must still be unique after the change, sign-up validation rules (`allowedPattern`, length) apply here too. ## Using the module directly `theauth.username` exposes typed methods in addition to `handleRequest`, useful when you want to call sign-up/sign-in from server code (e.g. an admin invite flow) instead of going through HTTP: ```typescript const result = await theauth.username?.signUp({ username: 'ada_lovelace', password: 'correct horse battery' }); const signedIn = await theauth.username?.signIn({ username: 'ada_lovelace', password: 'correct horse battery' }); await theauth.username?.changePassword(userId, 'correct horse battery', 'new-stronger-password'); ``` ## Configuration reference Minimum username length. Maximum username length. Regex the username must match. Whether usernames are normalized to lowercase before storage and lookup. Minimum password length. Maximum password length. ## Related Passwordless, email-based alternative when you don't want a password field at all. Add TOTP as a second factor on top of username/password sign-in. All available auth methods and how to combine them. What happens after sign-in: cookie and JWT session management. --- # Magic link Source: https://docs.theauth.dev/auth/magic-link Magic links let users sign in by clicking a link sent to their email. No password needed. The link carries a random single-use token that expires after a configurable window. ## Setup ### Install ```bash pnpm add @glinr/theauth ``` ### Add the plugin ```typescript title="lib/theauth.ts" import { createTheAuth } from '@glinr/theauth'; import { magicLink } from '@glinr/theauth/auth'; // [!code highlight] const theauth = await createTheAuth({ database: { provider: 'postgres', url: process.env.DATABASE_URL! }, auth: { session: { secret: process.env.SESSION_SECRET! } }, // [!code highlight] plugins: [ magicLink({ // [!code highlight] appUrl: 'https://auth.example.com/api/theauth', // [!code highlight] sendMagicLink: async (email, _token, url) => { // [!code highlight] await resend.emails.send({ from: 'auth@example.com', to: email, subject: 'Your sign-in link', html: `Sign in to Example. Link expires in 15 minutes.`, }); }, // [!code highlight] }), // [!code highlight] ], }); ``` The plugin throws at startup unless `auth.session` is configured, because it issues a session when the link is verified. `auth.session.secret` must be at least 32 characters. `appUrl` is prepended to the callback path to build the link, so it must point at where the verify endpoint is reachable (the adapter mount path, `/api/theauth` by default). You can also skip the plugin and pass the same options as a top-level `magicLink` key to `createTheAuth`. That exposes `theauth.magicLink` (`sendLink`, `verify`, `handleRequest`), which is `null` when the config key is absent or `auth.session` is not set. ### Handle the callback route Magic links point at `appUrl + callbackPath + ?token=...`. The plugin registers `GET /auth/magic-link/verify`, which matches the default `callbackPath`. Leave `callbackPath` unchanged when you use the plugin. ```typescript magicLink({ appUrl: 'https://auth.example.com/api/theauth', sendMagicLink: async (email, token, url) => { /* ... */ }, }), ``` ## How it works 1. User submits their email to `POST /auth/magic-link/send`. 2. theAuth generates a signed token and calls your `sendMagicLink` function with the email address, the raw token, and the full URL. 3. User clicks the link in their inbox. 4. theAuth validates the token, creates or retrieves the user, and returns the user and a session token as JSON. It does not set a cookie or redirect, so your app stores the session token (for example in a cookie) and navigates the user. If the email belongs to an existing account, the same user ID is returned. If it is new, an account is created automatically. ## Endpoints ### Send link `POST /auth/magic-link/send` ```typescript await fetch('/api/theauth/auth/magic-link/send', { // [!code highlight] method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: 'user@example.com' }), }); ``` The email is trimmed and lowercased. A successful response is `200` with `{ "sent": true }`. A missing `email` returns `400`. A user record is created for the address when the link is issued, so an unknown email gets an account. ### Verify token `GET /auth/magic-link/verify?token=` On success, returns `200` with `{ user: { id, email }, session: { token, expiresAt } }`. A missing `token` returns `400`, and an invalid, expired, or already-used token returns `401`. ## Rate limiting The send endpoint is limited to **5 requests per minute per IP address** (taken from `x-forwarded-for` or `x-real-ip`). Requests over the limit return `429 Too Many Requests` with a `Retry-After` header. Build a cooldown timer into your UI so users know when they can retry. Magic links are single-use. Clicking an expired or already-used link returns a 401. Show the user a "resend" option in your UI. ## Options | Option | Type | Default | Description | |--------|------|---------|-------------| | `sendMagicLink` | `(email: string, token: string, url: string) => Promise` | required | Called with the recipient email, the raw token, and the full magic link URL | | `appUrl` | `string` | required | Base URL prepended to the callback path, for example `https://app.example.com` | | `tokenExpiry` | `number` | `900` | Token lifetime in seconds (15 minutes) | | `callbackPath` | `string` | `/auth/magic-link/verify` | Path appended to `appUrl` in the link | ## Related Numeric code via email, useful when links are awkward to click. Password-based auth that can run alongside magic links. WebAuthn biometrics as an alternative passwordless method. All sign-in methods available in theAuth. --- # Email OTP Source: https://docs.theauth.dev/auth/email-otp Email OTP sends a short numeric code to the user's inbox. It works well for mobile flows where clicking a link is awkward and for verification steps inside an existing session. ## Setup ### Install ```bash pnpm add @glinr/theauth ``` ### Add the plugin ```typescript title="lib/theauth.ts" import { createTheAuth } from '@glinr/theauth'; import { emailOtp } from '@glinr/theauth/auth'; // [!code highlight] const theauth = await createTheAuth({ database: { provider: 'postgres', url: process.env.DATABASE_URL! }, auth: { session: { secret: process.env.SESSION_SECRET! } }, // [!code highlight] plugins: [ emailOtp({ // [!code highlight] sendOtp: async (email, code) => { // [!code highlight] await resend.emails.send({ from: 'auth@example.com', to: email, subject: `Your code: ${code}`, html: `

Your sign-in code is ${code}. It expires in 5 minutes.

`, }); }, // [!code highlight] }), // [!code highlight] ], }); ``` The plugin throws at startup unless `auth.session` is configured, because it issues a session on successful verification. `auth.session.secret` must be at least 32 characters. You can also pass the same options as a top-level `emailOtp` key to `createTheAuth` instead of using the plugin. That exposes `theauth.emailOtp` (`sendCode`, `verifyCode`, `handleRequest`), which is `null` when the key is absent or `auth.session` is not set.
## How it works 1. User submits their email to `POST /auth/email-otp/send`. 2. theAuth generates a cryptographically random code and calls your `sendOtp` function with the email and code. 3. User enters the code in your UI and submits to `POST /auth/email-otp/verify`. 4. On success, the response contains the user and a session token. theAuth does not set a cookie, so your app stores the token. If the email belongs to an existing account, the same user ID is returned. If it is new, an account is created on successful verification. ## Send a code `POST /auth/email-otp/send` ```typescript await fetch('/api/theauth/auth/email-otp/send', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: 'user@example.com' }), }); ``` A successful response is `200` with `{ "sent": true }`. The email is trimmed and lowercased, and a missing `email` returns `400`. Requesting a new code deletes any earlier code for that address. The endpoint is limited to 5 requests per minute per IP address, and requests over the limit return `429`. Build a countdown timer into your UI. ## Verify a code `POST /auth/email-otp/verify` ```typescript const res = await fetch('/api/theauth/auth/email-otp/verify', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: 'user@example.com', code: '482910', // [!code highlight] }), }); const { user, session } = await res.json(); // [!code highlight] // user: { id, email }, session: { token, expiresAt } ``` A wrong, expired, or exhausted code returns `401`. After `maxAttempts` verification attempts the stored code stops being accepted and a new one must be requested. Verification is limited to 10 requests per minute per IP address. Codes are single-use. A successful verification invalidates the code immediately. Do not retry the same code after a success response. ## Options | Option | Type | Default | Description | |--------|------|---------|-------------| | `sendOtp` | `(email: string, code: string) => Promise` | required | Called with the recipient email and the numeric code | | `codeLength` | `number` | `6` | Number of digits in the OTP | | `codeExpiry` | `number` | `300` | Code lifetime in seconds (5 minutes) | | `maxAttempts` | `number` | `5` | Verification attempts allowed per code | ## Related Single-use email link as an alternative to numeric codes. OTP delivered via SMS instead of email. Password-based auth that can run alongside OTP. All sign-in methods available in theAuth. --- # Phone number Source: https://docs.theauth.dev/auth/phone The phone module registers and authenticates users with a phone number and a one-time code. You supply the SMS delivery function. theAuth generates the code, hashes it before storage, enforces expiry, and limits verification attempts. It ships as a config key, not a plugin: there is no `phoneAuth()` plugin export. ## Setup ```typescript title="lib/theauth.ts" import { createTheAuth } from '@glinr/theauth'; import { Twilio } from 'twilio'; const twilio = new Twilio(process.env.TWILIO_SID!, process.env.TWILIO_TOKEN!); const theauth = await createTheAuth({ database: { provider: 'postgres', url: process.env.DATABASE_URL! }, auth: { session: { secret: process.env.SESSION_SECRET! } }, // [!code highlight] phone: { // [!code highlight] sendSms: async (phone, code) => { // [!code highlight] await twilio.messages.create({ body: `Your verification code: ${code}`, from: process.env.TWILIO_FROM!, to: phone, }); }, // [!code highlight] codeLength: 6, codeExpiry: 300, // seconds }, }); ``` `theauth.phone` is `null` unless the `phone` key is passed and `auth.session` is configured (a session is issued on verification). Forward matching requests to it from a catch-all route: ```typescript title="Mount the handler" async function handleAuth(request: Request): Promise { const response = await theauth.phone?.handleRequest(request); if (response) return response; return new Response('Not Found', { status: 404 }); } ``` `handleRequest` handles `POST` only and matches the literal pathnames below. If you mount it under a prefix such as `/api/theauth`, strip the prefix from the URL first. See [adapters](/adapters). ## Send code `POST /auth/phone/send-code` Sends a one-time code to the given phone number, replacing any earlier code for that number. ```typescript title="Send code (client)" const res = await fetch('/auth/phone/send-code', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ phoneNumber: '+14155550123', // E.164 format // [!code highlight] }), }); // 200: { sent: true } ``` Use [E.164 format](https://www.twilio.com/docs/glossary/what-e164) (`+` prefix, country code, number). theAuth only strips whitespace and does not validate or reformat the number, so normalize it before you call. A missing `phoneNumber` returns `400`. The module has no built-in rate limit for sending, so add one in front of this route (see [rate limiting](/rate-limiting)) to limit SMS cost and abuse. ## Verify code `POST /auth/phone/verify` Submits the code the user received. Returns the user and a session token on success. The user account is created on the first successful verification. ```typescript title="Verify code (client)" const res = await fetch('/auth/phone/verify', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ phoneNumber: '+14155550123', // [!code highlight] code: '482910', // [!code highlight] }), }); if (res.ok) { const { user, session } = await res.json(); // user: { id, phone }, session: { token, expiresAt } } ``` **Responses** | Status | Meaning | |--------|---------| | 200 | `{ user: { id, phone }, session: { token, expiresAt } }` | | 400 | Missing `phoneNumber` or `code`, or invalid JSON | | 401 | `{ error: "Invalid or expired code" }`, covering a wrong code, an expired code, and exhausted attempts | The handler does not set a cookie, so your app stores the session token. ## Configuration reference Promise`} required>Callback invoked to deliver the code. Receives the phone number and the numeric code as a string. Number of digits in the generated code. Code validity window in seconds. Verification attempts allowed per code before it stops being accepted. ## Related OTP via email instead of SMS. Passwordless sign-in via email link. Device biometric auth as a phone-free alternative. All sign-in methods available in theAuth. --- # Passkey Source: https://docs.theauth.dev/auth/passkey Passkeys use the WebAuthn standard (FIDO2) to authenticate users with device biometrics (Touch ID, Face ID, Windows Hello) or hardware security keys. No password is ever created or stored. ## Setup ### Install ```bash pnpm add @glinr/theauth ``` ### Add the plugin ```typescript title="lib/theauth.ts" import { createTheAuth } from '@glinr/theauth'; import { passkey } from '@glinr/theauth/auth'; // [!code highlight] const theauth = await createTheAuth({ database: { provider: 'postgres', url: process.env.DATABASE_URL! }, baseUrl: 'https://auth.example.com', auth: { session: { secret: process.env.SESSION_SECRET! } }, plugins: [ passkey({ // [!code highlight] rpName: 'My App', // Shown in the browser prompt // [!code highlight] rpId: 'example.com', // Must match your domain, no protocol // [!code highlight] origin: 'https://app.example.com', // Exact origin(s) the browser sends // [!code highlight] }), // [!code highlight] ], }); ``` `origin` is required and is compared exactly against the origin in the browser's `clientDataJSON`. Pass an array to allow several origins. Passkey registration and the credential endpoints need an authenticated user, resolved from `auth.adapter` or an `auth.session` token. The sign-in verify endpoint issues a session only when `auth.session` is configured. You can also pass the same options as a top-level `passkey` key to `createTheAuth`, which exposes `theauth.passkey` (`getRegistrationOptions`, `verifyRegistration`, `getAuthenticationOptions`, `verifyAuthentication`, `listCredentials`, `removeCredential`, `handleRequest`). It is `null` when the key is absent. `rpId` must be a registrable domain suffix of the origin. For `https://app.example.com`, valid values are `app.example.com` or `example.com`. Localhost works during development (set `origin` to the exact dev origin, for example `http://localhost:3000`). ## Registration ceremony A passkey is tied to a specific device. Users register once per device they want to use. ### Get registration options `POST /auth/passkey/register/options` Returns a WebAuthn challenge from the server. Requires an authenticated user (the user must already be signed in to register a passkey). No request body is needed. ```typescript const res = await fetch('/api/theauth/auth/passkey/register/options', { method: 'POST', credentials: 'include', }); const options = await res.json(); ``` ### Create the credential Pass the options to the browser's WebAuthn API: ```typescript import { startRegistration } from '@simplewebauthn/browser'; const credential = await startRegistration(options); ``` ### Verify and store `POST /auth/passkey/register/verify` ```typescript await fetch('/api/theauth/auth/passkey/register/verify', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ response: credential }), // [!code highlight] }); ``` The body wraps the browser's credential in a `response` field. On success, the credential is stored and the passkey is active, and the response is `{ credential }` with the stored record. ## Authentication ceremony ### Get authentication options `POST /auth/passkey/authenticate/options` Does not require a session, this is the start of sign-in. ```typescript const res = await fetch('/api/theauth/auth/passkey/authenticate/options', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userId: 'user_123' }), // optional }); const options = await res.json(); ``` The optional `userId` limits `allowCredentials` to that user's passkeys. Omitting it returns options with no credential restriction, so the authenticator offers any discoverable passkey for the relying party (useful for conditional UI). The body takes a user ID, not an email. ### Get the assertion ```typescript import { startAuthentication } from '@simplewebauthn/browser'; const assertion = await startAuthentication(options); ``` ### Verify and start session `POST /auth/passkey/authenticate/verify` ```typescript const res = await fetch('/api/theauth/auth/passkey/authenticate/verify', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ response: assertion }), // [!code highlight] }); const { user, session, credential } = await res.json(); // [!code highlight] ``` With `auth.session` configured, the response is `{ user: { id }, session: { token, expiresAt }, credential }` and a `theauth_session` cookie is set (marked `Secure` when `baseUrl` starts with `https://`). Without a session manager the response is the raw `{ userId, credential }` and no session is created. A failed assertion returns `401`. ## Managing credentials Users can register multiple passkeys across different devices. ### List credentials `GET /auth/passkey/credentials` ```typescript const res = await fetch('/api/theauth/auth/passkey/credentials', { credentials: 'include', }); const { credentials } = await res.json(); // each: { id, credentialId, publicKey, counter, userId, deviceName?, transports?, createdAt, lastUsedAt } ``` ### Delete a credential `DELETE /auth/passkey/credentials/:id` ```typescript await fetch(`/api/theauth/auth/passkey/credentials/${credentialId}`, { method: 'DELETE', credentials: 'include', }); ``` Returns `{ removed: true }`. Only the authenticated user's own credentials can be removed. Use the `id` field of the listed credential. If a user deletes their last passkey and has no other sign-in method, they will be locked out. Check the credential count before allowing deletion, or make sure they have another sign-in method first. ## Endpoints | Endpoint | Auth required | Description | |----------|--------------|-------------| | `POST /auth/passkey/register/options` | Yes | Get challenge to start registration | | `POST /auth/passkey/register/verify` | Yes | Store the new credential | | `POST /auth/passkey/authenticate/options` | No | Get challenge to start sign-in | | `POST /auth/passkey/authenticate/verify` | No | Verify assertion, issue session | | `GET /auth/passkey/credentials` | Yes | List registered credentials | | `DELETE /auth/passkey/credentials/:id` | Yes | Remove a credential | ## Options | Option | Type | Default | Description | |--------|------|---------|-------------| | `rpName` | `string` | required | App name shown in the browser passkey prompt | | `rpId` | `string` | required | Relying party ID, must match your domain | | `origin` | `string \| string[]` | required | Expected origin(s), compared exactly against the browser's `clientDataJSON` | | `attestation` | `string` | `'none'` | Attestation preference: `'none'`, `'indirect'`, `'direct'` | | `userVerification` | `string` | `'preferred'` | Whether biometric check is required: `'required'`, `'preferred'`, `'discouraged'` | | `challengeTimeout` | `number` | `60000` | Challenge timeout in milliseconds (maximum 300000) | ## Related Password-based auth that passkeys can supplement or replace. Another passwordless option using single-use email links. Wallet-based auth as an alternative to device biometrics. All sign-in methods available in theAuth. --- # Sign In With Ethereum Source: https://docs.theauth.dev/auth/siwe Sign In With Ethereum (SIWE) lets users authenticate by signing a structured message with their Ethereum wallet. No password. No email. The server verifies the signature came from the claimed address, then creates a session. The standard is [EIP-4361](https://eips.ethereum.org/EIPS/eip-4361). It works with any wallet that supports personal_sign: MetaMask, WalletConnect, Coinbase Wallet, Rainbow, and others. ## Setup ### Add the plugin ```typescript title="lib/theauth.ts" import { createTheAuth } from '@glinr/theauth'; import { siwe } from '@glinr/theauth/auth'; // [!code highlight] const theauth = await createTheAuth({ database: { provider: 'postgres', url: process.env.DATABASE_URL! }, auth: { session: { secret: process.env.SESSION_SECRET! } }, // for creating sessions in your own code plugins: [ siwe({ // [!code highlight] domain: 'example.com', // shown in the wallet prompt // [!code highlight] uri: 'https://example.com', // must match your app's origin // [!code highlight] statement: 'Sign in to Example App', // [!code highlight] }), // [!code highlight] ], }); ``` ### Add a signature verifier (production) Out of the box, the plugin validates message structure, domain, URI, version, and nonce but does not do secp256k1 signature recovery. For production, pass a `verifySignature` function using [viem](https://viem.sh) or [ethers](https://docs.ethers.org): ```typescript title="lib/theauth.ts" import { recoverMessageAddress } from 'viem'; import { siwe } from '@glinr/theauth/auth'; siwe({ domain: 'example.com', uri: 'https://example.com', verifySignature: async (message, signature) => { // [!code highlight] return recoverMessageAddress({ message, signature: signature as `0x${string}` }); // [!code highlight] }, // [!code highlight] }) ``` ```typescript title="lib/theauth.ts" import { ethers } from 'ethers'; import { siwe } from '@glinr/theauth/auth'; siwe({ domain: 'example.com', uri: 'https://example.com', verifySignature: async (message, signature) => { // [!code highlight] return ethers.verifyMessage(message, signature); // [!code highlight] }, // [!code highlight] }) ``` Without `verifySignature`, the plugin trusts the address in the message body and only checks that a signature string of at least 10 characters is present. Anyone can forge a sign-in by submitting a valid-looking message with a made-up signature. Always add signature recovery before going to production. ## Sign-in flow SIWE requires three steps: get a nonce, sign a message in the wallet, then submit both to the server. ### Get a nonce `GET /auth/siwe/nonce` Request a server-generated nonce before building the sign-in message. Nonces are single-use and expire after 5 minutes (configurable). They are held in memory in the server process, so run the nonce and verify requests against the same instance. ```typescript const res = await fetch('/api/theauth/auth/siwe/nonce'); const { nonce } = await res.json(); // [!code highlight] ``` ### Build and sign the message Construct the EIP-4361 message string from the user's address, the nonce, and your app metadata. Pass it to the wallet for signing. ```typescript import { createSiweModule } from '@glinr/theauth/auth'; // Build the message client-side using the same config as the server const siweModule = createSiweModule({ domain: 'example.com', uri: 'https://example.com', statement: 'Sign in to Example App', }); const message = siweModule.buildMessage( // [!code highlight] address, // e.g. '0xAbC...' from the connected wallet // [!code highlight] nonce, // from the previous step // [!code highlight] 1, // chain ID (1 = Ethereum mainnet) // [!code highlight] ); // [!code highlight] // Request wallet signature (works with any EIP-1193 provider) const signature = await window.ethereum.request({ // [!code highlight] method: 'personal_sign', // [!code highlight] params: [message, address], // [!code highlight] }); // [!code highlight] ``` The message the user sees in their wallet looks like: ``` example.com wants you to sign in with your Ethereum account: 0xAbC123... Sign in to Example App URI: https://example.com Version: 1 Chain ID: 1 Nonce: a3f9c2e1d8b04f7a Issued At: 2025-01-01T00:00:00.000Z ``` ### Verify and start a session `POST /auth/siwe/verify` Submit the original message and the wallet signature. On success, the server returns the verified Ethereum address and chain ID. The endpoint does not create a user or a session, so create those in your own code from here. ```typescript const res = await fetch('/api/theauth/auth/siwe/verify', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message, signature }), // [!code highlight] }); if (!res.ok) { const { error } = await res.json(); console.error(error); // e.g. "Nonce expired" or "Signature does not match address" } else { const { address, chainId } = await res.json(); // [!code highlight] // address is the verified Ethereum address. Use it to look up or create the user. } ``` **Response**, `200 OK` ```json { "address": "0xAbC123...", "chainId": 1 } ``` ## Nonce lifecycle Every sign-in attempt must use a fresh nonce from the server. Nonces: - Are 16 random bytes, hex encoded (32 characters, 128 bits of entropy) - Expire after `nonceTtlSeconds` (default: 300 seconds) - Are deleted as soon as the message passes the format, domain, URI, version and nonce checks, before the signature is checked, so a failed signature check also consumes the nonce The nonce is embedded in the signed message, so it cannot be stripped or replaced after signing. This prevents replay attacks: a captured `(message, signature)` pair from one session cannot be submitted again. If the nonce expires before the user signs, the verify endpoint returns `400` with `"Nonce expired"`. An unknown or already used nonce returns `400` with `"Nonce not found or already used"`. Request a new nonce and rebuild the message. ## Linking wallets to users SIWE verifies an address, it does not create or look up a user by itself. After a successful `/auth/siwe/verify`, use the returned `address` to find an existing user record or create a new one in your own storage, then create a session with the session manager (available when `auth.session` is configured): ```typescript const { address, chainId } = await res.json(); // Look up or create the user in your own wallet mapping (your schema) const userId = await findOrCreateUserByWallet(address.toLowerCase()); // Create a TheAuth session for this user const { token, session } = await theauth.auth.session!.create(userId); ``` `theauth.auth.session.create(userId)` returns `{ session, token }`. Send `token` to the client as a cookie or bearer token. ## Endpoints | Endpoint | Method | Auth required | Description | |----------|--------|--------------|-------------| | `/auth/siwe/nonce` | `GET` | No | Generate a single-use nonce | | `/auth/siwe/verify` | `POST` | No | Verify message and signature, return address | ## Configuration reference | Option | Type | Default | Description | |--------|------|---------|-------------| | `domain` | `string` | required | Your app's domain, shown in the wallet prompt (e.g. `example.com`) | | `uri` | `string` | required | Full origin URI (e.g. `https://example.com`). Must match what the wallet signed | | `statement` | `string` |, | Human-readable statement shown in the wallet (e.g. `Sign in to Example App`) | | `nonceTtlSeconds` | `number` | `300` | How long a nonce is valid before it expires | | `verifySignature` | `(message, signature) => Promise` |, | Custom signature recovery function. Returns the recovered address, which must match the address in the message. Required in production | ## Security considerations **Always add `verifySignature` in production.** Without it, the plugin validates message structure and nonce state, but anyone can submit a well-formed SIWE message for any address without a real signature. **Domain and URI binding.** The plugin rejects messages where `domain` or `uri` do not match the server's config exactly. This prevents phishing attacks where a malicious site captures a signature meant for a different origin. **Nonce reuse prevention.** Nonces are deleted once the message passes the structural, domain, URI, version and nonce checks, including when the signature check then fails. A second attempt with the same nonce always fails. **Chain ID.** The chain ID in the message is returned to your application but is not enforced by the plugin. If your app is chain-specific (e.g. only Ethereum mainnet), check that `chainId === 1` (or your expected value) after verification. ## Related Device biometric auth as another password-free option. Email-based passwordless sign-in with single-use links. Guest sessions you can upgrade into a real account later. All sign-in methods available in theAuth. --- # Device authorization Source: https://docs.theauth.dev/auth/device The device authorization grant ([RFC 8628](https://www.rfc-editor.org/rfc/rfc8628)) lets a device that cannot show a browser, a CLI tool, smart TV, game console, or IoT sensor, authenticate by delegating the sign-in step to a secondary device the user already trusts. The device displays a short code like `BDFK-RSTV`. The user opens a URL on their phone or laptop, signs in, types the code, and the waiting device's next poll returns `{ authorized: true, user_id }`. No credentials ever travel through the constrained device. The flow confirms which user approved the device, it does not mint a session or token for the device, so your app decides what credential to issue next. ## Setup ### Add the plugin ```typescript title="lib/theauth.ts" import { createTheAuth } from '@glinr/theauth'; import { deviceAuth } from '@glinr/theauth/auth'; // [!code highlight] const theauth = await createTheAuth({ database: { provider: 'postgres', url: process.env.DATABASE_URL! }, auth: { session: { secret: process.env.SESSION_SECRET! } }, // lets the approval page identify the signed-in user plugins: [ deviceAuth({ // [!code highlight] verificationUri: 'https://app.example.com/device', // [!code highlight] }), // [!code highlight] ], }); ``` Pending grants are kept in process memory. They are lost on restart and are not shared between server instances, so run the device flow on a single instance or route the three endpoints to the same one. ### Build the user-facing approval page Create a page at your `verificationUri`. It should let a signed-in user enter the code the device is showing and approve or deny the request. ```typescript title="app/device/page.tsx (Next.js)" export default function DevicePage({ searchParams, }: { searchParams: { user_code?: string }; }) { // user_code is pre-filled when the device uses verification_uri_complete const [code, setCode] = useState(searchParams.user_code ?? ''); async function approve() { await fetch('/api/theauth/auth/device/authorize', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user_code: code, action: 'approve' }), // [!code highlight] }); } async function deny() { await fetch('/api/theauth/auth/device/authorize', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user_code: code, action: 'deny' }), // [!code highlight] }); } return (
setCode(e.target.value)} placeholder="XXXX-XXXX" />
); } ``` The `/auth/device/authorize` endpoint requires an authenticated user (from `auth.adapter` or an `auth.session` token). The user must be signed in before they can approve or deny a device code. `action` is `'approve'` (the default) or `'deny'`.
## Device flow ### Request codes (on the device) `POST /auth/device/code` The device calls this endpoint to start a new authorization attempt. No authentication required. ```typescript title="CLI tool" const res = await fetch('https://auth.example.com/api/theauth/auth/device/code', { method: 'POST', }); const data = await res.json(); // { // device_code: 'a3f9c2e1d8b04f7a...', // opaque, used for polling // user_code: 'BDFK-RSTV', // shown to the user // verification_uri: 'https://app.example.com/device', // verification_uri_complete: 'https://app.example.com/device?user_code=BDFK-RSTV', // expires_in: 900, // seconds until the code expires // interval: 5, // minimum seconds between poll requests // } console.log(`Open ${data.verification_uri} and enter: ${data.user_code}`); ``` Display `user_code` prominently, this is what the user types. `verification_uri_complete` includes the code as a query parameter, so you can also show a QR code for it. ### Poll for authorization (on the device) `POST /auth/device/token` Poll this endpoint at the `interval` returned in the previous step (default: every 5 seconds). Keep polling until you get an `authorized` response or the code expires. While the grant is pending the endpoint answers `400` with `error: 'authorization_pending'`. ```typescript title="CLI tool" const { device_code, expires_in } = data; let interval: number = data.interval; const deadline = Date.now() + expires_in * 1000; while (Date.now() < deadline) { await new Promise(resolve => setTimeout(resolve, interval * 1000)); const pollRes = await fetch('https://auth.example.com/api/theauth/auth/device/token', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ device_code }), // [!code highlight] }); const result = await pollRes.json(); if (pollRes.ok && result.authorized) { console.log('Authorized! User ID:', result.user_id); // [!code highlight] break; } if (pollRes.status === 429) { // Rate limited, back off before the next poll interval += 5; continue; } if (result.error === 'slow_down') { // [!code highlight] // Server asked for a longer interval interval = result.interval; // [!code highlight] continue; } if (result.error === 'access_denied') { console.error('User denied the request.'); break; } if (result.error === 'expired_token') { console.error('Code expired. Start over.'); break; } // 'authorization_pending', keep polling } ``` ### User approves (on the secondary device) The user opens `verification_uri` on their phone or laptop, signs in, and enters the code. The approval page calls `/auth/device/authorize` with the user code and `action: 'approve'`. The next poll from the device returns `{ authorized: true, user_id: '...' }`. A denied grant returns `access_denied` instead. ## User code format User codes use the format `XXXX-XXXX`, two four-character segments separated by a hyphen. The character set is consonants only (`BCDFGHJKLMNPQRSTVWXZ`), which avoids: - Visually ambiguous characters (no `0`/`O`, `1`/`I`, `5`/`S`) - Characters that read awkwardly when pronounced aloud The alphabet and segment length are fixed. The `codeLength` option controls the length of each segment (default: 4). Input on the approval page is case-insensitive and whitespace-tolerant, `bdfk rstv`, `BDFK-RSTV`, and `BDFKRSTV` all resolve to the same grant. ## Polling interval and rate limits The initial interval is returned by the `/auth/device/code` endpoint (default: 5 seconds). Use it as your starting poll delay. The `deviceAuth()` plugin protects the code and token endpoints with a per-IP rate limit and answers over-limit requests with `429` and `{ "error": "Rate limit exceeded" }`. Back off when you see a `429`. The module's own `handleRequest` (from `createDeviceAuthModule`) additionally returns `400` with `error: 'slow_down'` and a larger `interval` when a device polls again within 4 seconds. The plugin endpoints do not emit `slow_down`. If you do receive `slow_down`, use the new `interval` for all later polls. ## Endpoints | Endpoint | Method | Auth required | Description | |----------|--------|--------------|-------------| | `/auth/device/code` | `POST` | No | Start a new device flow, returns device code and user code | | `/auth/device/token` | `POST` | No | Poll for authorization status | | `/auth/device/authorize` | `POST` | Yes | User approves or denies a device code, body `{ user_code, action }` | ### `/auth/device/code` response | Field | Type | Description | |-------|------|-------------| | `device_code` | `string` | Opaque code used for polling. Keep this secret on the device | | `user_code` | `string` | Short human-readable code shown to the user (e.g. `BDFK-RSTV`) | | `verification_uri` | `string` | URL the user visits to approve | | `verification_uri_complete` | `string` | Same URL with `user_code` as a query param, useful for QR codes | | `expires_in` | `number` | Seconds until the codes expire (default: 900) | | `interval` | `number` | Minimum seconds between poll requests (default: 5) | ### `/auth/device/token` responses An approved grant returns `200` with `{ "authorized": true, "user_id": "..." }`. Every other state returns `400` with an `error` code: | `error` | Meaning | |---------|---------| | `authorization_pending` | User has not acted yet, keep polling (HTTP 400) | | `slow_down` | Only from the module's `handleRequest`: polling too fast, use the new `interval` in the response | | `access_denied` | User denied the request, stop polling | | `expired_token` | Code has expired, start over with a new code | ## Configuration reference | Option | Type | Default | Description | |--------|------|---------|-------------| | `verificationUri` | `string` | required | URL the user visits to enter the code and approve | | `codeLength` | `number` | `4` | Length of each segment in the user code (`4` → `XXXX-XXXX`) | | `codeExpirySeconds` | `number` | `900` | How long the device code and user code stay valid | | `pollIntervalSeconds` | `number` | `5` | Minimum interval between polling attempts, returned to the device | ## Related All sign-in methods available in theAuth. Another passwordless flow for devices that can open a browser. How theAuth manages session lifetime and cookie settings. Agent-to-tool authentication for MCP servers. --- # Two-factor auth Source: https://docs.theauth.dev/auth/two-factor The `twoFactor()` plugin adds TOTP (time-based one-time password) support compatible with Google Authenticator, Authy, 1Password, and any RFC 6238 app. Users enroll once by scanning a QR code, then provide a 6-digit code as a second factor. theAuth stores and verifies the TOTP secret and backup codes. It does not sit inside your sign-in flow: there is no built-in "two factor required" challenge on the primary sign-in endpoints. You decide in your own sign-in code when to ask for a code, and call the verify endpoint (or `theauth.totp.verify`) to check it. ## Setup ### Install ```bash pnpm add @glinr/theauth ``` ### Add the plugin ```typescript title="lib/theauth.ts" import { createTheAuth } from '@glinr/theauth'; import { twoFactor } from '@glinr/theauth/auth'; // [!code highlight] const theauth = await createTheAuth({ database: { provider: 'postgres', url: process.env.DATABASE_URL! }, auth: { session: { secret: process.env.SESSION_SECRET! } }, plugins: [ twoFactor({ // [!code highlight] appName: 'My App', // Shown in the authenticator app // [!code highlight] }), // [!code highlight] ], }); ``` All plugin endpoints require an authenticated request, resolved from either a configured `auth.adapter` or an `auth.session` token (`Authorization: Bearer` header or the `theauth_session` cookie). `twoFactor()` works with any primary auth method. Instead of the plugin you can pass the same options as a top-level `totp` key to `createTheAuth`. That exposes `theauth.totp` (`setup`, `enable`, `verify`, `disable`, `isEnabled`, `regenerateBackupCodes`, `handleRequest`), which is `null` when the key is absent. The module's own `handleRequest` serves `POST /auth/2fa/setup`, `/enable`, `/verify`, `/disable` and `/backup-codes` with a `userId` in the body and no session check, so call the methods from server code rather than exposing that handler to browsers. ## Enrollment flow All plugin endpoints require an authenticated request. ### Generate a TOTP secret `POST /auth/2fa/enroll` ```typescript const res = await fetch('/api/theauth/auth/2fa/enroll', { method: 'POST', credentials: 'include', }); const { secret, uri, backupCodes } = await res.json(); // [!code highlight] ``` Render `uri` (an `otpauth://` URL) as a QR code in your UI with a QR library of your choice. The user scans it with their authenticator app, or enters `secret` manually. Show `backupCodes` once at this point. Only hashes are stored, so they cannot be retrieved again. Calling enroll again replaces the secret and backup codes and turns 2FA off until the new secret is confirmed. ### Confirm with the first code Ask the user to enter the code their authenticator app shows to confirm enrollment: `POST /auth/2fa/verify` ```typescript await fetch('/api/theauth/auth/2fa/verify', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code: '482910' }), }); ``` While 2FA is not yet enabled, a valid code activates it and the response is `{ "valid": true, "activated": true }`. An invalid code returns `400`. ## Checking a code at sign-in Once 2FA is enabled, the same endpoint checks a code for the authenticated user: `POST /auth/2fa/verify` ```typescript const res = await fetch('/api/theauth/auth/2fa/verify', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code: '482910', // From the user's authenticator app // [!code highlight] }), }); const { valid, usedBackupCode } = await res.json(); ``` The response is `{ valid: boolean, usedBackupCode?: boolean }`. A wrong code returns `200` with `valid: false`, so check the field rather than the status. The endpoint does not issue a session or set a cookie. To gate sign-in, complete the primary sign-in on your server, hold the user in a "pending second factor" state of your own, and call `theauth.totp.verify(userId, code)` before treating them as signed in. Codes are accepted within one 30-second step either side of the current time by default (`window` and `period` options). ## Backup codes Ten single-use backup codes are generated at enrollment (`backupCodeCount`). Each is an 8-character code from an alphabet that leaves out `0`, `O`, `1` and `I`. A backup code can be submitted in place of a TOTP code at the verify and disable endpoints, useful when the user has lost access to their authenticator app. A used backup code cannot be reused, and the response reports `usedBackupCode: true`. ### Regenerate backup codes `POST /auth/2fa/backup-codes` This invalidates all existing backup codes and issues a new set. It requires a valid TOTP or backup code in the body as confirmation. ```typescript const res = await fetch('/api/theauth/auth/2fa/backup-codes', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code: '482910' }), }); const { backupCodes } = await res.json(); ``` ## Disabling 2FA `POST /auth/2fa/disable` Requires a valid TOTP or backup code as confirmation. Disabling deletes the stored secret and backup codes: ```typescript await fetch('/api/theauth/auth/2fa/disable', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code: '482910' }), }); ``` ## Check enrollment status `GET /auth/2fa/status` ```typescript const res = await fetch('/api/theauth/auth/2fa/status', { credentials: 'include' }); const { enabled } = await res.json(); ``` ## Endpoints All endpoints require an authenticated request. | Endpoint | Description | |----------|-------------| | `POST /auth/2fa/enroll` | Generate a TOTP secret, `otpauth://` URI and backup codes | | `POST /auth/2fa/verify` | Confirm enrollment, or check a code once enabled | | `POST /auth/2fa/disable` | Disable 2FA, requires a current code | | `GET /auth/2fa/status` | Check whether 2FA is enabled | | `POST /auth/2fa/backup-codes` | Regenerate backup codes, requires a current code | ## Options | Option | Type | Default | Description | |--------|------|---------|-------------| | `appName` | `string` | `TheAuth` | App name shown in the authenticator (used as the `otpauth://` label and issuer) | | `period` | `number` | `30` | TOTP step in seconds | | `backupCodeCount` | `number` | `10` | Number of backup codes generated at enrollment | | `window` | `number` | `1` | Adjacent time steps accepted on either side, to tolerate clock drift | ## Related Skip 2FA on verified devices for a limited window. Block passwords found in known data breaches at sign-up. Built-in primary auth method that pairs with two-factor auth. Phishing-resistant WebAuthn alternative to TOTP. --- # Captcha Source: https://docs.theauth.dev/auth/captcha theAuth verifies captcha tokens against Cloudflare Turnstile, hCaptcha, or Google reCAPTCHA. It ships as a verification module, not a plugin: there is no `captcha()` plugin export, and theAuth does not intercept your auth endpoints. You pass the `captcha` config key, then call `theauth.captcha` from your own route handlers before you process a sign-up or sign-in request. ## Supported providers | Provider | `provider` value | |----------|------------------| | Cloudflare Turnstile | `'turnstile'` | | hCaptcha | `'hcaptcha'` | | Google reCAPTCHA (v2 or v3) | `'recaptcha'` | ## Setup ```typescript title="lib/theauth.ts" import { createTheAuth } from '@glinr/theauth'; const theauth = await createTheAuth({ database: { provider: 'postgres', url: process.env.DATABASE_URL! }, captcha: { // [!code highlight] provider: 'turnstile', // [!code highlight] secretKey: process.env.TURNSTILE_SECRET_KEY!, // [!code highlight] }, // [!code highlight] }); ``` `theauth.captcha` is `null` unless the `captcha` key is passed, so use `theauth.captcha?.` or `theauth.captcha!.` in handlers. ## Verify a token in your handler Add the provider's widget to your form and send the token to your server. Check it with `verify`, passing the client IP when you have it: ```typescript title="Protect a sign-up route (server)" export async function POST(request: Request): Promise { const { captchaToken } = (await request.clone().json()) as { captchaToken?: string }; const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim(); const result = await theauth.captcha?.verify(captchaToken ?? '', ip); // [!code highlight] if (!result?.success) { return new Response(JSON.stringify({ error: result?.error ?? 'Captcha failed' }), { status: 403 }); } // ...continue with sign-up return new Response('ok'); } ``` `verify(token, ip?)` returns `{ success: boolean, score?: number, error?: string }`. An empty token returns `{ success: false, error: 'Missing captcha token' }`. A network failure or non-2xx response from the provider also returns `success: false` with an `error` message. ## Header-based check `theauth.captcha.middleware(request)` reads the token from the `X-Captcha-Token` request header and returns `{ valid: boolean, error?: string }`. It takes the client IP from `CF-Connecting-IP` or the first `X-Forwarded-For` entry. ```typescript title="Header check (server)" const check = await theauth.captcha?.middleware(request); if (!check?.valid) { return new Response(JSON.stringify({ error: check?.error }), { status: 403 }); } ``` ```typescript title="Client" await fetch('/api/sign-up', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Captcha-Token': token }, body: JSON.stringify({ username: 'ada', password: 'correct horse battery' }), }); ``` ## reCAPTCHA v3 score threshold With `provider: 'recaptcha'`, when the provider response includes a `score` (reCAPTCHA v3), `verify` rejects scores below `minScore` (0.0 to 1.0, default 0.5): ```typescript title="lib/theauth.ts" const theauth = await createTheAuth({ database: { provider: 'postgres', url: process.env.DATABASE_URL! }, captcha: { provider: 'recaptcha', secretKey: process.env.RECAPTCHA_SECRET!, minScore: 0.5, // [!code highlight] }, }); ``` `minScore` is ignored for Turnstile and hCaptcha. For hCaptcha, a `score` from the provider is returned in the result but not compared with a threshold. ## Configuration reference Captcha provider to use. Server-side secret key from the provider dashboard. Minimum score for reCAPTCHA v3. Ignored for other providers. Endpoint names intended to require captcha (documented default: `sign-up`, `sign-in`, `reset-password`). Accepted in the config type, but theAuth does not currently read it, so enforcement is up to your own handlers. ## Related A sign-up and sign-in flow you can put captcha checks in front of. Guest sessions as an alternative to requiring sign-up upfront. Request-level rate limiting for agent and auth endpoints. Lifecycle hooks you can attach to your instance. --- # Anonymous auth Source: https://docs.theauth.dev/auth/anonymous The `anonymousAuth` plugin creates a lightweight guest identity on demand. The guest gets a real session and can use your app. When they decide to register, the guest user is upgraded in place by setting an email, so the user ID and any data tied to it are unchanged. Anonymous users are rows in the users table with a placeholder email (`anon_@theauth.anonymous`) and a `{ anonymous: true }` metadata flag. ## Setup ```typescript title="lib/theauth.ts" import { createTheAuth } from '@glinr/theauth'; import { anonymousAuth } from '@glinr/theauth/auth'; // [!code highlight] const theauth = await createTheAuth({ database: { provider: 'postgres', url: process.env.DATABASE_URL! }, auth: { session: { secret: process.env.SESSION_SECRET! } }, // [!code highlight] plugins: [ anonymousAuth({ // [!code highlight] sessionTtlSeconds: 24 * 60 * 60, // [!code highlight] }), // [!code highlight] ], }); ``` The plugin throws at startup unless `auth.session` is configured. Its config is optional, `anonymousAuth()` works with defaults. ## Create guest `POST /auth/anonymous` Creates a new anonymous user and returns a session token. No request body needed. ```typescript title="Create guest (client)" const res = await fetch('/api/theauth/auth/anonymous', { method: 'POST', }); const { userId, sessionToken } = await res.json(); // userId is a real user ID, safe to reference in your DB ``` The response is `{ userId, sessionToken }`. The plugin does not set a cookie, so your app stores the token. The endpoint is rate limited per IP address (20 requests per window). ## Check status `GET /auth/anonymous/status` Requires an authenticated request. Returns `{ anonymous: boolean }`, which is `true` while the user is an un-upgraded guest. ## Upgrade to real account `POST /auth/anonymous/upgrade` Requires an authenticated anonymous session. Sets the real email (and optionally a name) on the guest user. The session stays valid and the user ID does not change. ```typescript title="Upgrade guest (client)" const res = await fetch('/api/theauth/auth/anonymous/upgrade', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${sessionToken}`, }, body: JSON.stringify({ email: 'user@example.com', // [!code highlight] name: 'Ada Lovelace', }), }); const { upgraded } = await res.json(); // true ``` The body takes `email` (required) and `name`. It does not take a password, so after upgrading you attach a sign-in method (for example magic link or email OTP) to that email. The endpoint does not check whether the email is already used by another account, and users have a unique email constraint, so a duplicate email fails with a `500` error. **Error responses** | Status | Meaning | |--------|---------| | 400 | `email` missing, or the user is not an anonymous user | | 401 | No authenticated session | | 500 | Any other failure, including an email that is already taken | ## Guest cleanup Anonymous accounts that are never upgraded accumulate. The plugin does not schedule cleanup and does not expose the cleanup function. Use the module directly, with the instance's database and session manager: ```typescript title="Scheduled cleanup" import { createAnonymousAuthModule } from '@glinr/theauth/auth'; const anonymous = createAnonymousAuthModule({}, theauth.db, theauth.auth.session!); // Run daily via cron, queue worker, etc. const removed = await anonymous.cleanup(7 * 24 * 60 * 60 * 1000); // max age in milliseconds ``` `cleanup(maxAgeMs)` deletes anonymous users created before the cutoff (default 24 hours) along with their sessions, and returns the number of users removed. It selects on creation time, not last activity. Deleting anonymous users is permanent. Any app data tied to their user ID will become orphaned unless you cascade deletes in your schema. ## Configuration reference Intended lifetime of anonymous sessions, in seconds. Currently this value is only recorded in the session metadata. The session itself expires after `auth.session.maxAge` (7 days by default). Whether anonymous users may create agents. Accepted in the config type but not enforced by the plugin endpoints. ## Related Full registration flow users can upgrade into from a guest session. Passwordless sign-in that anonymous users can upgrade into. How theAuth manages session lifetime and cookie settings. Lifecycle hooks you can attach to your instance. --- # Google One-tap Source: https://docs.theauth.dev/auth/one-tap Google One-tap lets users sign in with a single tap using their Google account. The frontend shows Google's prompt, the backend verifies the ID token via Google's JWKS. No Google SDK needed server-side. ## Setup ### Get a client ID Go to the [Google Cloud Console](https://console.cloud.google.com/), create an OAuth 2.0 credential, copy the **Client ID**. ### Configure the plugin ```ts import { createTheAuth } from '@glinr/theauth'; import { oneTap } from '@glinr/theauth/auth'; const theauth = await createTheAuth({ database: { provider: 'sqlite', url: 'theauth.db' }, secret: process.env.THEAUTH_SECRET!, auth: { session: { secret: process.env.THEAUTH_SECRET! } }, // [!code highlight] plugins: [ oneTap({ clientId: process.env.GOOGLE_CLIENT_ID! }), ], }); ``` `oneTap()` issues a session on success, so `auth.session` must be configured. Without it the plugin throws on startup. ### Add Google's script to your frontend ```html
```
## How it works 1. Google's JS shows a sign-in prompt on your page 2. User taps their Google account 3. Google sends a `credential` (JWT ID token) to your callback 4. theAuth verifies the JWT against Google's JWKS (`https://www.googleapis.com/oauth2/v3/certs`) 5. Validates audience, issuer, expiry, and CSRF token 6. Finds the user by email (or creates one when `autoCreateUser` is true) and returns a session as JSON: `{ user: { id, email }, session: { token, expiresAt } }`. The plugin does not set a cookie or redirect ## CSRF protection Google sends a `g_csrf_token` cookie with the request. theAuth validates that the cookie value matches the `g_csrf_token` field in the POST body. ## Config | Option | Type | Default | Description | |--------|------|---------|-------------| | `clientId` | string | required | Google OAuth client ID | | `autoCreateUser` | boolean | `true` | Create user if not found | | `csrfCookieName` | string | `"g_csrf_token"` | CSRF cookie name | ## Endpoint | Method | Path | Description | |--------|------|-------------| | POST | `/auth/one-tap/callback` | Verify ID token, create session | Google One-tap requires HTTPS in production. It works on localhost for development. The One-tap handler only matches the exact path `/auth/one-tap/callback`. When theAuth is mounted under an adapter prefix such as `/api/theauth`, the request path includes the prefix and the handler responds with `Unexpected routing error` (500). Serve theAuth with no path prefix for One-tap, or route the callback to it at the root path. ## Related Full Google OAuth 2.0 flow with additional scopes. Generic OAuth 2.0 setup and account linking behavior. Another low-friction sign-in option without a password. All sign-in methods available in theAuth. --- # OAuth proxy Source: https://docs.theauth.dev/auth/oauth-proxy Mobile apps cannot safely store OAuth client secrets. Embedding a secret in an iOS or Android binary is not safe, it can be extracted. The standard workaround (PKCE without a secret) works for some providers but not all. The OAuth proxy sits in between: the mobile app kicks off an OAuth flow through theAuth, which holds the client secret and performs the code exchange on the device's behalf. The app gets back the provider's tokens via its custom URL scheme, never touching the secret directly. `oauthProxy()` takes its own `providers` map of provider instances (the same factories used by `oauth()`, such as `createGoogleProvider`), so it works with any provider you list there. The mobile app only needs to know the provider key and its own redirect URI. The proxy returns the provider's tokens as-is: it does not create a theAuth user or a theAuth session, and it does not need `auth.session`. ## How it works ``` Mobile app TheAuth Google │ │ │ │ GET /auth/oauth-proxy │ │ │ /start?provider=google │ │ │ &redirect_uri=myapp:// │ │ │──────────────────────────▶ │ │ │ │ │ { authUrl, proxyState } │ │ ◀──────────────────────────│ │ │ │ │ │ Open authUrl in browser │ │ │─────────────────────────────────────────────────────▶ │ │ │ │ │ Redirect to │ │ │ /auth/oauth-proxy/callback│ │ ◀─────────────────────────── │ │ │ │ │ │ Exchange code (secret │ │ │ never leaves server) │ │ │──────────────────────────▶│ │ │ access_token, id_token │ │ ◀───────────────────────────│ │ │ │ │ 302 → myapp://callback │ │ │ ?access_token=... │ │ ◀──────────────────────────│ │ ``` ## Setup ### Configure the plugin ```typescript title="lib/theauth.ts" import { createTheAuth } from '@glinr/theauth'; import { oauthProxy, createGoogleProvider } from '@glinr/theauth/auth'; // [!code highlight] const theauth = await createTheAuth({ database: { provider: 'postgres', url: process.env.DATABASE_URL! }, secret: process.env.THEAUTH_SECRET!, baseUrl: 'https://auth.example.com', // required: used to build {baseUrl}/auth/oauth-proxy/callback plugins: [ oauthProxy({ // [!code highlight] providers: { // [!code highlight] google: createGoogleProvider({ // [!code highlight] clientId: process.env.GOOGLE_CLIENT_ID!, // [!code highlight] clientSecret: process.env.GOOGLE_CLIENT_SECRET!, // [!code highlight] }), // [!code highlight] }, // [!code highlight] allowedRedirectUris: [ // [!code highlight] 'com.example.myapp://oauth/callback', // [!code highlight] ], // [!code highlight] }), // [!code highlight] ], }); ``` ### Register the server callback URI with your provider When registering the OAuth application with your provider (Google, GitHub, etc.), add the theAuth callback URL as an allowed redirect URI: ``` https://auth.example.com/auth/oauth-proxy/callback ``` The mobile app's custom scheme (`com.example.myapp://...`) is **not** registered with the provider, only theAuth's server URL is. If you mount theAuth under an adapter prefix such as `/api/theauth`, include it in `baseUrl` and in the URL you register. ### Implement the flow in your mobile app ```typescript title="Mobile app (React Native / Expo)" import * as Linking from 'expo-linking'; import * as WebBrowser from 'expo-web-browser'; const BASE_URL = 'https://auth.example.com'; async function signInWithGoogle() { // 1. Start the proxy flow const redirectUri = Linking.createURL('oauth/callback'); // e.g. com.example.myapp://oauth/callback const startRes = await fetch( `${BASE_URL}/auth/oauth-proxy/start?provider=google&redirect_uri=${encodeURIComponent(redirectUri)}` ); const { authUrl } = await startRes.json(); // 2. Open the provider auth page in a browser const result = await WebBrowser.openAuthSessionAsync(authUrl, redirectUri); if (result.type !== 'success') return; // 3. Parse tokens from the redirect URL const url = new URL(result.url); const accessToken = url.searchParams.get('access_token'); const refreshToken = url.searchParams.get('refresh_token'); const idToken = url.searchParams.get('id_token'); // Use the tokens to authenticate with your backend } ``` ## Endpoints | Endpoint | Method | Description | |----------|--------|-------------| | `/auth/oauth-proxy/start` | `GET` | Start a proxy flow, returns the provider auth URL | | `/auth/oauth-proxy/callback` | `GET` | Provider callback, exchanges the code and redirects to the mobile app | ### `/auth/oauth-proxy/start` query parameters | Parameter | Required | Description | |-----------|----------|-------------| | `provider` | Yes | Provider ID as configured in `providers` (e.g. `google`, `github`) | | `redirect_uri` | Yes | Mobile app callback URI. Must be in `allowedRedirectUris` | | `state` | No | Opaque value forwarded to the mobile app after the flow completes | | `code_challenge` | No | Accepted but ignored. The server always generates its own PKCE verifier | ### `/auth/oauth-proxy/start` response ```json { "authUrl": "https://accounts.google.com/o/oauth2/v2/auth?...", "proxyState": "3f8a2c1d-..." } ``` Redirect the user to `authUrl`. `proxyState` is managed internally and round-trips through the provider. ### Callback redirect to mobile app After a successful exchange, the server issues a `302` redirect to the mobile app URI with tokens as query parameters: ``` com.example.myapp://oauth/callback ?access_token=ya29.a0ARrdaM... &refresh_token=1//0eXz... &id_token=eyJhbGci... &expires_in=3600 &state= ← only if state was provided ``` If the user denies the request or the provider returns an error, the redirect includes `?error=access_denied` instead. ## PKCE support The proxy generates a PKCE code verifier and challenge for every flow. The verifier is stored server-side alongside the proxy state and is used when exchanging the authorization code. The mobile app never needs to supply its own verifier, the server handles this entirely, preventing authorization code interception attacks even for providers that do not require PKCE. Tokens are passed as URL query parameters so that custom-scheme handlers on iOS and Android can read them. Treat them as you would any OAuth token, store them in the device's secure keychain, not in plain storage. ## Security **Redirect URI validation**, only URIs in `allowedRedirectUris` are accepted. Exact matches and scheme-prefix matches (entries ending with `://`) are supported. Everything else returns `400`. **State TTL**, proxy state entries expire after 10 minutes by default. An expired or unknown state returns `400` and cannot be replayed. **One-time state**, the state entry is deleted before the token exchange network call, preventing replay attacks even if the callback is called twice. Proxy state is kept in the memory of the server process, so a flow must start and finish on the same instance, and pending flows are lost on restart. **No open redirects**, the final redirect destination always comes from the stored state entry, never from user-supplied query parameters at callback time. ## Configuration reference | Option | Type | Default | Description | |--------|------|---------|-------------| | `providers` | `Record` | required | Provider instances keyed by ID | | `allowedRedirectUris` | `string[]` | required | Allowlist of mobile app redirect URIs | | `rateLimit.max` | `number` | `20` | Max requests per window per IP | | `rateLimit.windowSeconds` | `number` | `60` | Rate limit window in seconds | | `stateTtlSeconds` | `number` | `600` | Proxy state lifetime in seconds | ## Related Standard browser-based OAuth 2.0 flow with PKCE. All sign-in methods available in theAuth. How theAuth manages session lifetime and cookie settings. A fully on-device alternative to OAuth for mobile apps. --- # OAuth overview Source: https://docs.theauth.dev/auth/oauth The `oauth()` plugin adds social sign-in to theAuth using the OAuth 2.0 authorization code flow with PKCE (S256). It handles the token exchange, account linking, and session creation. `oauth()` issues a session on a successful callback, so `auth.session` must be configured in `createTheAuth`. Without it the plugin throws on startup. ## Built-in providers Each provider is a factory exported from `@glinr/theauth/auth`. Pass the resulting instances to `oauth()` as a map keyed by provider ID. Other first-class factories (`createXProvider({ clientId, clientSecret, scopes?, redirectUri? })`) exist for Atlassian, Dropbox, Figma, Notion, Reddit, Spotify, Twitch, Twitter / X, and Zoom. Additional services (Facebook, Bitbucket, Yahoo, LINE, Coinbase, and others) have preset helpers such as `facebookProvider(clientId, clientSecret, scopes?)`, and Auth0, Okta, and Cognito take the tenant domain first: `auth0Provider(domain, clientId, clientSecret, scopes?)`. See the individual provider pages. ## Generic setup ```typescript title="lib/theauth.ts" import { createTheAuth } from '@glinr/theauth'; import { oauth, createGoogleProvider } from '@glinr/theauth/auth'; // [!code highlight] const theauth = await createTheAuth({ database: { provider: 'postgres', url: process.env.DATABASE_URL! }, secret: process.env.THEAUTH_SECRET!, baseUrl: 'https://auth.example.com', auth: { session: { secret: process.env.THEAUTH_SECRET! } }, // [!code highlight] plugins: [ oauth({ // [!code highlight] providers: { // [!code highlight] google: createGoogleProvider({ clientId: process.env.GOOGLE_CLIENT_ID!, clientSecret: process.env.GOOGLE_CLIENT_SECRET!, }), }, // [!code highlight] }), // [!code highlight] ], }); ``` `providers` is a `Record`. The key is the provider ID used in the URLs below. The session secret must be at least 32 characters. The callback URL you register with each provider is: ``` {baseUrl}/auth/oauth/callback/{provider} ``` `baseUrl` is the public URL where theAuth is served. When you mount theAuth under an adapter prefix such as `/api/theauth`, include the prefix in `baseUrl` (for example `https://your-app.com/api/theauth`), so the URL registered with the provider is `https://your-app.com/api/theauth/auth/oauth/callback/google`. To compute a different redirect URI, pass `buildRedirectUri: (provider, baseUrl) => string` to `oauth()`. A provider can also carry its own `redirectUri` override. ## Endpoints The plugin registers these routes (relative to the adapter prefix): | Method | Path | Description | |--------|------|-------------| | `GET` | `/auth/oauth/authorize/:provider` | Redirects (302) to the provider. Rate limited to 20 requests per minute. | | `GET` | `/auth/oauth/callback/:provider` | Handles the provider callback, creates a session, and redirects. | | `GET` | `/auth/oauth/providers` | Lists configured providers as `{ providers: [{ id, name }] }`. | | `POST` | `/auth/oauth/link` | Links a provider account to the signed-in user. Requires authentication. Body: `{ provider, userInfo, tokens }`. | ## The sign-in flow 1. Your frontend navigates to `/auth/oauth/authorize/{provider}`. theAuth generates a random state and a PKCE verifier, stores both in the database (default lifetime 10 minutes, set with `stateTtlSeconds`), and redirects to the provider with the S256 challenge. 2. The provider redirects back to `/auth/oauth/callback/{provider}` with an authorization code and the state. 3. theAuth validates and consumes the state, exchanges the code for tokens, and fetches the user profile. 4. theAuth finds the user by email or creates one, links the provider account, sets the `theauth_session` cookie, and redirects to `{baseUrl}/?auth_user=`. The provider account is identified by the provider's own user ID, so the same provider account always resolves to the same linked row. Every first-class provider sends the PKCE challenge except Notion, which does not support PKCE. GitHub accepts the parameter and ignores it. ## Account linking When the email returned by the provider matches an existing user, theAuth links the provider account to that user. Otherwise it creates a new user with the email marked as verified. A user can have several linked providers. Only the email and name are copied onto the user record; the avatar is not stored. If a provider returns no email (Reddit, for example), the callback cannot match or create a user. The account row stays unassigned and the callback responds with JSON (`isNewAccount`, `account`, `userInfo`) instead of a session. Auto-linking trusts the email reported by the provider and cannot be turned off. If your threat model requires stronger guarantees, only enable providers that verify email addresses. The `oauth()` plugin does not expose a way to list or disconnect linked providers on the `createTheAuth` instance. To work with linked accounts directly, build the module yourself with `createOAuthModule(db, { providers })`, which offers `getAuthorizationUrl`, `handleCallback`, `linkAccount`, and `findLinkedUser`. ## Custom providers Any OIDC-compliant provider works with the generic factory `genericOIDC`. Supply an issuer to use discovery, or supply explicit endpoints to skip it: ```typescript import { oauth, genericOIDC } from '@glinr/theauth/auth'; oauth({ providers: { acme: genericOIDC({ id: 'acme', name: 'Acme SSO', issuer: 'https://idp.example.com', // [!code highlight] clientId: process.env.ACME_CLIENT_ID!, clientSecret: process.env.ACME_CLIENT_SECRET!, scopes: ['openid', 'email', 'profile'], // authorizationUrl, tokenUrl, userinfoUrl: optional overrides that skip discovery }), }, }) ``` `genericOIDC` reads `sub`, `email`, `name`, and `picture` from the userinfo response and fails if `sub` or `email` is missing. For a provider whose profile endpoint does not return those claims, implement the `OAuthProvider` interface yourself (`getAuthorizationUrl`, `exchangeCode`, and `getUserInfo`, which returns `{ id, email, name?, avatar?, raw }`) and put the instance in `providers`. ## Related Sign in with Google using OIDC, the most common provider. OAuth 2.0 for GitHub with optional org and email scopes. All sign-in methods available in theAuth. Turn theAuth itself into an OpenID Connect identity provider. --- # Google Source: https://docs.theauth.dev/auth/google ## Get credentials ### Create a project Go to [Google Cloud Console](https://console.cloud.google.com/) and create a new project (or select an existing one). ### Enable the People API Navigate to **APIs and Services > Library**, search for "Google People API", and enable it. This is only needed if you plan to call the People API yourself; sign-in reads the profile from the OpenID userinfo endpoint. ### Create OAuth credentials Go to **APIs and Services > Credentials > Create Credentials > OAuth client ID**. - Application type: **Web application** - Authorized redirect URIs: `https://auth.example.com/auth/oauth/callback/google` Copy the **Client ID** and **Client Secret**. ### Configure the consent screen Under **OAuth consent screen**, set the app name, support email, and authorized domain. For production, submit for verification if you need access to sensitive scopes. ## Configuration ```typescript title="lib/theauth.ts" import { createTheAuth } from '@glinr/theauth'; import { oauth, createGoogleProvider } from '@glinr/theauth/auth'; const theauth = await createTheAuth({ database: { provider: 'postgres', url: process.env.DATABASE_URL! }, secret: process.env.THEAUTH_SECRET!, baseUrl: 'https://auth.example.com', auth: { session: { secret: process.env.THEAUTH_SECRET! } }, plugins: [ oauth({ providers: { google: createGoogleProvider({ // [!code highlight] clientId: process.env.GOOGLE_CLIENT_ID!, // [!code highlight] clientSecret: process.env.GOOGLE_CLIENT_SECRET!, // [!code highlight] }), // [!code highlight] }, }), ], }); ``` Add to your environment: ```bash GOOGLE_CLIENT_ID=...apps.googleusercontent.com GOOGLE_CLIENT_SECRET=GOCSPX-... ``` ## Scopes Default scopes: `openid email profile`. Scopes you pass are added on top of the defaults. These give you name, email, and profile picture. To request additional permissions: ```typescript createGoogleProvider({ clientId: process.env.GOOGLE_CLIENT_ID!, clientSecret: process.env.GOOGLE_CLIENT_SECRET!, scopes: ['https://www.googleapis.com/auth/calendar.readonly'], // [!code highlight] }) ``` Extra scopes beyond `openid email profile` require your app to complete Google's verification process before they work for users outside your organization. The provider also requests `access_type=offline` and `prompt=consent`, so Google returns a refresh token and shows the consent screen on every sign-in. ## User data returned | Field | Source | Notes | |-------|--------|-------| | `id` | `sub` claim | Stable Google user ID | | `email` | `email` claim | Verified by Google | | `name` | `name` claim | Full display name | | `avatar` | `picture` claim | Profile photo URL. Not stored on the user record | ## Initiating sign-in Redirect users to: ``` GET /auth/oauth/authorize/google ``` After the callback, theAuth sets the session cookie and redirects to `{baseUrl}/`. There is no `redirectTo` parameter. ## Related One-tap sign-in widget that uses Google ID tokens server-side. Generic OAuth 2.0 setup and account linking behavior. Sign in with Apple for iOS apps and web. All sign-in methods available in theAuth. --- # GitHub Source: https://docs.theauth.dev/auth/github ## Get credentials ### Register an OAuth App Go to [github.com/settings/applications/new](https://github.com/settings/applications/new) (personal account) or **Organization Settings > Developer Settings > OAuth Apps** for an org app. - **Application name**: your app name - **Homepage URL**: `https://example.com` - **Authorization callback URL**: `https://auth.example.com/auth/oauth/callback/github` ### Copy credentials After creating the app, copy the **Client ID**. Click **Generate a new client secret** and copy the secret immediately. GitHub only shows it once. GitHub also supports GitHub Apps, which have more granular permissions and work across organizations. OAuth Apps are simpler for sign-in use cases. ## Configuration ```typescript title="lib/theauth.ts" import { createTheAuth } from '@glinr/theauth'; import { oauth, createGithubProvider } from '@glinr/theauth/auth'; const theauth = await createTheAuth({ database: { provider: 'postgres', url: process.env.DATABASE_URL! }, secret: process.env.THEAUTH_SECRET!, baseUrl: 'https://auth.example.com', auth: { session: { secret: process.env.THEAUTH_SECRET! } }, plugins: [ oauth({ providers: { github: createGithubProvider({ // [!code highlight] clientId: process.env.GITHUB_CLIENT_ID!, // [!code highlight] clientSecret: process.env.GITHUB_CLIENT_SECRET!, // [!code highlight] }), // [!code highlight] }, }), ], }); ``` Extra scopes are added on top of the default `user:email`, which is always requested. ```typescript title="lib/theauth.ts" oauth({ providers: { github: createGithubProvider({ clientId: process.env.GITHUB_CLIENT_ID!, clientSecret: process.env.GITHUB_CLIENT_SECRET!, scopes: ['read:org'], // [!code highlight] }), }, }) ``` ```bash GITHUB_CLIENT_ID=Ov23li... GITHUB_CLIENT_SECRET=... ``` ## Scopes Default scope: `user:email` | Scope | What it unlocks | |-------|----------------| | `user:email` | Read the user's email addresses | | `read:user` | Read the user's profile data | | `read:org` | Read organization membership | | `repo` | Access private repositories | ## User data returned | Field | Source | Notes | |-------|--------|-------| | `id` | `id` field | Stable numeric GitHub user ID | | `email` | Primary verified email | Fetched separately if not public | | `name` | `name` field | Display name, may be null | | `avatar` | `avatar_url` | GitHub avatar URL | GitHub users can set their email to private. theAuth fetches the primary verified email from the `/user/emails` endpoint using the `user:email` scope, so you still get it even if the profile email is hidden. ## Related Generic OAuth 2.0 setup and account linking behavior. OAuth 2.0 for GitLab, including self-hosted instances. Sign in with Google using OIDC. All sign-in methods available in theAuth. --- # Apple Source: https://docs.theauth.dev/auth/apple Sign in with Apple uses OAuth 2.0 with a few Apple-specific requirements: the client secret is a JWT you generate from a private key, and Apple only returns the user's name on the very first authorization. `createAppleProvider` does not currently complete a sign-in through the `oauth()` plugin. Apple has no userinfo endpoint, so the provider reads the user from the `id_token`, but the plugin calls `getUserInfo` with the access token only, and the provider throws `Apple getUserInfo requires the id_token from the token response`. Apple also posts the callback (`response_mode=form_post`), while the plugin registers `GET /auth/oauth/callback/apple` only. The provider builds the authorization URL and exchanges the code, so you can use it with your own callback route, but the steps below do not give you a working end-to-end flow with `oauth()` alone. ## Get credentials from Apple ### Register an App ID 1. Sign in to [Apple Developer](https://developer.apple.com/account) and go to **Certificates, Identifiers & Profiles**. 2. Under **Identifiers**, click **+** and choose **App IDs**. 3. Select **App** as the type, then fill in your bundle identifier (e.g. `com.example.app`). 4. Scroll to **Capabilities** and enable **Sign In with Apple**. 5. Save the App ID. ### Create a Services ID The Services ID is your OAuth `client_id` for web and non-iOS flows. 1. Under **Identifiers**, click **+** and choose **Services IDs**. 2. Enter a description and an identifier (e.g. `com.example.app.auth`). 3. Enable **Sign In with Apple**. 4. Click **Configure** next to Sign In with Apple: - Set your **Primary App ID** to the one you just created. - Add your **domain** (e.g. `auth.example.com`, no trailing slash, no protocol). - Add your **Return URL** (e.g. `https://auth.example.com/auth/oauth/callback/apple`). 5. Save and register. ### Create a private key 1. Under **Keys**, click **+**. 2. Name the key and enable **Sign In with Apple**. 3. Click **Configure** and select your Primary App ID. 4. Download the `.p8` key file, **you can only download it once**. 5. Note your **Key ID** and **Team ID** (visible at the top right of the developer portal). Store the `.p8` file somewhere safe and never commit it to version control. You will use it to sign a JWT locally, then discard the file from your build environment. ## Configuration ```typescript title="lib/theauth.ts" import { createTheAuth } from '@glinr/theauth'; import { oauth, createAppleProvider } from '@glinr/theauth/auth'; // [!code highlight] const theauth = await createTheAuth({ database: { provider: 'postgres', url: process.env.DATABASE_URL! }, secret: process.env.THEAUTH_SECRET!, baseUrl: 'https://auth.example.com', auth: { session: { secret: process.env.THEAUTH_SECRET! } }, plugins: [ oauth({ providers: { apple: createAppleProvider({ // [!code highlight] clientId: process.env.APPLE_CLIENT_ID!, // Services ID // [!code highlight] clientSecret: process.env.APPLE_CLIENT_SECRET!, // Generated JWT (see below) // [!code highlight] }), // [!code highlight] }, }), ], }); ``` The provider requests the `name` and `email` scopes by default and sends a PKCE challenge. Scopes you pass are added to those. ## Generate the client secret Apple does not accept a static client secret. Instead, you generate a JWT signed with your `.p8` private key. The JWT is valid for up to 6 months, so you can generate it once and rotate it before it expires. ```typescript title="scripts/generate-apple-secret.ts" import { SignJWT, importPKCS8 } from 'jose'; // [!code highlight] import { readFileSync } from 'node:fs'; const teamId = process.env.APPLE_TEAM_ID!; // 10-character string, e.g. "ABC1234567" const clientId = process.env.APPLE_CLIENT_ID!; // Services ID, e.g. "com.example.app.auth" const keyId = process.env.APPLE_KEY_ID!; // Key ID from the portal const privateKeyPem = readFileSync('./AuthKey_XXXXXXXXXX.p8', 'utf-8'); const privateKey = await importPKCS8(privateKeyPem, 'ES256'); // [!code highlight] const clientSecret = await new SignJWT({}) // [!code highlight] .setProtectedHeader({ alg: 'ES256', kid: keyId }) // [!code highlight] .setIssuer(teamId) // [!code highlight] .setIssuedAt() // [!code highlight] .setAudience('https://appleid.apple.com') // [!code highlight] .setSubject(clientId) // [!code highlight] .setExpirationTime('180d') // [!code highlight] .sign(privateKey); // [!code highlight] console.log(clientSecret); // paste into APPLE_CLIENT_SECRET ``` Run this script locally once, copy the output JWT, and set it as your `APPLE_CLIENT_SECRET` environment variable. Re-run before the 6-month window closes. The `jose` library is a dependency of theAuth. Install it in your own project if your script imports it directly. ## Environment variables ```bash title=".env" APPLE_CLIENT_ID=com.example.app.auth APPLE_CLIENT_SECRET=eyJ... # JWT generated by the script above APPLE_TEAM_ID=ABC1234567 APPLE_KEY_ID=XXXXXXXXXX ``` Only `APPLE_CLIENT_ID` and `APPLE_CLIENT_SECRET` are needed at runtime. The Team ID and Key ID are only used when regenerating the secret. ## iOS native apps The provider has no separate setting for native apps. A native flow would use the App ID (bundle identifier) as the client ID, but no endpoint in the `oauth()` plugin accepts a code obtained by Apple's native SDK, so native Sign in with Apple is not supported by the plugin today. ## Localhost and development Apple requires HTTPS for redirect URIs. `localhost` will not work. Options: - **[ngrok](https://ngrok.com/)**, `ngrok http 3000` gives you a public HTTPS URL instantly. - **[cloudflared tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/do-more-with-tunnels/local-management/)**, persistent tunnel with a stable subdomain. - **mkcert + local proxy**, run a local HTTPS reverse proxy with a self-signed cert trusted by your browser. Update both your Apple Services ID redirect URI and your `baseUrl` config to match the tunnel URL while developing. ## User data Apple embeds user claims in the `id_token` JWT returned from the token endpoint. The provider decodes it without verifying the signature, and throws if the `sub` or `email` claim is missing. | Field | Notes | |-------|-------| | `id` | The `sub` claim, a stable Apple user identifier | | `email` | May be a private relay address (`random@privaterelay.appleid.com`) if the user chose to hide their email. Absent on repeat authorizations, which makes the provider throw | | `name` | Not available from the `id_token`. Apple delivers it only in the `user` form field on the first authorization, and the provider does not read it | Apple returns the name only once, on the very first sign-in, in a `user` form-post field alongside the authorization code. The provider returns `name: undefined`, so you must capture that field in your own callback handler if you need it. ## Endpoints The `oauth()` plugin registers the same routes for every provider: | Method | Path | Description | |--------|------|-------------| | `GET` | `/auth/oauth/authorize/apple` | Redirect to Apple. | | `GET` | `/auth/oauth/callback/apple` | Callback route. Apple posts to its return URL, so this `GET` route does not receive Apple's response. | ## Related Generic OAuth 2.0 setup and account linking behavior. Another popular sign-in provider using OIDC. Sign in with Microsoft Entra ID for personal and work accounts. All sign-in methods available in theAuth. --- # Microsoft Source: https://docs.theauth.dev/auth/microsoft ## Get credentials ### Register an application Go to the [Azure Portal](https://portal.azure.com/) and navigate to **Microsoft Entra ID > App registrations > New registration**. - **Name**: your app name - **Supported account types**: choose based on your needs (see below) - **Redirect URI**: Web, `https://auth.example.com/auth/oauth/callback/microsoft` ### Create a client secret Navigate to **Certificates and secrets > New client secret**. Set an expiry and copy the secret value immediately. ### Copy the Application ID From the app overview, copy the **Application (client) ID** and the **Directory (tenant) ID**. ## Configuration ```typescript title="lib/theauth.ts" import { createTheAuth } from '@glinr/theauth'; import { oauth, createMicrosoftProvider } from '@glinr/theauth/auth'; const theauth = await createTheAuth({ database: { provider: 'postgres', url: process.env.DATABASE_URL! }, secret: process.env.THEAUTH_SECRET!, baseUrl: 'https://auth.example.com', auth: { session: { secret: process.env.THEAUTH_SECRET! } }, plugins: [ oauth({ providers: { microsoft: createMicrosoftProvider({ // [!code highlight] clientId: process.env.MICROSOFT_CLIENT_ID!, // [!code highlight] clientSecret: process.env.MICROSOFT_CLIENT_SECRET!, // [!code highlight] }), // [!code highlight] }, }), ], }); ``` `createMicrosoftProvider` is fixed to the `common` authority and has no `tenant` option. A single-tenant app registration needs its tenant-specific endpoints, so build the provider with the generic OIDC factory instead. Note that this reads the OIDC `sub` and `email` claims from the userinfo response, so the user ID differs from the Graph `id` used by `createMicrosoftProvider`: ```typescript title="lib/theauth.ts" import { oauth, genericOIDC } from '@glinr/theauth/auth'; const tenant = process.env.MICROSOFT_TENANT_ID!; oauth({ providers: { microsoft: genericOIDC({ id: 'microsoft', name: 'Microsoft', issuer: `https://login.microsoftonline.com/${tenant}/v2.0`, // [!code highlight] clientId: process.env.MICROSOFT_CLIENT_ID!, clientSecret: process.env.MICROSOFT_CLIENT_SECRET!, scopes: ['openid', 'profile', 'email'], authorizationUrl: `https://login.microsoftonline.com/${tenant}/oauth2/v2.0/authorize`, // [!code highlight] tokenUrl: `https://login.microsoftonline.com/${tenant}/oauth2/v2.0/token`, // [!code highlight] userinfoUrl: 'https://graph.microsoft.com/oidc/userinfo', // [!code highlight] }), }, }) ``` ```bash MICROSOFT_CLIENT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx MICROSOFT_CLIENT_SECRET=... MICROSOFT_TENANT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx # only for single-tenant ``` ## Account types and tenant `createMicrosoftProvider` always uses the `common` authority (`https://login.microsoftonline.com/common/oauth2/v2.0/...`). The segment in the endpoint URLs decides who can sign in, which you can control with the generic factory shown above: | Authority segment | Who can sign in | |-------|----------------| | `common` (what `createMicrosoftProvider` uses) | Personal Microsoft accounts and work/school accounts | | `organizations` | Work and school accounts only | | `consumers` | Personal Microsoft accounts only | | Your tenant ID | Only users in your Azure AD directory | ## Scopes Default scopes: `openid profile email User.Read`. Scopes you pass are added on top of the defaults. | Scope | What it unlocks | |-------|----------------| | `openid email profile` | Standard OIDC identity | | `User.Read` | Read the signed-in user's profile from MS Graph | | `Calendars.Read` | Read calendar events | | `Mail.Read` | Read email | ## User data returned | Field | Source | Notes | |-------|--------|-------| | `id` | Graph `/me` `id` field | Stable object ID within the tenant | | `email` | Graph `mail`, falling back to `userPrincipalName` | Work email or Microsoft account email | | `name` | Graph `displayName` | Display name | | `avatar` | Not populated | The provider does not fetch the Graph photo | The profile photo is not fetched. Graph serves it from a separate `/me/photo/$value` call that you would make yourself with the user's access token. ## Related Generic OAuth 2.0 setup and account linking behavior. SAML 2.0 and OIDC enterprise SSO, including Azure AD. Sign in with Google for personal and workspace accounts. All sign-in methods available in theAuth. --- # Discord Source: https://docs.theauth.dev/auth/discord ## Get credentials ### Create an application Go to the [Discord Developer Portal](https://discord.com/developers/applications) and click **New Application**. Give it a name. ### Add a redirect URI Navigate to **OAuth2 > General**. Under **Redirects**, add: ``` https://auth.example.com/auth/oauth/callback/discord ``` ### Copy credentials From **OAuth2 > General**, copy the **Client ID** and **Client Secret**. ## Configuration ```typescript title="lib/theauth.ts" import { createTheAuth } from '@glinr/theauth'; import { oauth, createDiscordProvider } from '@glinr/theauth/auth'; const theauth = await createTheAuth({ database: { provider: 'postgres', url: process.env.DATABASE_URL! }, secret: process.env.THEAUTH_SECRET!, baseUrl: 'https://auth.example.com', auth: { session: { secret: process.env.THEAUTH_SECRET! } }, plugins: [ oauth({ providers: { discord: createDiscordProvider({ // [!code highlight] clientId: process.env.DISCORD_CLIENT_ID!, // [!code highlight] clientSecret: process.env.DISCORD_CLIENT_SECRET!, // [!code highlight] }), // [!code highlight] }, }), ], }); ``` ```bash DISCORD_CLIENT_ID=1234567890123456789 DISCORD_CLIENT_SECRET=... ``` ## Scopes Default scopes: `identify email`. Scopes you pass are added on top of the defaults. | Scope | What it unlocks | |-------|----------------| | `identify` | Read username, discriminator, avatar | | `email` | Read the user's email address | | `guilds` | Read the servers the user belongs to | | `guilds.members.read` | Read guild membership details | ## User data returned | Field | Source | Notes | |-------|--------|-------| | `id` | `id` field | Stable Discord snowflake ID | | `email` | `email` field | Verified email | | `name` | `global_name`, falling back to `username` (with `#discriminator` for legacy accounts) | Display name | | `avatar` | `avatar` hash | Constructed CDN URL | Discord email addresses are verified before the account can use OAuth. You will always receive a verified email when the `email` scope is requested. ## Related Generic OAuth 2.0 setup and account linking behavior. Sign in with Slack using OpenID Connect. Another developer-focused OAuth provider. All sign-in methods available in theAuth. --- # Slack Source: https://docs.theauth.dev/auth/slack ## Get credentials ### Create a Slack app Go to [api.slack.com/apps](https://api.slack.com/apps) and click **Create New App > From scratch**. Name your app and select a development workspace. ### Configure OAuth and permissions Navigate to **OAuth and Permissions**. Under **Redirect URLs**, add: ``` https://auth.example.com/auth/oauth/callback/slack ``` Under **Scopes > User Token Scopes**, add `openid`, `email`, and `profile`. ### Copy credentials Go to **Basic Information** and copy the **Client ID** and **Client Secret** under **App Credentials**. theAuth uses Slack's OpenID Connect flow (`/openid/connect/authorize`), not the older `identity.basic` scope approach. Make sure you add **User Token Scopes**, not Bot Token Scopes. ## Configuration ```typescript title="lib/theauth.ts" import { createTheAuth } from '@glinr/theauth'; import { oauth, createSlackProvider } from '@glinr/theauth/auth'; const theauth = await createTheAuth({ database: { provider: 'postgres', url: process.env.DATABASE_URL! }, secret: process.env.THEAUTH_SECRET!, baseUrl: 'https://auth.example.com', auth: { session: { secret: process.env.THEAUTH_SECRET! } }, plugins: [ oauth({ providers: { slack: createSlackProvider({ // [!code highlight] clientId: process.env.SLACK_CLIENT_ID!, // [!code highlight] clientSecret: process.env.SLACK_CLIENT_SECRET!, // [!code highlight] }), // [!code highlight] }, }), ], }); ``` ```bash SLACK_CLIENT_ID=1234567890.1234567890123 SLACK_CLIENT_SECRET=... ``` ## Scopes Default scopes: `openid email profile` These are standard OIDC scopes that Slack supports. No additional User Token Scopes are needed for basic sign-in. ## User data returned | Field | Source | Notes | |-------|--------|-------| | `id` | `sub` claim | Stable Slack user ID | | `email` | `email` claim | Workspace email | | `name` | `name` claim | Display name | | `avatar` | `picture` claim | Profile photo URL | The provider uses the OIDC `sub` claim as the user ID, which Slack documents as the same across workspaces for a given Slack user. Falls back to the `https://slack.com/user_id` claim if `sub` is absent. ## Related Generic OAuth 2.0 setup and account linking behavior. Another workspace-based OAuth provider. Professional network OAuth using OpenID Connect. All sign-in methods available in theAuth. --- # LinkedIn Source: https://docs.theauth.dev/auth/linkedin ## Get credentials ### Create an application Go to [linkedin.com/developers/apps/new](https://www.linkedin.com/developers/apps/new). You will need a LinkedIn Page associated with the app (create a company page if you do not have one). ### Enable Sign In with LinkedIn In your app dashboard, go to the **Products** tab and request access to **Sign In with LinkedIn using OpenID Connect**. This is usually granted immediately. ### Add a redirect URL Go to **Auth > OAuth 2.0 settings**. Under **Authorized redirect URLs for your app**, add: ``` https://auth.example.com/auth/oauth/callback/linkedin ``` ### Copy credentials From the **Auth** tab, copy the **Client ID** and **Client Secret**. LinkedIn's legacy `r_liteprofile` and `r_emailaddress` scopes are deprecated. theAuth uses the OpenID Connect flow with `openid`, `profile`, and `email` scopes, which requires the "Sign In with LinkedIn using OpenID Connect" product to be enabled on your app. ## Configuration ```typescript title="lib/theauth.ts" import { createTheAuth } from '@glinr/theauth'; import { oauth, createLinkedInProvider } from '@glinr/theauth/auth'; const theauth = await createTheAuth({ database: { provider: 'postgres', url: process.env.DATABASE_URL! }, secret: process.env.THEAUTH_SECRET!, baseUrl: 'https://auth.example.com', auth: { session: { secret: process.env.THEAUTH_SECRET! } }, plugins: [ oauth({ providers: { linkedin: createLinkedInProvider({ // [!code highlight] clientId: process.env.LINKEDIN_CLIENT_ID!, // [!code highlight] clientSecret: process.env.LINKEDIN_CLIENT_SECRET!, // [!code highlight] }), // [!code highlight] }, }), ], }); ``` ```bash LINKEDIN_CLIENT_ID=... LINKEDIN_CLIENT_SECRET=... ``` ## Scopes Default scopes: `openid profile email` | Scope | What it unlocks | |-------|----------------| | `openid` | OpenID Connect identity | | `profile` | Name and profile picture | | `email` | Primary email address | ## User data returned | Field | Source | Notes | |-------|--------|-------| | `id` | `sub` claim | Stable LinkedIn member ID | | `email` | `email` claim | Primary email (verified) | | `name` | `name` claim | Full name | | `avatar` | `picture` claim | Profile photo URL | LinkedIn profile photos are hosted on their CDN and may require authentication headers to load in `` tags depending on the user's privacy settings. Store the URL in your database but be prepared for it to become inaccessible. ## Related Generic OAuth 2.0 setup and account linking behavior. Another workspace-focused OAuth provider using OpenID Connect. Sign in with Microsoft Entra ID for work accounts. All sign-in methods available in theAuth. --- # Facebook Source: https://docs.theauth.dev/auth/facebook ## Setup ### Get credentials Go to the [Facebook Developer Portal](https://developers.facebook.com/) and create an app. Under **Facebook Login > Settings**, add your redirect URI: ``` https://your-app.com/api/theauth/auth/oauth/callback/facebook ``` Copy the **App ID** and **App Secret** from the app dashboard. ### Configure ```ts import { createTheAuth } from '@glinr/theauth'; import { oauth, facebookProvider } from '@glinr/theauth/auth'; const theauth = await createTheAuth({ database: { provider: 'sqlite', url: 'theauth.db' }, secret: process.env.THEAUTH_SECRET!, baseUrl: 'https://your-app.com/api/theauth', auth: { session: { secret: process.env.THEAUTH_SECRET! } }, plugins: [ oauth({ providers: { facebook: facebookProvider( // [!code highlight] process.env.FACEBOOK_CLIENT_ID!, // [!code highlight] process.env.FACEBOOK_CLIENT_SECRET!, // [!code highlight] ), // [!code highlight] }, }), ], }); ``` ## Environment variables ```bash FACEBOOK_CLIENT_ID=your_app_id FACEBOOK_CLIENT_SECRET=your_app_secret ``` ## Scopes Default scopes: `email`, `public_profile` To request additional data, pass a `scopes` array as the third argument. It replaces the defaults, so include `email` and `public_profile` too: ```ts facebookProvider( process.env.FACEBOOK_CLIENT_ID!, process.env.FACEBOOK_CLIENT_SECRET!, ['email', 'public_profile', 'user_birthday'], // [!code highlight] ) ``` Facebook requires app review before requesting most extended permissions beyond `email` and `public_profile`. This is a preset built on the generic OIDC factory, which reads the user from `https://graph.facebook.com/me?fields=id,email,name,picture` and requires the response to contain the OIDC `sub` and `email` fields. That endpoint returns a numeric `id` field, so sign-in is expected to fail with `Facebook userinfo response missing required "sub" field`. This preset has not been verified against the live service. ## Endpoints | Method | Path | Description | |--------|------|-------------| | GET | `/auth/oauth/authorize/facebook` | Redirect to Facebook | | GET | `/auth/oauth/callback/facebook` | Handle callback | --- # Spotify Source: https://docs.theauth.dev/auth/spotify ## Setup ### Get credentials Go to the [Spotify Developer Dashboard](https://developer.spotify.com/dashboard) and create an app. Under **Edit Settings**, add your redirect URI: ``` https://your-app.com/api/theauth/auth/oauth/callback/spotify ``` Copy the **Client ID** and **Client Secret** from the app overview. ### Configure ```ts import { createTheAuth } from '@glinr/theauth'; import { oauth, createSpotifyProvider } from '@glinr/theauth/auth'; const theauth = await createTheAuth({ database: { provider: 'sqlite', url: 'theauth.db' }, secret: process.env.THEAUTH_SECRET!, baseUrl: 'https://your-app.com/api/theauth', auth: { session: { secret: process.env.THEAUTH_SECRET! } }, plugins: [ oauth({ providers: { spotify: createSpotifyProvider({ // [!code highlight] clientId: process.env.SPOTIFY_CLIENT_ID!, // [!code highlight] clientSecret: process.env.SPOTIFY_CLIENT_SECRET!, // [!code highlight] }), // [!code highlight] }, }), ], }); ``` ## Environment variables ```bash SPOTIFY_CLIENT_ID=your_client_id SPOTIFY_CLIENT_SECRET=your_client_secret ``` ## Scopes Default scopes: `user-read-email`, `user-read-private` To access additional Spotify data, pass extra `scopes`. They are added on top of the defaults: ```ts createSpotifyProvider({ clientId: process.env.SPOTIFY_CLIENT_ID!, clientSecret: process.env.SPOTIFY_CLIENT_SECRET!, scopes: ['user-library-read'], // [!code highlight] }) ``` The `user-read-email` scope is required to retrieve the user's email address. Without it, the provider returns no email, and the `oauth()` plugin cannot match or create a user for that account (the callback returns JSON instead of a session). ## Endpoints | Method | Path | Description | |--------|------|-------------| | GET | `/auth/oauth/authorize/spotify` | Redirect to Spotify | | GET | `/auth/oauth/callback/spotify` | Handle callback | --- # Twitch Source: https://docs.theauth.dev/auth/twitch ## Get credentials ### Register an application Go to the [Twitch Developer Console](https://dev.twitch.tv/console/apps) and click **Register Your Application**. Set the **OAuth Redirect URL** to: ``` https://auth.example.com/auth/oauth/callback/twitch ``` Pick any category, **Website Integration** works for most apps. ### Copy credentials After saving, click **Manage** on the app. Copy the **Client ID**. Click **New Secret** to generate and copy the **Client Secret**. ## Configuration ```typescript title="lib/theauth.ts" import { createTheAuth } from '@glinr/theauth'; import { oauth, createTwitchProvider } from '@glinr/theauth/auth'; const theauth = await createTheAuth({ database: { provider: 'postgres', url: process.env.DATABASE_URL! }, secret: process.env.THEAUTH_SECRET!, baseUrl: 'https://auth.example.com', auth: { session: { secret: process.env.THEAUTH_SECRET! } }, plugins: [ oauth({ providers: { twitch: createTwitchProvider({ // [!code highlight] clientId: process.env.TWITCH_CLIENT_ID!, // [!code highlight] clientSecret: process.env.TWITCH_CLIENT_SECRET!, // [!code highlight] }), // [!code highlight] }, }), ], }); ``` ```bash TWITCH_CLIENT_ID=abcdef1234567890abcdef1234567890 TWITCH_CLIENT_SECRET=... ``` ## Endpoints | Endpoint | URL | |----------|-----| | Authorization | `https://id.twitch.tv/oauth2/authorize` | | Token | `https://id.twitch.tv/oauth2/token` | | User info | `https://api.twitch.tv/helix/users` | ## Scopes Default scope: `user:read:email` | Scope | What it unlocks | |-------|----------------| | `user:read:email` | Read the user's verified email address | | `user:read:follows` | Read the channels the user follows | | `channel:read:subscriptions` | Read the user's channel subscriptions | ## User data returned | Field | Source | Notes | |-------|--------|-------| | `id` | `data[0].id` | Stable numeric Twitch user ID | | `email` | `data[0].email` | Only present with `user:read:email` scope | | `name` | `data[0].display_name` | Localized display name (may differ from login) | | `avatar` | `data[0].profile_image_url` | Direct CDN URL; changes when user updates profile | The Twitch Helix API requires a `Client-ID` header on every request alongside the Bearer token. theAuth handles this automatically, you do not need to set it manually. Twitch email addresses may not be verified. Check the `broadcaster_type` and account age in the raw response if you need higher assurance. ## Related Generic OAuth configuration and custom provider setup. Another popular social provider with verified email addresses. Developer-focused OAuth provider with org membership scopes. Social provider in the same nav group. --- # Reddit Source: https://docs.theauth.dev/auth/reddit ## Get credentials ### Create an app Go to [Reddit App Preferences](https://www.reddit.com/prefs/apps) and scroll to the bottom. Click **Create another app...**. Select **web app** as the type and add your redirect URI: ``` https://auth.example.com/auth/oauth/callback/reddit ``` ### Copy credentials After saving, the **client ID** appears directly under the app name (a short string like `abc123XYZ`). Click **edit** to reveal or regenerate the **secret**. ## Configuration ```typescript title="lib/theauth.ts" import { createTheAuth } from '@glinr/theauth'; import { oauth, createRedditProvider } from '@glinr/theauth/auth'; const theauth = await createTheAuth({ database: { provider: 'postgres', url: process.env.DATABASE_URL! }, secret: process.env.THEAUTH_SECRET!, baseUrl: 'https://auth.example.com', auth: { session: { secret: process.env.THEAUTH_SECRET! } }, plugins: [ oauth({ providers: { reddit: createRedditProvider({ // [!code highlight] clientId: process.env.REDDIT_CLIENT_ID!, // [!code highlight] clientSecret: process.env.REDDIT_CLIENT_SECRET!, // [!code highlight] }), // [!code highlight] }, }), ], }); ``` ```bash REDDIT_CLIENT_ID=abc123XYZ REDDIT_CLIENT_SECRET=... ``` ## Endpoints | Endpoint | URL | |----------|-----| | Authorization | `https://www.reddit.com/api/v1/authorize` | | Token | `https://www.reddit.com/api/v1/access_token` | | User info | `https://oauth.reddit.com/api/v1/me` | ## Scopes Default scope: `identity`. Scopes you pass are added on top of the default. | Scope | What it unlocks | |-------|----------------| | `identity` | Read the user's account info (username, avatar, karma) | | `read` | Read posts and comments on the user's behalf | | `subscribe` | Read and manage subreddit subscriptions | | `history` | Read the user's post and comment history | ## User data returned | Field | Source | Notes | |-------|--------|-------| | `id` | `id` | Stable Reddit account ID (base-36 string) | | `email` |, | Not available. Reddit does not expose email via OAuth | | `name` | `name` | Reddit username | | `avatar` | `icon_img` | Query parameters stripped; may be a default avatar | Reddit does not expose the user's email address via OAuth. If your app requires an email, prompt the user to enter one after sign-in and store it separately. Reddit's token endpoint uses HTTP Basic authentication rather than posting credentials in the request body. theAuth handles this automatically. ## Handling missing email The `oauth()` plugin matches or creates a theAuth user by email. Because Reddit returns none, a Reddit callback does not produce a session: the provider account is stored without a user, and the callback responds with JSON (`isNewAccount`, `account`, `userInfo`) instead of redirecting. The same happens on later sign-ins with that account. To support Reddit sign-in you need to collect an email yourself and attach the account to a user, for example with `POST /auth/oauth/link` from an authenticated session or `linkAccount` on a module built with `createOAuthModule`. ## Related Generic OAuth 2.0 setup and account linking behavior. Another community-focused OAuth provider. Developer-focused OAuth provider with similar setup. All sign-in methods available in theAuth. --- # Twitter / X Source: https://docs.theauth.dev/auth/twitter ## Setup ### Get credentials Go to the [Twitter Developer Portal](https://developer.twitter.com) and create a project and app. Under **User authentication settings**, enable OAuth 2.0 and set the redirect URI to: ``` https://your-app.com/api/theauth/auth/oauth/callback/twitter ``` Set the app type to **Web App** and enable **Read** permissions at minimum. ### Configure ```ts title="lib/theauth.ts" import { createTheAuth } from '@glinr/theauth'; import { oauth, createTwitterProvider } from '@glinr/theauth/auth'; const theauth = await createTheAuth({ database: { provider: 'sqlite', url: 'theauth.db' }, secret: process.env.THEAUTH_SECRET!, baseUrl: 'https://your-app.com/api/theauth', auth: { session: { secret: process.env.THEAUTH_SECRET! } }, plugins: [ oauth({ providers: { twitter: createTwitterProvider({ clientId: process.env.TWITTER_CLIENT_ID!, clientSecret: process.env.TWITTER_CLIENT_SECRET!, }), }, }), ], }); ``` ```bash TWITTER_CLIENT_ID=... TWITTER_CLIENT_SECRET=... ``` ## Scopes Default scopes: `users.read`, `tweet.read`. Scopes you pass are added on top of the defaults. | Scope | What it unlocks | |-------|----------------| | `users.read` | Read the user's profile | | `tweet.read` | Read tweets | | `offline.access` | Refresh token support | Twitter does not return an email address through the standard OAuth 2.0 flow. The provider reports a synthetic non-deliverable address (`username@twitter.invalid`) as the email, and the `oauth()` plugin stores it on the user record because it matches users by email. Do not treat it as a real email. ## Endpoints | Method | Path | Description | |--------|------|-------------| | GET | `/auth/oauth/authorize/twitter` | Redirect to Twitter | | GET | `/auth/oauth/callback/twitter` | Handle callback | --- # TikTok Source: https://docs.theauth.dev/auth/tiktok ## Setup ### Get credentials Go to the [TikTok for Developers](https://developers.tiktok.com) portal and create an app. Under **Login Kit**, add your redirect URI: ``` https://your-app.com/api/theauth/auth/oauth/callback/tiktok ``` ### Configure ```ts title="lib/theauth.ts" import { createTheAuth } from '@glinr/theauth'; import { oauth, tiktokProvider } from '@glinr/theauth/auth'; const theauth = await createTheAuth({ database: { provider: 'sqlite', url: 'theauth.db' }, secret: process.env.THEAUTH_SECRET!, baseUrl: 'https://your-app.com/api/theauth', auth: { session: { secret: process.env.THEAUTH_SECRET! } }, plugins: [ oauth({ providers: { tiktok: tiktokProvider( process.env.TIKTOK_CLIENT_ID!, process.env.TIKTOK_CLIENT_SECRET!, ), }, }), ], }); ``` ```bash TIKTOK_CLIENT_ID=... TIKTOK_CLIENT_SECRET=... ``` ## Scopes Default scopes: `user.info.basic` | Scope | What it unlocks | |-------|----------------| | `user.info.basic` | Display name and avatar | | `user.info.profile` | Profile URL and bio | | `user.info.stats` | Follower and video counts | TikTok Login Kit requires app review before production use. During development you can test with sandbox accounts added to your app's tester list. This is a preset built on the generic OIDC factory, which reads the user from `https://open.tiktokapis.com/v2/user/info/` and requires the response to contain the OIDC `sub` and `email` fields. That endpoint returns an `open_id` nested under `data.user` and no email, so sign-in is expected to fail with `TikTok userinfo response missing required "sub" field`. This preset has not been verified against the live service. ## Endpoints | Method | Path | Description | |--------|------|-------------| | GET | `/auth/oauth/authorize/tiktok` | Redirect to TikTok | | GET | `/auth/oauth/callback/tiktok` | Handle callback | --- # Kakao Source: https://docs.theauth.dev/auth/kakao ## Setup ### Get credentials Go to the [Kakao Developers](https://developers.kakao.com) portal and create an application. Under **Kakao Login**, enable the feature and add your redirect URI: ``` https://your-app.com/api/theauth/auth/oauth/callback/kakao ``` ### Configure ```ts title="lib/theauth.ts" import { createTheAuth } from '@glinr/theauth'; import { oauth, kakaoProvider } from '@glinr/theauth/auth'; const theauth = await createTheAuth({ database: { provider: 'sqlite', url: 'theauth.db' }, secret: process.env.THEAUTH_SECRET!, baseUrl: 'https://your-app.com/api/theauth', auth: { session: { secret: process.env.THEAUTH_SECRET! } }, plugins: [ oauth({ providers: { kakao: kakaoProvider( process.env.KAKAO_CLIENT_ID!, process.env.KAKAO_CLIENT_SECRET!, ), }, }), ], }); ``` ```bash KAKAO_CLIENT_ID=... KAKAO_CLIENT_SECRET=... ``` ## Scopes Default scopes: `profile_nickname`, `account_email` | Scope | What it unlocks | |-------|----------------| | `profile_nickname` | Kakao nickname | | `profile_image` | Profile image | | `account_email` | Email address (requires business verification) | Email access requires business verification in the Kakao Developer Console. Without it, only `profile_nickname` and `profile_image` are available. This is a preset built on the generic OIDC factory, which reads the user from `https://kapi.kakao.com/v2/user/me` and requires the response to contain the OIDC `sub` and `email` fields. That endpoint returns a numeric `id` field with the email nested under `kakao_account`, so sign-in is expected to fail with `Kakao userinfo response missing required "sub" field`. This preset has not been verified against the live service. ## Endpoints | Method | Path | Description | |--------|------|-------------| | GET | `/auth/oauth/authorize/kakao` | Redirect to Kakao | | GET | `/auth/oauth/callback/kakao` | Handle callback | --- # Naver Source: https://docs.theauth.dev/auth/naver ## Setup ### Get credentials Go to [developers.naver.com](https://developers.naver.com) and create an application. Under **API Settings**, add **Naver Login** and set your redirect URI: ``` https://your-app.com/api/theauth/auth/oauth/callback/naver ``` ### Configure ```ts title="lib/theauth.ts" import { createTheAuth } from '@glinr/theauth'; import { oauth, naverProvider } from '@glinr/theauth/auth'; const theauth = await createTheAuth({ database: { provider: 'sqlite', url: 'theauth.db' }, secret: process.env.THEAUTH_SECRET!, baseUrl: 'https://your-app.com/api/theauth', auth: { session: { secret: process.env.THEAUTH_SECRET! } }, plugins: [ oauth({ providers: { naver: naverProvider( process.env.NAVER_CLIENT_ID!, process.env.NAVER_CLIENT_SECRET!, ), }, }), ], }); ``` ```bash NAVER_CLIENT_ID=... NAVER_CLIENT_SECRET=... ``` ## Scopes Default scopes: `email`, `profile` | Scope | What it unlocks | |-------|----------------| | `name` | Display name | | `email` | Email address | | `profile_image` | Profile image URL | | `mobile` | Mobile phone number | This is a preset built on the generic OIDC factory, which reads the user from `https://openapi.naver.com/v1/nid/me` and requires the response to contain the OIDC `sub` and `email` fields. That endpoint returns an `id` field nested under `response`, so sign-in is expected to fail with `Naver userinfo response missing required "sub" field`. This preset has not been verified against the live service. ## Endpoints | Method | Path | Description | |--------|------|-------------| | GET | `/auth/oauth/authorize/naver` | Redirect to Naver | | GET | `/auth/oauth/callback/naver` | Handle callback | --- # VK Source: https://docs.theauth.dev/auth/vk ## Setup ### Get credentials Go to [dev.vk.com](https://dev.vk.com) and create an application. Set the platform to **Website** and add your redirect URI: ``` https://your-app.com/api/theauth/auth/oauth/callback/vk ``` ### Configure ```ts title="lib/theauth.ts" import { createTheAuth } from '@glinr/theauth'; import { oauth, vkProvider } from '@glinr/theauth/auth'; const theauth = await createTheAuth({ database: { provider: 'sqlite', url: 'theauth.db' }, secret: process.env.THEAUTH_SECRET!, baseUrl: 'https://your-app.com/api/theauth', auth: { session: { secret: process.env.THEAUTH_SECRET! } }, plugins: [ oauth({ providers: { vk: vkProvider( process.env.VK_CLIENT_ID!, process.env.VK_CLIENT_SECRET!, ), }, }), ], }); ``` ```bash VK_CLIENT_ID=... VK_CLIENT_SECRET=... ``` ## Scopes Default scopes: `email` | Scope | What it unlocks | |-------|----------------| | `email` | Email address | | `profile` | Name and photo | | `friends` | Friends list | VK returns email as part of the access token response rather than a separate userinfo endpoint. theAuth handles this automatically. This is a preset built on the generic OIDC factory, which reads the user from `https://id.vk.com/oauth2/user_info` and requires the response to contain the OIDC `sub` and `email` fields. That endpoint returns a `user.user_id` field nested under `user`, so sign-in is expected to fail with `VK userinfo response missing required "sub" field`. This preset has not been verified against the live service. ## Endpoints | Method | Path | Description | |--------|------|-------------| | GET | `/auth/oauth/authorize/vk` | Redirect to VK | | GET | `/auth/oauth/callback/vk` | Handle callback | --- # LINE Source: https://docs.theauth.dev/auth/line ## Setup ### Get credentials Go to the [LINE Developers Console](https://developers.line.biz) and create a provider and channel. Choose **LINE Login** as the channel type. Add your redirect URI: ``` https://your-app.com/api/theauth/auth/oauth/callback/line ``` ### Configure ```ts title="lib/theauth.ts" import { createTheAuth } from '@glinr/theauth'; import { oauth, lineProvider } from '@glinr/theauth/auth'; const theauth = await createTheAuth({ database: { provider: 'sqlite', url: 'theauth.db' }, secret: process.env.THEAUTH_SECRET!, baseUrl: 'https://your-app.com/api/theauth', auth: { session: { secret: process.env.THEAUTH_SECRET! } }, plugins: [ oauth({ providers: { line: lineProvider( process.env.LINE_CLIENT_ID!, process.env.LINE_CLIENT_SECRET!, ), }, }), ], }); ``` ```bash LINE_CLIENT_ID=... LINE_CLIENT_SECRET=... ``` ## Scopes Default scopes: `openid`, `profile`, `email` | Scope | What it unlocks | |-------|----------------| | `openid` | OIDC identity token | | `profile` | Display name and profile picture | | `email` | Email address (requires email permission approval) | The `email` scope requires an additional approval step in the LINE Developer Console. You must agree to the LINE Login email permission terms and submit for review. This is a preset built on the generic OIDC factory, which reads the user from `https://api.line.me/v2/profile` and requires the response to contain the OIDC `sub` and `email` fields. That endpoint returns a `userId` field and no email, so sign-in is expected to fail with `LINE userinfo response missing required "sub" field`. This preset has not been verified against the live service. ## Endpoints | Method | Path | Description | |--------|------|-------------| | GET | `/auth/oauth/authorize/line` | Redirect to LINE | | GET | `/auth/oauth/callback/line` | Handle callback | --- # WeChat Source: https://docs.theauth.dev/auth/wechat ## Setup ### Get credentials Go to the [WeChat Open Platform](https://open.weixin.qq.com) and register a website app. Set the authorization callback domain (not a full URL, just the domain): ``` your-app.com ``` theAuth will handle the path `/api/theauth/auth/oauth/callback/wechat`. ### Configure ```ts title="lib/theauth.ts" import { createTheAuth } from '@glinr/theauth'; import { oauth, wechatProvider } from '@glinr/theauth/auth'; const theauth = await createTheAuth({ database: { provider: 'sqlite', url: 'theauth.db' }, secret: process.env.THEAUTH_SECRET!, baseUrl: 'https://your-app.com/api/theauth', auth: { session: { secret: process.env.THEAUTH_SECRET! } }, plugins: [ oauth({ providers: { wechat: wechatProvider( process.env.WECHAT_APP_ID!, process.env.WECHAT_APP_SECRET!, ), }, }), ], }); ``` ```bash WECHAT_APP_ID=... WECHAT_APP_SECRET=... ``` ## Scopes Default scopes: `snsapi_login` | Scope | What it unlocks | |-------|----------------| | `snsapi_login` | Web login, returns `openid` and `unionid` | | `snsapi_userinfo` | Full profile (name, avatar, gender) | WeChat uses `appid` and `secret` rather than the standard `client_id` / `client_secret` naming, but theAuth maps these correctly. The WeChat Open Platform requires ICP filing for mainland China deployments. This is a preset built on the generic OIDC factory, which reads the user from `https://api.weixin.qq.com/sns/userinfo` and requires the response to contain the OIDC `sub` and `email` fields. That endpoint returns an `openid` field and no email, so sign-in is expected to fail with `WeChat userinfo response missing required "sub" field`. This preset has not been verified against the live service. ## Endpoints | Method | Path | Description | |--------|------|-------------| | GET | `/auth/oauth/authorize/wechat` | Redirect to WeChat | | GET | `/auth/oauth/callback/wechat` | Handle callback | --- # Kick Source: https://docs.theauth.dev/auth/kick ## Setup ### Get credentials Go to [kick.com](https://kick.com) and navigate to **Dashboard > Developer > Applications**. Create an app and set your redirect URI: ``` https://your-app.com/api/theauth/auth/oauth/callback/kick ``` ### Configure ```ts title="lib/theauth.ts" import { createTheAuth } from '@glinr/theauth'; import { oauth, kickProvider } from '@glinr/theauth/auth'; const theauth = await createTheAuth({ database: { provider: 'sqlite', url: 'theauth.db' }, secret: process.env.THEAUTH_SECRET!, baseUrl: 'https://your-app.com/api/theauth', auth: { session: { secret: process.env.THEAUTH_SECRET! } }, plugins: [ oauth({ providers: { kick: kickProvider( process.env.KICK_CLIENT_ID!, process.env.KICK_CLIENT_SECRET!, ), }, }), ], }); ``` ```bash KICK_CLIENT_ID=... KICK_CLIENT_SECRET=... ``` ## Scopes Default scopes: `user:read` | Scope | What it unlocks | |-------|----------------| | `user:read` | Read user profile and email | | `channel:read` | Read channel information | | `chat:read` | Read chat messages | Kick's OAuth implementation is relatively new. Check the [Kick API documentation](https://kick.com/api) for the latest scope definitions. ## Endpoints | Method | Path | Description | |--------|------|-------------| | GET | `/auth/oauth/authorize/kick` | Redirect to Kick | | GET | `/auth/oauth/callback/kick` | Handle callback | --- # Roblox Source: https://docs.theauth.dev/auth/roblox ## Setup ### Get credentials Go to [create.roblox.com](https://create.roblox.com) and navigate to **Dashboard > Credentials**. Create an OAuth 2.0 app and set your redirect URI: ``` https://your-app.com/api/theauth/auth/oauth/callback/roblox ``` ### Configure ```ts title="lib/theauth.ts" import { createTheAuth } from '@glinr/theauth'; import { oauth, robloxProvider } from '@glinr/theauth/auth'; const theauth = await createTheAuth({ database: { provider: 'sqlite', url: 'theauth.db' }, secret: process.env.THEAUTH_SECRET!, baseUrl: 'https://your-app.com/api/theauth', auth: { session: { secret: process.env.THEAUTH_SECRET! } }, plugins: [ oauth({ providers: { roblox: robloxProvider( process.env.ROBLOX_CLIENT_ID!, process.env.ROBLOX_CLIENT_SECRET!, ), }, }), ], }); ``` ```bash ROBLOX_CLIENT_ID=... ROBLOX_CLIENT_SECRET=... ``` ## Scopes Default scopes: `openid`, `profile` | Scope | What it unlocks | |-------|----------------| | `openid` | OIDC identity token | | `profile` | Display name and profile URL | | `email` | Email address (if verified) | Roblox OAuth 2.0 is currently in open beta. User IDs are stable numeric values that persist across username changes. This is a preset built on the generic OIDC factory, which fails with `Roblox userinfo response missing "email"` when the response has no email. The default scopes (`openid`, `profile`) do not release an email address, so sign-in is expected to fail unless Roblox returns one. This preset has not been verified against the live service. ## Endpoints | Method | Path | Description | |--------|------|-------------| | GET | `/auth/oauth/authorize/roblox` | Redirect to Roblox | | GET | `/auth/oauth/callback/roblox` | Handle callback | --- # Notion Source: https://docs.theauth.dev/auth/notion ## Get credentials ### Create an integration Go to [Notion Integrations](https://www.notion.so/profile/integrations) and click **New integration**. Set the type to **Public**, this is required for OAuth with external users. ### Configure OAuth settings In the integration settings, scroll to **OAuth Domain & URIs**. Add your redirect URI: ``` https://auth.example.com/auth/oauth/callback/notion ``` Set **Redirect URIs** and save. ### Copy credentials Under **Basic Information**, copy the **OAuth client ID** and generate an **OAuth client secret**. ## Configuration ```typescript title="lib/theauth.ts" import { createTheAuth } from '@glinr/theauth'; import { oauth, createNotionProvider } from '@glinr/theauth/auth'; const theauth = await createTheAuth({ database: { provider: 'postgres', url: process.env.DATABASE_URL! }, secret: process.env.THEAUTH_SECRET!, baseUrl: 'https://auth.example.com', auth: { session: { secret: process.env.THEAUTH_SECRET! } }, plugins: [ oauth({ providers: { notion: createNotionProvider({ // [!code highlight] clientId: process.env.NOTION_CLIENT_ID!, // [!code highlight] clientSecret: process.env.NOTION_CLIENT_SECRET!, // [!code highlight] }), // [!code highlight] }, }), ], }); ``` ```bash NOTION_CLIENT_ID=your-notion-oauth-client-id NOTION_CLIENT_SECRET=secret_... ``` ## Endpoints | Endpoint | URL | |----------|-----| | Authorization | `https://api.notion.com/v1/oauth/authorize` | | Token | `https://api.notion.com/v1/oauth/token` | | User info | Embedded in token response (`owner.user`) | ## Scopes Notion does not use granular OAuth scopes, and the provider does not send a PKCE challenge. Permissions are configured at the integration level in the Notion UI. When a user authorizes your integration, they choose which pages and databases to share. ## User data returned | Field | Source | Notes | |-------|--------|-------| | `id` | `owner.user.id` | Stable Notion user UUID | | `email` | `owner.user.person.email` | Present only for person-type authorizations | | `name` | `owner.user.name` | Full display name | | `avatar` | `owner.user.avatar_url` | May be null if the user has no profile photo | Notion returns user identity as part of the token exchange response, there is no separate `/me` endpoint. The provider keeps the last token payload in memory so no extra network call is made. The `email` field is only present when a **person** authorizes the integration. If a workspace bot is the owner (`owner.type === "workspace"`), the email will be absent. Without an email, the `oauth()` plugin cannot match or create a user, so the callback returns JSON instead of a session. ## Workspace data The token response also includes workspace context (`workspace_id`, `workspace_name`), available in `userInfo.raw` on the result of `createOAuthModule(...).handleCallback`. The `oauth()` plugin does not persist this data, so if you need the workspace per user, drive the flow with the module and store it yourself. ## Related Generic OAuth 2.0 setup and account linking behavior. Another work and productivity OAuth provider. Sign in with Slack using OpenID Connect. All sign-in methods available in theAuth. --- # Figma Source: https://docs.theauth.dev/auth/figma ## Setup ### Get credentials Go to [figma.com/developers](https://www.figma.com/developers) and click **Create a new app**. Set the redirect URI to: ``` https://your-app.com/api/theauth/auth/oauth/callback/figma ``` ### Configure ```ts title="lib/theauth.ts" import { createTheAuth } from '@glinr/theauth'; import { oauth, createFigmaProvider } from '@glinr/theauth/auth'; const theauth = await createTheAuth({ database: { provider: 'sqlite', url: 'theauth.db' }, secret: process.env.THEAUTH_SECRET!, baseUrl: 'https://your-app.com/api/theauth', auth: { session: { secret: process.env.THEAUTH_SECRET! } }, plugins: [ oauth({ providers: { figma: createFigmaProvider({ clientId: process.env.FIGMA_CLIENT_ID!, clientSecret: process.env.FIGMA_CLIENT_SECRET!, }), }, }), ], }); ``` ```bash FIGMA_CLIENT_ID=... FIGMA_CLIENT_SECRET=... ``` ## Scopes Default scopes: `file_read` | Scope | What it unlocks | |-------|----------------| | `file_read` | Read files, projects, and user info | ## Endpoints | Method | Path | Description | |--------|------|-------------| | GET | `/auth/oauth/authorize/figma` | Redirect to Figma | | GET | `/auth/oauth/callback/figma` | Handle callback | --- # Dropbox Source: https://docs.theauth.dev/auth/dropbox ## Setup ### Get credentials Go to the [Dropbox App Console](https://www.dropbox.com/developers/apps) and create an app. Under **OAuth 2**, add your redirect URI: ``` https://your-app.com/api/theauth/auth/oauth/callback/dropbox ``` ### Configure ```ts title="lib/theauth.ts" import { createTheAuth } from '@glinr/theauth'; import { oauth, createDropboxProvider } from '@glinr/theauth/auth'; const theauth = await createTheAuth({ database: { provider: 'sqlite', url: 'theauth.db' }, secret: process.env.THEAUTH_SECRET!, baseUrl: 'https://your-app.com/api/theauth', auth: { session: { secret: process.env.THEAUTH_SECRET! } }, plugins: [ oauth({ providers: { dropbox: createDropboxProvider({ clientId: process.env.DROPBOX_CLIENT_ID!, clientSecret: process.env.DROPBOX_CLIENT_SECRET!, }), }, }), ], }); ``` ```bash DROPBOX_CLIENT_ID=... DROPBOX_CLIENT_SECRET=... ``` ## Scopes Default scopes: `account_info.read` | Scope | What it unlocks | |-------|----------------| | `account_info.read` | Read the user's account info | | `files.metadata.read` | List file metadata | | `files.content.read` | Read file contents | ## Endpoints | Method | Path | Description | |--------|------|-------------| | GET | `/auth/oauth/authorize/dropbox` | Redirect to Dropbox | | GET | `/auth/oauth/callback/dropbox` | Handle callback | --- # Zoom Source: https://docs.theauth.dev/auth/zoom ## Setup ### Get credentials Go to the [Zoom App Marketplace](https://marketplace.zoom.us) and click **Develop > Build App**. Choose **OAuth** as the app type. Add your redirect URI: ``` https://your-app.com/api/theauth/auth/oauth/callback/zoom ``` ### Configure ```ts title="lib/theauth.ts" import { createTheAuth } from '@glinr/theauth'; import { oauth, createZoomProvider } from '@glinr/theauth/auth'; const theauth = await createTheAuth({ database: { provider: 'sqlite', url: 'theauth.db' }, secret: process.env.THEAUTH_SECRET!, baseUrl: 'https://your-app.com/api/theauth', auth: { session: { secret: process.env.THEAUTH_SECRET! } }, plugins: [ oauth({ providers: { zoom: createZoomProvider({ clientId: process.env.ZOOM_CLIENT_ID!, clientSecret: process.env.ZOOM_CLIENT_SECRET!, }), }, }), ], }); ``` ```bash ZOOM_CLIENT_ID=... ZOOM_CLIENT_SECRET=... ``` ## Scopes Default scope: `user:read`. Scopes you pass are added on top of the default. | Scope | What it unlocks | |-------|----------------| | `user:read` | Read the user's profile and email (default) | | `meeting:read` | Read meeting info | ## Endpoints | Method | Path | Description | |--------|------|-------------| | GET | `/auth/oauth/authorize/zoom` | Redirect to Zoom | | GET | `/auth/oauth/callback/zoom` | Handle callback | --- # Atlassian Source: https://docs.theauth.dev/auth/atlassian ## Setup ### Get credentials Go to the [Atlassian Developer Console](https://developer.atlassian.com) and create an OAuth 2.0 app. Add your redirect URI: ``` https://your-app.com/api/theauth/auth/oauth/callback/atlassian ``` ### Configure ```ts title="lib/theauth.ts" import { createTheAuth } from '@glinr/theauth'; import { oauth, createAtlassianProvider } from '@glinr/theauth/auth'; const theauth = await createTheAuth({ database: { provider: 'sqlite', url: 'theauth.db' }, secret: process.env.THEAUTH_SECRET!, baseUrl: 'https://your-app.com/api/theauth', auth: { session: { secret: process.env.THEAUTH_SECRET! } }, plugins: [ oauth({ providers: { atlassian: createAtlassianProvider({ clientId: process.env.ATLASSIAN_CLIENT_ID!, clientSecret: process.env.ATLASSIAN_CLIENT_SECRET!, }), }, }), ], }); ``` ```bash ATLASSIAN_CLIENT_ID=... ATLASSIAN_CLIENT_SECRET=... ``` ## Scopes Default scope: `read:me`. Scopes you pass are added on top of the default. | Scope | What it unlocks | |-------|----------------| | `read:me` | Read the user's profile | | `offline_access` | Refresh token support (not requested by default, add it to `scopes`) | | `read:jira-user` | Read Jira user info | | `read:confluence-user` | Read Confluence user info | Atlassian uses a 3-legged OAuth (3LO) flow. Users grant access per product (Jira, Confluence, etc.). The accessible resources endpoint returns which sites the user has authorized. ## Endpoints | Method | Path | Description | |--------|------|-------------| | GET | `/auth/oauth/authorize/atlassian` | Redirect to Atlassian | | GET | `/auth/oauth/callback/atlassian` | Handle callback | --- # Linear Source: https://docs.theauth.dev/auth/linear ## Setup ### Get credentials Go to [linear.app/settings/api](https://linear.app/settings/api) and click **Create new OAuth application**. Add your redirect URI: ``` https://your-app.com/api/theauth/auth/oauth/callback/linear ``` ### Configure ```ts title="lib/theauth.ts" import { createTheAuth } from '@glinr/theauth'; import { oauth, linearProvider } from '@glinr/theauth/auth'; const theauth = await createTheAuth({ database: { provider: 'sqlite', url: 'theauth.db' }, secret: process.env.THEAUTH_SECRET!, baseUrl: 'https://your-app.com/api/theauth', auth: { session: { secret: process.env.THEAUTH_SECRET! } }, plugins: [ oauth({ providers: { linear: linearProvider( process.env.LINEAR_CLIENT_ID!, process.env.LINEAR_CLIENT_SECRET!, ), }, }), ], }); ``` ```bash LINEAR_CLIENT_ID=... LINEAR_CLIENT_SECRET=... ``` ## Scopes Default scopes: `read` | Scope | What it unlocks | |-------|----------------| | `read` | Read access to the user's data | | `write` | Write access to issues, comments, etc. | | `issues:create` | Create issues | | `app:assignIssues` | Assign issues to the app | Linear uses UUIDs as user identifiers. The user's email is always returned and is verified. This is a preset built on the generic OIDC factory, which reads the user from `https://api.linear.app/graphql` and requires the response to contain the OIDC `sub` and `email` fields. That endpoint returns a GraphQL `data.viewer` object, so sign-in is expected to fail with `Linear userinfo response missing required "sub" field`. This preset has not been verified against the live service. ## Endpoints | Method | Path | Description | |--------|------|-------------| | GET | `/auth/oauth/authorize/linear` | Redirect to Linear | | GET | `/auth/oauth/callback/linear` | Handle callback | --- # GitLab Source: https://docs.theauth.dev/auth/gitlab ## Get credentials ### Create an application For **gitlab.com**: Go to [gitlab.com/-/profile/applications](https://gitlab.com/-/profile/applications). For a **self-hosted instance**: Go to your instance URL, then **User Settings > Applications**. - **Name**: your app name - **Redirect URI**: `https://auth.example.com/auth/oauth/callback/gitlab` - **Scopes**: check `read_user` (and `email` if you request it) ### Copy credentials After saving, copy the **Application ID** and **Secret**. ## Configuration ```typescript title="lib/theauth.ts" import { createTheAuth } from '@glinr/theauth'; import { oauth, createGitlabProvider } from '@glinr/theauth/auth'; const theauth = await createTheAuth({ database: { provider: 'postgres', url: process.env.DATABASE_URL! }, secret: process.env.THEAUTH_SECRET!, baseUrl: 'https://auth.example.com', auth: { session: { secret: process.env.THEAUTH_SECRET! } }, plugins: [ oauth({ providers: { gitlab: createGitlabProvider({ // [!code highlight] clientId: process.env.GITLAB_CLIENT_ID!, // [!code highlight] clientSecret: process.env.GITLAB_CLIENT_SECRET!, // [!code highlight] }), // [!code highlight] }, }), ], }); ``` `createGitlabProvider` is fixed to gitlab.com. For a self-hosted instance, use the generic OIDC factory with explicit endpoints (GitLab serves OpenID Connect, so the userinfo response carries the `sub` and `email` claims the factory requires): ```typescript title="lib/theauth.ts" import { oauth, genericOIDC } from '@glinr/theauth/auth'; oauth({ providers: { gitlab: genericOIDC({ id: 'gitlab', name: 'GitLab', issuer: 'https://gitlab.yourcompany.com', // [!code highlight] clientId: process.env.GITLAB_CLIENT_ID!, clientSecret: process.env.GITLAB_CLIENT_SECRET!, scopes: ['openid', 'read_user', 'email'], authorizationUrl: 'https://gitlab.yourcompany.com/oauth/authorize', // [!code highlight] tokenUrl: 'https://gitlab.yourcompany.com/oauth/token', // [!code highlight] userinfoUrl: 'https://gitlab.yourcompany.com/oauth/userinfo', // [!code highlight] }), }, }) ``` ```bash GITLAB_CLIENT_ID=... GITLAB_CLIENT_SECRET=... ``` ## Scopes Default scope: `read_user`. Scopes you pass are added on top of the default. | Scope | What it unlocks | |-------|----------------| | `read_user` | Read the user's profile | | `email` | Read the user's primary email (only if you add it to `scopes`) | | `read_api` | Read access to the API | | `read_repository` | Read repository data | ## User data returned | Field | Source | Notes | |-------|--------|-------| | `id` | `id` field | Stable numeric GitLab user ID | | `email` | `email` field | Primary email | | `name` | `name` field | Display name | | `avatar` | `avatar_url` field | Profile picture URL | For self-hosted GitLab instances, make sure your theAuth server can reach the GitLab API. If you are behind a VPN or firewall, the token exchange and user info calls will fail if the instance is not reachable from your server. ## Related Generic OAuth 2.0 setup and account linking behavior. Sign in with GitHub OAuth 2.0. Another work and productivity OAuth provider. All sign-in methods available in theAuth. --- # Bitbucket Source: https://docs.theauth.dev/auth/bitbucket ## Get credentials ### Create an OAuth consumer Go to your Bitbucket workspace settings: **Workspace Settings > Apps and features > OAuth consumers > Add consumer**. Set the **Callback URL** to: ``` https://your-app.com/api/theauth/auth/oauth/callback/bitbucket ``` Under **Permissions**, enable at minimum **Account: Read**. ### Copy your credentials After saving, expand the consumer to see the **Key** (client ID) and **Secret** (client secret). ## Configuration ```typescript title="lib/theauth.ts" import { createTheAuth } from '@glinr/theauth'; import { oauth, bitbucketProvider } from '@glinr/theauth/auth'; const theauth = await createTheAuth({ database: { provider: 'postgres', url: process.env.DATABASE_URL! }, secret: process.env.THEAUTH_SECRET!, baseUrl: 'https://your-app.com/api/theauth', auth: { session: { secret: process.env.THEAUTH_SECRET! } }, plugins: [ oauth({ providers: { bitbucket: bitbucketProvider( process.env.BITBUCKET_CLIENT_ID!, process.env.BITBUCKET_CLIENT_SECRET!, ), }, }), ], }); ``` ```bash BITBUCKET_CLIENT_ID=... BITBUCKET_CLIENT_SECRET=... ``` ## Scopes Default scopes: `account`, `email` | Scope | What it unlocks | |-------|----------------| | `account` | Read account info, email, profile | | `email` | Read primary email address | | `repository` | Read repository list | | `team` | Read workspace/team memberships | Bitbucket does not expose the user's email by default through the profile endpoint if it is set to private. The `email` scope fetches it from a separate endpoint. theAuth requests both automatically. This is a preset built on the generic OIDC factory, which reads the user from `https://api.bitbucket.org/2.0/user` and requires the response to contain the OIDC `sub` and `email` fields. That endpoint returns `uuid` and `account_id` fields and no email, so sign-in is expected to fail with `Bitbucket userinfo response missing required "sub" field`. This preset has not been verified against the live service. ## Endpoints | Method | Path | Description | |--------|------|-------------| | GET | `/auth/oauth/authorize/bitbucket` | Redirect to Bitbucket | | GET | `/auth/oauth/callback/bitbucket` | Handle callback | --- # Salesforce Source: https://docs.theauth.dev/auth/salesforce ## Setup ### Get credentials Go to [developer.salesforce.com](https://developer.salesforce.com) and set up a Connected App in **Setup > App Manager**. Under **OAuth Settings**, enable OAuth and add your redirect URI: ``` https://your-app.com/api/theauth/auth/oauth/callback/salesforce ``` ### Configure ```ts title="lib/theauth.ts" import { createTheAuth } from '@glinr/theauth'; import { oauth, salesforceProvider } from '@glinr/theauth/auth'; const theauth = await createTheAuth({ database: { provider: 'sqlite', url: 'theauth.db' }, secret: process.env.THEAUTH_SECRET!, baseUrl: 'https://your-app.com/api/theauth', auth: { session: { secret: process.env.THEAUTH_SECRET! } }, plugins: [ oauth({ providers: { salesforce: salesforceProvider( process.env.SALESFORCE_CLIENT_ID!, process.env.SALESFORCE_CLIENT_SECRET!, ), }, }), ], }); ``` ```bash SALESFORCE_CLIENT_ID=... SALESFORCE_CLIENT_SECRET=... ``` ## Scopes Default scopes: `openid`, `email`, `profile` | Scope | What it unlocks | |-------|----------------| | `openid` | OIDC identity token | | `id` | Identity URL and user info | | `email` | Email address | | `profile` | Display name and photo | | `api` | Access Salesforce APIs | Salesforce uses org-specific domains (e.g. `mycompany.my.salesforce.com`). The default authorization endpoint is `login.salesforce.com` but this can be customized for sandbox orgs using `test.salesforce.com`. ## Endpoints | Method | Path | Description | |--------|------|-------------| | GET | `/auth/oauth/authorize/salesforce` | Redirect to Salesforce | | GET | `/auth/oauth/callback/salesforce` | Handle callback | --- # Vercel Source: https://docs.theauth.dev/auth/vercel ## Setup ### Get credentials Go to [vercel.com/integrations](https://vercel.com/integrations) and click **Add New Integration**. Under **OAuth 2.0**, add your redirect URI: ``` https://your-app.com/api/theauth/auth/oauth/callback/vercel ``` ### Configure ```ts title="lib/theauth.ts" import { createTheAuth } from '@glinr/theauth'; import { oauth, vercelProvider } from '@glinr/theauth/auth'; const theauth = await createTheAuth({ database: { provider: 'sqlite', url: 'theauth.db' }, secret: process.env.THEAUTH_SECRET!, baseUrl: 'https://your-app.com/api/theauth', auth: { session: { secret: process.env.THEAUTH_SECRET! } }, plugins: [ oauth({ providers: { vercel: vercelProvider( process.env.VERCEL_CLIENT_ID!, process.env.VERCEL_CLIENT_SECRET!, ), }, }), ], }); ``` ```bash VERCEL_CLIENT_ID=... VERCEL_CLIENT_SECRET=... ``` ## Scopes Default scopes: `openid`, `email`, `profile` | Scope | What it unlocks | |-------|----------------| | `openid` | OIDC identity | | `email` | Email address | | `profile` | Name and profile picture | Vercel OAuth apps are created as marketplace integrations. The access token grants access to the user's personal account and any teams they belong to. This is a preset built on the generic OIDC factory, which reads the user from `https://api.vercel.com/v2/user` and requires the response to contain the OIDC `sub` and `email` fields. That endpoint returns a `user` object with an `id` field, so sign-in is expected to fail with `Vercel userinfo response missing required "sub" field`. This preset has not been verified against the live service. ## Endpoints | Method | Path | Description | |--------|------|-------------| | GET | `/auth/oauth/authorize/vercel` | Redirect to Vercel | | GET | `/auth/oauth/callback/vercel` | Handle callback | --- # Railway Source: https://docs.theauth.dev/auth/railway ## Setup ### Get credentials Go to [railway.com](https://railway.com) and navigate to **Account Settings > Developer > OAuth Apps**. Create an app and add your redirect URI: ``` https://your-app.com/api/theauth/auth/oauth/callback/railway ``` ### Configure ```ts title="lib/theauth.ts" import { createTheAuth } from '@glinr/theauth'; import { oauth, railwayProvider } from '@glinr/theauth/auth'; const theauth = await createTheAuth({ database: { provider: 'sqlite', url: 'theauth.db' }, secret: process.env.THEAUTH_SECRET!, baseUrl: 'https://your-app.com/api/theauth', auth: { session: { secret: process.env.THEAUTH_SECRET! } }, plugins: [ oauth({ providers: { railway: railwayProvider( process.env.RAILWAY_CLIENT_ID!, process.env.RAILWAY_CLIENT_SECRET!, ), }, }), ], }); ``` ```bash RAILWAY_CLIENT_ID=... RAILWAY_CLIENT_SECRET=... ``` ## Scopes Default scopes: `read:user`, `read:project` | Scope | What it unlocks | |-------|----------------| | `read:user` | Read user profile | | `read:project` | Read project list | This is a preset built on the generic OIDC factory, which reads the user from `https://backboard.railway.com/graphql/v2` and requires the response to contain the OIDC `sub` and `email` fields. That endpoint returns a GraphQL `data` object, so sign-in is expected to fail with `Railway userinfo response missing required "sub" field`. This preset has not been verified against the live service. ## Endpoints | Method | Path | Description | |--------|------|-------------| | GET | `/auth/oauth/authorize/railway` | Redirect to Railway | | GET | `/auth/oauth/callback/railway` | Handle callback | --- # Hugging Face Source: https://docs.theauth.dev/auth/huggingface ## Setup ### Get credentials Go to [huggingface.co/settings/applications](https://huggingface.co/settings/applications) and click **New OAuth application**. Set the redirect URI to: ``` https://your-app.com/api/theauth/auth/oauth/callback/huggingface ``` ### Configure ```ts title="lib/theauth.ts" import { createTheAuth } from '@glinr/theauth'; import { oauth, huggingfaceProvider } from '@glinr/theauth/auth'; const theauth = await createTheAuth({ database: { provider: 'sqlite', url: 'theauth.db' }, secret: process.env.THEAUTH_SECRET!, baseUrl: 'https://your-app.com/api/theauth', auth: { session: { secret: process.env.THEAUTH_SECRET! } }, plugins: [ oauth({ providers: { huggingface: huggingfaceProvider( process.env.HUGGINGFACE_CLIENT_ID!, process.env.HUGGINGFACE_CLIENT_SECRET!, ), }, }), ], }); ``` ```bash HUGGINGFACE_CLIENT_ID=... HUGGINGFACE_CLIENT_SECRET=... ``` ## Scopes Default scopes: `openid`, `profile`, `email` | Scope | What it unlocks | |-------|----------------| | `openid` | OIDC identity token | | `profile` | Username and full name | | `email` | Email address | | `read-repos` | Read access to user repositories | | `read-billing` | Read billing information | Hugging Face supports the OIDC discovery endpoint at `https://huggingface.co`. User IDs are stable numeric values. ## Endpoints | Method | Path | Description | |--------|------|-------------| | GET | `/auth/oauth/authorize/huggingface` | Redirect to Hugging Face | | GET | `/auth/oauth/callback/huggingface` | Handle callback | --- # PayPal Source: https://docs.theauth.dev/auth/paypal ## Setup ### Get credentials Go to the [PayPal Developer Portal](https://developer.paypal.com) and create an app under **My Apps & Credentials**. Add your redirect URI: ``` https://your-app.com/api/theauth/auth/oauth/callback/paypal ``` Make sure to enable **Log In with PayPal** for the app. ### Configure ```ts title="lib/theauth.ts" import { createTheAuth } from '@glinr/theauth'; import { oauth, paypalProvider } from '@glinr/theauth/auth'; const theauth = await createTheAuth({ database: { provider: 'sqlite', url: 'theauth.db' }, secret: process.env.THEAUTH_SECRET!, baseUrl: 'https://your-app.com/api/theauth', auth: { session: { secret: process.env.THEAUTH_SECRET! } }, plugins: [ oauth({ providers: { paypal: paypalProvider( process.env.PAYPAL_CLIENT_ID!, process.env.PAYPAL_CLIENT_SECRET!, ), }, }), ], }); ``` ```bash PAYPAL_CLIENT_ID=... PAYPAL_CLIENT_SECRET=... ``` ## Scopes Default scopes: `openid`, `email` | Scope | What it unlocks | |-------|----------------| | `openid` | OIDC identity token | | `email` | Email address | | `profile` | Name and locale | | `address` | Billing address | PayPal supports both sandbox and live environments. Use sandbox credentials during development and switch to live credentials before going to production. ## Endpoints | Method | Path | Description | |--------|------|-------------| | GET | `/auth/oauth/authorize/paypal` | Redirect to PayPal | | GET | `/auth/oauth/callback/paypal` | Handle callback | --- # Coinbase Source: https://docs.theauth.dev/auth/coinbase ## Get credentials ### Create an OAuth application Go to [Coinbase Developer Platform](https://www.coinbase.com/settings/api) and create a new OAuth2 application. Set the **Redirect URI** to: ``` https://your-app.com/api/theauth/auth/oauth/callback/coinbase ``` ### Copy your credentials After creating the app, copy the **Client ID** and **Client Secret** from the application settings. ## Configuration ```typescript title="lib/theauth.ts" import { createTheAuth } from '@glinr/theauth'; import { oauth, coinbaseProvider } from '@glinr/theauth/auth'; const theauth = await createTheAuth({ database: { provider: 'postgres', url: process.env.DATABASE_URL! }, secret: process.env.THEAUTH_SECRET!, baseUrl: 'https://your-app.com/api/theauth', auth: { session: { secret: process.env.THEAUTH_SECRET! } }, plugins: [ oauth({ providers: { coinbase: coinbaseProvider( process.env.COINBASE_CLIENT_ID!, process.env.COINBASE_CLIENT_SECRET!, ), }, }), ], }); ``` ```bash COINBASE_CLIENT_ID=... COINBASE_CLIENT_SECRET=... ``` ## Scopes Default scope: `wallet:user:read`, `wallet:user:email` | Scope | What it unlocks | |-------|----------------| | `wallet:user:read` | Read user profile and account info | | `wallet:user:email` | Read user's email address | | `wallet:accounts:read` | Read wallet account balances | Coinbase scopes use a namespaced format (`wallet:resource:action`). Request only the scopes your app needs. Users see the full permission list during authorization. This is a preset built on the generic OIDC factory, which reads the user from `https://api.coinbase.com/v2/user` and requires the response to contain the OIDC `sub` and `email` fields. That endpoint returns a `data.id` field nested under `data`, so sign-in is expected to fail with `Coinbase userinfo response missing required "sub" field`. This preset has not been verified against the live service. ## Endpoints | Method | Path | Description | |--------|------|-------------| | GET | `/auth/oauth/authorize/coinbase` | Redirect to Coinbase | | GET | `/auth/oauth/callback/coinbase` | Handle callback | --- # Polar Source: https://docs.theauth.dev/auth/polar ## Setup ### Get credentials Go to [polar.sh](https://polar.sh) and navigate to **Settings > OAuth Apps**. Create an app and set your redirect URI: ``` https://your-app.com/api/theauth/auth/oauth/callback/polar ``` ### Configure ```ts title="lib/theauth.ts" import { createTheAuth } from '@glinr/theauth'; import { oauth, polarProvider } from '@glinr/theauth/auth'; const theauth = await createTheAuth({ database: { provider: 'sqlite', url: 'theauth.db' }, secret: process.env.THEAUTH_SECRET!, baseUrl: 'https://your-app.com/api/theauth', auth: { session: { secret: process.env.THEAUTH_SECRET! } }, plugins: [ oauth({ providers: { polar: polarProvider( process.env.POLAR_CLIENT_ID!, process.env.POLAR_CLIENT_SECRET!, ), }, }), ], }); ``` ```bash POLAR_CLIENT_ID=... POLAR_CLIENT_SECRET=... ``` ## Scopes Default scopes: `openid`, `profile`, `email` | Scope | What it unlocks | |-------|----------------| | `openid` | OIDC identity token | | `profile` | Username and avatar | | `email` | Email address | | `organizations:read` | Read organization memberships | Polar is an open source monetization platform for developers. The OAuth integration is useful for gating content based on subscriptions or purchases. ## Endpoints | Method | Path | Description | |--------|------|-------------| | GET | `/auth/oauth/authorize/polar` | Redirect to Polar | | GET | `/auth/oauth/callback/polar` | Handle callback | --- # Auth0 Source: https://docs.theauth.dev/auth/auth0 ## Get credentials ### Create an application Go to the [Auth0 dashboard](https://manage.auth0.com) and create a **Regular Web Application**. Set the **Allowed Callback URL** to: ``` https://your-app.com/api/theauth/auth/oauth/callback/auth0 ``` ### Copy your credentials From the application settings, copy the **Domain**, **Client ID**, and **Client Secret**. Your domain looks like `your-tenant.auth0.com`. ## Configuration ```typescript title="lib/theauth.ts" import { createTheAuth } from '@glinr/theauth'; import { oauth, auth0Provider } from '@glinr/theauth/auth'; const theauth = await createTheAuth({ database: { provider: 'postgres', url: process.env.DATABASE_URL! }, secret: process.env.THEAUTH_SECRET!, baseUrl: 'https://your-app.com/api/theauth', auth: { session: { secret: process.env.THEAUTH_SECRET! } }, plugins: [ oauth({ providers: { auth0: auth0Provider( process.env.AUTH0_DOMAIN!, // your-tenant.auth0.com process.env.AUTH0_CLIENT_ID!, process.env.AUTH0_CLIENT_SECRET!, ), }, }), ], }); ``` ```bash AUTH0_DOMAIN=your-tenant.auth0.com AUTH0_CLIENT_ID=... AUTH0_CLIENT_SECRET=... ``` ## Scopes Default scopes: `openid`, `profile`, `email` | Scope | What it unlocks | |-------|----------------| | `openid` | OIDC authentication, issues ID token | | `profile` | Name, picture, and profile metadata | | `email` | Email address and verification status | | `offline_access` | Refresh token support | Auth0 supports custom scopes and roles via the Management API. Standard OIDC scopes work out of the box. ## Endpoints | Method | Path | Description | |--------|------|-------------| | GET | `/auth/oauth/authorize/auth0` | Redirect to Auth0 | | GET | `/auth/oauth/callback/auth0` | Handle callback | --- # Okta Source: https://docs.theauth.dev/auth/okta ## Get credentials ### Create an OIDC app In the Okta Admin Console, go to **Applications > Create App Integration** and choose **OIDC - OpenID Connect** with application type **Web Application**. Set the **Sign-in redirect URI** to: ``` https://your-app.com/api/theauth/auth/oauth/callback/okta ``` ### Copy your credentials From the app settings, copy the **Client ID** and **Client Secret**. Your domain is shown at the top of the console: `your-org.okta.com`. ## Configuration ```typescript title="lib/theauth.ts" import { createTheAuth } from '@glinr/theauth'; import { oauth, oktaProvider } from '@glinr/theauth/auth'; const theauth = await createTheAuth({ database: { provider: 'postgres', url: process.env.DATABASE_URL! }, secret: process.env.THEAUTH_SECRET!, baseUrl: 'https://your-app.com/api/theauth', auth: { session: { secret: process.env.THEAUTH_SECRET! } }, plugins: [ oauth({ providers: { okta: oktaProvider( process.env.OKTA_DOMAIN!, // your-org.okta.com process.env.OKTA_CLIENT_ID!, process.env.OKTA_CLIENT_SECRET!, ), }, }), ], }); ``` ```bash OKTA_DOMAIN=your-org.okta.com OKTA_CLIENT_ID=... OKTA_CLIENT_SECRET=... ``` ## Scopes Default scopes: `openid`, `profile`, `email` | Scope | What it unlocks | |-------|----------------| | `openid` | OIDC authentication, issues ID token | | `profile` | Name, locale, and profile metadata | | `email` | Email address and verification status | | `groups` | Group membership (requires group claim in Okta) | | `offline_access` | Refresh token support | For Okta Identity Engine orgs, the domain may be a custom domain. Use the exact domain shown in your Okta Admin Console rather than the default `okta.com` subdomain. ## Endpoints | Method | Path | Description | |--------|------|-------------| | GET | `/auth/oauth/authorize/okta` | Redirect to Okta | | GET | `/auth/oauth/callback/okta` | Handle callback | --- # Yahoo Source: https://docs.theauth.dev/auth/yahoo ## Get credentials ### Create an app Go to the [Yahoo Developer Console](https://developer.yahoo.com/apps/) and create a new app. Select **Web Application** as the application type. Set the **Callback Domain** and add your redirect URI: ``` https://your-app.com/api/theauth/auth/oauth/callback/yahoo ``` Enable the **OpenID Connect** API and the **Email** scope. ### Copy your credentials After creating the app, copy the **Client ID (Consumer Key)** and **Client Secret (Consumer Secret)**. ## Configuration ```typescript title="lib/theauth.ts" import { createTheAuth } from '@glinr/theauth'; import { oauth, yahooProvider } from '@glinr/theauth/auth'; const theauth = await createTheAuth({ database: { provider: 'postgres', url: process.env.DATABASE_URL! }, secret: process.env.THEAUTH_SECRET!, baseUrl: 'https://your-app.com/api/theauth', auth: { session: { secret: process.env.THEAUTH_SECRET! } }, plugins: [ oauth({ providers: { yahoo: yahooProvider( process.env.YAHOO_CLIENT_ID!, process.env.YAHOO_CLIENT_SECRET!, ), }, }), ], }); ``` ```bash YAHOO_CLIENT_ID=... YAHOO_CLIENT_SECRET=... ``` ## Scopes Default scopes: `openid`, `profile`, `email` | Scope | What it unlocks | |-------|----------------| | `openid` | OIDC authentication | | `profile` | Display name and profile details | | `email` | Yahoo email address | Yahoo requires the OpenID Connect API to be explicitly enabled in your app's API permissions. Without it, the token endpoint will return an error even if `openid` is in the scope. ## Endpoints | Method | Path | Description | |--------|------|-------------| | GET | `/auth/oauth/authorize/yahoo` | Redirect to Yahoo | | GET | `/auth/oauth/callback/yahoo` | Handle callback | --- # Organizations Source: https://docs.theauth.dev/auth/organizations ## Setup There are two ways to use organizations, and the `theauth.org` object used in the examples below only exists with the first one. **Programmatic API (`theauth.org`).** Pass an `org` config object to `createTheAuth`. Without it, `theauth.org` is `null` and every `theauth.org.*` call below fails. ```ts import { createTheAuth } from '@glinr/theauth'; const theauth = await createTheAuth({ database: { provider: 'sqlite', url: 'theauth.db' }, org: { // [!code highlight] maxMembers: 100, // [!code highlight] maxOrgsPerUser: 5, // [!code highlight] allowCustomRoles: true, // [!code highlight] }, // [!code highlight] }); // theauth.org is typed as nullable const org = await theauth.org?.create({ name: 'Acme', slug: 'acme', ownerId: 'user_123' }); ``` **HTTP endpoints (`organization` plugin).** The `organization()` plugin registers `/auth/org/*` routes for an adapter to serve. It does not add `theauth.org` to the instance. ```ts import { organization } from '@glinr/theauth/auth'; const theauth = await createTheAuth({ database: { provider: 'sqlite', url: 'theauth.db' }, auth: { session: { secret: process.env.SESSION_SECRET! } }, plugins: [organization({ maxMembers: 100, maxOrgsPerUser: 5, allowCustomRoles: true })], }); ``` The plugin routes act on the authenticated user, resolved from `auth.adapter` or an `auth.session` token. The code samples below call `theauth.org!.(...)`. They assume the `org` config was passed. Use `?.` instead of `!` if you want to tolerate it being absent. ## Creating organizations ```ts const org = await theauth.org!.create({ name: 'Acme Corp', slug: 'acme-corp', // lowercase letters, numbers, hyphens only (no leading, trailing or doubled hyphens) ownerId: 'user_abc', metadata: { plan: 'pro' }, }); // org.id = 'org_...' ``` The creator is automatically added as a member with the `owner` role. ## Inviting members ```ts const invitation = await theauth.org!.invite({ orgId: org.id, email: 'alice@acme.com', role: 'admin', invitedBy: 'user_abc', }); // invitation.id, invitation.expiresAt (7 days by default, set invitationExpiryMs to change) ``` Accept on the invited user's side: ```ts const member = await theauth.org!.acceptInvitation(invitation.id, 'user_xyz'); ``` ## Managing members ```ts // List all members const members = await theauth.org!.getMembers(org.id); // Change a member's role await theauth.org!.updateMemberRole(org.id, 'user_xyz', 'member'); // Remove a member await theauth.org!.removeMember(org.id, 'user_xyz'); ``` ## Roles and permissions Four built-in roles ship by default: | Role | Permissions | |------|-------------| | `owner` | All permissions including `org:manage`, `org:delete`, `roles:manage` | | `admin` | `members:invite`, `members:remove`, `agents:create`, `agents:revoke`, `agents:manage` | | `member` | `agents:create`, `agents:manage` | | `viewer` | None | Check permissions at runtime: ```ts const allowed = await theauth.org!.hasPermission(org.id, userId, 'agents:create'); // [!code highlight] ``` ### Custom roles ```ts await theauth.org!.createRole(org.id, { name: 'billing', permissions: ['invoices:read', 'invoices:pay'], }); ``` Set `allowCustomRoles: false` in the `org` config (or the plugin options) to restrict orgs to the built-in roles only. ## Configuration reference Roles created for new organizations. Defaults to the four built-in roles. Maximum members per organization. Maximum organizations a user can create. Whether `createRole` is allowed. Invitation lifetime in milliseconds (7 days). Maximum invitations per organization per hour. ## Endpoints ### `organization()` plugin All plugin routes require an authenticated user. | Method | Path | Description | |--------|------|-------------| | POST | `/auth/org/create` | Create an organization, body `{ name, slug, metadata? }`. The caller becomes owner | | GET | `/auth/org/list` | List the caller's organizations as `{ organizations }` | | POST | `/auth/org/:id/invite` | Send an invitation, body `{ email, role? }` (default role `member`). Owner or admin only | | POST | `/auth/org/:id/members` | List members as `{ members }`. Caller must be a member (this route lists, it does not add) | | PATCH | `/auth/org/:id/members/:userId` | Update a member's role, body `{ role }`. Owner or admin only | | DELETE | `/auth/org/:id/members/:userId` | Remove a member. Owner or admin only | ### `theauth.org.handleRequest` (module) The module's own handler serves a larger set of paths. It does no authentication, so only mount it behind your own checks. | Method | Path | Description | |--------|------|-------------| | POST | `/auth/org` | Create organization | | GET | `/auth/org/user/:userId` | List orgs for user | | GET | `/auth/org/:orgId` | Get organization | | PATCH | `/auth/org/:orgId` | Update organization | | DELETE | `/auth/org/:orgId` | Delete organization | | GET | `/auth/org/:orgId/members` | List members | | POST | `/auth/org/:orgId/members` | Add member | | PATCH | `/auth/org/:orgId/members/:userId` | Update member role | | DELETE | `/auth/org/:orgId/members/:userId` | Remove member | | POST | `/auth/org/:orgId/invite` | Send invitation | | GET | `/auth/org/:orgId/invitations` | List invitations | | POST | `/auth/org/invite/:invitationId/accept` | Accept invitation | | DELETE | `/auth/org/invite/:invitationId` | Revoke invitation | | GET | `/auth/org/:orgId/roles` | List roles | | POST | `/auth/org/:orgId/roles` | Create role | | GET | `/auth/org/:orgId/permissions/:userId/:permission` | Check permission | ## Related Enterprise SSO connections scoped to an organization. Automated user and group sync from your identity provider. Cross-organization user management tools. Scoped permissions that integrate with org membership. --- # SSO Source: https://docs.theauth.dev/auth/sso ## Setup Configure your identity providers when creating the theAuth instance. `theauth.sso` is `null` unless the `sso` key is passed, so use `theauth.sso!.` (or `?.`) in your code. There is no SSO plugin: routes are served through `theauth.sso.handleRequest`. ```ts import { createTheAuth } from '@glinr/theauth'; const theauth = await createTheAuth({ database: { provider: 'sqlite', url: 'theauth.db' }, sso: { // [!code highlight] saml: [ // [!code highlight] { id: 'okta', // [!code highlight] name: 'Okta', // [!code highlight] entryPoint: 'https://your-org.okta.com/app/appId/sso/saml', // [!code highlight] issuer: 'https://your-app.com', // [!code highlight] cert: process.env.OKTA_SAML_CERT!, // [!code highlight] callbackUrl: 'https://your-app.com/api/theauth/auth/sso/saml/conn_id/acs', // [!code highlight] }, ], }, // [!code highlight] }); ``` ```ts import { createTheAuth } from '@glinr/theauth'; const theauth = await createTheAuth({ database: { provider: 'sqlite', url: 'theauth.db' }, sso: { // [!code highlight] oidc: [ // [!code highlight] { id: 'azure', // [!code highlight] name: 'Azure AD', // [!code highlight] issuer: 'https://login.microsoftonline.com/your-tenant-id/v2.0', // [!code highlight] clientId: process.env.AZURE_CLIENT_ID!, // [!code highlight] clientSecret: process.env.AZURE_CLIENT_SECRET!, // [!code highlight] callbackUrl: 'https://your-app.com/api/theauth/auth/sso/oidc/conn_id/callback', // [!code highlight] scopes: ['openid', 'profile', 'email'], // [!code highlight] }, ], }, // [!code highlight] }); ``` ## SSO connections Connections link an org to an identity provider and route by email domain. ```ts // Create a connection for an org const connection = await theauth.sso!.createConnection({ // [!code highlight] orgId: 'org_acme', providerId: 'okta', // matches the id in your sso config // [!code highlight] type: 'saml', // 'saml' | 'oidc' // [!code highlight] domain: 'acme.com', // emails at this domain are routed here // [!code highlight] }); // Look up a connection by domain (e.g., to redirect on login) const conn = await theauth.sso!.getConnectionByDomain('acme.com'); // List all connections for an org const conns = await theauth.sso!.listConnections('org_acme'); // Remove a connection await theauth.sso!.removeConnection(connection.id); ``` ## Identity mapping After a successful SAML or OIDC login, theAuth returns the identity from the IdP response: `{ user: { id, email, name? }, orgId }`. Email and name come from the SAML assertion or the OIDC `email` and `name` claims. An OIDC `id_token` without an `email` claim is rejected. The `user.id` is derived deterministically, so the same IdP user always maps to the same ID: `saml_` plus a SHA-256 hash of `providerId:email` for SAML, and `oidc_` plus a SHA-256 hash of `providerId:sub` for OIDC. The SSO module does not create a user row, a membership, or a session. Your callback code receives the verified identity and decides what to do with it: create or look up your user, add them to the org, and issue a session. Do not treat the derived ID as an existing row in the users table. ## SAML flow ```ts // 1. Redirect the user to the IdP const authUrl = await theauth.sso!.getSamlAuthUrl(connection.id, '/dashboard'); // redirect to authUrl // 2. Handle the ACS callback (POST from IdP) const { user, orgId } = await theauth.sso!.handleSamlResponse( connection.id, samlResponseFromFormData, ); // user.id, user.email, user.name ``` SAML responses are verified against the IdP certificate. Unsigned responses are rejected unless you set `wantAuthnResponseSigned: false` on the provider. Encrypted assertions are not supported, so configure your IdP to send unencrypted assertions. Pass `expectedRequestId` as a third argument to `handleSamlResponse` to bind the response to a request you started. ## OIDC flow ```ts // 1. Redirect the user to the IdP const authUrl = await theauth.sso!.getOidcAuthUrl(connection.id, stateParam); // redirect to authUrl // 2. Handle the callback const { user, orgId } = await theauth.sso!.handleOidcCallback(connection.id, codeFromQuery); // user.id, user.email, user.name ``` OIDC discovery is fetched automatically from `issuer/.well-known/openid-configuration`. The `id_token` is verified using the IdP's JWKS endpoint. `getOidcAuthUrl` also accepts a `nonce` as a third argument, and `handleOidcCallback` accepts `expectedNonce` as a third argument. Use `theauth.sso.generateState()` and `validateState(state)` to create and check short-lived state tokens (`stateTtlSeconds`, default 300). ## Endpoints Served by `theauth.sso.handleRequest(request)`. The handler does no authentication of its own, so put your own admin check in front of the connection routes. | Method | Path | Description | |--------|------|-------------| | POST | `/auth/sso/connections` | Create SSO connection, body `{ orgId, providerId, type, domain }` | | GET | `/auth/sso/connections/:orgId` | List connections for org | | DELETE | `/auth/sso/connections/:id` | Remove connection | | GET | `/auth/sso/saml/:connectionId` | Initiate SAML login (302 redirect, optional `relayState` query) | | POST | `/auth/sso/saml/:connectionId/acs` | SAML assertion consumer, returns the identity as JSON | | GET | `/auth/sso/oidc/:connectionId` | Initiate OIDC login (302 redirect, optional `state` and `nonce` query) | | GET | `/auth/sso/oidc/:connectionId/callback` | OIDC callback, returns the identity as JSON | Failed logins return `401` (or `429` when rate limited, by default 10 attempts per connection per 60 seconds, set with `rateLimitMax` and `rateLimitWindowSeconds`) with `{ error, code }`. Pass `onAuditEvent` in the `sso` config to receive login success and failure events. ## Related SSO connections are scoped to organizations. Automated user provisioning that pairs with SSO connections. Turn theAuth into an OIDC identity provider for downstream apps. User management tools. --- # Admin Source: https://docs.theauth.dev/auth/admin ## Setup Pass an `admin` config when creating your theAuth instance. `theauth.admin` is `null` unless the `admin` key is passed, so use `theauth.admin!.` (or `?.`) in your code. ```ts import { createTheAuth } from '@glinr/theauth'; const theauth = await createTheAuth({ database: { provider: 'sqlite', url: 'theauth.db' }, auth: { session: { secret: process.env.SESSION_SECRET! } }, // needed for impersonation admin: { // [!code highlight] adminUserIds: [process.env.ADMIN_USER_ID!], // [!code highlight] allowImpersonation: true, // [!code highlight] impersonationTtlSeconds: 3600, onAdminAction: async (entry) => { // [!code highlight] console.info('admin action', entry.action, entry.targetUserId); // [!code highlight] }, // [!code highlight] }, // [!code highlight] }); ``` Admin status is determined by the `adminUserIds` list. There is no role column, keep these IDs in environment variables, not hardcoded. `theauth.admin.isAdmin(userId)` checks membership. To expose HTTP endpoints, add the `admin()` plugin (from `@glinr/theauth/auth`) with the same options instead. The plugin endpoints require an authenticated user who is in `adminUserIds` (401 without a user, 403 for non-admins). ```ts import { admin } from '@glinr/theauth/auth'; const theauth = await createTheAuth({ database: { provider: 'sqlite', url: 'theauth.db' }, auth: { session: { secret: process.env.SESSION_SECRET! } }, plugins: [admin({ adminUserIds: [process.env.ADMIN_USER_ID!] })], }); ``` ## Listing users ```ts const { users, total } = await theauth.admin!.listUsers({ limit: 50, offset: 0, search: 'alice', // optional, substring match on email }); ``` Each user object includes `id`, `email`, `name`, `banned`, `banReason`, `banExpiresAt`, `agentCount`, and `createdAt`. `limit` defaults to 50. `theauth.admin.getUser(userId)` returns one user or `null`. ## Banning users ```ts // Permanent ban await theauth.admin!.banUser('user_xyz', 'Violating terms of service'); // Ban with an expiry date recorded await theauth.admin!.banUser('user_xyz', 'Spam', new Date('2027-06-01')); // Lift the ban await theauth.admin!.unbanUser('user_xyz'); ``` Banning deletes all of the user's sessions and marks every agent they own as `revoked`. Unbanning clears the ban fields but does not restore revoked agents or sessions. theAuth records the ban (`banned`, `banReason`, `banExpiresAt`) but the sign-in modules do not check it, and nothing lifts a ban when `banExpiresAt` passes. Check `theauth.admin.getUser(id)` in your own sign-in flow and call `unbanUser` yourself when a temporary ban lapses. ## Impersonation Impersonation creates a real session token. Use it only for debugging and support. Impersonated sessions carry `impersonating: true` and the originating `adminUserId` in their session metadata. ```ts const { session, impersonating } = await theauth.admin!.impersonate('admin_abc', 'user_xyz'); // [!code highlight] // session.token: use this as a regular session token // session.expiresAt: now + impersonationTtlSeconds (default 1 hour) // Stop impersonating (revokes the session) await theauth.admin!.stopImpersonation(session.token); ``` `impersonate` throws if `allowImpersonation` is `false`, if no session manager is configured, or if the first argument is not in `adminUserIds`. The returned `session.expiresAt` and the `impersonationExpiresAt` metadata reflect `impersonationTtlSeconds`, but the session record itself expires after `auth.session.maxAge` (7 days by default). Call `stopImpersonation` when the support session ends. ## Force password reset ```ts await theauth.admin!.forcePasswordReset('user_xyz'); ``` This sets a `forcePasswordReset` flag on the user. The [username module](/auth/username) refuses sign-in for flagged users with a `403` `PASSWORD_RESET_REQUIRED` error. If you use another sign-in method, check the flag yourself. ## Deleting users ```ts await theauth.admin!.deleteUser('user_xyz'); ``` Deleting removes all sessions, marks owned agents as `revoked` so their records remain, then removes the user record. ## Audit callback `onAdminAction` receives `{ adminUserId, action, targetUserId, details?, timestamp }` for `ban_user`, `unban_user`, `delete_user`, `force_password_reset`, and `impersonate`. Only `impersonate` records the real admin ID. The other actions are logged with `adminUserId: 'system'`. Errors thrown by the callback are swallowed. ## Endpoints With the `admin()` plugin, all endpoints require an authenticated admin: | Method | Path | Description | |--------|------|-------------| | GET | `/auth/admin/users` | List users (`limit`, `offset`, `search`) | | GET | `/auth/admin/users/:id` | Get user | | POST | `/auth/admin/users/:id/ban` | Ban user, optional body `{ reason, expiresAt }` | | POST | `/auth/admin/users/:id/unban` | Unban user | | DELETE | `/auth/admin/users/:id` | Delete user | | POST | `/auth/admin/users/:id/impersonate` | Impersonate user | The plugin has no stop-impersonation endpoint, call `theauth.admin.stopImpersonation` from your own code. The module's own `theauth.admin.handleRequest` is a different, unauthenticated surface (it serves the same paths but takes `adminUserId` in the body for impersonation and adds `POST /auth/admin/stop-impersonation`). Do not expose it to the internet without your own admin check. ## Related Multi-tenant support with org-level roles and membership management. Create scoped API keys for machine-to-machine callers. Audit trail for agent activity. Admin actions are reported through `onAdminAction`. Automated user provisioning and deprovisioning via directory sync. --- # API keys Source: https://docs.theauth.dev/auth/api-keys ## Setup `theauth.apiKeys` is `null` unless the `apiKeys` key is passed to `createTheAuth`, so use `theauth.apiKeys!.` (or `?.`) in your code. ```ts import { createTheAuth } from '@glinr/theauth'; const theauth = await createTheAuth({ database: { provider: 'sqlite', url: 'theauth.db' }, apiKeys: { // [!code highlight] prefix: 'kos_', // default // [!code highlight] defaultExpiryDays: 90, // default: 365 // [!code highlight] }, // [!code highlight] }); ``` ## Creating a key ```ts const { key, apiKey } = await theauth.apiKeys!.create({ userId: 'user_abc', name: 'CI deploy token', permissions: ['agents:read', 'agents:create'], // [!code highlight] expiresAt: new Date('2027-01-01'), // optional, falls back to defaultExpiryDays // [!code highlight] }); // key = 'kos_' + 64 hex characters, the full secret, returned once only // apiKey.id, apiKey.prefix (prefix + first 8 hex chars), apiKey.permissions, apiKey.expiresAt ``` The full key is never stored. Show it to the user immediately after creation, it cannot be recovered later. Only a SHA-256 hash is kept in the database. ## Validating a key ```ts const result = await theauth.apiKeys!.validate('kos_a3f8c2e1...'); if (result) { // result.userId, result.permissions, result.keyId } ``` `validate` returns `null` for an unknown or expired key and updates `lastUsedAt` on success. To check a permission in the same call, use `validateWithScope`. It returns `null` unless the key's permissions include the required string or the `*` wildcard: ```ts const result = await theauth.apiKeys!.validateWithScope(key, 'agents:read'); ``` Permission strings are compared for exact equality (plus the literal `*`). There is no path matching here. ## Listing and revoking ```ts // All keys for a user (no secrets exposed) const keys = await theauth.apiKeys!.list('user_abc'); // Revoke by key ID (deletes the key record) await theauth.apiKeys!.revoke('key_...'); ``` ## Rotating a key Rotation deletes the existing key and creates a new one with the same name, permissions, and expiry date: ```ts const { key, apiKey } = await theauth.apiKeys!.rotate('key_...'); // key = new full secret, store it now ``` `rotate` throws if the key ID does not exist. ## Endpoints Add the `apiKeys()` plugin (from `@glinr/theauth/auth`) to expose HTTP endpoints. They act on the authenticated user, resolved from `auth.adapter` or an `auth.session` token, so a caller cannot manage another user's keys. ```ts import { apiKeys } from '@glinr/theauth/auth'; const theauth = await createTheAuth({ database: { provider: 'sqlite', url: 'theauth.db' }, auth: { session: { secret: process.env.SESSION_SECRET! } }, plugins: [apiKeys({ prefix: 'kos_' })], }); ``` | Method | Path | Description | |--------|------|-------------| | POST | `/auth/api-keys` | Create a key for the signed-in user, body `{ name, permissions, expiresAt? }`, returns `{ apiKey, key }` with status 201 | | GET | `/auth/api-keys` | List the signed-in user's keys as `{ apiKeys }` | | DELETE | `/auth/api-keys/:id` | Revoke one of the user's keys, returns `{ revoked: true }` | | POST | `/auth/api-keys/:id/rotate` | Rotate one of the user's keys | The module's own `theauth.apiKeys.handleRequest` serves similar paths but takes `userId` from the request body or path and does no authentication. Do not expose it directly to clients. ## Related Bearer-token identities for autonomous agents, distinct from API keys. User management tools including ban, impersonate, and delete. Attach scoped permissions to agents and keys. Audit trail for agent activity. --- # Stripe Source: https://docs.theauth.dev/auth/stripe The Stripe integration handles checkout sessions, billing portals, and subscription webhooks. It calls Stripe's REST API directly (no `stripe` npm package needed). The `stripe()` plugin exposes HTTP endpoints. The plugin does not add `theauth.stripe` to the instance, so to call the methods from your own code you create the module yourself (see Usage). ## Setup ### Get your keys From [Stripe Dashboard](https://dashboard.stripe.com/apikeys), copy your **Secret Key**. Under Webhooks, create an endpoint pointing to `/api/theauth/auth/stripe/webhook` and copy the **Signing Secret**. ### Configure the plugin ```ts import { createTheAuth } from '@glinr/theauth'; import { stripe } from '@glinr/theauth/auth'; const theauth = await createTheAuth({ database: { provider: 'postgres', url: process.env.DATABASE_URL! }, auth: { session: { secret: process.env.SESSION_SECRET! } }, plugins: [ stripe({ secretKey: process.env.STRIPE_SECRET_KEY!, webhookSecret: process.env.STRIPE_WEBHOOK_SECRET!, onSubscriptionChange: async (userId, sub) => { console.log(`User ${userId} subscription: ${sub.status}`); }, }), ], }); ``` The `secretKey` and `webhookSecret` options are both required. Other options: `autoCreateCustomer` (default `true`), `apiVersion` (default `2024-12-18.acacia`), and the `onSubscriptionChange(userId, subscription)` callback. The checkout, portal and subscription endpoints act on the authenticated user, resolved from `auth.adapter` or an `auth.session` token. ## Usage Create the module with the same config and your instance's database. You can build it once and share it with the plugin config. ```ts import { createStripeModule } from '@glinr/theauth/auth'; const stripeModule = createStripeModule( { secretKey: process.env.STRIPE_SECRET_KEY!, webhookSecret: process.env.STRIPE_WEBHOOK_SECRET!, }, theauth.db, ); ``` ### Create a checkout session ```ts // From an authenticated endpoint const result = await stripeModule.createCheckoutSession(userId, 'price_xxx', { successUrl: 'https://myapp.com/billing?success=true', cancelUrl: 'https://myapp.com/billing', trialDays: 14, }); // Redirect user to result.url (result also has sessionId) ``` Always pass `successUrl` and `cancelUrl`. If you omit `successUrl`, it falls back to a placeholder (`https://example.com/success`). ### Open the billing portal ```ts const result = await stripeModule.createPortalSession(userId, 'https://myapp.com/settings'); // Redirect to result.url ``` ### Check subscription status ```ts const sub = await stripeModule.getSubscription(userId); if (sub?.status === 'active') { // User has an active subscription } // sub: { id, status, priceId, currentPeriodEnd, cancelAtPeriodEnd } or null ``` ## Webhook events The plugin handles these Stripe events automatically: | Event | Action | |-------|--------| | `checkout.session.completed` | Links Stripe customer to user | | `customer.subscription.created` | Stores subscription status | | `customer.subscription.updated` | Updates status, price, period | | `customer.subscription.deleted` | Marks subscription canceled | | `invoice.payment_failed` | Sets status to `past_due` | Other event types are acknowledged and ignored. Webhook signatures are verified using HMAC-SHA256. Timestamps more than 5 minutes from the current time are rejected. A missing or invalid signature returns `400`. ## Endpoints | Method | Path | Auth | Description | |--------|------|------|-------------| | POST | `/auth/stripe/checkout` | Yes | Create checkout session, body `{ priceId, successUrl?, cancelUrl?, trialDays?, metadata? }` | | POST | `/auth/stripe/portal` | Yes | Create billing portal, body `{ returnUrl }` | | GET | `/auth/stripe/subscription` | Yes | Get subscription info as `{ subscription }` | | POST | `/auth/stripe/webhook` | No | Stripe webhook (signature verified) | ## Database columns These Stripe columns are part of the users table that theAuth creates (they are not added by the plugin): | Column | Type | Description | |--------|------|-------------| | `stripe_customer_id` | text | Stripe customer ID | | `stripe_subscription_id` | text | Active subscription ID | | `stripe_subscription_status` | text | active, canceled, past_due, etc. | | `stripe_price_id` | text | Current price/plan ID | | `stripe_current_period_end` | timestamp | When the current period ends | | `stripe_cancel_at_period_end` | boolean | Whether cancellation is scheduled | `webhookSecret` is required. Webhook events whose signature does not verify against it are rejected. ## Related Alternative billing integration using Polar subscriptions and webhooks. Manage org membership alongside subscription status. Issue scoped API keys for server-to-server billing integrations. Lifecycle hooks you can attach to your instance. For subscription changes use the `onSubscriptionChange` callback. --- # Polar payments Source: https://docs.theauth.dev/auth/polar-payment Polar is an open-source funding platform for developers. The theAuth plugin wires Polar checkout sessions and subscription webhooks to your user records, no Polar SDK required. The plugin exposes HTTP endpoints only. It does not add `theauth.polar` or `theauth.plugins.polar` to the instance, so to call the methods from your own code you create the module yourself (see Usage). ## Setup ### Get your credentials From your [Polar dashboard](https://polar.sh), go to **Settings → API** and create an access token. Under **Webhooks**, create an endpoint pointing to `/api/theauth/auth/polar/webhook` and copy the signing secret. ### Configure the plugin ```ts import { createTheAuth } from '@glinr/theauth'; import { polar } from '@glinr/theauth/auth'; const theauth = await createTheAuth({ database: { provider: 'postgres', url: process.env.DATABASE_URL! }, auth: { session: { secret: process.env.SESSION_SECRET! } }, plugins: [ polar({ accessToken: process.env.POLAR_ACCESS_TOKEN!, webhookSecret: process.env.POLAR_WEBHOOK_SECRET!, sandbox: process.env.NODE_ENV !== 'production', onSubscriptionChange: async (userId, sub) => { console.log(`User ${userId}: ${sub.status}`); }, }), ], }); ``` The checkout and subscription endpoints act on the authenticated user, resolved from `auth.adapter` or an `auth.session` token. ## Usage Create the module with the same config and your instance's database: ```ts import { createPolarModule } from '@glinr/theauth/auth'; const polarModule = createPolarModule( { accessToken: process.env.POLAR_ACCESS_TOKEN!, webhookSecret: process.env.POLAR_WEBHOOK_SECRET!, }, theauth.db, ); ``` ### Create a checkout session ```ts // From an authenticated endpoint const { url } = await polarModule.createCheckout(userId, 'product_xxx', { successUrl: 'https://example.com/success', customerEmail: 'user@example.com', // optional }); // Redirect the user redirect(url); ``` ### Check subscription status ```ts const sub = await polarModule.getSubscription(userId); if (sub?.status === 'active') { // grant access } ``` ## HTTP endpoints The plugin registers three routes under your theAuth base path. ### POST /auth/polar/checkout Creates a checkout session. Requires an authenticated user. ```json // Request body { "productId": "product_xxx", "successUrl": "https://example.com/success", "customerEmail": "user@example.com" } // Response { "url": "https://buy.polar.sh/...", "id": "checkout_xxx" } ``` ### GET /auth/polar/subscription Returns the current subscription for the authenticated user. ```json { "subscription": { "id": "sub_xxx", "status": "active", "productId": "product_xxx", "currentPeriodEnd": "2025-12-31T00:00:00.000Z", "cancelAtPeriodEnd": false } } ``` Returns `{ "subscription": null }` when no subscription is stored for the user. ### POST /auth/polar/webhook Receives webhook events from Polar. The `webhook-signature` header is verified with HMAC-SHA256 against `webhookSecret`, no session auth needed. A missing or invalid signature is rejected. Handled events: | Event | Action | |---|---| | `subscription.created` | Links customer, stores subscription | | `subscription.updated` | Updates status and period end | | `subscription.revoked` | Marks the stored subscription `canceled` and fires the callback with status `canceled` | ## Sandbox mode Set `sandbox: true` to point at `sandbox.api.polar.sh` during development. Polar's sandbox environment mirrors the production API and lets you test webhooks without real payments. ```ts polar({ accessToken: process.env.POLAR_SANDBOX_TOKEN!, webhookSecret: process.env.POLAR_SANDBOX_WEBHOOK_SECRET!, sandbox: true, }) ``` ## Reference ### `PolarConfig` | Field | Type | Description | |---|---|---| | `accessToken` | `string` | Polar API access token | | `webhookSecret` | `string` | Webhook signing secret for HMAC verification | | `organizationId` | `string?` | Scope checkouts to a specific organization | | `sandbox` | `boolean?` | Use sandbox environment (default: `false`) | | `onSubscriptionChange` | `function?` | Called whenever subscription status changes | ### `PolarSubscription` | Field | Type | Description | |---|---|---| | `id` | `string` | Polar subscription ID | | `status` | `string` | `active`, `canceled`, `incomplete`, `past_due`, `trialing`, or `unpaid` | | `productId` | `string` | Polar product ID | | `currentPeriodEnd` | `Date` | When the current billing period ends | | `cancelAtPeriodEnd` | `boolean` | Whether cancellation is scheduled | theAuth stores Polar subscription data in `polar_*` columns of the users table (`polar_customer_id`, `polar_subscription_id`, `polar_subscription_status`, `polar_product_id`, `polar_current_period_end`, `polar_cancel_at_period_end`). You can query them directly with Drizzle or any SQL client. ## Related Scope subscription access to org members. User management for subscription-gated features. Lifecycle hooks you can attach to your instance. For subscription changes use the `onSubscriptionChange` callback. Store subscription metadata on user records. --- # SCIM Source: https://docs.theauth.dev/auth/scim SCIM 2.0 lets Okta, Azure AD, and Google Workspace automatically provision and deprovision users in your app. When an employee is onboarded in the directory, they get access. When they leave, access is removed. ## Setup ### Add the plugin ```ts import { createTheAuth } from '@glinr/theauth'; import { scim } from '@glinr/theauth/auth'; const theauth = await createTheAuth({ database: { provider: 'postgres', url: process.env.DATABASE_URL! }, plugins: [ scim({ bearerToken: process.env.SCIM_TOKEN!, }), ], }); ``` ### Configure your identity provider Point your IdP's SCIM provisioning settings at: ``` Base URL: https://your-app.com/api/theauth/scim/v2 Auth: Bearer token Token: ``` The token must match `SCIM_TOKEN` exactly. Use a long random secret (32+ bytes). The `/api/theauth` part of the base URL is the default adapter mount path, so it follows wherever you mounted the adapter. Optional config: `autoCreateUsers` (default `true`), `autoDeactivateUsers` (default `true`), `onProvision(user)` and `onDeprovision(userId)` callbacks. ## User endpoints The plugin exposes SCIM 2.0 user CRUD endpoints. Your IdP calls these automatically. Users map to theAuth's users table. | Method | Path | Description | |--------|------|-------------| | GET | `/scim/v2/Users` | List users (with optional filter) | | GET | `/scim/v2/Users/:id` | Get a single user | | POST | `/scim/v2/Users` | Provision a new user | | PUT | `/scim/v2/Users/:id` | Replace a user's attributes | | PATCH | `/scim/v2/Users/:id` | Update specific attributes | | DELETE | `/scim/v2/Users/:id` | Deprovision a user | Deprovisioning (`DELETE`, or `active: false` in a `PUT` or `PATCH`) deactivates the user by setting their `banned` flag and a `scim:deprovisioned` metadata marker when `autoDeactivateUsers` is on. With it off, `DELETE` removes the user row. theAuth's sign-in modules do not check the `banned` flag, so check it in your own sign-in flow (see [Admin](/auth/admin)) for deprovisioning to cut off access. ## Group endpoints Groups are mapped to theAuth organizations. | Method | Path | Description | |--------|------|-------------| | GET | `/scim/v2/Groups` | List groups | | GET | `/scim/v2/Groups/:id` | Get a single group | | POST | `/scim/v2/Groups` | Create a group / org | | PUT | `/scim/v2/Groups/:id` | Replace group attributes | | PATCH | `/scim/v2/Groups/:id` | Update group membership | | DELETE | `/scim/v2/Groups/:id` | Remove a group | ## Filtering and sorting The Users and Groups list endpoints accept the full RFC 7644 filter grammar through the `filter` query parameter: ``` GET /scim/v2/Users?filter=userName eq "john@example.com" GET /scim/v2/Users?filter=emails[type eq "work" and value ew "@acme.com"] ``` Supported operators are `eq`, `ne`, `co`, `sw`, `ew`, `gt`, `ge`, `lt`, `le` and `pr`, combined with `and`, `or`, `not` and parentheses, plus value path selectors. A filter that does not parse returns `400 invalidFilter`. See the [filter reference](/auth/scim-filter-reference) for the grammar. List endpoints also accept `sortBy` and `sortOrder`, with `id` as the tie break, along with `startIndex` and `count` for pagination. ## PATCH PATCH supports path expressions with value filters, such as `emails[type eq "work"].value`, and rejects changes to immutable attributes with `400 mutability`. A single request is capped at 1000 operations. See the [PATCH reference](/auth/scim-patch-reference). ## Me and Bulk `GET /scim/v2/Me` is opt in. Pass a `resolveSelf` callback to map the authenticated caller to a SCIM user, otherwise the endpoint returns `501`. `POST /scim/v2/Bulk` returns a spec compliant `501`, and `ServiceProviderConfig` advertises bulk as unsupported. ## Enterprise User extension User resources accept and return the RFC 7643 Enterprise User extension (`employeeNumber`, `department`, `manager` and the rest). It is advertised in `/Schemas`. ## Audit Pass `audit: { agentId }` to record an audit log row for every successful provisioning write (POST, PUT, PATCH and DELETE). The agent must already exist. Audit writes are best effort, so a failed audit insert never fails the SCIM call. ## Discovery endpoints SCIM clients use these to learn what your server supports: | Path | Description | |------|-------------| | `/scim/v2/ServiceProviderConfig` | Supported features and auth schemes (PATCH, filter and sort supported, bulk not supported) | | `/scim/v2/Schemas` | User and Group schema definitions | | `/scim/v2/ResourceTypes` | Registered resource type metadata | Rotate `SCIM_TOKEN` immediately if it is exposed. The module rejects requests without a valid `Authorization: Bearer ` header with `401`. The token is a single static secret compared as-is. ## Related SCIM groups are mapped to theAuth organizations. Enterprise SSO via SAML 2.0 and OIDC, often paired with SCIM. Manual user management tools alongside automated provisioning. All sign-in methods available in theAuth. --- # SCIM filter grammar reference Source: https://docs.theauth.dev/auth/scim-filter-reference ## Overview SCIM filter strings tell list endpoints which resources to return. The theAuth parser implements the complete grammar from RFC 7644 §3.4.2.2. Any filter string that does not conform to that grammar returns `400 invalidFilter` before the database is touched. ## Grammar The grammar below is taken directly from RFC 7644 §3.4.2.2. ```abnf FILTER = attrExp / logExp / valuePath / "not" "(" FILTER ")" valuePath = attrPath "[" valFilter "]" attrExp = attrPath SP "pr" / attrPath SP compareOp SP compValue logExp = FILTER SP ("and" / "or") SP FILTER compareOp = "eq" / "ne" / "co" / "sw" / "ew" / "gt" / "lt" / "ge" / "le" compValue = false / null / true / number / string attrPath = [URI ":"] ATTRNAME *1subAttr ATTRNAME = ALPHA *(nameChar) nameChar = "-" / "_" / DIGIT / ALPHA subAttr = "." ATTRNAME ``` ### Productions in plain English - **attrExp** is the basic building block: an attribute, an operator, and a value. Example: `userName eq "bjensen"`. - **valuePath** wraps a multi-valued attribute with square brackets. Example: `emails[type eq "work"]`. - **logExp** chains two filters with `and` or `or`. Precedence is handled by the parser, not you. - **not(...)** negates whatever is inside the parentheses. - **attrPath** can be a simple name (`userName`), a dotted path (`name.givenName`), or URN-prefixed (`urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:department`). ## Comparison operators All string comparisons are case-insensitive per RFC 7644 §3.4.2.2. `"BJensen"` and `"bjensen"` are the same value. ### eq Returns true when the attribute value equals the comparison value exactly (after case-folding for strings). ```http GET /scim/v2/Users?filter=userName%20eq%20%22bjensen%22 ``` Matches: ```json { "userName": "bjensen" } { "userName": "BJensen" } ``` ### ne The inverse of `eq`. Returns true when the attribute value does not equal the comparison value. ```http GET /scim/v2/Users?filter=userType%20ne%20%22Admin%22 ``` Matches any user whose `userType` is not `Admin`. ### co Returns true when the attribute value contains the comparison value as a substring. ```http GET /scim/v2/Users?filter=name.familyName%20co%20%22jens%22 ``` Matches `Jensen`, `Jensens`, `jenson`. Does not match `jen`. ### sw Returns true when the attribute value starts with the comparison value. ```http GET /scim/v2/Users?filter=userName%20sw%20%22bj%22 ``` Matches `bjensen`, `bjoern`. Does not match `barbara`. ### ew Returns true when the attribute value ends with the comparison value. ```http GET /scim/v2/Users?filter=emails.value%20ew%20%22%40example.com%22 ``` Matches `bjensen@example.com`. Does not match `bjensen@example.org`. ### gt Returns true when the attribute value is greater than the comparison value. For strings, comparison is lexicographic. ISO 8601 timestamps (`2024-06-01T00:00:00Z`) are lexicographically ordered, so `gt` and `lt` work correctly on `meta.lastModified` without any special date parsing. ```http GET /scim/v2/Users?filter=meta.lastModified%20gt%20%222025-01-01T00:00:00Z%22 ``` Matches users modified after 2025-01-01. ### ge Returns true when the attribute value is greater than or equal to the comparison value. Same rules as `gt`. ```http GET /scim/v2/Users?filter=meta.lastModified%20ge%20%222024-06-01T00:00:00Z%22 ``` ### lt Returns true when the attribute value is less than the comparison value. ```http GET /scim/v2/Users?filter=meta.lastModified%20lt%20%222025-01-01T00:00:00Z%22 ``` ### le Returns true when the attribute value is less than or equal to the comparison value. ```http GET /scim/v2/Users?filter=meta.lastModified%20le%20%222024-06-01T00:00:00Z%22 ``` ## Presence operator `pr` tests whether an attribute is present and non-empty. It takes no comparison value. ```http GET /scim/v2/Users?filter=emails%20pr ``` Returns users that have at least one email address. An empty array, `null`, or a missing key all fail `pr`. ```http GET /scim/v2/Users?filter=nickName%20pr ``` Returns only users with a non-empty `nickName`. Users without the attribute are excluded. ## Logical combinators ### and Both clauses must match. ```http filter=userName eq "bjensen" and active eq true ``` ### or At least one clause must match. ```http filter=userName eq "bjensen" or userName eq "jsmith" ``` ### not Negates the enclosed filter. The parentheses are required. ```http filter=not (userType eq "Admin") ``` ### Precedence The parser follows standard logic precedence: `not` binds tightest, then `and`, then `or`. This means: ``` a eq "1" or b eq "2" and c eq "3" ``` is parsed as: ``` a eq "1" or (b eq "2" and c eq "3") ``` If that is not what you want, use parentheses: ``` (a eq "1" or b eq "2") and c eq "3" ``` The second form requires `c eq "3"` to be true for any result to be returned. ## Value-path selectors A value-path selector applies a filter to the elements of a multi-valued attribute. The expression is true when at least one element satisfies the inner filter. ```http filter=emails[type eq "work"] ``` This matches a user who has at least one email object where `type` is `"work"`. It does not require the work email to be the primary address or the only address. Combinators work inside the brackets: ```http filter=emails[type eq "work" and value ew "@example.com"] ``` This matches a user whose work email also ends with `@example.com`. Both conditions must hold on the same array element, not across different elements. Nested value paths are not supported. `emails[addresses[...]]` returns `400 invalidFilter`. ## Attribute paths ### Dot notation Use a dot to navigate into a sub-attribute: ``` name.givenName name.familyName meta.lastModified ``` ### URN-qualified paths Schema extensions use a URN prefix followed by the attribute name, separated by a colon: ``` urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:department urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:employeeNumber ``` The parser splits on the last `:` when the path starts with `urn:`, then reads the attribute from the extension object keyed by the URN in the resource. ```http filter=urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:department%20eq%20%22Engineering%22 ``` Dot notation inside a URN path is also valid: ``` urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:manager.value ``` ## Data types in comparisons | Type | Syntax | Example | |------|--------|---------| | String | Double-quoted | `userName eq "bjensen"` | | Number | Bare numeric literal | `employeeNumber eq 42` | | Boolean | Bare `true` or `false` | `active eq true` | | Null | Bare `null` | `manager eq null` | Strings support JSON-style escape sequences inside quotes: `\"`, `\\`, `\/`, `\t`, `\n`, `\r`. Ordering operators (`gt`, `ge`, `lt`, `le`) on booleans and null always return false. Ordering on a number against a string also returns false. ## Error responses When the filter string is malformed, the server returns: ```json { "schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"], "status": "400", "scimType": "invalidFilter", "detail": "Unterminated string literal at position 14" } ``` The `detail` field contains the position in the original filter string where the parser stopped. Common causes: | Cause | Example | |-------|---------| | Unterminated string | `userName eq "bje` | | Unknown operator | `userName foo "x"` | | Missing operator | `userName "bjensen"` | | Unbalanced brackets | `emails[type eq "work"` | | Unbalanced parentheses | `not (active eq true` | | Trailing garbage | `userName eq "x" nonsense` | | Empty filter | _(empty string)_ | | Bad URN prefix | `urn:bad` (no final `:attrName` segment) | ## Security notes The parser builds a finite AST and walks it. No eval, no regex matching against untrusted input. Filter complexity grows linearly with input length. The recursive descent parser does not recurse in a way that is proportional to nesting depth beyond what the input length permits, so deeply nested parentheses do not cause a stack overflow. Very long input strings are rejected at the HTTP transport layer before the parser runs. ## Practical examples | Filter | What it matches | Endpoint | |--------|----------------|----------| | `userName eq "bjensen"` | Exact username lookup | `GET /scim/v2/Users` | | `emails[type eq "work"]` | Users with a work email (Okta probe) | `GET /scim/v2/Users` | | `emails[type eq "work" and value ew "@example.com"]` | Work email from a specific domain | `GET /scim/v2/Users` | | `active eq false` | Deactivated users | `GET /scim/v2/Users` | | `active eq true and userType eq "Employee"` | Active employees only | `GET /scim/v2/Users` | | `meta.lastModified gt "2026-01-01T00:00:00Z"` | Recently provisioned users (Azure AD sync probe) | `GET /scim/v2/Users` | | `meta.lastModified gt "2026-01-01T00:00:00Z" and active eq true` | Active users provisioned this year | `GET /scim/v2/Users` | | `not (userType eq "Admin")` | Non-admin users | `GET /scim/v2/Users` | | `name.familyName sw "J"` | Users whose surname starts with J | `GET /scim/v2/Users` | | `emails.value co "@example.com"` | Any email containing that domain | `GET /scim/v2/Users` | | `userName sw "svc-"` | Service accounts (by naming convention) | `GET /scim/v2/Users` | | `urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:department eq "Engineering"` | Users in Engineering | `GET /scim/v2/Users` | | `displayName pr` | Groups with a non-empty display name | `GET /scim/v2/Groups` | | `(userName eq "bjensen" or userName eq "jsmith") and active eq true` | Specific users, active only | `GET /scim/v2/Users` | --- # SCIM PATCH reference Source: https://docs.theauth.dev/auth/scim-patch-reference ## Overview PATCH lets an identity provider update a SCIM resource without sending the full representation. Instead of replacing the entire user object, you send a list of targeted operations. theAuth implements the RFC 7644 §3.5.2 PATCH protocol at `PATCH /scim/v2/Users/{id}`. The endpoint deserializes the current database row into a SCIM view, applies your operations against that view using the path engine in `scim-patch.ts`, and then maps the mutated fields back to database columns. --- ## Operation types Every PATCH request body must include the patch schema and an `Operations` array with at least one entry. ```json { "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], "Operations": [...] } ``` ### add Adds a value. On a multi-valued attribute (like `emails`), it appends rather than replaces. On a scalar, it sets the value. When `path` is omitted, the `value` object is merged into the resource root. ```json { "op": "add", "path": "emails", "value": [{ "value": "extra@example.com", "type": "other" }] } ``` ### replace Overwrites the targeted attribute. On a multi-valued attribute targeted by a value filter, it updates only the matching elements. Without a path, it merges the `value` object into the root (same merge behavior as `add`). ```json { "op": "replace", "path": "active", "value": false } ``` ### remove Removes the targeted attribute or, with a value filter, drops matching array elements. `remove` without a `path` is always rejected with `noTarget`. ```json { "op": "remove", "path": "emails[type eq \"home\"]" } ``` --- ## Path expressions Paths follow the RFC 7644 §3.5.2 grammar: ``` PATH = attrPath [ "[" valFilter "]" ] [ "." attrPath ] ``` In plain English: a base attribute name, optionally filtered by a bracketed expression that selects elements of a multi-valued attribute, optionally followed by a dot-separated sub-attribute. ### Simple attribute Targets a top-level attribute directly. | Path | Targets | |------|---------| | `displayName` | The `displayName` field | | `active` | The `active` boolean | ```json { "op": "replace", "path": "displayName", "value": "Barbara Jensen" } ``` ### Nested attribute A dot separates the base attribute from a sub-attribute. The engine creates intermediate objects if they do not exist. | Path | Targets | |------|---------| | `name.givenName` | `name.givenName` | | `name.familyName` | `name.familyName` | ```json { "op": "replace", "path": "name.givenName", "value": "Barbara-Ann" } ``` ### Multi-valued attribute Targeting the base attribute without a filter operates on the whole array. ```json { "op": "add", "path": "emails", "value": [{ "value": "ops@example.com", "type": "other" }] } ``` ### Value-filter selector Brackets contain a filter expression that selects elements of the array. The filter uses SCIM attribute operators: `eq`, `ne`, `co`, `sw`, `ew`, `pr`, `gt`, `lt`, `ge`, `le`, and logical operators `and`/`or`/`not`. | Path | Selects | |------|---------| | `emails[type eq "work"]` | All email entries where `type` is `"work"` | | `emails[primary eq true]` | The primary email entry | | `phoneNumbers[type eq "mobile"]` | Mobile phone numbers | ```json { "op": "replace", "path": "emails[type eq \"work\"]", "value": { "primary": false } } ``` The filter expression is parsed into an AST. No regex matching runs against user input. ### Value-filter with sub-attribute Append `.attrName` after the closing bracket to target a specific field on each matching element. | Path | Effect | |------|--------| | `emails[type eq "work"].value` | The `value` field of the work email | | `emails[primary eq true].display` | The `display` field of the primary email | ```json { "op": "replace", "path": "emails[type eq \"work\"].value", "value": "new-work@example.com" } ``` The value filter must attach to a top-level attribute. A path like `name.sub[filter]` is rejected with `invalidPath`. ### URN-prefixed attribute Extension schema attributes use the full URN as a namespace prefix. The engine splits on the last `:` to extract the schema URN and the attribute name, then writes the value under the URN key in the resource object. ``` urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:department ``` Parsed as: - `schemaUrn`: `urn:ietf:params:scim:schemas:extension:enterprise:2.0:User` - `base`: `department` ```json { "op": "replace", "path": "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:department", "value": "Engineering" } ``` --- ## Add semantics When `op` is `add` and the target is a multi-valued attribute (an existing array), the engine appends rather than replaces. This is intentional per RFC 7644 §3.5.2.2. ```json { "op": "add", "path": "emails", "value": [{ "value": "extra@example.com", "type": "other" }] } ``` After this operation, the original work and home emails are still present. The new entry is appended. Contrast with `replace`, which overwrites the array: ```json { "op": "replace", "path": "emails", "value": [{ "value": "only@example.com", "type": "work", "primary": true }] } ``` This leaves only the one entry. No-path `add` has the same append behavior for arrays nested inside the value object: ```json { "op": "add", "value": { "emails": [{ "value": "extra@example.com", "type": "other" }] } } ``` --- ## Remove semantics `remove` with a plain path sets the attribute to `undefined`: ```json { "op": "remove", "path": "active" } ``` `remove` with a value filter drops all matching array elements and keeps the rest: ```json { "op": "remove", "path": "emails[type eq \"home\"]" } ``` If the filter matches no elements, the operation is a no-op. The array is not modified. `remove` without any `path` is rejected: ```json { "op": "remove" } ``` ```json { "schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"], "scimType": "noTarget", "detail": "PATCH remove requires a path", "status": "400" } ``` --- ## No-path operations `add` and `replace` accept operations without a `path`. The `value` must be an object. Each key in the object is merged into the resource root. ```json { "op": "replace", "value": { "displayName": "Barb J", "active": false } } ``` This is equivalent to two separate `replace` operations with explicit paths. Immutable checks still apply: if the value object contains `id`, `schemas`, or `meta`, the entire operation is rejected before any write happens. `remove` without a path is always rejected regardless of what `value` contains. --- ## Immutable attributes The following paths cannot be modified by any PATCH operation. They are server-controlled per RFC 7643 §7. | Path | Why it is immutable | |------|---------------------| | `id` | Server-generated primary key | | `schemas` | Controlled by resource type, not client | | `meta` | Entire meta object is server-managed | | `meta.created` | Set once at creation time | | `meta.lastModified` | Updated by the server on each write | | `meta.location` | Derived from server URL and resource ID | | `meta.resourceType` | Fixed for the resource type | Attempting to PATCH any of these returns: ```json { "schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"], "scimType": "mutability", "detail": "Attribute \"id\" is immutable", "status": "400" } ``` The check runs before any write. If the first operation in a batch is valid and the second targets `id`, the first write does not persist. --- ## Caller-defined readonly paths The `handlePatchUser` handler passes an extra `readonlyPaths` option to the engine: ```typescript applyPatchOps(scimView, body.Operations, { readonlyPaths: ["id", "externalid"], }); ``` This means `externalId` cannot be changed via PATCH after the resource is created, even though it is not in the base immutable list. Paths in `readonlyPaths` are lowercased before comparison, so `externalId`, `externalid`, and `ExternalID` all match. If you build a custom SCIM handler, you can pass any additional paths you want to lock: ```typescript applyPatchOps(resource, ops, { readonlyPaths: ["username", "externalid", "entitlements"], }); ``` --- ## DoS guardrails The engine caps the number of operations per request. The default is 1000. If the `Operations` array exceeds the cap, the engine throws before processing any operation. ```json { "schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"], "scimType": "tooMany", "detail": "PATCH has 1001 operations, limit is 1000", "status": "413" } ``` This matters for public-facing deployments. A client that generates one PATCH operation per attribute per user can construct a single request containing thousands of operations. Without a cap, that becomes an amplification vector: one HTTP request triggers unbounded in-memory work. You can lower the cap per-handler by passing `maxOperations`: ```typescript applyPatchOps(resource, ops, { maxOperations: 50 }); ``` --- ## Error reference | `scimType` | HTTP status | Cause | Fix | |------------|-------------|-------|-----| | `invalidPath` | 400 | Path string is malformed, has unbalanced brackets, has a filter on a nested attribute, or the value filter expression is invalid | Check the path grammar. Value filters must attach to a top-level attribute: `emails[type eq "work"]` not `name.sub[filter]` | | `invalidValue` | 400 | Operation type is not `add`, `replace`, or `remove`; or the value is the wrong shape for the operation | Verify `op` is one of the three allowed strings. No-path operations require an object value, not a scalar | | `noTarget` | 400 | `remove` was sent without a `path` | Add a `path` to the `remove` operation | | `mutability` | 400 | The path targets an immutable attribute (`id`, `schemas`, `meta.*`) or a caller-defined readonly path | Remove that operation from the batch. These attributes are set by the server | | `tooMany` | 413 | The `Operations` array exceeds the engine cap (default 1000) | Send fewer operations per request, or batch them across multiple PATCH calls | --- ## Worked examples ### 1. Deactivate a user (Okta-style) Okta sends a single `replace` on `active` to suspend a user. ```json { "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], "Operations": [ { "op": "Replace", "path": "active", "value": false } ] } ``` Note: `op` values are case-insensitive. `Replace` and `replace` are equivalent. ### 2. Update display name and email in one request (Okta-style) ```json { "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], "Operations": [ { "op": "replace", "path": "displayName", "value": "Barbara Jensen" }, { "op": "replace", "path": "emails[type eq \"work\"].value", "value": "bjensen-new@example.com" } ] } ``` ### 3. No-path merge (Azure AD-style) Azure AD often sends `replace` without a path, merging a partial object. ```json { "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], "Operations": [ { "op": "replace", "value": { "displayName": "Barb J", "active": true, "name": { "givenName": "Barb", "familyName": "Jensen" } } } ] } ``` ### 4. Add a phone number (Azure AD-style) ```json { "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], "Operations": [ { "op": "add", "path": "phoneNumbers", "value": [{ "value": "+1-555-0100", "type": "work" }] } ] } ``` If `phoneNumbers` already exists, this appends the new entry. ### 5. Set enterprise extension attribute (Google Workspace-style) Google Workspace sends URN-prefixed paths for extension attributes. ```json { "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], "Operations": [ { "op": "replace", "path": "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:department", "value": "Engineering" }, { "op": "replace", "path": "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:organization", "value": "Acme Corp" } ] } ``` ### 6. Remove the home email ```json { "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], "Operations": [ { "op": "remove", "path": "emails[type eq \"home\"]" } ] } ``` ### 7. Replace nested name fields ```json { "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], "Operations": [ { "op": "replace", "path": "name.givenName", "value": "Barbara-Ann" }, { "op": "replace", "path": "name.familyName", "value": "Jensen-Smith" } ] } ``` ### 8. Add multiple emails at once ```json { "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], "Operations": [ { "op": "add", "path": "emails", "value": [ { "value": "work2@example.com", "type": "work" }, { "value": "personal@example.com", "type": "home" } ] } ] } ``` --- ## Current limitations **Groups PATCH is partial.** `PATCH /scim/v2/Groups/{id}` routes through a separate handler (`handlePatchGroup`) that does not use the `scim-patch.ts` engine. Value-filter operations on group members are not supported. A full rewrite to use the shared engine is planned for a follow-up release. **Value-filter member operations.** Filtering on `members[value eq "user-id"]` in a group context is not yet evaluated against the engine's AST walker. Sending such a path against the groups endpoint will not produce a predictable error -- it will silently fail or return unexpected results. **Filter operators.** The current AST walker in `scim-filter.ts` supports the comparison operators (`eq`, `ne`, `co`, `sw`, `ew`, `pr`, `gt`, `lt`, `ge`, `le`) and logical operators (`and`, `or`, `not`). Complex nested logical expressions with more than two clauses may not parse correctly. --- ## Security posture The path parser and value-filter evaluator are pure AST walkers. No user-provided string is ever passed to `eval`, `new Function`, or a dynamic property accessor outside a whitelisted attribute list. Immutable checks happen before any write begins. If an operation batch is partially valid and the first invalid operation is the third in the list, no writes from operations one or two persist. The operation count cap prevents request-amplification attacks. Without it, a single PATCH request could encode thousands of operations, each triggering DB reads and writes. Audit logs record the final resource state after the patch is applied, not the raw operation strings. This prevents log injection through maliciously crafted path expressions or filter values. --- # OIDC provider Source: https://docs.theauth.dev/auth/oidc-provider theAuth can act as a full OpenID Connect identity provider. External apps register as clients and authenticate their users through theAuth using the standard authorization code flow with PKCE, ID tokens, refresh tokens, discovery, and JWKS. This is distinct from using OAuth providers *with* theAuth. Here, theAuth *is* the provider. ## Setup ### Generate a signing key ```typescript import { generateKeyPair, exportJWK } from 'jose'; const { privateKey } = await generateKeyPair('RS256'); ``` Store the private key securely. The corresponding public key is exposed at the JWKS endpoint so clients can verify tokens. ### Create the module ```typescript title="lib/theauth.ts" import { generateKeyPair } from 'jose'; import { createOidcProviderModule } from '@glinr/theauth/auth'; const { privateKey } = await generateKeyPair('RS256'); const oidc = createOidcProviderModule( { issuer: 'https://auth.example.com', signingKey: privateKey, }, db, async (userId, scopes) => { const user = await getUser(userId); return { sub: user.id, email: scopes.includes('email') ? user.email : undefined, name: scopes.includes('profile') ? user.name : undefined, emailVerified: user.emailVerified, }; }, ); ``` The third argument is a `GetUserClaimsFn` callback. theAuth calls it to build ID tokens and userinfo responses. You control what data is returned per scope. ### Register a client ```typescript const result = await oidc.registerClient({ clientName: 'My App', redirectUris: ['https://app.example.com/callback'], }); if (result.success) { console.log(result.data.clientId); console.log(result.data.clientSecret); // shown once, store it now } ``` The `clientSecret` is only returned on registration. It is stored hashed. If you lose it, delete and re-register the client. ## Configuration options | Option | Type | Default | Description | |--------|------|---------|-------------| | `issuer` | `string` | required | Issuer URL, e.g. `https://auth.example.com` | | `signingKey` | `CryptoKey \| JWK` | required | RSA or EC private key for signing tokens | | `signingAlgorithm` | `string` | `RS256` | JWT algorithm (`RS256`, `ES256`, etc.) | | `accessTokenTtl` | `number` | `3600` | Access token lifetime in seconds | | `refreshTokenTtl` | `number` | `2592000` | Refresh token lifetime in seconds (30 days) | | `authCodeTtl` | `number` | `600` | Authorization code lifetime in seconds | | `idTokenTtl` | `number` | `3600` | ID token lifetime in seconds | | `supportedScopes` | `string[]` | `['openid', 'profile', 'email']` | Scopes this provider accepts | ## Authorization code flow `createOidcProviderModule` returns a module of methods. It does not register any HTTP routes, so you mount the endpoints in your own server and call the matching method from each handler. 1. Client redirects the user to your authorization route (the discovery document advertises `{issuer}/authorize`) with `response_type=code`, `client_id`, `redirect_uri`, `scope`, and optionally `code_challenge` + `code_challenge_method=S256`. 2. Your app authenticates the user, then calls `oidc.authorize({ ...params, userId })` to issue a code. 3. Client exchanges the code at your token route (`{issuer}/token`), which calls `oidc.exchangeToken(...)` and returns `access_token`, `id_token`, and `refresh_token`. 4. Client can refresh tokens using `grant_type=refresh_token`. Refresh tokens rotate on each use. PKCE with `S256` is supported. If the authorization request includes a `code_challenge`, the token request must include the matching `code_verifier`. Authorization codes are single-use and expire after 10 minutes by default. ## Endpoints The module does not serve these routes. The discovery document returned by `getDiscoveryDocument()` advertises the following URLs under `issuer`, and the table shows the module method each route should call: | Method | Advertised path | Module method | |--------|-----------------|---------------| | GET | `/.well-known/openid-configuration` | `getDiscoveryDocument()` | | GET | `/.well-known/jwks.json` | `getJwks()` | | GET | `/authorize` | `authorize(params)` after you authenticate the user | | POST | `/token` | `exchangeToken(params)` | | GET/POST | `/userinfo` | `getUserInfo(accessToken)` | | POST | `/register` | `registerClient(input)` | Other module methods: `getClient`, `deleteClient`, and `validateAccessToken`. ## Related Using theAuth as an OAuth client to sign in via other providers. Enterprise SSO via SAML 2.0 and OIDC connections. OAuth 2.1 authorization server for MCP tool connections. All sign-in methods available in theAuth. --- # Agent identity Source: https://docs.theauth.dev/agents
An `AgentIdentity` is the primary entity in theAuth. It represents one AI agent, a process acting on behalf of a human user. Agents are not users. No password, no session, no OAuth flow. Just a token, a set of permissions, and an audit row for every call. Create one against an existing user ID, hand the token to the agent, and check every action through `authorize()`.
```ts const agent = await theauth.agent.create({ ownerId: user.id, name: 'code-reviewer', type: 'autonomous', permissions: [ { resource: 'mcp:github:*', actions: ['read'] }, ], }); // agent.token is a kv_... bearer, shown once, hashed at rest console.log(agent.token); ```
## Anatomy Stable identifier (a UUID). Never changes. Bearer token with a `kv_` prefix. Shown once at creation, then SHA-256 hashed. Rotate to replace. What this agent is allowed to do. Evaluated at every `authorize()` call. Current state. Revocation is permanent. The user who owns this agent. Must match an existing row in `theauth_users` (foreign key). Human-readable label. Determines how the agent acquires and uses permissions. Optional at creation; when omitted it defaults to `agents.tokenExpiry` from now. Token validation (`authorizeByToken`) rejects an expired agent and flips its `status` to `expired`. `authorize(agentId)` only checks `status`, so it does not notice an elapsed `expiresAt` until the agent has been marked expired. Arbitrary key/value pairs for your own use. ## Agent types Acts independently without requiring human approval on each call, unless a permission constraint mandates it. The standard type for background agents, cron jobs, and AI assistants that run unattended. Receives permissions from another agent via a delegation chain rather than having them declared at creation. Use this for ephemeral sub-agents spun up to complete one task, then discarded. Long-lived identity for infrastructure, such as an MCP server or an internal microservice that calls other services on behalf of users. Treat it like a service account. ## Lifecycle ```ts const agent = await theauth.agent.create({ ownerId: 'user-123', name: 'github-reader', type: 'autonomous', permissions: [ { resource: 'mcp:github:*', actions: ['read'] }, ], expiresAt: new Date(Date.now() + 7 * 24 * 3_600_000), // optional, defaults to 24h from now metadata: { purpose: 'nightly PR review' }, }); console.log(agent.token); // kv_..., only shown here ``` The token is returned once at creation. theAuth stores only the SHA-256 hash, so the plaintext cannot be recovered later. Save it immediately or rotate to get a new one. Tokens use the `kv_` prefix followed by 32 random bytes encoded as base64url (43 characters, 46 in total). Pass as a Bearer credential: ```http Authorization: Bearer kv_a3f8c2e1d4b5... ``` Hash-only storage: a full database dump cannot reveal an active token. There are no secrets to rotate after a DB breach, only the tokens themselves. When a caller only has the raw token, use `authorizeByToken` in your HTTP middleware: ```ts const token = request.headers.get('Authorization')?.replace('Bearer ', ''); if (!token) return new Response('Unauthorized', { status: 401 }); const result = await theauth.authorizeByToken(token, { action: 'read', resource: 'mcp:github:repos', }); if (!result.allowed) { return new Response(result.reason ?? 'Forbidden', { status: 403 }); } ``` The token is hashed and looked up in the database, then permissions are evaluated and the decision is written to the audit log. No JWTs, no network round-trip. This path checks the agent's own permissions only; permissions it holds through delegation are applied by `authorize(agentId, ...)`. Rotation issues a new token and immediately invalidates the old one. It is a single update, so there is no window where both are valid. Only active agents can be rotated. ```ts const rotated = await theauth.agent.rotate(agentId); // rotated.token is the new plaintext token ``` Rotate on a schedule, or any time you suspect a token has been exposed. Permission updates take effect immediately. In-flight requests that already passed authorization are not affected. ```ts await theauth.agent.update(agentId, { name: 'github-reader-v2', permissions: [{ resource: 'mcp:github:*', actions: ['read', 'comment'] }], }); const active = await theauth.agent.list({ userId: 'user-123', status: 'active', type: 'autonomous', }); ``` ```ts await theauth.agent.revoke(agentId); // All future authorize() calls return allowed: false // The agent's token is rejected immediately ``` Revocation is permanent. There is no un-revoke. To restore access, create a new agent. ## Limits The default is 10 active agents per user. Raise at initialization: ```ts const theauth = await createTheAuth({ database: { provider: 'sqlite', url: 'theauth.db' }, agents: { enabled: true, maxPerUser: 50, }, }); ``` The cap counts active agents. Creating one past it throws a plain `Error` with the message `User has reached the maximum of active agents.` (there is no dedicated error code, and the REST endpoint reports it as `500 INTERNAL_ERROR`). ## What agents aren't An agent is not a user. It has no email, no password, no session, no OAuth account. It has a bearer token and a permission set. If you find yourself reaching for password reset, email verification, or social sign-in on an agent, you want a user, not an agent. Create the user first, then create agents that the user owns. ## Next steps Define what agents can and cannot do. Let agents delegate access to sub-agents. Short-lived agents for one-off tasks. Use agents in HTTP middleware. --- # Ephemeral sessions Source: https://docs.theauth.dev/ephemeral-sessions ## What ephemeral sessions are An ephemeral session is a temporary agent identity that expires on its own. You create one, hand the token to an AI agent, and the token stops working when the TTL runs out or the agent exhausts its action budget, whichever comes first. The pattern is designed for computer-use agents (Claude computer use, GPT browsing, operator loops) that need just enough access to complete one task without holding persistent credentials across invocations. Key differences from a regular agent: | | Regular agent | Ephemeral session | |---|---|---| | Lifetime | Indefinite by default | Bounded TTL (default 5 min) | | Token | Rotatable, persistent | `kveph_` token that stops working at expiry or exhaustion | | Cleanup | Manual revocation | Marked expired on use, or by `cleanupExpired()` | | Action budget | None | Optional hard cap | | Audit | Per-agent | Grouped under one session ID | ## Setup No extra configuration is needed, ephemeral sessions are part of the `@glinr/theauth` core package and share the same database as the rest of theAuth. ```typescript import { createTheAuth } from '@glinr/theauth'; import { createEphemeralSessionModule } from '@glinr/theauth/auth'; const theauth = await createTheAuth({ database: { provider: 'sqlite', url: 'theauth.db' }, }); const ephemeral = createEphemeralSessionModule({ db: theauth.db, defaultTtlSeconds: 300, // 5 minutes maxTtlSeconds: 3600, // 1 hour ceiling autoRevokeOnExpiry: true, // revoke underlying agent on expiry auditGrouping: true, // group all actions under one audit session ID }); ``` ## Creating a session for a computer-use agent Call `createSession` right before you hand control to the agent. The token is shown exactly once. ```typescript const result = await ephemeral.createSession({ ownerId: 'user-abc', // the human who owns this task name: 'fill-checkout-form', // optional label permissions: [ { resource: 'tool:browser', actions: ['navigate', 'click', 'type'] }, ], ttlSeconds: 120, // 2 minutes for this particular task maxActions: 20, // hard cap: fail after 20 actions }); if (!result.success) throw new Error(result.error.message); const { token, expiresAt, auditGroupId } = result.data; // Pass `token` to the agent. Never store it or log it. ``` The returned `token` is `kveph_` followed by 64 hex characters, to distinguish it from long-lived agent tokens (`kv_`). Only its SHA-256 hash is stored. Creating a session also creates a temporary agent (type `autonomous`) that holds the permissions, so `result.data` includes `agentId` as well. `ttlSeconds` above `maxTtlSeconds` returns a `TTL_EXCEEDS_MAX` error, and `permissions` must contain at least one entry. ## Validating a session Each time the agent makes a request, validate the token before granting access. ```typescript const check = await ephemeral.validateSession(token); if (!check.success) { // error.code is one of: // SESSION_NOT_FOUND, SESSION_EXPIRED, SESSION_EXHAUSTED, SESSION_REVOKED return new Response('Unauthorized', { status: 401 }); } const { sessionId, agentId, remainingActions, expiresIn, auditGroupId } = check.data; ``` `expiresIn` is in seconds. `remainingActions` is `null` when no action cap was set. ## Tracking actions Call `consumeAction` once per agent action to decrement the budget counter. ```typescript const consumed = await ephemeral.consumeAction(token); if (!consumed.success) { // SESSION_EXHAUSTED when the budget runs out return new Response('Budget exhausted', { status: 429 }); } const { actionsRemaining } = consumed.data; // actionsRemaining is null when no maxActions was configured ``` When `actionsRemaining` hits zero, the session transitions to `exhausted`, and the underlying agent is revoked if `autoRevokeOnExpiry` is on (the default). Further calls return `SESSION_EXHAUSTED`. After the session leaves `active`, `consumeAction` returns `SESSION_EXPIRED`, `SESSION_REVOKED` or `SESSION_EXHAUSTED` accordingly. ## Action limits `maxActions` is a hard cap on how many times `consumeAction` can succeed. It is independent of the TTL, a session can expire by time with actions remaining, or exhaust its action budget before the TTL lapses. Set a tight budget for tasks with a well-known scope and leave it as `null` for tasks where the step count is unpredictable. ```typescript // A session with both a time limit and an action cap await ephemeral.createSession({ ownerId: userId, permissions: [{ resource: 'tool:search', actions: ['query'] }], ttlSeconds: 60, maxActions: 5, }); ``` ## Revoking early If the agent completes the task before the TTL expires, revoke the session manually. ```typescript await ephemeral.revokeSession(sessionId); ``` Revocation also revokes the underlying agent. It is idempotent, calling it on an already-revoked session returns success. An unknown session ID returns a `SESSION_NOT_FOUND` error. ## Listing active sessions Useful for dashboards or for building kill-switch UI. ```typescript const result = await ephemeral.listActiveSessions('user-abc'); if (result.success) { for (const session of result.data) { console.log(session.sessionId, session.expiresAt, session.actionsUsed); } } ``` The returned objects have `token` set to `""`, the token is never readable after the initial `createSession` response. ## Cleanup strategies Two approaches to cleaning up expired sessions: ### Scheduled background job Run `cleanupExpired()` on a cron schedule, every minute for busy systems, every 5 minutes for lighter loads. ```typescript // With node:timers/promises or any scheduler setInterval(async () => { const result = await ephemeral.cleanupExpired(); if (result.success && result.data.count > 0) { console.log(`Cleaned up ${result.data.count} expired sessions`); } }, 60_000); ``` ### On-demand at validate time `validateSession` already detects and transitions expired sessions. For low-traffic systems, this is enough, no background job needed. ## Audit grouping When `auditGrouping: true` (the default), the session gets its own `auditGroupId`. With `auditGrouping: false`, `auditGroupId` is set to the session ID instead. theAuth returns the ID from `validateSession` but does not stamp it onto audit entries for you, so attach it to the entries you record. That makes it straightforward to reconstruct the full activity trace for a single task. ```typescript const validated = await ephemeral.validateSession(token); if (validated.success) { // Attach to every audit entry for this action const { auditGroupId } = validated.data; } ``` ## Session status lifecycle ``` active → exhausted (maxActions reached) → expired (TTL elapsed) → revoked (manual revocation) ``` A session starts as `active`. Once it leaves `active`, it cannot be reactivated. Create a new session for a new task. Expiry is recorded lazily, when the session is next validated, listed, or swept by `cleanupExpired()`. ## Error codes | Code | Meaning | |------|---------| | `SESSION_NOT_FOUND` | Token does not match any session | | `SESSION_EXPIRED` | TTL has elapsed | | `SESSION_EXHAUSTED` | Action budget is fully consumed | | `SESSION_REVOKED` | Manually revoked | | `TTL_EXCEEDS_MAX` | Requested TTL is above `maxTtlSeconds` | | `VALIDATION_ERROR` | Input failed schema validation (e.g. empty permissions) | ## Related Long-lived agent identities that ephemeral sessions are built on top of. Hand a subset of permissions to a sub-agent with depth limits. Audit trail for agent activity. Cookie and JWT sessions, plus how they differ from ephemeral agent sessions. --- # Delegation chains Source: https://docs.theauth.dev/delegation ## What is a delegation chain A delegation chain lets one agent grant a subset of its permissions to another. The delegating agent keeps its own permissions unchanged. The receiving agent gains access only to what was explicitly delegated. This pattern is most useful when an orchestrator spins up sub-agents for specific tasks. Each sub-agent gets only the access it needs, for only as long as it needs it. The delegating agent must currently hold every permission it is trying to delegate. Attempting to delegate a permission not in the agent's own set throws an `Error` ("Delegated permissions must be a subset of the parent agent's permissions..."). The check runs against the agent's own permissions only, not against permissions it received through other delegations. ## Working with delegations ### Creating a delegation ```typescript const chain = await theauth.delegate({ fromAgent: orchestrator.id, toAgent: subAgent.id, permissions: [ { resource: 'mcp:github:issues', actions: ['read'] }, ], expiresAt: new Date(Date.now() + 3_600_000), // 1 hour maxDepth: 2, }); console.log(chain.id); // dlg_... console.log(chain.depth); // 1 console.log(chain.expiresAt); // Date ``` Agent ID granting the permissions. Must hold every permission being delegated. Agent ID receiving the permissions. Subset of permissions to delegate. Must not exceed the fromAgent\'s own permissions. When the delegation expires. After this point, the chain is no longer valid. Highest chain depth this call may create. The new link's depth (1 for a first hop, parent depth plus 1 for a re-delegation) must be less than or equal to this value, otherwise the call throws. Default is 3. The value is not inherited by later hops: each `delegate()` call is checked against its own `maxDepth`. ### Permission subset enforcement The permissions you delegate must be a subset of what the `fromAgent` holds. Narrower resources and fewer actions are allowed. Wider resources or new actions are rejected. Given an orchestrator with: ```typescript { resource: 'mcp:github:*', actions: ['read', 'write', 'comment'] } ``` Valid delegations: ```typescript { resource: 'mcp:github:issues', actions: ['read'] } // narrower resource, fewer actions { resource: 'mcp:github:*', actions: ['read'] } // same resource, fewer actions { resource: 'mcp:github:repos', actions: ['read', 'comment'] } // narrower resource, same actions ``` Invalid delegations: ```typescript { resource: 'mcp:github:*', actions: ['delete'] } // action not held by parent { resource: 'mcp:slack:*', actions: ['read'] } // resource not held by parent ``` ### Depth limiting Each link has a depth: 1 for a delegation from an agent that did not itself receive one, otherwise the deepest active chain into the delegating agent plus 1. On every `delegate()` call the code compares the new link's depth with the `maxDepth` passed on that same call (default 3) and throws if the depth is larger. `maxDepth` is not stored as a ceiling for later hops, so to stop re-delegation you pass a small `maxDepth` on each call, and the hop that would exceed it is rejected. ```typescript // Orchestrator → Sub (depth 1, maxDepth 2): allowed await theauth.delegate({ fromAgent: orchestrator.id, toAgent: sub.id, permissions: [{ resource: 'mcp:github:issues', actions: ['read'] }], expiresAt: new Date(Date.now() + 3_600_000), maxDepth: 2, }); // Sub → SubSub (depth 2, maxDepth 2): allowed await theauth.delegate({ fromAgent: sub.id, toAgent: subSub.id, permissions: [{ resource: 'mcp:github:issues', actions: ['read'] }], expiresAt: new Date(Date.now() + 1_800_000), maxDepth: 2, }); // Same call with maxDepth: 1 is rejected: depth 2 exceeds maxDepth 1 // SubSub → anything with maxDepth: 2 is rejected: depth 3 exceeds maxDepth 2 ``` The default `maxDepth` when not specified is `3`. The subset check uses the delegating agent's own permissions, not the permissions it received through delegation. An agent that holds only delegated permissions (such as `sub` above, created with `permissions: []`) cannot re-delegate them: that call throws the subset error before depth is considered. Give the intermediate agent its own copy of the permissions if it must re-delegate. ### Cascading revocation Revoking a chain marks it revoked and does the same for active chains that start from the receiving agent. ```typescript await theauth.delegation.revoke(chain.id); ``` If the chain is `orchestrator → sub → subSub`, revoking `orchestrator → sub` also revokes `sub → subSub` immediately. Any agent that relied on the revoked permissions will get `allowed: false` on its next authorization check. Revocation takes effect on the next `authorize()` call. It does not terminate any in-progress operations. ### Effective permissions To see the full set of permissions an agent has at a given moment, including those received through active chains: ```typescript const effective = await theauth.delegation.getEffectivePermissions(subAgent.id); // Returns only the Permission[] received through active, unexpired delegation chains ``` `authorize()` checks the agent's own permissions first and, if they deny, falls back to these delegated permissions. You can call it directly to inspect what an agent has received through delegation. ### Listing chains ```typescript // All chains originating from the orchestrator (fromAgent === agentId) const outbound = await theauth.delegation.listChains(orchestrator.id); ``` ### DelegationChain type Unique chain identifier, prefixed dlg_. Agent ID that created the delegation. Agent ID that received the delegation. The permissions granted by this chain. Expiry time for the chain. Current depth of this chain in the delegation tree. When the chain was created. ## Typical pattern An orchestrator holds broad permissions, plans a task, and issues short-lived narrow delegations to sub-agents. Create the orchestrator with the full permission set it needs. ```typescript const orchestrator = await theauth.agent.create({ ownerId: 'user-123', name: 'planner', type: 'autonomous', permissions: [ { resource: 'mcp:github:*', actions: ['read', 'write', 'comment'] }, { resource: 'mcp:linear:*', actions: ['read', 'write'] }, ], }); ``` Create the sub-agent with no direct permissions. ```typescript const codeReviewer = await theauth.agent.create({ ownerId: 'user-123', name: 'code-reviewer', type: 'delegated', permissions: [], }); ``` Delegate only what the sub-agent needs, with a short expiry and a small `maxDepth` (re-delegation calls must pass a larger one, see depth limiting above). ```typescript await theauth.delegate({ fromAgent: orchestrator.id, toAgent: codeReviewer.id, permissions: [ { resource: 'mcp:github:pulls', actions: ['read', 'comment'] }, ], expiresAt: new Date(Date.now() + 30 * 60_000), // 30 minutes maxDepth: 1, }); ``` ## Next steps Every delegation is logged with agent and depth info. Create the agents that participate in delegation chains. Use delegation with MCP-authenticated agents. --- # Permission engine Source: https://docs.theauth.dev/permissions ## How permissions work A permission grants an agent the right to perform one or more actions on a resource. theAuth evaluates permissions at call time by checking whether any of the agent's permissions match the requested resource and include the requested action, then verifying all constraints pass. Colon-separated resource path. A `*` segment matches the rest of the path from that position on. The actions this permission grants. No fixed set, define what fits your tools. Optional conditions that must all be satisfied for the permission to apply. ## Matching rules ### Resource pattern matching Resources follow a colon-separated hierarchy. The `resource` field in a permission is matched against the resource in the authorization request using exact matches or wildcards. ``` mcp:github:repos // exact match mcp:github:* // matches mcp:github and everything beneath it mcp:* // matches mcp and everything beneath it * // matches all resources ``` A `*` segment is not a single-segment wildcard. When the matcher reaches a `*` segment in the pattern, it stops comparing and accepts the resource, so the wildcard covers every remaining segment (including none). `mcp:github:*` matches `mcp:github:repos` and also `mcp:github:repos:comments` and `mcp:github` itself. Without a wildcard, the pattern and the resource must have the same number of segments. Use `*` only as the last segment. A wildcard in the middle, such as `mcp:*:repos`, also accepts everything after `mcp:`, so it is not a "any server, repos only" pattern. ```typescript // This permission... { resource: 'mcp:github:*', actions: ['read'] } // ...matches these: // mcp:github:repos ✓ // mcp:github:issues ✓ // mcp:github:pull_requests ✓ // mcp:github:repos:comments ✓ (the wildcard covers all deeper segments) // mcp:github ✓ (the wildcard also matches zero segments) // ...but NOT these: // mcp:slack:channels ✗ (different namespace) // mcpx:github:repos ✗ (first segment differs) ``` | Pattern | Resource | Match | |---|---|---| | `mcp:github:repos` | `mcp:github:repos` | yes | | `mcp:github:repos` | `mcp:github:repos:comments` | no (segment counts differ) | | `mcp:github:*` | `mcp:github:repos` | yes | | `mcp:github:*` | `mcp:github:repos:comments` | yes | | `mcp:github:*` | `mcp:github` | yes | | `mcp:github:*` | `mcp:slack:channels` | no | | `mcp:*:repos` | `mcp:slack:channels` | yes (middle wildcard accepts the rest) | | `*` | anything | yes | ### Actions Actions describe what the agent can do to a resource. theAuth does not enforce a fixed set, you define what makes sense for your tools. Common actions in MCP contexts: | Action | Typical use | |---|---| | `read` | Fetching data, listing resources | | `write` | Creating or modifying resources | | `execute` | Running tools, shell commands, deployments | | `delete` | Permanent removal of resources | An authorization check passes when an agent has a permission with: a matching resource pattern, the requested action in that permission's `actions` array, and all constraints satisfied. Only the first permission whose resource and action match is evaluated: if its constraints fail, later matching permissions are not tried. ## Constraints Constraints add conditions to a permission. All conditions must be met for the permission to apply. Rolling one-hour limit per agent and resource, counted in 5-minute buckets. Calls beyond it are denied with a reason starting `Rate limit exceeded`. Regular expression strings (compiled with `new RegExp`). Every string value in the request's `arguments` must match every pattern. Non-string values are ignored, and the check is skipped when the request has no `arguments`. When true, the call is always denied with the reason `This action requires human approval before execution`. Your app must handle the approval flow. HH:MM format, compared as strings against the server's local time (not UTC). Calls outside this range are denied. Ranges that wrap past midnight are not supported. Exact IPv4 addresses or IPv4 CIDR ranges. Requests from IPs outside this list, or with no `ip` on the request, are denied. IPv6 is not supported. Limits how many times an agent can call a resource per hour. Uses a 5-minute sliding window. ```typescript { resource: 'mcp:deploy:staging', actions: ['execute'], constraints: { maxCallsPerHour: 20, }, } ``` Calls beyond the limit return `allowed: false` with a reason such as `Rate limit exceeded: 20/20 calls per hour for resource "mcp:deploy:staging"`. The permission engine writes these denials to the audit log with `result: 'denied'`; it does not write `rate_limited`. Restricts what arguments an agent can pass to a tool. Patterns are regular expressions tested against each string value in the `arguments` field of the authorization request. Every string value must match every pattern, so use one alternation pattern rather than several alternatives. ```typescript { resource: 'tool:file_write', actions: ['execute'], constraints: { allowedArgPatterns: ['^/(home/agent|tmp)/'], }, } ``` If any string argument fails any pattern, the call is denied. Prevents automatic authorization. The call is logged and denied until a human approves it through your application's approval flow. ```typescript { resource: 'mcp:deploy:production', actions: ['execute'], constraints: { requireApproval: true, }, } ``` The authorization result has `allowed: false` and the reason `This action requires human approval before execution`. Your application is responsible for presenting the approval UI and re-authorizing after approval. theAuth does not ship an approval UI. It provides the enforcement point. How you build the human review step is up to you. Restricts when an action is allowed. Times are in 24-hour `HH:MM` format and are compared with the server's local clock, not UTC. A window that wraps past midnight (for example `22:00` to `06:00`) is not supported. ```typescript { resource: 'mcp:github:*', actions: ['read', 'write'], constraints: { timeWindow: { start: '09:00', end: '17:00' }, }, } ``` Calls outside the window return `allowed: false`. Useful for business-hours-only policies or maintenance windows. Restricts which source IPs can use the permission. Accepts exact IPv4 addresses and IPv4 CIDR notation. ```typescript { resource: 'mcp:internal:*', actions: ['read', 'write', 'execute'], constraints: { ipAllowlist: ['10.0.0.0/8', '172.16.0.0/12'], }, } ``` Pass the caller's IP as `ip` on the authorization request (`authorize(agentId, { action, resource, ip })`). `context.ip` is only used for audit logging. Requests with no `ip`, or from IPs outside the list, are denied. ## Permission templates theAuth ships named templates for common patterns. Import them from `@glinr/theauth`: ```typescript import { permissionTemplates, getPermissionTemplate } from '@glinr/theauth'; const agent = await theauth.agent.create({ ownerId: 'user-123', name: 'readonly-agent', type: 'autonomous', permissions: permissionTemplates.readonly, }); ``` Available templates: | Template | Actions | Resource | Notes | |---|---|---|---| | `readonly` | `read` | `*` | | | `readwrite` | `read`, `write` | `*` | | | `admin` | `*` | `*` | All actions on all resources | | `mcpBasic` | `read`, `execute` | `mcp:*` | | | `mcpFull` | `read`, `write`, `execute` | `mcp:*` | | | `rateLimitedRead` | `read` | `*` | 100 calls/hour | | `approvalRequired` | `*` | `*` | Every call requires human approval | | `businessHours` | `read`, `write`, `execute` | `*` | 09:00 to 17:00 server local time | Templates are plain `Permission[]` arrays. Spread and extend them directly: ```typescript permissions: [ ...permissionTemplates.mcpBasic, { resource: 'tool:custom_tool', actions: ['execute'] }, ] ``` Use `getPermissionTemplate(name)` when you need a deep copy rather than a reference to the shared array: ```typescript const perms = getPermissionTemplate('mcpBasic'); perms[0].actions.push('write'); // safe, does not mutate the original ``` Spreading directly from `permissionTemplates` gives you a reference to the original array entries. If you mutate the objects after spreading, you will modify the template. Use `getPermissionTemplate` when you need to modify entries. ## Next steps Delegate permission subsets to other agents. See how permissions are logged on every call. Map permissions to EU AI Act and NIST requirements. --- # Policy engine Source: https://docs.theauth.dev/policy-engine The policy engine is the single decision point for all authorization in theAuth. One `evaluate()` call consults direct permissions, delegated permissions, role memberships, and ReBAC relationship tuples, then combines the results with a configurable combining algorithm. Use the policy engine when you need to make authorization decisions that depend on org membership, relationship graphs, or delegation chains. `theauth.authorize()` is a separate code path: it shares the ABAC primitives (resource matching and constraint checks) with the policy engine, but it evaluates only the first matching permission on the agent, then falls back to permissions received through delegation. It does not call `evaluate()`, and it does not do role expansion or ReBAC checks. Call `evaluate()` directly when you want those, combine-all semantics, or richer decision metadata. ## Quick start `createPolicyEngine` is an internal factory, it is not part of the public API surface. Access the engine through `theauth.policy` on a constructed `TheAuth` instance instead, it is always available (zero-config uses safe defaults). ```typescript import { createTheAuth } from '@glinr/theauth'; const theauth = await createTheAuth({ database: { provider: 'sqlite', url: 'theauth.db' }, }); const engine = theauth.policy; const decision = await engine.evaluate({ subject: { agentId: 'agt_abc123', orgId: 'org_acme' }, action: 'read', resource: 'mcp:github:repos', context: { ip: '203.0.113.42' }, }); if (!decision.allowed) { throw new Error(`Denied: ${decision.reason}`); } // decision.cacheHit === false on first call // decision.durationMs shows wall time in milliseconds // Constraints that inspect arguments read them from context.arguments ``` ## The PolicyDecision shape Every `evaluate()` call returns a `PolicyDecision`. Never throw on a denied decision inside the engine: all failures, including validation errors, return `allowed: false` with a reason code. | Field | Type | Description | |---|---|---| | `allowed` | `boolean` | Whether the request is permitted. | | `effect` | `"permit" \| "deny" \| "indeterminate"` | The combining result. `indeterminate` means no permission matched or an error occurred; callers should treat it as deny. | | `reason` | `string` | A human-readable or error-code string explaining the decision. On clean permits, this is `"matched"`. When nothing matched it is `POLICY_NO_MATCHING_PERMISSION`. Other codes: `POLICY_INVALID_INPUT`, `POLICY_SUBJECT_NOT_FOUND`, `POLICY_GRAPH_QUERY_FAILED`. | | `matchedPermissionId` | `string \| undefined` | Declared on the type but not populated by the engine today; it is always `undefined`. | | `matchedRelation` | `string \| undefined` | The ReBAC relation string of the permission that produced the result, if that permission has `relation` set. | | `cacheHit` | `boolean` | Whether this decision came from the LRU cache. | | `durationMs` | `number` | Wall time for the evaluation, rounded to the nearest millisecond. | | `auditId` | `string \| undefined` | ID of the audit row written for this evaluation. Undefined if audit is disabled, sampling excluded it, or the subject is user-only. | ## What it combines ### Direct and delegated permissions For agent subjects, the engine fetches permissions stored directly on the agent (via the `permissions` table) and permissions received through active, non-expired delegation chains. Delegated permissions carry only `resource` and `actions` into the engine; constraints set on the delegated permission are not applied. Unlike `authorize()`, the engine evaluates every matching permission (not just the first) and combines the results. ### Role-derived permissions For user subjects (when `subject.userId` is present), the engine joins `orgMembers` to `orgRoles` and folds the resulting permissions into the effective set. Role permissions are stored as `resource:action` strings (split on the last colon). Supply `subject.orgId` to scope the lookup to a specific org; without it the engine considers all orgs the user belongs to. ```typescript // User subject with org scope const decision = await engine.evaluate({ subject: { userId: 'usr_alice', orgId: 'org_acme', }, action: 'write', resource: 'mcp:github:issues', }); ``` ### ReBAC relationships When a permission has `relation` set, the engine makes a graph query against the `rebacRelationships` table before counting that permission as a match. ```typescript // This permission only fires if the subject has relation "viewer" on the resource const perm: Permission = { resource: 'document:*', actions: ['read'], relation: 'viewer', // triggers a ReBAC graph walk }; ``` At evaluation time the engine calls into the existing ReBAC module to check whether `(subject, "viewer", resource)` is satisfied, including inherited relations from parent objects. The requested resource must be a concrete `type:id` (for example `document:123`); a resource containing `*` never matches a relation. Set only one of `agentId` or `userId` on the subject for relation checks, because a subject with both is treated as invalid and does not match. If no tuple matches, the permission is skipped. It does not contribute a PERMIT or DENY, the next permission in the effective set is checked instead. If the graph query itself fails (database error or depth limit exceeded), the engine records `POLICY_GRAPH_QUERY_FAILED` and returns `indeterminate` rather than accidentally granting access. The engine is fail-closed on graph errors. ## Caching The engine keeps a process-local LRU cache. Defaults are 10,000 entries and a 60-second TTL. Configure via environment variables: | Variable | Type | Default | Effect | |---|---|---|---| | `THEAUTH_POLICY_CACHE` | `"true"` \| `"false"` | `"true"` | Disable the cache entirely. | | `THEAUTH_POLICY_CACHE_MAX` | number | `10000` | Maximum number of cached entries. | | `THEAUTH_POLICY_CACHE_TTL_MS` | number | `60000` | Time-to-live per entry in milliseconds. | Or pass config directly through `createTheAuth()`: ```typescript const theauth = await createTheAuth({ database: { provider: 'sqlite', url: 'theauth.db' }, policy: { cache: { maxEntries: 5000, ttlMs: 30_000, }, }, }); const engine = theauth.policy; ``` **What gets cached:** Decisions that came from a normal evaluation and involved no state-dependent constraint. The cache key is subject (agent ID, else user ID), org ID, action, resource, and IP. A second `evaluate()` call with the same key is served from the cache with `cacheHit: true`. Validation errors, subject lookup failures, and graph query failures are not cached. **What does not get cached:** Permissions with `maxCallsPerHour`, `timeWindow`, or `allowedArgPatterns` constraints are state or input dependent. Caching those would let a rate-limited agent exceed its limit, let a timed-out permission linger past its window, or let a safe-arguments decision serve an unsafe-arguments request (arguments are not part of the key). The engine skips the cache write for any evaluation where a matching permission carries one of these constraints. **Invalidation:** TTL expiry happens lazily on read. For immediate invalidation after a write: ```typescript // Flush all decisions for a specific agent engine.invalidate({ agentId: 'agt_abc123' }); // Flush all decisions for a specific user engine.invalidate({ userId: 'usr_alice' }); // Flush all decisions for a specific resource (clears the full cache) engine.invalidate({ resource: 'mcp:github:repos' }); ``` Resource-scoped invalidation flushes the entire cache rather than walking all keys to find matches, which is cheaper at high entry counts. **Cross-instance invalidation:** The cache is process-local. In a multi-process or multi-instance deployment, one process calling `invalidate()` does not affect other processes. Permissions granted or revoked will take up to `ttlMs` to propagate naturally via TTL expiry across all instances. See [Limitations](#limitations-and-follow-ups). Cache statistics are available for dashboards and benchmark assertions: ```typescript const { hits, misses, size, evictions } = engine.stats(); ``` ## Combining algorithm The default strategy is deny-overrides. Every matching permission produces a PERMIT (constraints pass) or a DENY (a constraint fails); permissions that do not match the resource or action, or whose ReBAC relation is not satisfied, produce nothing. Any DENY wins over any number of PERMITs. This reflects the conservative principle: if any rule says no, the answer is no. A worked example: ``` Effective permissions for agent agt_abc123 on resource "mcp:deploy:prod": perm-1: resource=mcp:deploy:*, actions=[execute] → PERMIT perm-2: resource=mcp:deploy:prod, actions=[execute], constraints.timeWindow={start:"09:00",end:"17:00"} → DENY (outside window) combine([PERMIT, DENY]) → DENY decision.allowed === false decision.effect === "deny" ``` Even though `perm-1` would allow the call in isolation, the time-window constraint on `perm-2` produces a DENY, and deny-overrides means the final answer is no. If no permission matched at all, the result is `indeterminate` with reason `POLICY_NO_MATCHING_PERMISSION`, and `allowed` is `false`. Callers should treat `indeterminate` the same as an explicit deny. To flip to permit-overrides (any PERMIT wins over DENYs), pass `combineStrategy: "permit-overrides"` in the `policy` config. That is less common; most deployments want the conservative default. ## Audit Every `evaluate()` call with an `agentId` in the subject writes one audit row to `audit_logs`, including cache hits. The `cache_hit` column distinguishes hot from cold decisions in dashboards. The `result` column is `allowed` or `denied`. Calls rejected by input validation, and calls where the agent has no owner, write no row. ```typescript const theauth = await createTheAuth({ database: { provider: 'sqlite', url: 'theauth.db' }, policy: { audit: true, // default; set false to disable entirely auditSampleRate: 0.1, // write audit rows for 10% of evaluations }, }); ``` `auditSampleRate` defaults to `1.0` (every evaluation). Dial it down in high-throughput environments where a hot cache path would otherwise generate millions of rows per hour. **Current limitation:** Only evaluations with an `agentId` in the subject write audit rows. The `audit_logs` table has `agent_id NOT NULL`, so user-only subjects (where only `userId` is present) are silently skipped. Audit writes are non-blocking. A write failure is swallowed and does not affect the returned decision. The `auditId` on the decision is generated before the write, so it can refer to a row that was never written if the insert failed. It is `undefined` when audit is disabled, the sample excluded the call, or the subject has no `agentId`. ## Configuration Full `PolicyEngineConfig` reference: Maximum LRU entries. Evicts oldest entries when exceeded. Time-to-live per cache entry, in milliseconds. Set to false to disable the cache entirely. All evaluations go cold. How to combine PERMIT and DENY results from multiple matching permissions. Whether to write audit rows. Disabling suppresses all audit writes. Fraction of evaluations that write audit rows, from 0.0 to 1.0. ## Migration from `authorize()` `authorize()` still works and is the path adapters use. It does not call `evaluate()` internally, so the two can disagree: `authorize()` stops at the first permission matching resource and action, while `evaluate()` considers all matching permissions (deny-overrides) and also reads roles and ReBAC relations. Also, `authorize()` takes the request context as an optional third argument, while `evaluate()` takes `context` inside its input. If you want the full `PolicyDecision` (cache hit, matched relation, duration), call `evaluate()` directly: ```typescript // Before: using theauth.authorize() const result = await theauth.authorize( agent.id, { action: 'read', resource: 'mcp:github:repos', ip: '203.0.113.42' }, ); // result: { allowed: boolean; reason?: string; auditId: string } // After: using theauth.policy.evaluate() directly const decision = await theauth.policy.evaluate({ subject: { agentId: agent.id }, action: 'read', resource: 'mcp:github:repos', context: { ip: '203.0.113.42' }, }); // decision: PolicyDecision (richer shape) ``` `requireScopes()` in `mcp/require-scopes.ts` still uses its own code path. It does not yet call `evaluate()`. ## Performance targets and benchmarks Design targets (not guarantees; run the benchmark on your own hardware): | Path | p99 target | |---|---| | Cache hit | < 1 ms | | Cold path, RBAC only | < 5 ms | | Cold path, ReBAC walk depth 3 | < 5 ms | | Throughput, cache warm | ≥ 50,000 evaluations/sec | The benchmark suite lives at `packages/core/bench/policy-engine.bench.ts` and uses `tinybench`. Run it locally: ```bash pnpm --filter @glinr/theauth bench ``` ## Limitations and follow-ups These are known gaps in v1, tracked for future work: **Process-local cache only.** `invalidate()` affects only the current process. In a multi-instance deployment, stale entries persist in other processes until TTL expiry. A KV or Redis cache layer can slot in later through the same `PolicyCache` interface, but it is not implemented yet. **No auto-invalidation on writes.** Nothing calls `invalidate()` when permissions, delegation chains, org roles, org members, or ReBAC relationships change. Callers must call `engine.invalidate()` manually after mutations that should take effect immediately. **User-only subjects skip audit.** If `subject.agentId` is absent, no audit row is written. This is a schema constraint (`audit_logs.agent_id NOT NULL`), not a policy choice. **No declarative policy DSL.** Permissions are plain typed objects. Cedar, Rego, and Casbin-style rule languages are out of scope for v1. Permissions stay as structured data. **`requireScopes()` is not wired.** MCP scope checks in `mcp/require-scopes.ts` still use their own path and do not call `evaluate()`. The combining and caching benefits of the policy engine do not apply to scope checks yet. ## Related Direct permissions, constraints, and templates. Relationship graph, hierarchy traversal, and tuple management. Grant permission subsets between agents. How decisions are logged and surfaced in the dashboard. --- # Relationship-based access control Source: https://docs.theauth.dev/rebac ## Why ReBAC Traditional RBAC assigns roles to users globally or per-org. That works until you need finer-grained questions like "can agent X view this specific document because it belongs to a project in a workspace where X is an editor?" RBAC flattens that into a single role check and loses the context. ReBAC models authorization as a graph. Subjects (users, agents, teams) connect to objects (orgs, workspaces, projects, documents) through typed relationships. Permission checks walk the graph, following direct relationships and parent-child inheritance. This is the same approach Google uses internally (Zanzibar) and what WorkOS FGA, Ory Keto, and SpiceDB implement. theAuth ships a built-in ReBAC engine that works with agents as first-class subjects. ## Quick start ```typescript import { createReBACModule, createDatabase, createTables } from '@glinr/theauth'; const db = await createDatabase({ provider: 'sqlite', url: 'theauth.db' }); await createTables(db, 'sqlite'); const rebac = createReBACModule({}, db); // Build a resource hierarchy await rebac.createResource({ id: 'acme', type: 'org' }); await rebac.createResource({ id: 'eng', type: 'workspace', parentId: 'acme', parentType: 'org' }); await rebac.createResource({ id: 'api', type: 'project', parentId: 'eng', parentType: 'workspace' }); await rebac.createResource({ id: 'spec', type: 'document', parentId: 'api', parentType: 'project' }); // Grant a relationship await rebac.addRelationship({ subjectType: 'user', subjectId: 'alice', relation: 'editor', objectType: 'workspace', objectId: 'eng', }); // Check: can Alice view the spec document? const result = await rebac.check({ subjectType: 'user', subjectId: 'alice', permission: 'viewer', objectType: 'document', objectId: 'spec', }); // result.data.allowed === true (editor on workspace inherits viewer on child documents) ``` ## Resource hierarchy Resources form a tree. Each resource has a `type` and a globally unique `id`. A resource can optionally point to a parent. ``` org:acme workspace:eng project:api document:spec document:changelog project:web workspace:design ``` You register resources with `createResource`. The engine validates that the parent exists before accepting a child. ```typescript await rebac.createResource({ id: 'acme', type: 'org' }); await rebac.createResource({ id: 'eng', type: 'workspace', parentId: 'acme', parentType: 'org', }); ``` Resource ids are unique across all types (the id is the primary key), so `document:spec` and `project:spec` cannot both exist. Deleting a resource cascades: all child resources and the relationships that name the resource as subject or object are removed. Methods return a `Result` (`{ success, data }` or `{ success: false, error }`) rather than throwing for expected failures such as `PARENT_NOT_FOUND`, `RESOURCE_EXISTS`, or `RELATIONSHIP_EXISTS`. ## Relationships A relationship is a tuple: `(subjectType, subjectId, relation, objectType, objectId)`. Subjects can be users, agents, teams, or any string type you define. ```typescript // Alice is an owner of the org await rebac.addRelationship({ subjectType: 'user', subjectId: 'alice', relation: 'owner', objectType: 'org', objectId: 'acme', }); // An agent has viewer access to a project await rebac.addRelationship({ subjectType: 'agent', subjectId: 'agent_summarizer', relation: 'viewer', objectType: 'project', objectId: 'api', }); ``` ## Permission checks `check` answers "does this subject have this permission on this object?" It returns `{ allowed: boolean, path?: string[] }` where `path` shows the traversal steps when access is granted. The engine resolves permissions in two ways: ### Implied relations For each resource type, some relations imply others. The built-in rules: | Resource type | owner implies | admin implies | editor implies | member implies | |---|---|---|---|---| | org | admin, editor, viewer, member | editor, viewer, member | viewer | viewer | | workspace | admin, editor, viewer, member | editor, viewer, member | viewer | viewer | | project | admin, editor, viewer, member | editor, viewer, member | viewer | viewer | | document | editor, viewer | - | viewer | - | | resource | editor, viewer | - | viewer | - | Any type not in this table (for example `doc`) has no implication or inheritance rules: only a tuple whose relation equals the requested permission grants access. Inheritance from the parent is on for `workspace`, `project`, `document`, and `resource`, and off for `org`. So if you're an `editor` on a document, a check for `viewer` succeeds. If you're an `owner`, both `editor` and `viewer` checks succeed. ### Parent inheritance When a resource type has `inheritFromParent` enabled (see the defaults above), the engine walks up the tree through the parent links stored by `createResource`. If Alice is a `viewer` on workspace `eng`, she's also a `viewer` on project `api` (a child of `eng`) and document `spec` (a grandchild). This combines with implied relations. Alice as `editor` on workspace `eng` gets `viewer` access to everything underneath. ## Custom permission rules Override the defaults by passing `permissionRules` to the config: ```typescript const rebac = createReBACModule({ permissionRules: { wiki: { implies: { admin: ['editor', 'viewer', 'commenter'], editor: ['viewer', 'commenter'], commenter: ['viewer'], }, inheritFromParent: true, }, secret: { implies: { owner: ['viewer'] }, // no inheritFromParent - secrets don't inherit from parent }, }, }, db); ``` You can also limit which permissions inherit by passing an array: ```typescript permissionRules: { file: { implies: { owner: ['editor', 'viewer'], editor: ['viewer'] }, inheritFromParent: ['viewer'], // only viewer inherits, not editor }, } ``` ## Listing and expansion ### List objects Find all objects of a type that a subject can access: ```typescript const projects = await rebac.listObjects({ subjectType: 'user', subjectId: 'alice', permission: 'viewer', objectType: 'project', }); // projects.data = ['api', 'web'] ``` ### List subjects Find all subjects that have a permission on an object: ```typescript const editors = await rebac.listSubjects({ objectType: 'project', objectId: 'api', permission: 'editor', subjectType: 'user', }); // editors.data = ['alice', 'bob'] ``` ### Expand Get all relationships for an entity (as both subject and object): ```typescript const rels = await rebac.expand({ type: 'user', id: 'alice' }); // rels.data = [{ relation: 'owner', objectType: 'org', objectId: 'acme', ... }, ...] ``` ## Agent integration Agents are first-class subjects. Use `subjectType: 'agent'` with the agent's ID: ```typescript await rebac.addRelationship({ subjectType: 'agent', subjectId: agentId, relation: 'viewer', objectType: 'project', objectId: 'api', }); const allowed = await rebac.check({ subjectType: 'agent', subjectId: agentId, permission: 'viewer', objectType: 'document', objectId: 'spec', }); ``` `theauth.authorize()` does not consult ReBAC. To combine the two, set `relation` on a permission and call `theauth.policy.evaluate()`, which runs the check for you (see [Policy engine](/policy-engine)). Two limits apply there: the policy engine builds its own ReBAC module with the default rules only, so custom `permissionRules` are not used, and the requested resource must be a concrete `type:id`. ## Depth limiting The `maxDepth` config caps how many parent hops the engine will traverse; a check that would go deeper returns `allowed: false`. Default is 10. Set it lower if your hierarchy is shallow and you want faster checks: ```typescript const rebac = createReBACModule({ maxDepth: 5 }, db); ``` ## Comparison with alternatives | Feature | theAuth ReBAC | Google Zanzibar | SpiceDB | WorkOS FGA | |---|---|---|---|---| | Relationship tuples | Yes | Yes | Yes | Yes | | Resource hierarchy | Built-in | Userland | Userland | Userland | | Permission derivation | Config-driven | Schema DSL | Schema DSL | JSON model | | Agent-first | Yes | No | No | No | | Self-hosted | Yes | No | Yes | No | | Depth limiting | Configurable | Internal | Internal | Internal | | Database | SQLite/Postgres/MySQL | Spanner | CockroachDB/Postgres | Managed | theAuth ReBAC is opinionated toward the common hierarchy pattern (org > workspace > project > resource) with sensible defaults. Zanzibar and SpiceDB are more general but require you to write a schema DSL. WorkOS FGA is SaaS-only. ## Related Unified RBAC, ABAC, and ReBAC evaluation behind one evaluate() call. Direct resource-action permissions that complement ReBAC relationships. Agent-to-agent permission delegation with depth and expiry. Tenant isolation that pairs with org-scoped ReBAC hierarchies. --- # Policy templates Source: https://docs.theauth.dev/policies/templates Each template is a self-contained directory under `docs/policies/templates/` in the repository (the `policy.ts` and `README.md` files are not rendered as pages on this site). It contains a `policy.ts` file with the permission definitions and a `README.md` with the scenario, expected decisions, and notes on engine limitations where relevant. Seed the exported arrays into `theauth_permissions` (and the supporting tables noted in each README), then call `theauth.policy.evaluate()` against them. ## Templates | # | Slug | Summary | |---|------|---------| | 01 | `tool-allowlist` | One agent, only tools on an explicit allowlist can execute | | 02 | `principal-and-delegate` | Principal owns read+write; delegated agent gets read-only with expiry | | 03 | `org-scoped-agents` | Multi-tenant: each agent sees only its own org's resources | | 04 | `budget-gated` | Hard cap on calls per hour via `maxCallsPerHour` | | 05 | `step-up-for-writes` | Reads are free; writes and deletes are denied with the `requireApproval` reason | | 06 | `friends-of-a-friend-rebac` | Document access via ReBAC graph tuples with concrete IDs | | 07 | [business-hours-only](/policies/templates/07-business-hours-only) | Tool calls gated to a server-local HH:MM window | ## How to use a template 1. Copy `policy.ts` from the template directory into your project. 2. Seed the exported permission arrays into `theauth_permissions` using your database adapter. 3. For templates that need supporting rows (delegation chains, ReBAC tuples, rate-limit counters), follow the instructions in the template's `README.md`. 4. Call `theauth.policy.evaluate({ subject, action, resource })` in your request handler. See [Policy engine](/policy-engine) for how decisions are combined. The tests under `packages/core/tests/policies/templates/` show exactly how each template behaves and can serve as integration tests in your own suite. --- # Audit trail Source: https://docs.theauth.dev/audit ## How audit logging works Every call to `theauth.authorize()` or `theauth.authorizeByToken()` writes an entry to the audit log, regardless of outcome. Allowed and denied calls are both recorded (the `rate_limited` value exists in the type, but the built-in permission engine records rate limit denials as `denied`). Calls rejected before the permission engine runs, such as an unknown or revoked agent, are not logged. The SDK never updates entries; `theauth.audit.cleanup({ retentionDays })` deletes entries older than the cutoff. `authorize()` returns an `auditId` linking the decision to its log entry: ```typescript const result = await theauth.authorize(agent.id, { action: 'read', resource: 'mcp:github:repos', }); console.log(result.auditId); // a UUID, e.g. "7b0c1d52-..." ``` ## Querying logs ### AuditEntry type Unique entry identifier (a UUID). Equal to the `auditId` returned by `authorize()`. The agent that triggered the authorization check. The user who owns the agent. The action the agent attempted (e.g. read, write, delete). The resource the action was attempted on. `}>Arguments passed to the tool at the time of the call. The outcome of the authorization check. The built-in permission engine writes only `allowed` or `denied`. Free-text explanation of a denial. Absent on allowed entries. Time taken to evaluate the decision, in milliseconds. Optional token usage. The built-in permission engine does not populate it. When the authorization check occurred. ### Filtering All filter fields are optional and combinable. Results are ordered newest first. Without filters or a `limit`, the query returns every entry. ```typescript const logs = await theauth.audit.query({ agentId: 'agt_...', userId: 'user-123', result: 'denied', since: new Date('2025-01-01'), until: new Date('2025-02-01'), actions: ['write', 'delete'], limit: 100, offset: 0, }); ``` Filter to a specific agent. Filter to all agents owned by a user. Include entries on or after this timestamp. Include entries on or before this timestamp. Filter to specific action names. Applied after `limit` and `offset`, so a page can come back shorter than `limit`. Filter by outcome. Maximum entries to return. No default: all matching entries are returned when omitted. Pagination offset. ### Exporting logs ```typescript // JSON export const json = await theauth.audit.export({ format: 'json' }); // CSV export, suitable for spreadsheets and compliance tools const csv = await theauth.audit.export({ format: 'csv' }); // Export a specific time range const q4 = await theauth.audit.export({ format: 'csv', since: new Date('2024-10-01'), until: new Date('2025-01-01'), }); ``` The CSV export includes a header row (`id,agentId,userId,action,resource,result,reason,durationMs,tokensCost,timestamp`). Each row is one authorization decision. The `parameters` field appears only in the JSON export. The export returns a string, not a file. Both `since` and `until` are optional on exports. Exports are capped at the 10,000 most recent matching entries, so page through `audit.query` if you need more. ## Compliance references Article 12 requires high-risk AI systems to log events automatically throughout the system lifecycle, including the period of activity and data used. theAuth records every authorization decision with agent identity, resource, action, parameters, outcome, and timestamp. The NIST AI Risk Management Framework calls for documented accountability mechanisms and the ability to trace AI actions to specific identities. The append-only audit trail links every decision to a named agent and user. SOC 2 trust services criteria for logical access controls and system monitoring require evidence that access is granted only to authorized identities and that access events are logged. The `allowed`/`denied` result field is evidence that maps to CC6.1 to CC6.3, and the append-only structure maps to CC7.2. theAuth does not make you compliant on its own, and tamper-evidence needs protections at the database level. ISO 42001 recommends documenting the behavior of AI systems in production. Exporting the audit log as JSON or CSV gives auditors a machine-readable record of every decision the system made. ## Usage patterns **Monthly access report for a user** ```typescript const entries = await theauth.audit.query({ userId: 'user-123', since: new Date('2025-03-01'), until: new Date('2025-04-01'), }); ``` **Agents with repeated denials today** ```typescript const denied = await theauth.audit.query({ result: 'denied', since: new Date(new Date().setHours(0, 0, 0, 0)), }); const countsByAgent = denied.reduce>((acc, entry) => { acc[entry.agentId] = (acc[entry.agentId] ?? 0) + 1; return acc; }, {}); ``` **High-frequency agent detection** ```typescript const highActivity = await theauth.audit.query({ agentId: 'agt_...', since: new Date(Date.now() - 3_600_000), // last hour }); console.log(`${highActivity.length} calls in the last hour`); ``` A sudden spike in call volume from a single agent often indicates a runaway loop. Use `theauth.agent.revoke()` to cut off the agent while you investigate (revocation is permanent, so create a new agent afterwards). ## Next steps Map audit data to EU AI Act, NIST, SOC 2, and ISO 42001. Query audit logs via HTTP endpoints. Visual audit log viewer with filters and export. --- # Privilege analyzer Source: https://docs.theauth.dev/analyzer The privilege analyzer scans agent permissions to find over-privileged agents, unused permissions, and potential security issues. ## Usage ```ts const analysis = await theauth.analyzer.analyzeAgent(agentId); console.log(analysis.score); // "minimal" | "appropriate" | "over-permissioned" | "wildcard-heavy" console.log(analysis.findings); // PrivilegeFinding[] console.log(analysis.recommendations); // suggested permission changes (string[]) ``` `analyzeAgent(agentId, { since })` compares the agent's permissions against audit log entries since `since` (default: the last 30 days). If the agent does not exist, it returns an `"appropriate"` result with `agentName: "unknown"` and no findings. ## Analyze all agents ```ts const analyses = await theauth.analyzer.analyzeAll(); for (const analysis of analyses) { for (const finding of analysis.findings) { console.log(`${analysis.agentId}: ${finding.type} (${finding.severity}) - ${finding.description}`); } } const summary = await theauth.analyzer.getSummary(); // { total, byScore: Record, criticalFindings } ``` `analyzeAll` covers agents whose status is `active`. There is no `scanAll` method. ## Finding types | Type | Severity | Description | |------|----------|-------------| | `wildcard_permission` | critical | The resource is `*` or ends in `:*` or `/*`, or the actions include `*` | | `unused_permission` | warning | The resource does not appear in the audit log within the lookback window | | `overly_broad` | warning | A namespace permission where only one or two specific sub-resources were used | | `no_constraints` | info | No `maxCallsPerHour`, `timeWindow`, `requireApproval`, or `ipAllowlist` on the permission | | `no_expiry` | info | The agent has no expiration date set | ## Score | Score | When | |-------|------| | `wildcard-heavy` | Any critical finding, or two or more wildcard findings | | `over-permissioned` | One wildcard finding, or two or more warnings | | `minimal` | No findings | | `appropriate` | Only info-level findings, or a single warning | ## Configuration The analyzer has no configuration options. The lookback window is 30 days by default and can be overridden per call with `since`. The analyzer reads from the audit trail. Keep `agents.auditAll` enabled (the default) so that permission usage is recorded; otherwise every permission looks unused. --- # Trust scoring Source: https://docs.theauth.dev/trust ## What trust scores are A trust score is a 0 to 100 number that reflects how much an agent has earned autonomous operation. New agents start with a baseline of 50. Over time, successful calls raise the score; denied requests, permission violations, and anomalous patterns lower it. The score maps to one of five named levels that your application can use to gate behavior, requiring human approval for low-trust agents, unlocking faster paths for high-trust ones. Scores are computed from the audit log, not guessed. An agent cannot self-report a high score. ## TrustScore fields The agent this score belongs to. Numeric value from 0 to 100. Named trust level derived from the score. Percentage of all calls that were allowed. Percentage of all calls that were denied. Days since the agent was created. Total authorization calls in the audit log. Denied calls whose reason contains `INSUFFICIENT_PERMISSIONS`, `privilege`, or `escalation`. Built-in denial reasons do not contain these words, so this stays 0 unless your hooks produce them (see [Anomaly detection](/anomaly)). ISO timestamp of the most recent denied call. ISO timestamp of when this score was last computed. ## How scores are computed The formula starts at 50 and applies adjustments: ``` score = 50 score += min(25, floor(allowedCalls / 100)) // +1 per 100 successful calls, capped at +25 score -= deniedCalls × 5 // -5 per denial score -= anomalyCount × 10 // -10 per counted anomaly (a denial whose reason matches, see anomalyCount) score += 10 if ageInDays > 30 // bonus for established agents score += 5 if ageInDays > 7 // smaller bonus for recent agents score = clamp(score, 0, 100) ``` An agent with 200 successful calls, no denials, and 40 days of history scores: `50 + 2 + 10 = 62`, landing in the `standard` level (60 to 79). Note that a brand-new agent with no history sits at the baseline of 50, which is `limited`. ## Trust levels | Level | Default threshold | Meaning | |---|---|---| | `untrusted` | score < 40 | Misbehaving or heavily denied. Approve all sensitive actions manually. | | `limited` | 40 to 59 | Early history (a new agent starts here at 50). Apply stricter rate limits. | | `standard` | 60 to 79 | Baseline autonomous operation. | | `trusted` | 80 to 94 | Established track record. Fewer restrictions warranted. | | `elevated` | 95 to 100 | Long-running, clean history. Maximum autonomy. | The thresholds are lower bounds hardcoded in the core (`createTheAuth` passes an empty trust config), so they cannot be changed through `createTheAuth` today. Use `score.score` in your own logic if you need different cut-offs. ## Code examples ### Compute a score on demand ```typescript const score = await theauth.trust.computeScore('agt_abc123'); console.log(score.score); // 72 console.log(score.level); // 'standard' console.log(score.factors.denialRate); // 1.4 ``` `computeScore` always reads live audit data and writes the result to the `trust_scores` table. Call it whenever you need a fresh value. ### Read the last computed score ```typescript const score = await theauth.trust.getScore('agt_abc123'); if (!score) { // Score has never been computed for this agent console.log('No score yet, call computeScore first'); } ``` `getScore` returns the cached row from the database without recomputing. It returns `null` for agents that have never been scored. ### Recompute scores for all active agents ```typescript const scores = await theauth.trust.computeAll(); for (const score of scores) { console.log(`${score.agentId}: ${score.level} (${score.score})`); } ``` Useful for a scheduled job that refreshes scores nightly or after a batch of activity. ### Filter agents by trust level ```typescript // All agents currently in the 'untrusted' band const untrusted = await theauth.trust.getScores({ level: 'untrusted' }); // Agents with a score of at least 80 const highTrust = await theauth.trust.getScores({ minScore: 80 }); ``` `getScores` reads from the `trust_scores` table. Agents that have not been scored yet do not appear in results. ### Gate sensitive operations by trust level ```typescript const score = await theauth.trust.computeScore(agentId); if (score.level === 'untrusted' || score.level === 'limited') { // Require manual approval before proceeding await theauth.approval.request({ agentId, userId: agent.ownerId, action: 'delete', resource: 'file:prod-data/*', }); return { queued: true }; } // Proceed autonomously for trusted agents await deleteFiles(agentId); ``` Scores are recomputed on demand. theAuth does not maintain a background scorer. Call `computeScore` or `computeAll` on a schedule that fits your use case, every request, every few minutes, or nightly. ## Next steps Surface unusual patterns in agent behavior. Route high-risk actions through human review. The raw data that feeds trust scoring. --- # Anomaly detection Source: https://docs.theauth.dev/anomaly ## What exists today theAuth does not ship an anomaly detector. There is no `scan()` function and no anomaly finding type. Three small pieces exist, and everything else on this page is something you build on top of the audit log. | Piece | What it does | |---|---| | Trust score `anomalyCount` | `theauth.trust.computeScore()` counts denied audit entries whose `reason` contains `INSUFFICIENT_PERMISSIONS`, `privilege`, or `escalation` (case-insensitive for the last two). Each counted entry lowers the score by 10, on top of 5 for the denial itself. | | `onViolation` hook | A lifecycle hook that fires on every denied `authorize()` call, with a coarse `type`: `permission_denied`, `rate_limited`, `ip_blocked`, `time_restricted`, or `approval_required`. | | `anomaly.detected` event type | A name reserved in the event stream's `EventType` list. Nothing in the library emits it. You can emit it yourself with the stream's `emit()`. | The built-in denial reasons are plain sentences such as `No permission grants agent "x" access to "write" on "y"`. They do not contain `INSUFFICIENT_PERMISSIONS`, `privilege`, or `escalation`, so `anomalyCount` stays at 0 unless a `beforeAuthorize` hook or your own wrapper produces a reason with one of those words. Do not rely on `anomalyCount` as a privilege escalation detector without that. ### Not implemented These were described on earlier versions of this page and are not in the TypeScript package: a `scan()` API, the finding types `privilege_escalation`, `high_denial_rate`, `rapid_fire`, `unusual_resource_access`, and `off_hours_activity`, the `AnomalyConfig` options (`since`, `until`, `minDenialRate`, `rapidFireThreshold`, `agentId`), and finding objects with `severity`, `evidence`, and `detectedAt`. If you want these signals, compute them from the audit log as shown below. ## Code examples ### Find denied calls for an agent ```typescript const denied = await theauth.audit.query({ agentId: 'agt_abc123', result: 'denied', since: new Date(Date.now() - 24 * 3_600_000), }); console.log(`${denied.length} denied call(s) in the last 24 hours`); ``` ### Compute a denial rate per agent ```typescript const since = new Date(Date.now() - 24 * 3_600_000); const logs = await theauth.audit.query({ since, limit: 5000 }); const byAgent: Record = {}; for (const entry of logs) { const bucket = (byAgent[entry.agentId] ??= { total: 0, denied: 0 }); bucket.total++; if (entry.result === 'denied') bucket.denied++; } const highDenialAgents = Object.entries(byAgent) .filter(([, { total, denied }]) => total > 0 && denied / total > 0.2) .map(([agentId, { total, denied }]) => ({ agentId, denialRate: (denied / total) * 100, })); console.log('Agents with >20% denial rate:', highDenialAgents); ``` ### React to denials with the `onViolation` hook ```typescript import { createTheAuth } from '@glinr/theauth'; const violations = new Map(); const theauth = await createTheAuth({ database: { provider: 'sqlite', url: 'theauth.db' }, hooks: { onViolation: async ({ agentId, type, resource, reason }) => { const count = (violations.get(agentId) ?? 0) + 1; violations.set(agentId, count); console.warn(`[violation] ${agentId} ${type} on ${resource}: ${reason}`); }, }, }); ``` The hook is called without being awaited, so a slow handler does not delay the `authorize()` result. Keep counters in a durable store if you need them to survive restarts. ### Pause an agent after repeated denials ```typescript const denied = await theauth.audit.query({ agentId: 'agt_abc123', result: 'denied', since: new Date(Date.now() - 3_600_000), }); if (denied.length >= 3) { await theauth.agent.revoke('agt_abc123'); console.log('Agent revoked after repeated denials.'); } ``` `agent.revoke()` is permanent. If you want to temporarily suspend an agent and restore it later, create a new agent with the same configuration instead of revoking. ### Read the trust score factors ```typescript const score = await theauth.trust.computeScore('agt_abc123'); console.log(score.score, score.level); console.log(score.factors.denialRate, score.factors.anomalyCount); if (score.factors.lastViolation) { console.warn(`Last denial: ${score.factors.lastViolation}`); } ``` `lastViolation` is the timestamp of the most recent denied call of any kind. Computing a score also stores it. ## Next steps Turn denial history into a graduated trust level. Route flagged agents through human review before they act. Query the raw log data these signals are built on. --- # Approval flows Source: https://docs.theauth.dev/approval ## What approval flows solve Some agent actions are too sensitive to run without a human saying yes first. File deletions, financial transfers, permission escalations, these benefit from a checkpoint before execution. theAuth supports this with a permission constraint called `requireApproval`. When the permission engine sees it, authorization is denied with the reason `"This action requires human approval before execution"`. Your application catches that signal, creates an approval request, notifies a human, and retries the action after a response comes back. The flow is async. The agent does not block waiting. The human can respond minutes or hours later, within the request's TTL. Approval is a record, not a bypass. `theauth.authorize()` does not look up approval requests, so a permission with `requireApproval: true` is denied every time, even after a human approves. After `approve()` succeeds, your application performs the action itself (or calls a code path that does not run `authorize()` for that permission). Also note that `approve()` does not check `expiresAt`; the TTL is only applied when you call `cleanup()`, which marks overdue pending requests as `expired`. theAuth creates the approval request and persists it. Delivering the notification to the human, email, Slack, push notification, is your application's job. Use `webhookUrl` or `onApprovalNeeded` to hook into your existing notification stack. ## How the flow works **Agent triggers approval** The agent calls an action protected by `requireApproval: true`. Authorization is denied. Your application detects the denial reason and calls `theauth.approval.request()`. **Human gets notified** When you call `theauth.approval.request()`, theAuth persists the request and fires your `webhookUrl` or `onApprovalNeeded` handler with the request details. Your app sends an email, opens a Slack DM, or surfaces a notification in your dashboard. **Human approves or denies** The human clicks a button in your UI. Your UI calls your backend, which calls `theauth.approval.approve(requestId)` or `theauth.approval.deny(requestId)`. **Agent retries** Check the decision with `theauth.approval.get(requestId)`. Once the status is `approved`, your application carries out the original action. For maximum simplicity, pass the request ID back to the agent so it can poll or be woken up when a decision arrives. ## ApprovalRequest fields Unique identifier prefixed apr_. The agent requesting approval. The user who owns the agent and should receive the notification. The action the agent wants to perform. The resource the action targets. | undefined`}>The arguments the agent passed at the time of the call. Current state. Only `pending` requests can be approved or denied; `approve()` and `deny()` throw on any other status. When the request expires if no response is received. Default TTL is 5 minutes (`ttl` is in seconds). When the human responded. Identifier of the person who approved or denied. When the request was created. ## Configuration Pass approval config to `createTheAuth`: ```typescript const theauth = await createTheAuth({ database: { provider: 'sqlite', url: 'theauth.db' }, approval: { ttl: 600, // 10-minute window (seconds) webhookUrl: 'https://your-app.com/webhooks/approval', }, }); ``` Or use a custom handler for full control over delivery: ```typescript const theauth = await createTheAuth({ database: { provider: 'sqlite', url: 'theauth.db' }, approval: { ttl: 300, onApprovalNeeded: async (request) => { await sendSlackDm(request.userId, { text: `Agent ${request.agentId} wants to ${request.action} on ${request.resource}.`, approveUrl: `https://your-app.com/approvals/${request.id}/approve`, denyUrl: `https://your-app.com/approvals/${request.id}/deny`, }); }, }, }); ``` Both `webhookUrl` and `onApprovalNeeded` fire asynchronously (not awaited) so the `request()` call is not delayed by notification latency. If both are set, both fire. ## How webhooks work When `webhookUrl` is set, theAuth sends a `POST` to that URL with a JSON body: ```json { "event": "approval_needed", "request": { "id": "apr_...", "agentId": "agt_...", "userId": "user-123", "action": "delete", "resource": "file:prod-data/*", "arguments": { "path": "/prod/dataset.csv" }, "status": "pending", "expiresAt": "2026-03-21T10:15:00.000Z", "createdAt": "2026-03-21T10:10:00.000Z" } } ``` The webhook is a plain `POST` with no signature header and no retries. Delivery failures are non-fatal. The request is already persisted, your app can poll `listPending()` as a fallback. ## Code examples ### Set up a permission that requires approval ```typescript const agent = await theauth.agent.create({ ownerId: 'user-123', name: 'file-manager', type: 'autonomous', permissions: [ { resource: 'file:prod-data/*', actions: ['read'], }, { resource: 'file:prod-data/*', actions: ['delete'], constraints: { requireApproval: true }, }, ], }); ``` ### Catch the denial and create a request ```typescript const result = await theauth.authorize(agent.id, { action: 'delete', resource: 'file:prod-data/dataset.csv', arguments: { path: '/prod/dataset.csv' }, }); if (!result.allowed && result.reason?.includes('requires human approval')) { const approvalRequest = await theauth.approval.request({ agentId: agent.id, userId: agent.ownerId, action: 'delete', resource: 'file:prod-data/dataset.csv', arguments: { path: '/prod/dataset.csv' }, }); return { pending: true, approvalId: approvalRequest.id }; } ``` ### Approve or deny from your UI handler ```typescript // In your API route handler app.post('/approvals/:id/approve', async (req, res) => { const updated = await theauth.approval.approve(req.params.id, req.user.email); res.json({ status: updated.status }); }); app.post('/approvals/:id/deny', async (req, res) => { const updated = await theauth.approval.deny(req.params.id, req.user.email); res.json({ status: updated.status }); }); ``` ### List pending requests for a user ```typescript // All pending approvals across all users const all = await theauth.approval.listPending(); // Pending approvals for a specific user const forUser = await theauth.approval.listPending('user-123'); console.log(`${forUser.length} approvals waiting on user-123`); ``` ### Expire stale requests Requests that exceed their TTL are still stored with `status: 'pending'` until you run cleanup. Call this from a cron job: ```typescript const result = await theauth.approval.cleanup(); console.log(`Expired ${result.expired} stale approval requests`); ``` ### Check the status before retrying ```typescript const request = await theauth.approval.get('apr_...'); if (request?.status === 'approved') { // Safe to carry out the action. authorize() will still deny this // permission, so run the action directly from your own code. await deleteFile('/prod/dataset.csv'); } else if (request?.status === 'denied') { console.log('Human denied the request.'); } else if (request?.status === 'expired') { console.log('Request expired without a response.'); } ``` ## Next steps Add requireApproval constraints to individual permissions. Trust levels are separate from approval. Use them in your own logic to decide which agents get requireApproval permissions. Denials caused by requireApproval are written to the audit log as denied. The approval module itself does not write audit entries. --- # Rate limiting Source: https://docs.theauth.dev/rate-limiting ## Overview theAuth has three separate rate limiting mechanisms: 1. The `rateLimit()` plugin: per-IP limits on `/auth/*` endpoints, with a pluggable store. It is opt-in. 2. Per-endpoint limits that individual plugins declare in their endpoint metadata. These apply automatically once the plugin is installed. 3. The `maxCallsPerHour` permission constraint, enforced by the permission engine for agent calls. There is no top-level `rateLimit` option on `createTheAuth()`, no Redis store, and no single shared 429 response shape. Each mechanism is described below. ## The rateLimit plugin Install the plugin to throttle `/auth/*` requests by IP. Nothing is limited until you add it, and a request is only limited if its path matches a configured key (or `default` is set). ```typescript import { createTheAuth } from '@glinr/theauth'; import { rateLimit } from '@glinr/theauth/auth'; const theauth = await createTheAuth({ database: { provider: 'postgres', url: process.env.DATABASE_URL! }, plugins: [ rateLimit({ signIn: { window: '1m', max: 5 }, signUp: { window: '1m', max: 2 }, passwordReset: { window: '1h', max: 3 }, agentAuthorize: { window: '1m', max: 100 }, default: { window: '1m', max: 60 }, }), ], }); ``` Limit for paths ending in `/auth/sign-in`. Limit for paths ending in `/auth/sign-up`. Limit for paths ending in `/auth/password-reset`. Limit for paths ending in `/auth/agent/authorize`. Fallback for every other path containing `/auth/`. Counter storage. Omit it to follow `secondaryStorage.rateLimit` from `createTheAuth` (memory by default). Pass `redisStorage()`, `databaseStorage()`, `cloudflareKvStorage()` or any `RateLimitStore`. See [Secondary storage](/secondary-storage) for the tradeoffs; KV counters are approximate. How many reverse proxies you run. With `x-forwarded-for: a, b, c` and a count of 1 the client is `c`, the entry your proxy added. With 0 the header is ignored, because clients can forge it. A header your edge overwrites, such as `cf-connecting-ip` or `x-real-ip`. Takes precedence over `trustedProxyCount`. Only set it when the app is reachable only through that edge. What identifies a caller. `client_id` is read from the query string, a form or JSON body, or Basic auth, and falls back to the IP. A caller can rotate client ids, so use `ip+client_id` when you want both bounds. Limits for `/mcp/token`, `/mcp/register` and the device endpoints. Defaults: 60/min, 10/hour, 10/min, 60/min, 20/min. `false` turns one off. Rate limit key for the client part. Defaults to the IP from `trustedHeader` or `trustedProxyCount`, and to a shared `"unknown"` bucket when neither is set. Forwarded headers are no longer trusted by default: if you run behind a proxy, set `trustedProxyCount` or `trustedHeader`, otherwise all clients share one bucket. Custom 429 response factory. `window` is a duration string: a number followed by `s`, `m`, `h`, or `d` (for example `"30s"`, `"15m"`, `"1h"`). Any other format throws when the first request is checked. The window is a fixed window starting at the first request for that key and path, and the counter is keyed by path plus client key. When the limit is exceeded, the response is `429` with this body, plus `Retry-After`, `X-RateLimit-Limit`, `X-RateLimit-Remaining` (always `0`), and `X-RateLimit-Reset` headers: ```json { "error": { "code": "RATE_LIMITED", "message": "Too many requests" } } ``` Requests under the limit pass through without rate limit headers. ## Per-endpoint limits declared by plugins Some plugin endpoints carry their own `metadata.rateLimit` (for example the email OTP, magic link, anonymous, device, SIWE, OAuth proxy, and OAuth authorize endpoints). The plugin router enforces these with an in-process counter keyed by IP and endpoint path, and responds `429` with `{ "error": "Rate limit exceeded" }` and no `Retry-After` header. They are fixed in the plugin source, not configurable through `rateLimit()`, and not shared across instances. Note that the router interprets `window` in seconds, so `window: 60` means 60 seconds. ## Per-agent limits with maxCallsPerHour The permission engine supports a `maxCallsPerHour` constraint. When an agent exceeds its hourly call budget, `authorize()` returns `allowed: false` with a reason that starts with `Rate limit exceeded`. ```typescript const agent = await theauth.agent.create({ ownerId: 'user-123', name: 'data-sync-bot', type: 'autonomous', permissions: [ { resource: 'db:reports:*', actions: ['read'], constraints: { maxCallsPerHour: 100, }, }, { resource: 'db:reports:*', actions: ['export'], constraints: { maxCallsPerHour: 10, // stricter for expensive operations }, }, ], }); ``` How the counter works, from the permission engine source: - It is a rolling hour, not a clock-hour reset. Calls are recorded in 5 minute buckets in the `theauth_rate_limits` table, and every bucket from the last hour is summed. Because the counters live in the database, they are shared across instances. - The counter is keyed by agent ID and the requested resource string (for example `db:reports:monthly`), not by the permission pattern. A `db:reports:*` permission therefore keeps a separate count for each concrete resource requested. - A call is counted when the rate check passes, even if a later constraint on the same permission (argument patterns, approval, time window, IP allowlist) denies it. - Only the first permission whose resource and action match is evaluated by `authorize()`. - Budget policies (`theauth.policies`) are a different module and are not part of this check. ### Checking the limit in your code ```typescript const result = await theauth.authorize(agent.id, { action: 'read', resource: 'db:reports:monthly', }); if (!result.allowed && result.reason?.startsWith('Rate limit exceeded')) { // result.reason looks like: // Rate limit exceeded: 100/100 calls per hour for resource "db:reports:monthly" return new Response('Too many requests', { status: 429, headers: { 'Retry-After': '300' }, }); } ``` `authorize()` does not produce an HTTP response, so choosing the status and `Retry-After` value is up to your code. A slot frees up when the oldest 5 minute bucket leaves the window, so a short `Retry-After` is reasonable. The `onViolation` hook receives these denials with type `rate_limited`. ## createRateLimiter `createRateLimiter` is a small in-memory sliding window limiter. It is synchronous, per process, and keyed by a string you pass in. ```typescript import { createRateLimiter } from '@glinr/theauth/auth'; const inferenceLimit = createRateLimiter({ max: 20, window: 3600, // seconds }); const result = inferenceLimit.check(userId); if (!result.allowed) { // result.remaining === 0, result.resetAt is a Date } inferenceLimit.reset(userId); // clear a key, for example after a successful login ``` Maximum number of hits allowed within the window. Window length in seconds. `check(key)` returns `{ allowed, remaining, resetAt }` and consumes a slot when allowed. There are no `limit`, `keyFn`, `store`, or `redisUrl` options. ## withRateLimit middleware `withRateLimit(handler, limiter, options?)` wraps a plugin endpoint handler (the `(request, ctx) => Promise` shape used by `TheAuthPlugin` endpoints) so it is checked against a limiter first. ```typescript import { createRateLimiter, withRateLimit } from '@glinr/theauth/auth'; const inferenceLimit = createRateLimiter({ max: 20, window: 3600 }); const handler = withRateLimit( async (request, ctx) => { const result = await runInference(await request.json()); return Response.json(result); }, inferenceLimit, { keyExtractor: (req) => req.headers.get('x-user-id') ?? 'anonymous' }, ); ``` When the limit is exceeded, `withRateLimit` returns a `429` response with a `Retry-After` header and the body `{ "error": { "code": "RATE_LIMITED", "message": "Too many requests" } }`, and does not call the wrapped handler. Unlike the plugin, `withRateLimit` still defaults to the first `x-forwarded-for` entry, then `x-real-ip`, then `"unknown"`. Pass your own `keyExtractor` (for example one built on `resolveClientIp`) when you run behind a proxy. ## Next steps Add maxCallsPerHour and other constraints to individual permissions. Pair rate limits with human-in-the-loop approval for sensitive actions. React to rate_limited denials with the onViolation hook. ## Protecting /mcp and the device endpoints The plugin hook runs for requests that go through `theauth.plugins.handleRequest`, which covers the device endpoints. The `/mcp/token` and `/mcp/register` handlers are mounted by your app, so run the hooks first: ```ts app.post("/mcp/token", async (req) => { const gate = await theauth.plugins.runRequestHooks(req); if (gate instanceof Response) return gate; // 429 return mcp.token(gate); }); ``` --- # CLI login (device flow) Source: https://docs.theauth.dev/cli-auth ## Overview The device flow lets a program with no browser (a CLI, a headless agent, an MCP client) get a signed-in session. The program asks for a short code, the user approves it in a browser where they are already signed in, and the program receives a token. ## Server setup ```ts import { createTheAuth, deviceAuth } from "@glinr/theauth"; const theauth = await createTheAuth({ database: { provider: "sqlite", url: "theauth.db" }, auth: { session: { secret: process.env.THEAUTH_SECRET! } }, secondaryStorage: "database", // device codes must survive restarts and reach every instance plugins: [deviceAuth({ verificationUri: "https://app.example.com/device" })], }); ``` This mounts three endpoints under your TheAuth base path: | Endpoint | Caller | Purpose | | --- | --- | --- | | `POST /auth/device/code` | the CLI | Returns `device_code`, `user_code`, `verification_uri`, `verification_uri_complete`, `expires_in`, `interval`. | | `POST /auth/device/token` | the CLI | Poll. Returns `authorization_pending`, `slow_down`, `access_denied`, `expired_token`, or the token. | | `POST /auth/device/authorize` | your `/device` page | The signed-in user approves or denies. | Your `/device` page reads `user_code` from the URL, shows who is asking, and posts to the authorize endpoint with the user's session cookie: ```ts await fetch("/api/theauth/auth/device/authorize", { method: "POST", credentials: "include", headers: { "content-type": "application/json" }, body: JSON.stringify({ user_code, action: "approve" }), // or "deny" }); ``` ## What is enforced **Breaking fix.** `/auth/device/authorize` used to read `user_id` from the request body when called through `module.handleRequest`, so anyone could approve a code as any user. The user now always comes from the authenticated session (`deviceAuth()` plugin) or from `resolveUser` (the standalone module). A body `user_id` is ignored, and with no signed-in user the endpoint answers 401. - The device code is stored only as a SHA-256 hash, as is the user code index. - Each approving user gets 5 wrong guesses per 15 minutes (`userCodeAttemptLimit`, `userCodeAttemptWindowSeconds`); further attempts get 429. - `slow_down` is tracked in secondary storage, raises the interval by 5 seconds each time as RFC 8628 requires, and holds across instances. - A device code can be exchanged once. A second poll gets `expired_token`. - The approval endpoint requires `Content-Type: application/json` and refuses a cross-site `Origin` header, which blocks one-click approval from another site. Add origins with `trustedOrigins`. - Per-IP rate limits cover all three endpoints when you use `rateLimit()` (see [Rate limiting](/rate-limiting)). With `auth.session` configured, the token is a TheAuth session token you can send as `Authorization: Bearer ...`. To issue something else, pass `issueToken(userId, { clientId, scope })`. ## The CLI ```bash theauth login --server https://app.example.com/api/theauth theauth whoami theauth logout ``` `--server` falls back to `$THEAUTH_URL`, then to the only server you are logged in to. `--no-browser` prints the code without opening a browser. Credentials are saved with file mode `0600` in a `0700` directory: | OS | Path | | --- | --- | | macOS, Linux | `$XDG_CONFIG_HOME/theauth/credentials.json`, else `~/.config/theauth/credentials.json` | | Windows | `%APPDATA%\theauth\credentials.json` | Set `THEAUTH_CREDENTIALS_FILE` to use another path. `logout` revokes the session on the server (best effort) and deletes the local copy. On Windows the mode bits do not apply; the file sits in your per-user profile directory. ## Use it from your own CLI or MCP client ```ts import { loginWithDeviceFlow, loadCredential } from "@glinr/theauth-cli"; const serverUrl = "https://app.example.com/api/theauth"; const saved = await loadCredential(serverUrl); const token = saved?.accessToken ?? (await loginWithDeviceFlow({ serverUrl, clientId: "my-cli", onPrompt: ({ userCode, verificationUri }) => console.error(`Go to ${verificationUri} and enter ${userCode}`), })).accessToken; ``` `loginWithDeviceFlow` handles polling, `slow_down`, denial and expiry, and throws a `DeviceFlowError` with a `code` (`access_denied`, `expired_token`, `aborted`, `server_error`, `no_token`). Pass `save: false` to skip the credential cache, and an `AbortSignal` to cancel. ## Standalone module Without the plugin, use `createDeviceAuthModule({ verificationUri, resolveUser, issueToken, storage })` and route requests through `module.handleRequest(request)`. --- # Agent registration tokens Source: https://docs.theauth.dev/agent-registration-tokens ## Overview Normally a signed-in human creates an agent. That does not work for a CI job or a freshly deployed worker that has no browser. A registration token fixes this: a human (or an admin script) mints a token that carries a fixed set of permissions, hands it to the agent, and the agent redeems it once to become a registered agent. - The token is shown once and stored as a SHA-256 hash. - It is single use, including under concurrent attempts. - It expires (default 15 minutes, at most 7 days). - The agent chooses its name but cannot change its permissions, type or owner. ## Setup Needs the agent tables (set `agents` in your config). ```ts import { createTheAuth, agentRegistration } from "@glinr/theauth"; const theauth = await createTheAuth({ database: { provider: "postgres", url: process.env.DATABASE_URL }, agents: { enabled: true, maxPerUser: 20 }, auth: { session: { secret: process.env.THEAUTH_SECRET! } }, plugins: [ agentRegistration({ isAdmin: (user) => user.metadata?.role === "admin", onEvent: (event) => auditSink.write(event), }), ], }); ``` ## Endpoints | Endpoint | Auth | Purpose | | --- | --- | --- | | `POST /auth/agent-registration/tokens` | session | Mint a token. | | `GET /auth/agent-registration/tokens` | session | List tokens (`?status=active\|used\|revoked\|expired`). | | `DELETE /auth/agent-registration/tokens/:id` | session | Revoke an unused token. | | `POST /auth/agent-registration/register` | the registration token as Bearer | Redeem it. | Regular users manage tokens for themselves. Users for whom `isAdmin` returns true can mint, list and revoke for any owner (`owner_id`). With no `isAdmin`, nobody is an admin. ```bash # 1. A signed-in user mints a token curl -X POST $BASE/auth/agent-registration/tokens \ -H "Authorization: Bearer $SESSION" -H "content-type: application/json" \ -d '{"permissions":[{"resource":"mcp:github:*","actions":["read"]}],"expires_in":600,"name_prefix":"ci-","label":"nightly job"}' # => { "id": "...", "token": "kvr_...", "expires_at": "..." } # 2. The agent redeems it, no session needed curl -X POST $BASE/auth/agent-registration/register \ -H "Authorization: Bearer kvr_..." -H "content-type: application/json" \ -d '{"name":"ci-nightly"}' # => { "agent_id": "...", "token": "kv_...", "permissions": [...] } ``` Mint body fields: `permissions` (required), `owner_id`, `label`, `agent_type`, `name_prefix`, `expires_in` (seconds), `agent_ttl_seconds`. ## Using the module directly ```ts import { createAgentRegistrationModule } from "@glinr/theauth"; const registration = createAgentRegistrationModule({ db: theauth.db, agents: theauth.agent }); const minted = await registration.create({ ownerId: user.id, permissions, expiresInSeconds: 600 }); const agent = await registration.redeem(token, { name: "ci-nightly" }); ``` Results use the `{ success, data | error }` shape. Every unusable token (unknown, expired, revoked, already used) returns the same `INVALID_TOKEN` error so the endpoint does not reveal which case it was. ## Audit `onEvent` receives `agent_registration.token_created`, `token_revoked`, `redeemed` and `redeem_failed` (with a `reason`). A successful redemption also writes a row to the audit log (`action: "register"`, resource `agent_registration_token:`). Create and revoke have no agent yet, so they are reported through `onEvent` only. If agent creation fails after the token is claimed (for example the owner is at `maxPerUser`), the token is released so it can be retried. The `beforeAgentCreate` and `afterAgentCreate` hooks run for redeemed agents like any other. The register endpoint is limited to 20 requests per minute per client. Behind a proxy, set `trustedProxy` (see [Rate limiting](/rate-limiting)) so that limit is per client. --- # Budget policies Source: https://docs.theauth.dev/budget-policies ## The problem policies solve When agents make LLM calls, costs accumulate in the background. Without limits, a single runaway agent or a misconfigured loop can burn through a month's budget in hours. Budget policies let you set hard caps and choose what happens when those caps are hit. Budget policies are a separate module at `theauth.policies`. `theauth.authorize()` does not consult them. Call `theauth.policies.checkBudget()` yourself before an LLM call, and `theauth.policies.recordUsage()` after it. Nothing is enforced unless your code makes those calls and acts on the result. `theauth.policy` is a different thing: the unified policy engine (see [Policy engine](/policy-engine)). Budget policies live on `theauth.policies`. `checkBudget` and `recordUsage` select policies by `agentId` only: a policy for that exact agent, or any policy whose `agentId` is empty. A policy created with only `userId` or `tenantId` has no `agentId`, so it is treated as applying to every agent. The `userId` and `tenantId` fields are stored and can be used to filter `list()`, but they do not scope the check. ## Data model Stable identifier with a pol_ prefix. Agent this policy applies to. Omit to create a global policy that applies to all agents. Stored on the policy and usable as a `list()` filter. Not used by `checkBudget` or `recordUsage`. Stored on the policy and usable as a `list()` filter. Not used by `checkBudget` or `recordUsage`. The numeric thresholds for this policy. What happens when a limit is exceeded. Current policy state. 'triggered' means a limit has been hit. Running counters for this policy. ### BudgetLimits Maximum token cost units allowed per day. Counters reset when you call `resetDaily()`. Maximum token cost units allowed per month. Counters reset when you call `resetMonthly()`. Maximum calls per day, as counted by `recordUsage()` (one per call). Maximum calls per month, as counted by `recordUsage()` (one per call). ## Actions The action only changes what `checkBudget()` returns once a limit is reached (usage greater than or equal to the limit): | Action | `checkBudget()` result | |--------|--------------| | `warn` | `allowed: true`, with `reason` and `policy` set so you can alert. | | `throttle` | `allowed: false`. | | `block` | `allowed: false`. | | `revoke` | `allowed: false`. The agent token is not revoked automatically. Call `theauth.agent.revoke()` yourself if you want that. | `throttle`, `block`, and `revoke` are not distinguished by the code today: all three return `allowed: false`, and the policy stays `triggered` until a reset brings usage back under the limit. `warn` is useful for sending alerts before you start blocking. Set a `warn` policy at 80% of your limit and a `block` policy at 100%. ## Creating a policy ```typescript // Per-agent daily token cap const policy = await theauth.policies.create({ agentId: 'agt_abc123', limits: { maxTokensCostPerDay: 1000, maxCallsPerDay: 500, }, action: 'block', }); // Per-user monthly cap (applies to all agents owned by this user) const userPolicy = await theauth.policies.create({ userId: 'user-456', limits: { maxTokensCostPerMonth: 10_000, }, action: 'throttle', }); // Tenant-wide monthly cap const tenantPolicy = await theauth.policies.create({ tenantId: 'tnt_acme', limits: { maxTokensCostPerMonth: 50_000, maxCallsPerMonth: 1_000_000, }, action: 'block', }); ``` ## Checking a budget before a call Call `checkBudget` with an optional speculative `tokensCost` to see whether this call would exceed any policy. The cost is included in the check but not recorded yet. Call it before your LLM call; `authorize()` does not do it for you. ```typescript const check = await theauth.policies.checkBudget('agt_abc123', 50); if (!check.allowed) { console.error(check.reason); // check.policy contains the policy that was exceeded } ``` `checkBudget` evaluates every policy that is not `disabled` (including `triggered` ones) for the agent, both exact-match and global. It returns at the first policy whose limit is reached. Note that a `warn` policy that is reached also returns early with `allowed: true`, so a `block` policy later in the list is not reported in that call. ## Recording usage after a call Call `recordUsage` after the LLM call completes to update the counters. ```typescript const result = await llm.complete(prompt); const actualCost = result.usage.totalTokens; await theauth.policies.recordUsage('agt_abc123', actualCost); ``` `recordUsage` increments `callsToday`, `callsThisMonth`, `tokensCostToday`, and `tokensCostThisMonth` on every policy that is not `disabled` and applies to this agent. It also sets a policy to `triggered` if the new totals reach a limit. ## Resetting counters Nothing resets counters automatically. Schedule these calls yourself. Reset daily counters from a UTC midnight cron job: ```typescript const { reset } = await theauth.policies.resetDaily(); console.log(`Reset ${reset} policies`); ``` Reset monthly counters on the first of each month: ```typescript const { reset } = await theauth.policies.resetMonthly(); ``` Both reset calls apply to every policy. After a reset, policies that were `triggered` move back to `active` if the new totals are within limits. ## Listing and updating policies ```typescript // All policies for an agent (including global ones) const policies = await theauth.policies.list({ agentId: 'agt_abc123' }); // Change the limit and action await theauth.policies.update(policy.id, { limits: { maxTokensCostPerDay: 2000 }, action: 'warn', }); // Remove a policy await theauth.policies.remove(policy.id); ``` ## Combining warn and block A common pattern: warn at a soft limit, block at the hard limit. ```typescript // Warn at 800 tokens/day await theauth.policies.create({ agentId: 'agt_abc123', limits: { maxTokensCostPerDay: 800 }, action: 'warn', }); // Block at 1000 tokens/day await theauth.policies.create({ agentId: 'agt_abc123', limits: { maxTokensCostPerDay: 1000 }, action: 'block', }); ``` When the agent hits 800, the `warn` policy triggers: `checkBudget` returns `allowed: true` with the policy attached, and you can send an alert from your own code. At 1000, `checkBudget` returns `allowed: false`, and your code is responsible for stopping the call. ## Next steps Run your own logic around authorization calls. Aggregate token costs per agent for billing reports. Attach policies to entire tenants. --- # Cost attribution Source: https://docs.theauth.dev/cost-attribution ## Why cost attribution matters When agents make LLM calls, every token has a price. In multi-agent systems where one agent can spawn or delegate to others, costs accumulate across multiple providers and chains. Without proper attribution you cannot answer basic questions: which agent is responsible for a $200 spike? Which tool is burning the most budget? Did the delegation chain for last night's job exceed its allocation? Cost attribution gives you per-agent, per-tool, and per-delegation-chain cost records. Alert callbacks and a budget check read the same cost records, so you can react to spend as it is recorded. The module reports and alerts; it does not block anything by itself. ## Setup ```typescript import { createTheAuth } from '@glinr/theauth'; import { createCostAttributionModule } from '@glinr/theauth/auth'; const theauth = await createTheAuth({ database: { provider: 'sqlite', url: 'theauth.db' }, }); // Standalone module: it is not attached to the theauth instance const costs = createCostAttributionModule(theauth.db, { currency: 'USD', retentionDays: 90, alertThresholds: { warn: 5.00, critical: 20.00 }, onAlert: async (alert) => { console.warn(`[cost] ${alert.type}: agent ${alert.agentId} spent $${alert.currentCostUsd.toFixed(4)} (threshold: $${alert.threshold})`); }, }); ``` ### Configuration options Currency code stored on each cost record. It is a label only, no conversion happens, and the input field is still named `costUsd`. Amounts for 24-hour rolling spend that trigger alerts. Only used when `onAlert` is also set. void | Promise`} default="undefined">Called from `recordCost()` when 24-hour spend is at or above a threshold, or when monthly spend has reached a budget policy limit. How many days of cost events to keep. Older rows are deleted on cleanup(). ## Recording costs Call `recordCost()` after each LLM response or API call. Pass the raw token counts and the exact dollar amount from the provider's response. ```typescript // After an OpenAI completion const completion = await openai.chat.completions.create({ model: 'gpt-4o', messages }); await costs.recordCost({ agentId: agent.id, tool: 'openai:gpt-4o', inputTokens: completion.usage?.prompt_tokens, outputTokens: completion.usage?.completion_tokens, costUsd: calculateOpenAiCost(completion.usage), }); ``` ### RecordCostInput The agent that incurred this cost. Provider and model identifier, e.g. 'openai:gpt-4o', 'anthropic:claude-3-5-sonnet', 'mcp:github'. Prompt tokens consumed. Completion tokens generated. Exact cost in the configured currency. `} default="undefined">Any additional data to store alongside this event (request IDs, model version, etc.). When set, this event is also attributed to the given delegation chain. Costs are stored internally as integer microdollars (value × 1,000,000) to avoid floating-point drift across aggregations. ### Provider helpers ```typescript function openAiCostUsd(usage: OpenAI.CompletionUsage, model: string): number { const rates: Record = { 'gpt-4o': { input: 2.50 / 1_000_000, output: 10.00 / 1_000_000 }, 'gpt-4o-mini': { input: 0.15 / 1_000_000, output: 0.60 / 1_000_000 }, }; const rate = rates[model] ?? { input: 0, output: 0 }; return rate.input * usage.prompt_tokens + rate.output * usage.completion_tokens; } await costs.recordCost({ agentId, tool: `openai:${completion.model}`, inputTokens: completion.usage.prompt_tokens, outputTokens: completion.usage.completion_tokens, costUsd: openAiCostUsd(completion.usage, completion.model), }); ``` ```typescript function anthropicCostUsd(usage: Anthropic.Usage, model: string): number { const rates: Record = { 'claude-3-5-sonnet-20241022': { input: 3.00 / 1_000_000, output: 15.00 / 1_000_000 }, 'claude-3-5-haiku-20241022': { input: 0.80 / 1_000_000, output: 4.00 / 1_000_000 }, }; const rate = rates[model] ?? { input: 0, output: 0 }; return rate.input * usage.input_tokens + rate.output * usage.output_tokens; } await costs.recordCost({ agentId, tool: `anthropic:${message.model}`, inputTokens: message.usage.input_tokens, outputTokens: message.usage.output_tokens, costUsd: anthropicCostUsd(message.usage, message.model), }); ``` ```typescript // For any provider with a flat per-call cost await costs.recordCost({ agentId, tool: 'mcp:github', costUsd: 0.0001, metadata: { operation: 'create_issue', repo: 'acme/app' }, }); ``` ## Generating cost reports ### Per-agent report ```typescript const result = await costs.getAgentCost(agent.id); if (result.success) { console.log('Total:', result.data.totalCostUsd.toFixed(4)); console.log('By tool:', result.data.byTool); console.log('By day:', result.data.byDay); } ``` Pass a custom period to narrow the query: ```typescript const result = await costs.getAgentCost(agent.id, { start: new Date('2025-01-01'), end: new Date('2025-01-31'), }); ``` ### CostReport Agent (or owner/chain) this report covers. The time window the report covers. Total cost across all events in the period. `}>Cost breakdown per tool, sorted by cost descending. `}>Daily spend as YYYY-MM-DD strings, sorted ascending. ### Owner report Aggregate cost across all agents owned by a user: ```typescript const result = await costs.getOwnerCost(userId); if (result.success) { console.log(`Total spend for ${userId}: $${result.data.totalCostUsd.toFixed(2)}`); } ``` ### Top agents by cost Find the most expensive agents in any period: ```typescript const result = await costs.getTopAgentsByCost(10, { start: new Date('2025-01-01'), end: new Date('2025-01-31'), }); if (result.success) { for (const { agentId, totalCostUsd } of result.data) { console.log(`${agentId}: $${totalCostUsd.toFixed(4)}`); } } ``` ### Delegation chain report `getDelegationChainCost(chainId)` returns all events recorded with that `delegationChainId`, regardless of date. The returned report uses the chain ID in its `agentId` field and a default 30-day `period` value that does not filter the events. When agents delegate to sub-agents, you can attribute all costs back to the originating chain by passing `delegationChainId` in `recordCost()`: ```typescript // When the parent agent creates a delegation const chain = await theauth.delegate({ fromAgent: parentAgent.id, toAgent: childAgent.id, permissions: [{ resource: 'tool:summarize', actions: ['execute'] }], expiresAt: new Date(Date.now() + 60 * 60 * 1000), }); // In the child agent's handler await costs.recordCost({ agentId: childAgent.id, tool: 'openai:gpt-4o-mini', costUsd: 0.02, delegationChainId: chain.id, }); // Later: total cost across all agents in that chain const result = await costs.getDelegationChainCost(chain.id); ``` ## Setting up alerts Alerts are evaluated inside `recordCost()` and only when `onAlert` is set. There are three alert types: | Type | When it fires | |------|---------------| | `warn` | 24-hour rolling spend is at or above the `warn` threshold (and below `critical`) | | `critical` | 24-hour rolling spend is at or above the `critical` threshold | | `budget_exceeded` | Calendar-month spend (server local time) has reached the smallest `maxTokensCostPerMonth` among the agent's budget policies | Alerts are level-triggered, not edge-triggered: every `recordCost()` call made while spend is above a threshold fires the alert again, so deduplicate in `onAlert` if you notify humans. Budget alerts need `onAlert` but not `alertThresholds`. A failed `recordCost()` returns an error result; an `onAlert` that throws is caught and also returns `RECORD_COST_FAILED` (the cost row is already written). ```typescript const costs = createCostAttributionModule(theauth.db, { alertThresholds: { warn: 5.00, // $5 in 24 hours critical: 20.00, // $20 in 24 hours }, onAlert: async (alert) => { if (alert.type === 'budget_exceeded') { // Revoke or suspend the agent await theauth.agent.revoke(alert.agentId); } // Send to Slack, PagerDuty, etc. await notifyOpsChannel({ text: `[${alert.type.toUpperCase()}] Agent ${alert.agentId} spent $${alert.currentCostUsd.toFixed(2)} (limit: $${alert.threshold}) over ${alert.period}`, }); }, }); ``` ### CostAlert Severity of the alert. The agent that triggered the alert. The actual spend that triggered the alert. The limit that was crossed. Time window for this alert, e.g. '24h' or 'monthly'. ## Integration with budget policies `checkBudget()` reads budget policies created via `theauth.policies` and compares them against calendar-month spend from the cost events table. It only considers policies whose `agentId` equals the agent (user-wide or tenant-wide policies are ignored), and only the `maxTokensCostPerMonth` limit; the daily and call-count limits and the policy `action` are not used. It reports; enforcing the result is up to your code. `theauth.authorize()` does not call it. ```typescript const result = await costs.checkBudget(agent.id); if (result.success) { const { withinBudget, spent, limit, remaining } = result.data; if (!withinBudget) { return new Response('Agent has exceeded its monthly cost budget', { status: 402 }); } console.log(`$${spent.toFixed(4)} of $${limit?.toFixed(2) ?? 'no limit'} used`); } ``` To set a budget limit, create a policy with `maxTokensCostPerMonth`: ```typescript await theauth.policies.create({ agentId: agent.id, limits: { maxTokensCostPerMonth: 50, // $50/month }, action: 'block', }); ``` The `budget_exceeded` alert fires from `recordCost()` once spend has reached this limit (`withinBudget` is `false` when spend is equal to or above the limit). The `action: 'block'` value is stored on the policy but nothing in the cost module acts on it, so revoke or block the agent yourself in `onAlert`. ## Maintenance Cost events accumulate. Run `cleanup()` periodically to remove events older than the retention window: ```typescript // In a cron job const result = await costs.cleanup({ retentionDays: 90 }); if (result.success) { console.log(`Deleted ${result.data.deleted} cost events`); } ``` If you configured `retentionDays` on the module, you can call `cleanup()` with no arguments and it uses that value. ## Return types All methods return a `Result` union: ```typescript type Result = | { success: true; data: T } | { success: false; error: TheAuthError }; ``` Check `result.success` before accessing `result.data`. Error codes: | Code | Cause | |------|-------| | `RECORD_COST_FAILED` | Database insert failed | | `GET_AGENT_COST_FAILED` | Query failed for agent report | | `GET_OWNER_COST_FAILED` | Query failed for owner report | | `GET_TOP_AGENTS_FAILED` | Aggregation query failed | | `GET_CHAIN_COST_FAILED` | Chain attribution query failed | | `CHECK_BUDGET_FAILED` | Budget policy lookup failed | | `CLEANUP_FAILED` | Cleanup delete failed | ## Related Create the policies that `checkBudget()` reads. Policies are not enforced by authorize(). The authorization log. Cost events are stored separately, in their own table. Trust levels you can combine with spend data in your own logic. Hard call-count caps per agent, per resource, per hour. --- # MCP OAuth 2.1 Source: https://docs.theauth.dev/mcp Building in Go? See the [theAuth Go library docs](/go/getting-started/overview). ## What MCP auth is The Model Context Protocol defines how AI clients connect to tool servers. The 2025-03 revision added an auth layer: MCP servers can now require OAuth 2.1 tokens before accepting tool calls. theAuth implements the full MCP auth stack: - OAuth 2.1 with PKCE (S256 code challenge method only) - Protected Resource Metadata (RFC 9728) - Authorization Server Metadata (RFC 8414) - Resource Indicators (RFC 8707) - Dynamic Client Registration (RFC 7591) This page describes the TypeScript package. The Go server in this repository exposes its OAuth endpoints under `/oauth/*` instead (`/oauth/authorize`, `/oauth/token`). The TypeScript adapters serve `/mcp/register`, `/mcp/authorize`, and `/mcp/token`, as listed below. ## Setup The OAuth server is a separate module from the `theauth` instance. You create it with `createMcpModule` from `@glinr/theauth/mcp` and pass it to a framework adapter. The module has no built-in database: you supply the storage callbacks. ### Setting up ```typescript import { createTheAuth } from '@glinr/theauth'; import { createMcpModule } from '@glinr/theauth/mcp'; import type { McpAccessToken, McpAuthorizationCode, McpClient } from '@glinr/theauth/mcp'; // In-memory stores for illustration. Back these with your database in production. const clients = new Map(); const codes = new Map(); const tokens = new Map(); const byRefreshToken = new Map(); export const mcpStore = { storeClient: async (client: McpClient) => { clients.set(client.clientId, client); }, findClient: async (clientId: string) => clients.get(clientId) ?? null, storeAuthorizationCode: async (code: McpAuthorizationCode) => { codes.set(code.code, code); }, consumeAuthorizationCode: async (code: string) => { const found = codes.get(code) ?? null; if (found) codes.delete(code); // a code must be single use return found; }, storeToken: async (token: McpAccessToken) => { tokens.set(token.accessToken, token); if (token.refreshToken) byRefreshToken.set(token.refreshToken, token.accessToken); }, findTokenByRefreshToken: async (refreshToken: string) => { const accessToken = byRefreshToken.get(refreshToken); return accessToken ? (tokens.get(accessToken) ?? null) : null; }, revokeToken: async (accessToken: string) => { tokens.delete(accessToken); }, // Return the signed-in user's ID from your session, or null to send them to loginPage resolveUserId: async (request: Request) => { void request; return null as string | null; }, }; export const theauth = await createTheAuth({ database: { provider: 'sqlite', url: 'theauth.db' }, baseUrl: 'https://auth.yourapp.com', mcp: { enabled: true }, // creates the MCP server registry table }); export const mcp = createMcpModule({ config: { enabled: true, issuer: 'https://auth.yourapp.com', // Public origin plus the path where you mount the adapter baseUrl: 'https://auth.yourapp.com/api/theauth', signingSecret: process.env.MCP_SIGNING_SECRET!, // at least 32 characters scopes: ['mcp:read', 'mcp:execute'], loginPage: 'https://auth.yourapp.com/login', }, ...mcpStore, }); ``` Then mount the module via a framework adapter by passing it as the `mcp` option, for example `theAuthHono(theauth, { mcp, authenticate })`. See [Framework adapters](/adapters). `createTheAuth` accepts an `mcp` config key, but it only makes the instance create the MCP tables (including the registry behind `theauth.mcp.register`). It does not build the OAuth server, and `audience` is not an option. The OAuth server comes only from `createMcpModule`. ### MCP config options These go in the `config` object passed to `createMcpModule`. Required by the type. Set to `true`. The authorization server URL. Appears in token claims and metadata documents. Required at runtime. The URL under which the adapter serves the endpoints (origin plus mount path). The metadata documents are built from it, for example `${baseUrl}/mcp/token`. Required at runtime. HMAC secret for access tokens, at least 32 characters. Required at runtime. Extra scopes to advertise and accept, in addition to `openid`, `profile`, `email`, and `offline_access`. Access token lifetime in seconds. Defaults to 3600. Refresh token lifetime in seconds. Defaults to 604800 (7 days). Authorization code lifetime in seconds. Defaults to 600. Resource URIs (RFC 8707) that a client may request. A `resource` outside this list is rejected when the list is set. Where users are sent when `resolveUserId` returns `null`. When set, the authorize endpoint redirects here instead of issuing a code, and your page calls `mcp.approveConsent(...)` after the user allows. Clients stored at startup through `storeClient`. ### Endpoints Adapters register these routes relative to their mount path (default `/api/theauth`): | Endpoint | RFC | Purpose | |---|---|---| | `GET /.well-known/oauth-authorization-server` | RFC 8414 | Authorization server metadata | | `GET /.well-known/oauth-protected-resource` | RFC 9728 | Protected resource metadata | | `POST /mcp/register` | RFC 7591 | Dynamic client registration | | `GET /mcp/authorize` | OAuth 2.1 | Authorization endpoint | | `POST /mcp/token` | OAuth 2.1 | Token endpoint | So with the default mount, the token endpoint is `https://auth.yourapp.com/api/theauth/mcp/token`. The authorization server metadata also advertises `revocation_endpoint` (`/mcp/revoke`) and `jwks_uri` (`/mcp/jwks`), but the TypeScript adapters do not serve either route today. The well-known documents are registered relative to the mount point, so with the default mount they live at `/api/theauth/.well-known/...`. MCP clients look for them at the root of your origin (`/.well-known/oauth-authorization-server` and `/.well-known/oauth-protected-resource`). When you mount the adapter under a prefix, add root-level routes (or rewrites) that serve the same documents: ```typescript app.get('/.well-known/oauth-authorization-server', (c) => c.json(mcp.getMetadata())); app.get('/.well-known/oauth-protected-resource', (c) => c.json(mcp.getProtectedResourceMetadata())); ``` ## OAuth flow ### PKCE flow theAuth only accepts `S256`. Plain PKCE is rejected at the authorization endpoint. ### Generate a code verifier The client generates a cryptographically random string between 43 and 128 characters. ```typescript const array = new Uint8Array(32); crypto.getRandomValues(array); const codeVerifier = btoa(String.fromCharCode(...array)) .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); ``` ### Compute the code challenge ```typescript const encoder = new TextEncoder(); const data = encoder.encode(codeVerifier); const digest = await crypto.subtle.digest('SHA-256', data); const codeChallenge = btoa(String.fromCharCode(...new Uint8Array(digest))) .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); ``` ### Redirect to the authorization endpoint ```typescript const authUrl = new URL('https://auth.yourapp.com/api/theauth/mcp/authorize'); authUrl.searchParams.set('response_type', 'code'); authUrl.searchParams.set('client_id', clientId); authUrl.searchParams.set('redirect_uri', redirectUri); authUrl.searchParams.set('code_challenge', codeChallenge); authUrl.searchParams.set('code_challenge_method', 'S256'); authUrl.searchParams.set('scope', 'mcp:read mcp:execute'); ``` ### Exchange the code for tokens After the user approves, the server issues an authorization code. The client sends it to `/mcp/token` along with the original `code_verifier`. The server computes `base64url(sha256(code_verifier))` and compares it to the stored challenge. A mismatch is rejected. ### Token format Access tokens are JWTs signed with HS256 (`typ: at+jwt`). They carry: - `sub`: the user ID of the person who authorized the client - `client_id`: the OAuth client - `aud`: the `resource` the client requested (RFC 8707), or the issuer when none was requested - `scope`: space-separated granted scopes - `jti`: unique token ID - `exp` and `iat`: expiry and issued-at timestamps A refresh token is issued only when the granted scope includes `offline_access`. Each refresh call revokes the old access token and issues a new access and refresh token pair. The module does not detect refresh token reuse: that depends on what your `findTokenByRefreshToken` and `revokeToken` callbacks do. ## Token validation ### Validating tokens Call `mcp.validateToken(token, requiredScopes?)` on the module returned by `createMcpModule`. It returns a `Result`, not a flat object: ```typescript const result = await mcp.validateToken(token, ['mcp:read']); if (!result.success) { // result.error.code: INVALID_TOKEN, TOKEN_EXPIRED, INVALID_AUDIENCE, INVALID_ISSUER, INSUFFICIENT_SCOPE, ... return new Response('Unauthorized', { status: 401 }); } const session = result.data; // session.userId: the user who authorized the client // session.clientId: the OAuth client // session.scopes: granted scopes as a string array // session.resource: the audience the token is bound to // session.expiresAt: Date object ``` Validation checks the JWT signature (HS256), issuer, expiry, that an audience claim is present, and (when you pass `requiredScopes`) that every scope is on the token. It does not compare the audience with your resource URL unless you use the lower-level `validateAccessToken` with `expectedAudience`. `theauth.mcp` on the main instance is only a registry of MCP tool servers (`register`, `list`, `get`). It has no `validate` method. Token validation lives on the module from `createMcpModule`. ### Protecting a route `mcp.requireScopes(request, scopes)` extracts the bearer token, validates it, and either returns the session or a ready-made `Response`: 401 with a `WWW-Authenticate` header when the token is missing or invalid, and 403 with a step-up challenge when it lacks a required scope. ```typescript // Hono example. See the adapters doc for other frameworks. import { Hono } from 'hono'; import type { McpSession } from '@glinr/theauth/mcp'; import { theAuthHono } from '@glinr/theauth-hono'; import { theauth, mcp } from './lib/theauth.js'; const app = new Hono<{ Variables: { mcpSession: McpSession } }>(); app.route('/api/theauth', theAuthHono(theauth, { mcp, authenticate })); app.use('/mcp/*', async (c, next) => { const check = await mcp.requireScopes(c.req.raw, ['mcp:read']); if (!check.authorized) return check.response; c.set('mcpSession', check.session); await next(); }); ``` `mcp.middleware(request)` is the lighter variant: it returns the same `Result` as `validateToken` after reading the `Authorization` header. The adapters do not add a `withMcpAuth` helper to the app. ### Unauthorized responses `buildUnauthorizedResponse` and `createMcpResponseHelpers` are exported from `@glinr/theauth/mcp`, but they take an internal context object that the module does not expose. For ordinary handlers, use `mcp.requireScopes` (which builds the 401 and 403 responses for you) or `mcp.buildStepUpResponse({ currentScopes, requiredScopes })`. ## Registering MCP servers `theauth.mcp` stores a registry of the tool servers you operate. The registry is a database table (created when you pass `mcp` to `createTheAuth`): ```typescript const server = await theauth.mcp.register({ name: 'github-mcp', endpoint: 'https://mcp.yourapp.com/github', tools: ['list_repos', 'get_issue', 'create_comment'], authRequired: true, rateLimit: { rpm: 60 }, }); // server.id is a generated ID const all = await theauth.mcp.list(); const one = await theauth.mcp.get(server.id); ``` The registry is bookkeeping for your own tooling and dashboards. It is not read by the metadata documents or the OAuth endpoints. Tool names can still be used in permission resources, for example `mcp:github-mcp:list_repos`. Generated from the module `config` (issuer, baseUrl, scopes), not from the registry: ```json { "resource": "https://auth.yourapp.com", "authorization_servers": ["https://auth.yourapp.com"], "jwks_uri": "https://auth.yourapp.com/api/theauth/mcp/jwks", "scopes_supported": ["openid", "profile", "email", "offline_access", "mcp:read", "mcp:execute"], "bearer_methods_supported": ["header"], "resource_signing_alg_values_supported": ["HS256"] } ``` ```json { "issuer": "https://auth.yourapp.com", "authorization_endpoint": "https://auth.yourapp.com/api/theauth/mcp/authorize", "token_endpoint": "https://auth.yourapp.com/api/theauth/mcp/token", "registration_endpoint": "https://auth.yourapp.com/api/theauth/mcp/register", "jwks_uri": "https://auth.yourapp.com/api/theauth/mcp/jwks", "revocation_endpoint": "https://auth.yourapp.com/api/theauth/mcp/revoke", "scopes_supported": ["openid", "profile", "email", "offline_access", "mcp:read", "mcp:execute"], "code_challenge_methods_supported": ["S256"], "grant_types_supported": ["authorization_code", "refresh_token"], "response_types_supported": ["code"], "response_modes_supported": ["query"], "token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post", "none"] } ``` ## Security hardening These behaviors are on by default unless noted. **Resource and audience are mandatory.** `resource` (RFC 8707) is required at the authorize and token endpoints, and access tokens are always bound to it. Resource servers must say which audience they accept: set `config.resource` (used by `middleware`, `validateToken` and `requireScopes`) or pass `expectedAudience` to `withMcpAuth` and `validateAccessToken`. Calls without one return a `SERVER_ERROR` that names the missing option. ```ts const result = await withMcpAuth(ctx, request, { expectedAudience: "https://mcp.example.com", requiredScopes: ["mcp:read"], }); ``` **Hashed storage.** Client secrets are stored as `sha256:`, and `McpAccessToken.accessToken` and `refreshToken` hold SHA-256 digests, not raw values. Secrets are compared in constant time. Rows written by older versions still work: a legacy plaintext secret is accepted once and upgraded when you implement the optional `updateClientSecret(clientId, hash)` callback, and a raw refresh token is found by a second `findTokenByRefreshToken` call. **Refresh rotation with reuse detection (RFC 9700).** Each refresh token belongs to a family. Replaying a rotated token revokes the whole family. By default families live in process memory; for more than one instance pass the database-backed store: ```ts createMcpModule({ config: { /* ... */ tokenFamilies: createTokenFamilyStore(db), refreshTokenAbsoluteTtl: 2592000 }, revokeTokenFamily: async (familyId) => { /* delete stored tokens with this familyId */ }, // ... }); ``` On refresh, a `scope` that was not part of the original grant returns `invalid_scope`, and a `resource` that differs from the original grant returns `invalid_target`. **Revocation and the jti denylist.** `mcp.revoke(request)` implements RFC 7009 (`POST /mcp/revoke`, client authenticated, own tokens only). Access tokens are JWTs, so revoking one only takes effect immediately if you set `config.jtiDenylist` (`createInMemoryJtiDenylist()` for a single process, or your own shared store). Without it a revoked access token stays valid until `exp`. **Asymmetric signing (opt in).** HS256 with `signingSecret` stays the default. To publish keys and sign with ES256 or EdDSA: ```ts const key = await generateMcpSigningKey("ES256", "2026-10"); const mcp = createMcpModule({ config: { // ... signing: { alg: "ES256", current: { kid: key.kid, privateKey: key.privateKey } }, }, // ... }); app.get("/mcp/jwks", async () => mcp.getJwks()); ``` To rotate, move the old key into `previous: [{ kid, publicKey }]` and set a new `current`. Both keys are published, tokens signed by either verify, and you drop the old entry once its tokens have expired. If `signingSecret` is still set, HS256 tokens keep verifying during the migration. **Issuer identification (RFC 9207).** Authorization responses carry `iss`, and metadata sets `authorization_response_iss_parameter_supported`. **Client ID Metadata Documents (opt in).** With `clientIdMetadataDocuments: { enabled: true }`, an unknown `https://` client_id is fetched and its document must contain the same `client_id`. The fetcher resolves DNS and refuses loopback, private, link-local, CGNAT, unique-local and metadata addresses, never follows redirects, caps the body at 5 KB, accepts JSON only, times out after 5 seconds, and fails closed. It cannot pin the connection to the address it checked, so a hostile DNS server with a near-zero TTL can in theory still rebind; supply a `fetchImpl` that pins the IP if that matters for you. Registration no longer fetches `client_uri`. ## Related Agent tokens that MCP clients receive after authorization. Reverse proxy that enforces MCP token validation without code changes. IETF draft claims emitted on MCP-issued agent JWTs. Delegate a subset of MCP scopes to sub-agents with depth limits. --- # Gateway Source: https://docs.theauth.dev/gateway ## What the gateway does The theAuth Gateway is a standalone HTTP reverse proxy. Every request to your API or MCP server passes through it first. The gateway: - Validates Bearer tokens against a theAuth instance - Checks agent permissions before forwarding (for routes whose policy lists `requiredPermissions`) - Enforces global and per-route rate limits - Records audit entries for authenticated requests - Handles CORS preflight (when `cors` is configured) - Strips or forwards the Authorization header depending on your config The upstream service sees only clean, authenticated traffic. No SDK changes required on the upstream side. ## When to use it Use the gateway when you want to add auth to something that doesn't have it. Common cases: - A local tool server or MCP server that accepts unauthenticated connections - A third-party API you're exposing to agents - A service you can't modify but need to wrap with auth - Staging environments where you want to restrict access If you own the upstream service and can add the theAuth SDK directly, that's more flexible. The gateway is best when you can't or don't want to touch upstream code. ## Quick start The fastest path is the one-liner: ```bash npx @glinr/theauth-gateway --upstream http://localhost:8080 ``` This starts a gateway on port 3000 that proxies all traffic to `http://localhost:8080`. Requests without a valid theAuth Bearer token are rejected with 401. The default `--database` is `:memory:`, a fresh empty SQLite database, so no agent token can validate against it and every protected request gets 401. For real use, point `--database` at the SQLite file your application uses (the CLI itself does not create the agent tables). The CLI supports SQLite only; use embedded mode for Postgres or MySQL. Check the health endpoint to confirm it's running: ```bash curl http://localhost:3000/_theauth/health # {"status":"ok","upstream":"http://localhost:8080","timestamp":"..."} ``` ## Configuration ### CLI flags ```bash npx @glinr/theauth-gateway \ --upstream http://localhost:8080 \ --port 4000 \ --database ./theauth.db \ --config gateway.json \ --strip-auth \ --no-audit ``` | Flag | Default | Description | |------|---------|-------------| | `--upstream`, `-u` | required (or `upstream` in the config file) | URL of the upstream service | | `--port`, `-p` | `3000` | Port the gateway listens on | | `--database`, `-d` | `:memory:` | SQLite database path for theAuth | | `--config`, `-c` | none | Path to a `gateway.json` config file | | `--strip-auth` | false | Remove the `Authorization` header before forwarding | | `--no-audit` | false | Disable audit trail recording | `--strip-auth` is the only way to strip the header from the CLI: the `stripAuthHeader` key in `gateway.json` is read but overridden by the flag's default of `false`. `audit` in the file works, and `--no-audit` overrides it. ### gateway.json For more control, put your config in a JSON file and pass it with `--config`: ```json { "upstream": "http://localhost:8080", "audit": true, "stripAuthHeader": false, "cors": { "origins": ["https://app.example.com"], "methods": ["GET", "POST", "PUT", "DELETE"], "credentials": true }, "rateLimit": { "windowMs": 60000, "max": 100 }, "policies": [ { "path": "/health", "public": true }, { "path": "/api/read/**", "method": "GET", "requiredPermissions": [ { "resource": "api", "actions": ["read"] } ] }, { "path": "/api/**", "requiredPermissions": [ { "resource": "api", "actions": ["read", "write"] } ], "rateLimit": { "windowMs": 60000, "max": 20 } } ] } ``` ### Embedded mode Use the gateway inside your own Node.js application without starting a separate process: ```typescript import { createTheAuth } from '@glinr/theauth'; import { createGateway } from '@glinr/theauth-gateway'; const theauth = await createTheAuth({ database: { provider: 'sqlite', url: 'theauth.db' }, agents: { enabled: true }, }); const gateway = createGateway({ upstream: 'http://localhost:8080', theauth, audit: true, cors: { origins: '*' }, rateLimit: { windowMs: 60_000, max: 100 }, policies: [ { path: '/_health', public: true }, { path: '/api/**', requiredPermissions: [{ resource: 'api', actions: ['read'] }], }, ], }); // Start a standalone server await gateway.listen(3000); // Or use it as a handler in your existing server framework // (pass it a Web API Request, get back a Response) const response = await gateway.handleRequest(request); ``` ## Policy rules Policies are matched in order. The first policy whose `path` pattern and `method` (if set) match the incoming request wins. A request that matches no policy still requires a valid token, but no permission or per-route rate limit is applied. When `requiredPermissions` lists several entries or actions, the agent needs all of them (each `resource` and `action` pair is checked in turn with `authorizeByToken`). Only the agent's own permissions count here, not delegated ones. ```typescript interface GatewayPolicy { path: string; // glob pattern, e.g. '/api/*', '/tools/**' method?: string | string[]; // 'GET', ['GET', 'POST'], etc. public?: boolean; // skip auth entirely requireAuth?: boolean; // default true requiredPermissions?: Array<{ resource: string; actions: string[]; }>; rateLimit?: { windowMs: number; max: number; }; } ``` ### Glob patterns Patterns use standard glob syntax via [micromatch](https://github.com/micromatch/micromatch): | Pattern | Matches | |---------|---------| | `/api/*` | `/api/users`, `/api/items` (one level) | | `/api/**` | `/api/users`, `/api/v2/users/123` (any depth) | | `/health` | `/health` exactly | | `/**` | everything | ### Auth flow per request 1. Check if the path is `/_theauth/health`, serve locally, no upstream 2. Handle CORS preflight (any `OPTIONS` request) if `cors` is configured; without `cors`, `OPTIONS` is treated like any other request 3. Match the request against policies (path + method) 4. If a Bearer token is present, validate it against theAuth (an invalid token just means no identity) 5. Unless the matched policy is `public: true` or `requireAuth: false`, reject with 401 when there is no valid identity 6. Check global rate limit (keyed by agent ID, or client IP when unauthenticated), reject with 429 and `Retry-After` if exceeded 7. Check policy-level rate limit, reject with 429 if exceeded 8. Check `requiredPermissions` if any (authenticated requests only), reject with 403 if insufficient 9. Proxy the request to the upstream; an unreachable upstream returns 502 10. Record an audit entry if `audit: true` (authenticated requests only) 11. Return the upstream response with CORS headers merged in Gateway errors use `{ "error": { "code": "...", "message": "..." } }` with codes `UNAUTHORIZED`, `RATE_LIMITED`, `FORBIDDEN`, and `BAD_GATEWAY`. ### How audit entries are written The gateway has no audit writer of its own. For each authenticated request it calls `theauth.authorizeByToken` with the lowercased HTTP method as `action` and `gateway:` as `resource`. The resulting entry's `result` therefore reflects whether the agent holds a matching permission for that resource, not whether the gateway allowed the request. Requests that were rejected, rate limited, or proxied successfully are all recorded this way, and the gateway's own reason text is not stored. Permission checks from `requiredPermissions` also write their own entries. ## Integration with MCP servers The gateway works well in front of MCP tool servers. Agents that have been issued theAuth tokens can call tools through the gateway, with permissions enforced per-route: ```json { "upstream": "http://localhost:3001", "policies": [ { "path": "/tools/read-file", "method": "POST", "requiredPermissions": [ { "resource": "filesystem", "actions": ["read"] } ] }, { "path": "/tools/write-file", "method": "POST", "requiredPermissions": [ { "resource": "filesystem", "actions": ["write"] } ] }, { "path": "/tools/**", "requiredPermissions": [ { "resource": "mcp", "actions": ["call"] } ] } ] } ``` ## Standalone vs embedded | | Standalone (CLI) | Embedded | |---|---|---| | Setup | `npx @glinr/theauth-gateway` | `createGateway(config)` | | Process | Separate process | In-process | | Framework | None required | Works with any framework | | Hot reload | Restart required | Your app's reload | | Use case | Quick wrap, Docker sidecar | Full control, custom middleware | In Docker, the standalone mode works as a sidecar: ```dockerfile # Start your API CMD ["node", "server.js"] # Or start the gateway in front of it CMD ["npx", "@glinr/theauth-gateway", "--upstream", "http://localhost:8080", "--port", "3000", "--database", "/data/theauth.db"] ``` ## Configuration reference URL of the upstream service to proxy to Accepted in the config and in `gateway.json`, but not used by the gateway today: it has no effect on routing. theAuth instance created with createTheAuth() Array of path-based access policies CORS configuration Global rate limit applied to all requests Record an audit entry for every request. Default: true Remove the Authorization header before forwarding to upstream. Default: false Rate limits are tracked in memory. If you run multiple gateway instances, each tracks limits independently. For distributed rate limiting, wrap the gateway with an external store or use a single gateway instance. ## Related The authorization server the gateway validates tokens against. Per-agent rate limits that complement the gateway's route-level limits. How the gateway's per-request audit entries appear in the log. Define the resource and action permissions that gateway policies check. --- # Overview Source: https://docs.theauth.dev/adapters/index ## How adapters work The `@glinr/theauth` package has zero framework dependencies. It operates entirely on the Web platform `Request`/`Response` API. Adapter packages wrap the core and expose framework-idiomatic handlers for authentication, authorization, and MCP OAuth routes. Because the core uses only Web platform APIs, theAuth runs on edge runtimes without modification: Next.js Edge Runtime, Cloudflare Workers, Deno Deploy, Vercel Edge Functions, and Bun. The Hono and SvelteKit adapters are fully edge-compatible out of the box. The Next.js adapter works on edge when you use `export const runtime = 'edge'` in your route file. Each adapter follows the same pattern: accept a `theauth` instance, optionally accept a config object, and return something your framework knows how to mount. ```typescript import { createTheAuth } from '@glinr/theauth'; import { createMcpModule } from '@glinr/theauth/mcp'; import { mcpStore } from './mcp-store.js'; // storage callbacks, see the MCP page const theauth = await createTheAuth({ database: { provider: 'sqlite', url: 'theauth.db' }, baseUrl: 'https://auth.yourapp.com', }); // optional, enables MCP OAuth endpoints const mcp = createMcpModule({ config: { enabled: true, issuer: 'https://auth.yourapp.com', baseUrl: 'https://auth.yourapp.com/api/theauth', // origin plus the mount path signingSecret: process.env.MCP_SIGNING_SECRET!, // at least 32 characters }, ...mcpStore, }); ``` Pass `mcp` to the adapter to enable the MCP OAuth 2.1 endpoints under the same mount path. `createMcpModule` comes from `@glinr/theauth/mcp` and takes a config object plus storage callbacks, see [MCP](/mcp). ## Authenticate the management routes The agent, delegation, audit and dashboard routes (`/agents`, `/delegations`, `/audit`, `/dashboard`, and `POST /authorize`) manage your whole agent fleet, so every adapter requires an authenticated caller. `/authorize/token` (agent bearer token), the MCP endpoints, password reset, email verification and plugin routes are not affected; they carry their own checks. Pass an `authenticate` function. It receives the standard `Request` and returns `{ id }` for an allowed caller or `null` to reject with 401: ```ts const authenticate = async (request: Request) => { const user = await theauth.auth.resolveUser(request); return user?.id === process.env.ADMIN_USER_ID ? { id: user.id } : null; }; app.route('/api/theauth', theAuthHono(theauth, { authenticate })); ``` If you leave `authenticate` out and `auth.session` is configured on `createTheAuth`, the adapter accepts any valid session (cookie or `Authorization: Bearer `). That admits every signed-in user, so write your own resolver when only admins should reach these routes. If neither is configured, the adapter throws when you create it: ``` [theauth] theAuthHono: the management routes (/agents, /delegations, /audit, /dashboard, /authorize) require authentication, but none is configured. ``` For a local demo you can opt out with `allowUnauthenticated: true`. A warning is logged on startup. Do not ship it. ### Upgrading Earlier versions mounted these routes with no authentication. After upgrading, pass `authenticate` (or configure `auth.session`) to each adapter call, or add `allowUnauthenticated: true` while you work on it locally. ## Available adapters | Package | Framework | Mount pattern | |---|---|---| | [`@glinr/theauth-hono`](/adapters/hono) | Hono | `app.route('/api/theauth', theAuthHono(theauth))` | | [`@glinr/theauth-express`](/adapters/express) | Express | `app.use('/api/theauth', theAuthExpress(theauth))` | | [`@glinr/theauth-nextjs`](/adapters/nextjs) | Next.js App Router | catch-all route `app/api/theauth/[...theauth]/route.ts` | | [`@glinr/theauth-fastify`](/adapters/fastify) | Fastify | `app.register(theAuthFastify(theauth), { prefix: '/api/theauth' })` | | [`@glinr/theauth-nuxt`](/adapters/nuxt) | Nuxt (H3) | catch-all file `server/api/theauth/[...].ts` | | [`@glinr/theauth-sveltekit`](/adapters/sveltekit) | SvelteKit | catch-all route `src/routes/api/theauth/[...path]/+server.ts` | | [`@glinr/theauth-astro`](/adapters/astro) | Astro | catch-all page `src/pages/api/theauth/[...path].ts` | | [`@glinr/theauth-nestjs`](/adapters/nestjs) | NestJS | `AuthModule.forRoot({ theauth, mcp })` | | [`@glinr/theauth-solidstart`](/adapters/solidstart) | SolidStart | catch-all route `src/routes/api/theauth/[...theauth].ts` | | [`@glinr/theauth-tanstack`](/adapters/tanstack) | TanStack Start | catch-all route `app/routes/api/theauth.$.ts` | ## Endpoints registered All adapters register the same set of REST routes under `basePath` (default `/api/theauth`): | Path | Methods | Description | |---|---|---| | `/agents` | `GET`, `POST` | List and create agents | | `/agents/:id` | `GET`, `PATCH`, `DELETE` | Get, update, revoke an agent | | `/agents/:id/rotate` | `POST` | Rotate an agent's token | | `/authorize` | `POST` | Authorize an action by agent ID | | `/authorize/token` | `POST` | Authorize an action by bearer token | | `/delegations` | `POST` | Create a delegation chain | | `/delegations/:id` | `DELETE` | Revoke a delegation | | `/delegations/:agentId` | `GET` | List delegation chains for an agent | | `/audit` | `GET` | Query audit logs | | `/audit/export` | `GET` | Export audit logs as JSON or CSV | When `mcp` is passed, additional endpoints are registered: | Path | Methods | Description | |---|---|---| | `/.well-known/oauth-authorization-server` | `GET` | OAuth 2.1 server metadata (RFC 8414) | | `/.well-known/oauth-protected-resource` | `GET` | Protected resource metadata (RFC 9728) | | `/mcp/register` | `POST` | Dynamic client registration (RFC 7591) | | `/mcp/authorize` | `GET` | Authorization endpoint (PKCE + S256) | | `/mcp/token` | `POST` | Token endpoint | These paths are relative to the mount point, but MCP clients look for the `/.well-known/*` documents at the root of your origin. When you mount the adapter under a prefix such as `/api/theauth`, also serve `mcp.getMetadata()` and `mcp.getProtectedResourceMetadata()` from root-level `/.well-known/oauth-authorization-server` and `/.well-known/oauth-protected-resource` routes. See [MCP](/mcp). ## Using without an adapter If your framework is not listed, call the module methods directly from any server that handles standard `Request` objects. The `mcp` module from `createMcpModule` does not route URLs for you: you map paths to its methods yourself. ```typescript import { mcp } from './lib/theauth.js'; async function handler(request: Request): Promise { const url = new URL(request.url); if (url.pathname === '/.well-known/oauth-authorization-server') { return Response.json(mcp.getMetadata()); } if (url.pathname === '/.well-known/oauth-protected-resource') { return Response.json(mcp.getProtectedResourceMetadata()); } // Protect everything else: 401 or 403 responses are built for you const check = await mcp.requireScopes(request, ['mcp:read']); if (!check.authorized) return check.response; return Response.json({ tools: [] }); } ``` You would also route `/mcp/register`, `/mcp/authorize`, and `/mcp/token` to `mcp.registerClient(body)`, `mcp.authorize(request)`, and `mcp.token(request)`, which return a `Result` you turn into a `Response`. The adapters in this table already do that, and the agent REST routes are only available through an adapter. ## Choose your framework Lightweight, fast, runs everywhere. The most widely used Node.js framework. App Router with catch-all route handler. High-performance with plugin architecture. Vue-based with H3 server routes. Svelte with +server.ts handlers. Content-focused with API routes. ## Related OAuth 2.1 authorization server, works with any adapter. Full endpoint reference for all routes the adapters mount. Core theAuth instance options passed to every adapter. Creating and managing agents via the mounted REST endpoints. --- # Hono Source: https://docs.theauth.dev/adapters/hono `theAuthHono(theauth, options?)` returns a `Hono` app instance with all theAuth routes pre-mounted. Use `app.route` to attach it to your main app. ## Install ```bash pnpm add @glinr/theauth @glinr/theauth-hono hono @hono/node-server ``` ## Setup Create this once and reuse it across your app (e.g. `lib/theauth.ts`): ```typescript // lib/theauth.ts import { createTheAuth } from '@glinr/theauth'; import { createMcpModule } from '@glinr/theauth/mcp'; import { mcpStore } from './mcp-store.js'; // storage callbacks, see the MCP page export const theauth = await createTheAuth({ database: { provider: 'sqlite', url: 'theauth.db' }, baseUrl: process.env.AUTH_BASE_URL!, mcp: { enabled: true }, // creates the MCP tables }); // baseUrl is the public origin plus the adapter mount path (default /api/theauth) export const mcp = createMcpModule({ config: { enabled: true, issuer: process.env.AUTH_BASE_URL!, baseUrl: `${process.env.AUTH_BASE_URL!}/api/theauth`, signingSecret: process.env.MCP_SIGNING_SECRET!, // at least 32 characters }, ...mcpStore, }); ``` ```typescript // src/index.ts import { serve } from '@hono/node-server'; import { Hono } from 'hono'; import { theAuthHono } from '@glinr/theauth-hono'; import { theauth, mcp } from './lib/theauth.js'; const app = new Hono(); // Mount all TheAuth routes (agents, permissions, audit, MCP OAuth) app.route('/api/theauth', theAuthHono(theauth, { mcp, authenticate })); serve({ fetch: app.fetch, port: 3000 }); ``` ## Authentication and mount prefixes `theAuthHono` refuses to start without a way to authenticate callers on `/agents`, `/delegations`, `/audit`, `/dashboard` and `POST /authorize`. Pass `authenticate`, configure `auth.session`, or set `allowUnauthenticated: true` for local development. The full rules and the upgrade note are on the [adapters overview](/adapters#authenticate-the-management-routes). ```typescript const authenticate = async (request: Request) => { const user = await theauth.auth.resolveUser(request); return user ? { id: user.id } : null; }; app.route('/api/theauth', theAuthHono(theauth, { authenticate })); ``` Plugin routes (magic link, email OTP and so on) work under any prefix. The adapter works out the prefix from the request path, so `app.route('/x', theAuthHono(...))` and `app.route('/api/v1/theauth', ...)` both reach `/auth/magic-link/send` at the right place. ## MCP endpoints Pass `mcp` to enable the full MCP OAuth 2.1 authorization server. All MCP endpoints are mounted under the same `/api/theauth` prefix alongside the REST API: ```typescript app.route('/api/theauth', theAuthHono(theauth, { mcp, authenticate })); // registers: // GET /api/theauth/.well-known/oauth-authorization-server // GET /api/theauth/.well-known/oauth-protected-resource // POST /api/theauth/mcp/register // GET /api/theauth/mcp/authorize // POST /api/theauth/mcp/token ``` MCP routes include CORS headers (`Access-Control-Allow-Origin: *`) and respond to OPTIONS preflight requests automatically. ## Endpoint reference | Method | Path | Description | |---|---|---| | `POST` | `/agents` | Create an agent | | `GET` | `/agents` | List agents | | `GET` | `/agents/:id` | Get an agent | | `PATCH` | `/agents/:id` | Update an agent | | `DELETE` | `/agents/:id` | Revoke an agent | | `POST` | `/agents/:id/rotate` | Rotate token | | `POST` | `/authorize` | Authorize by agent ID | | `POST` | `/authorize/token` | Authorize by bearer token | | `POST` | `/delegations` | Create delegation | | `GET` | `/delegations/:agentId` | List delegation chains | | `DELETE` | `/delegations/:id` | Revoke delegation | | `GET` | `/audit` | Query audit logs | | `GET` | `/audit/export` | Export audit logs | ## Full example ```typescript import { serve } from '@hono/node-server'; import { Hono } from 'hono'; import { createTheAuth } from '@glinr/theauth'; import { createMcpModule } from '@glinr/theauth/mcp'; import { mcpStore } from './mcp-store.js'; // storage callbacks, see the MCP page import { theAuthHono } from '@glinr/theauth-hono'; const theauth = await createTheAuth({ database: { provider: 'sqlite', url: 'theauth.db' }, baseUrl: 'https://auth.yourapp.com', mcp: { enabled: true }, // creates the MCP tables }); // baseUrl is the public origin plus the adapter mount path (default /api/theauth) const mcp = createMcpModule({ config: { enabled: true, issuer: 'https://auth.yourapp.com', baseUrl: 'https://auth.yourapp.com/api/theauth', signingSecret: process.env.MCP_SIGNING_SECRET!, // at least 32 characters }, ...mcpStore, }); const app = new Hono(); app.route('/api/theauth', theAuthHono(theauth, { mcp, authenticate })); app.get('/health', (c) => c.json({ ok: true })); serve({ fetch: app.fetch, port: 3000 }); ``` ## Related Compare all available framework adapters and their mount patterns. Plugin-based Node.js adapter for high-throughput apps. Router-based adapter for Express apps. OAuth 2.1 authorization server endpoints mounted by the adapter. --- # Express Source: https://docs.theauth.dev/adapters/express `theAuthExpress(theauth, options?)` returns an Express `Router` with all theAuth routes pre-mounted. Use `app.use` to attach it at your chosen path. ## Install ```bash pnpm add @glinr/theauth @glinr/theauth-express express pnpm add -D @types/express ``` ## Setup ```typescript // lib/theauth.ts import { createTheAuth } from '@glinr/theauth'; import { createMcpModule } from '@glinr/theauth/mcp'; import { mcpStore } from './mcp-store.js'; // storage callbacks, see the MCP page export const theauth = await createTheAuth({ database: { provider: 'postgres', url: process.env.DATABASE_URL! }, baseUrl: process.env.AUTH_BASE_URL!, mcp: { enabled: true }, // creates the MCP tables }); // baseUrl is the public origin plus the adapter mount path (default /api/theauth) export const mcp = createMcpModule({ config: { enabled: true, issuer: process.env.AUTH_BASE_URL!, baseUrl: `${process.env.AUTH_BASE_URL!}/api/theauth`, signingSecret: process.env.MCP_SIGNING_SECRET!, // at least 32 characters }, ...mcpStore, }); ``` ```typescript // src/index.ts import express from 'express'; import { theAuthExpress } from '@glinr/theauth-express'; import { theauth, mcp } from './lib/theauth.js'; const app = express(); // Required: parse JSON and URL-encoded bodies before the adapter app.use(express.json()); app.use(express.urlencoded({ extended: true })); // Mount all TheAuth routes app.use('/api/theauth', theAuthExpress(theauth, { mcp, authenticate })); app.listen(3000); ``` Call `express.json()` and `express.urlencoded()` before mounting the adapter. The adapter reads `req.body` which requires those parsers to be in place. ## MCP endpoints Pass `mcp` to enable the MCP OAuth 2.1 authorization server. All MCP endpoints are registered on the same router alongside the REST API: ```typescript app.use('/api/theauth', theAuthExpress(theauth, { mcp, authenticate })); // registers: // GET /api/theauth/.well-known/oauth-authorization-server // GET /api/theauth/.well-known/oauth-protected-resource // POST /api/theauth/mcp/register // GET /api/theauth/mcp/authorize // POST /api/theauth/mcp/token ``` ## Endpoint reference | Method | Path | Description | |---|---|---| | `POST` | `/agents` | Create an agent | | `GET` | `/agents` | List agents | | `GET` | `/agents/:id` | Get an agent | | `PATCH` | `/agents/:id` | Update an agent | | `DELETE` | `/agents/:id` | Revoke an agent | | `POST` | `/agents/:id/rotate` | Rotate token | | `POST` | `/authorize` | Authorize by agent ID | | `POST` | `/authorize/token` | Authorize by bearer token | | `POST` | `/delegations` | Create delegation | | `GET` | `/delegations/:agentId` | List delegation chains | | `DELETE` | `/delegations/:id` | Revoke delegation | | `GET` | `/audit` | Query audit logs | | `GET` | `/audit/export` | Export audit logs | ## Full example ```typescript import express from 'express'; import { createTheAuth } from '@glinr/theauth'; import { createMcpModule } from '@glinr/theauth/mcp'; import { mcpStore } from './mcp-store.js'; // storage callbacks, see the MCP page import { theAuthExpress } from '@glinr/theauth-express'; const theauth = await createTheAuth({ database: { provider: 'postgres', url: process.env.DATABASE_URL! }, baseUrl: process.env.AUTH_BASE_URL!, mcp: { enabled: true }, // creates the MCP tables }); // baseUrl is the public origin plus the adapter mount path (default /api/theauth) const mcp = createMcpModule({ config: { enabled: true, issuer: process.env.AUTH_BASE_URL!, baseUrl: `${process.env.AUTH_BASE_URL!}/api/theauth`, signingSecret: process.env.MCP_SIGNING_SECRET!, // at least 32 characters }, ...mcpStore, }); const app = express(); app.use(express.json()); app.use(express.urlencoded({ extended: true })); app.use('/api/theauth', theAuthExpress(theauth, { mcp, authenticate })); app.get('/health', (_req, res) => res.json({ ok: true })); app.listen(3000, () => { console.log('Server running on port 3000'); }); ``` ## Related Compare all available framework adapters and their mount patterns. High-performance alternative with async plugin architecture. Module-based setup for NestJS apps using Express under the hood. OAuth 2.1 authorization server endpoints mounted by the adapter. --- # Fastify Source: https://docs.theauth.dev/adapters/fastify `theAuthFastify(theauth, options?)` returns an async Fastify plugin. Register it with `fastify.register` and use Fastify's built-in `prefix` option to control the mount path. ## Install ```bash pnpm add @glinr/theauth @glinr/theauth-fastify fastify ``` ## Setup ```typescript // lib/theauth.ts import { createTheAuth } from '@glinr/theauth'; import { createMcpModule } from '@glinr/theauth/mcp'; import { mcpStore } from './mcp-store.js'; // storage callbacks, see the MCP page export const theauth = await createTheAuth({ database: { provider: 'postgres', url: process.env.DATABASE_URL! }, baseUrl: process.env.AUTH_BASE_URL!, mcp: { enabled: true }, // creates the MCP tables }); // baseUrl is the public origin plus the adapter mount path (default /api/theauth) export const mcp = createMcpModule({ config: { enabled: true, issuer: process.env.AUTH_BASE_URL!, baseUrl: `${process.env.AUTH_BASE_URL!}/api/theauth`, signingSecret: process.env.MCP_SIGNING_SECRET!, // at least 32 characters }, ...mcpStore, }); ``` ```typescript // src/index.ts import Fastify from 'fastify'; import { theAuthFastify } from '@glinr/theauth-fastify'; import { theauth, mcp } from './lib/theauth.js'; const app = Fastify(); // Use Fastify's prefix option to set the mount path await app.register(theAuthFastify(theauth, { mcp, authenticate }), { prefix: '/api/theauth', }); await app.listen({ port: 3000 }); ``` The prefix is controlled by Fastify's `register` options, not by a `basePath` option on the adapter. This is consistent with how Fastify plugins work. ## MCP endpoints When `mcp` is passed, the MCP OAuth 2.1 endpoints are registered on the same plugin. Because Fastify's prefix scopes routes, the well-known paths are served relative to the plugin root: ``` GET /api/theauth/.well-known/oauth-authorization-server GET /api/theauth/.well-known/oauth-protected-resource POST /api/theauth/mcp/register GET /api/theauth/mcp/authorize POST /api/theauth/mcp/token ``` All MCP routes include CORS headers and respond to OPTIONS preflight requests. ## Endpoint reference | Method | Path | Description | |---|---|---| | `POST` | `/agents` | Create an agent | | `GET` | `/agents` | List agents | | `GET` | `/agents/:id` | Get an agent | | `PATCH` | `/agents/:id` | Update an agent | | `DELETE` | `/agents/:id` | Revoke an agent | | `POST` | `/agents/:id/rotate` | Rotate token | | `POST` | `/authorize` | Authorize by agent ID | | `POST` | `/authorize/token` | Authorize by bearer token | | `POST` | `/delegations` | Create delegation | | `GET` | `/delegations/:agentId` | List delegation chains | | `DELETE` | `/delegations/:id` | Revoke delegation | | `GET` | `/audit` | Query audit logs | | `GET` | `/audit/export` | Export audit logs | ## Full example ```typescript import Fastify from 'fastify'; import { createTheAuth } from '@glinr/theauth'; import { createMcpModule } from '@glinr/theauth/mcp'; import { mcpStore } from './mcp-store.js'; // storage callbacks, see the MCP page import { theAuthFastify } from '@glinr/theauth-fastify'; const theauth = await createTheAuth({ database: { provider: 'postgres', url: process.env.DATABASE_URL! }, baseUrl: process.env.AUTH_BASE_URL!, mcp: { enabled: true }, // creates the MCP tables }); // baseUrl is the public origin plus the adapter mount path (default /api/theauth) const mcp = createMcpModule({ config: { enabled: true, issuer: process.env.AUTH_BASE_URL!, baseUrl: `${process.env.AUTH_BASE_URL!}/api/theauth`, signingSecret: process.env.MCP_SIGNING_SECRET!, // at least 32 characters }, ...mcpStore, }); const app = Fastify({ logger: true }); await app.register(theAuthFastify(theauth, { mcp, authenticate }), { prefix: '/api/theauth', }); app.get('/health', async () => ({ ok: true })); await app.listen({ port: 3000, host: '0.0.0.0' }); ``` ## Related Compare all available framework adapters and their mount patterns. Router-based adapter for Express apps. Lightweight adapter that runs on Workers, Bun, and Deno. OAuth 2.1 authorization server endpoints mounted by the adapter. --- # NestJS Source: https://docs.theauth.dev/adapters/nestjs `AuthModule.forRoot(options)` is a NestJS dynamic module that mounts all theAuth routes as Express middleware. Import it once in your root `AppModule`. ## Install ```bash pnpm add @glinr/theauth @glinr/theauth-nestjs ``` ## Setup ```typescript // lib/theauth.ts import { createTheAuth } from '@glinr/theauth'; import { createMcpModule } from '@glinr/theauth/mcp'; import { mcpStore } from './mcp-store.js'; // storage callbacks, see the MCP page export const theauth = await createTheAuth({ database: { provider: 'postgres', url: process.env.DATABASE_URL! }, baseUrl: process.env.AUTH_BASE_URL!, mcp: { enabled: true }, // creates the MCP tables }); // baseUrl is the public origin plus the adapter mount path (default /api/theauth) export const mcp = createMcpModule({ config: { enabled: true, issuer: process.env.AUTH_BASE_URL!, baseUrl: `${process.env.AUTH_BASE_URL!}/api/theauth`, signingSecret: process.env.MCP_SIGNING_SECRET!, // at least 32 characters }, ...mcpStore, }); ``` ```typescript // app.module.ts import { Module } from '@nestjs/common'; import { AuthModule } from '@glinr/theauth-nestjs'; import { theauth, mcp } from './lib/theauth.js'; @Module({ imports: [ AuthModule.forRoot({ theauth, mcp, basePath: '/api/theauth', // default }), ], }) export class AppModule {} ``` ```typescript // main.ts import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module.js'; async function bootstrap() { const app = await NestFactory.create(AppModule); await app.listen(3000); } bootstrap(); ``` NestJS uses Express under the hood by default. The adapter mounts an Express Router directly, so no extra configuration is needed. ## Route prefix The default mount path is `/api/theauth`. Change it with the `basePath` option: ```typescript AuthModule.forRoot({ theauth, basePath: '/auth' }) ``` All theAuth routes will then be available under `/auth/*`. ## Without a module If you prefer to mount routes imperatively in `main.ts` rather than importing a module, use `theAuthMiddleware` directly: ```typescript // main.ts import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module.js'; import { theAuthMiddleware } from '@glinr/theauth-nestjs'; import { theauth, mcp } from './lib/theauth.js'; async function bootstrap() { const app = await NestFactory.create(AppModule); app.use('/api/theauth', theAuthMiddleware({ theauth, mcp, authenticate })); await app.listen(3000); } bootstrap(); ``` ## MCP endpoints Pass `mcp` to enable the MCP OAuth 2.1 authorization server: ```typescript AuthModule.forRoot({ theauth, mcp, basePath: '/api/theauth' }) // registers: // GET /api/theauth/.well-known/oauth-authorization-server // GET /api/theauth/.well-known/oauth-protected-resource // POST /api/theauth/mcp/register // GET /api/theauth/mcp/authorize // POST /api/theauth/mcp/token ``` ## Endpoint reference | Method | Path | Description | |---|---|---| | `POST` | `/agents` | Create an agent | | `GET` | `/agents` | List agents | | `GET` | `/agents/:id` | Get an agent | | `PATCH` | `/agents/:id` | Update an agent | | `DELETE` | `/agents/:id` | Revoke an agent | | `POST` | `/agents/:id/rotate` | Rotate token | | `POST` | `/authorize` | Authorize by agent ID | | `POST` | `/authorize/token` | Authorize by bearer token | | `POST` | `/delegations` | Create delegation | | `GET` | `/delegations/:agentId` | List delegation chains | | `DELETE` | `/delegations/:id` | Revoke delegation | | `GET` | `/audit` | Query audit logs | | `GET` | `/audit/export` | Export audit logs | ## Related Compare all available framework adapters and their mount patterns. Router-based adapter for Express apps (NestJS uses Express internally). Plugin-based adapter if you use NestJS with the Fastify platform. OAuth 2.1 authorization server endpoints mounted by the adapter. --- # Next.js Source: https://docs.theauth.dev/adapters/nextjs `theAuthNextjs(theauth, options?)` returns named route handlers `{ GET, POST, PATCH, DELETE, OPTIONS }` for the Next.js App Router. Mount them in a catch-all route file so all theAuth paths are handled. ## Install ```bash pnpm add @glinr/theauth @glinr/theauth-nextjs ``` ## Setup Create this in a shared module so it is initialized once at server startup: ```typescript // lib/theauth.ts import { createTheAuth } from '@glinr/theauth'; import { createMcpModule } from '@glinr/theauth/mcp'; import { mcpStore } from './mcp-store'; // storage callbacks, see the MCP page export const theauth = await createTheAuth({ database: { provider: 'postgres', url: process.env.DATABASE_URL! }, baseUrl: process.env.AUTH_BASE_URL!, mcp: { enabled: true }, // creates the MCP tables }); // baseUrl is the public origin plus the adapter mount path (default /api/theauth) export const mcp = createMcpModule({ config: { enabled: true, issuer: process.env.AUTH_BASE_URL!, baseUrl: `${process.env.AUTH_BASE_URL!}/api/theauth`, signingSecret: process.env.MCP_SIGNING_SECRET!, // at least 32 characters }, ...mcpStore, }); ``` Create `app/api/theauth/[...theauth]/route.ts`. The `[...theauth]` segment catches every sub-path under `/api/theauth/`. ```typescript // app/api/theauth/[...theauth]/route.ts import { theAuthNextjs } from '@glinr/theauth-nextjs'; import { theauth, mcp } from '@/lib/theauth'; const handlers = theAuthNextjs(theauth, { mcp, authenticate }); export const GET = handlers.GET; export const POST = handlers.POST; export const PATCH = handlers.PATCH; export const DELETE = handlers.DELETE; export const OPTIONS = handlers.OPTIONS; ``` The `basePath` option defaults to `/api/theauth`. If you mount at a different path such as `/api/auth/theauth`, pass `basePath: '/api/auth/theauth'` so the dispatcher can strip the prefix correctly. ## Options ```typescript interface AuthNextjsOptions { mcp?: McpAuthModule; // enables MCP OAuth 2.1 endpoints basePath?: string; // defaults to '/api/theauth' } ``` ## MCP endpoints When `mcp` is passed, the following endpoints are available: ``` GET /api/theauth/.well-known/oauth-authorization-server GET /api/theauth/.well-known/oauth-protected-resource POST /api/theauth/mcp/register GET /api/theauth/mcp/authorize POST /api/theauth/mcp/token ``` ## Endpoint reference | Method | Path | Description | |---|---|---| | `POST` | `/agents` | Create an agent | | `GET` | `/agents` | List agents | | `GET` | `/agents/:id` | Get an agent | | `PATCH` | `/agents/:id` | Update an agent | | `DELETE` | `/agents/:id` | Revoke an agent | | `POST` | `/agents/:id/rotate` | Rotate token | | `POST` | `/authorize` | Authorize by agent ID | | `POST` | `/authorize/token` | Authorize by bearer token | | `POST` | `/delegations` | Create delegation | | `GET` | `/delegations/:agentId` | List delegation chains | | `DELETE` | `/delegations/:id` | Revoke delegation | | `GET` | `/audit` | Query audit logs | | `GET` | `/audit/export` | Export audit logs | ## Full example ```typescript // app/api/theauth/[...theauth]/route.ts import { createTheAuth } from '@glinr/theauth'; import { createMcpModule } from '@glinr/theauth/mcp'; import { mcpStore } from './mcp-store'; // storage callbacks, see the MCP page import { theAuthNextjs } from '@glinr/theauth-nextjs'; const theauth = await createTheAuth({ database: { provider: 'postgres', url: process.env.DATABASE_URL! }, baseUrl: process.env.AUTH_BASE_URL!, mcp: { enabled: true }, // creates the MCP tables }); // baseUrl is the public origin plus the adapter mount path (default /api/theauth) const mcp = createMcpModule({ config: { enabled: true, issuer: process.env.AUTH_BASE_URL!, baseUrl: `${process.env.AUTH_BASE_URL!}/api/theauth`, signingSecret: process.env.MCP_SIGNING_SECRET!, // at least 32 characters }, ...mcpStore, }); const handlers = theAuthNextjs(theauth, { mcp, authenticate }); export const GET = handlers.GET; export const POST = handlers.POST; export const PATCH = handlers.PATCH; export const DELETE = handlers.DELETE; export const OPTIONS = handlers.OPTIONS; ``` Do not define `createTheAuth` inside the route file if you need the instance elsewhere in your app. Export it from `lib/theauth.ts` and import it where needed to avoid creating multiple instances. ## Related Compare all available framework adapters and their mount patterns. Lightweight edge-compatible adapter for Hono apps. Edge-compatible adapter with similar catch-all pattern. OAuth 2.1 authorization server endpoints mounted by the adapter. --- # Nuxt Source: https://docs.theauth.dev/adapters/nuxt `theAuthNuxt(theauth, options?)` returns an H3 `EventHandler`. Mount it in a catch-all server route so all theAuth paths are handled. ## Install ```bash pnpm add @glinr/theauth @glinr/theauth-nuxt ``` ## Setup Create this outside the event handler so it is initialized once at server startup: ```typescript // server/utils/theauth.ts import { createTheAuth } from '@glinr/theauth'; import { createMcpModule } from '@glinr/theauth/mcp'; import { mcpStore } from './mcp-store'; // storage callbacks, see the MCP page export const theauth = await createTheAuth({ database: { provider: 'postgres', url: process.env.DATABASE_URL! }, baseUrl: process.env.AUTH_BASE_URL!, mcp: { enabled: true }, // creates the MCP tables }); // baseUrl is the public origin plus the adapter mount path (default /api/theauth) export const mcp = createMcpModule({ config: { enabled: true, issuer: process.env.AUTH_BASE_URL!, baseUrl: `${process.env.AUTH_BASE_URL!}/api/theauth`, signingSecret: process.env.MCP_SIGNING_SECRET!, // at least 32 characters }, ...mcpStore, }); ``` In Nuxt, a file named `[...].ts` in the server routes directory catches all sub-paths. Create `server/api/theauth/[...].ts`: ```typescript // server/api/theauth/[...].ts import { theAuthNuxt } from '@glinr/theauth-nuxt'; import { theauth, mcp } from '../utils/theauth'; export default theAuthNuxt(theauth, { mcp, authenticate }); ``` The `basePath` option defaults to `/api/theauth`. If your Nuxt app uses a different route prefix, pass `basePath` to match. ## Options ```typescript interface AuthNuxtOptions { mcp?: McpAuthModule; // enables MCP OAuth 2.1 endpoints basePath?: string; // defaults to '/api/theauth' } ``` ## MCP endpoints When `mcp` is passed, the MCP OAuth 2.1 authorization server is available at: ``` GET /api/theauth/.well-known/oauth-authorization-server GET /api/theauth/.well-known/oauth-protected-resource POST /api/theauth/mcp/register GET /api/theauth/mcp/authorize POST /api/theauth/mcp/token ``` ## Endpoint reference | Method | Path | Description | |---|---|---| | `POST` | `/agents` | Create an agent | | `GET` | `/agents` | List agents | | `GET` | `/agents/:id` | Get an agent | | `PATCH` | `/agents/:id` | Update an agent | | `DELETE` | `/agents/:id` | Revoke an agent | | `POST` | `/agents/:id/rotate` | Rotate token | | `POST` | `/authorize` | Authorize by agent ID | | `POST` | `/authorize/token` | Authorize by bearer token | | `POST` | `/delegations` | Create delegation | | `GET` | `/delegations/:agentId` | List delegation chains | | `DELETE` | `/delegations/:id` | Revoke delegation | | `GET` | `/audit` | Query audit logs | | `GET` | `/audit/export` | Export audit logs | ## Full example ```typescript // server/api/theauth/[...].ts import { createTheAuth } from '@glinr/theauth'; import { createMcpModule } from '@glinr/theauth/mcp'; import { mcpStore } from './mcp-store'; // storage callbacks, see the MCP page import { theAuthNuxt } from '@glinr/theauth-nuxt'; const theauth = await createTheAuth({ database: { provider: 'postgres', url: process.env.DATABASE_URL! }, baseUrl: process.env.AUTH_BASE_URL!, mcp: { enabled: true }, // creates the MCP tables }); // baseUrl is the public origin plus the adapter mount path (default /api/theauth) const mcp = createMcpModule({ config: { enabled: true, issuer: process.env.AUTH_BASE_URL!, baseUrl: `${process.env.AUTH_BASE_URL!}/api/theauth`, signingSecret: process.env.MCP_SIGNING_SECRET!, // at least 32 characters }, ...mcpStore, }); export default theAuthNuxt(theauth, { mcp, authenticate }); ``` All theAuth routes are then available at `/api/theauth/...` within your Nuxt server. ## Related Compare all available framework adapters and their mount patterns. Similar catch-all pattern for SvelteKit server routes. App Router catch-all handler with MCP OAuth 2.1 support. OAuth 2.1 authorization server endpoints mounted by the adapter. --- # SvelteKit Source: https://docs.theauth.dev/adapters/sveltekit `theAuthSvelteKit(theauth, options?)` returns named route handlers `{ GET, POST, PATCH, DELETE, OPTIONS }`. Mount them in a catch-all server route file so all theAuth paths are handled. ## Install ```bash pnpm add @glinr/theauth @glinr/theauth-sveltekit ``` ## Setup ```typescript // src/lib/theauth.ts import { createTheAuth } from '@glinr/theauth'; import { createMcpModule } from '@glinr/theauth/mcp'; import { mcpStore } from './mcp-store'; // storage callbacks, see the MCP page export const theauth = await createTheAuth({ database: { provider: 'postgres', url: process.env.DATABASE_URL! }, baseUrl: process.env.AUTH_BASE_URL!, mcp: { enabled: true }, // creates the MCP tables }); // baseUrl is the public origin plus the adapter mount path (default /api/theauth) export const mcp = createMcpModule({ config: { enabled: true, issuer: process.env.AUTH_BASE_URL!, baseUrl: `${process.env.AUTH_BASE_URL!}/api/theauth`, signingSecret: process.env.MCP_SIGNING_SECRET!, // at least 32 characters }, ...mcpStore, }); ``` Create `src/routes/api/theauth/[...path]/+server.ts`. The `[...path]` segment catches every sub-path under `/api/theauth/`. ```typescript // src/routes/api/theauth/[...path]/+server.ts import { theAuthSvelteKit } from '@glinr/theauth-sveltekit'; import { theauth, mcp } from '$lib/theauth'; const handlers = theAuthSvelteKit(theauth, { mcp, authenticate }); export const GET = handlers.GET; export const POST = handlers.POST; export const PATCH = handlers.PATCH; export const DELETE = handlers.DELETE; export const OPTIONS = handlers.OPTIONS; ``` SvelteKit passes a standard Web API `Request` to route handlers, so the adapter delegates directly to the theAuth dispatcher without any conversion. ## Options ```typescript interface AuthSvelteKitOptions { mcp?: McpAuthModule; // enables MCP OAuth 2.1 endpoints basePath?: string; // defaults to '/api/theauth' } ``` ## MCP endpoints When `mcp` is passed, the MCP OAuth 2.1 endpoints are available at: ``` GET /api/theauth/.well-known/oauth-authorization-server GET /api/theauth/.well-known/oauth-protected-resource POST /api/theauth/mcp/register GET /api/theauth/mcp/authorize POST /api/theauth/mcp/token ``` ## Endpoint reference | Method | Path | Description | |---|---|---| | `POST` | `/agents` | Create an agent | | `GET` | `/agents` | List agents | | `GET` | `/agents/:id` | Get an agent | | `PATCH` | `/agents/:id` | Update an agent | | `DELETE` | `/agents/:id` | Revoke an agent | | `POST` | `/agents/:id/rotate` | Rotate token | | `POST` | `/authorize` | Authorize by agent ID | | `POST` | `/authorize/token` | Authorize by bearer token | | `POST` | `/delegations` | Create delegation | | `GET` | `/delegations/:agentId` | List delegation chains | | `DELETE` | `/delegations/:id` | Revoke delegation | | `GET` | `/audit` | Query audit logs | | `GET` | `/audit/export` | Export audit logs | ## Full example ```typescript // src/routes/api/theauth/[...path]/+server.ts import { createTheAuth } from '@glinr/theauth'; import { createMcpModule } from '@glinr/theauth/mcp'; import { mcpStore } from './mcp-store'; // storage callbacks, see the MCP page import { theAuthSvelteKit } from '@glinr/theauth-sveltekit'; const theauth = await createTheAuth({ database: { provider: 'postgres', url: process.env.DATABASE_URL! }, baseUrl: process.env.AUTH_BASE_URL!, mcp: { enabled: true }, // creates the MCP tables }); // baseUrl is the public origin plus the adapter mount path (default /api/theauth) const mcp = createMcpModule({ config: { enabled: true, issuer: process.env.AUTH_BASE_URL!, baseUrl: `${process.env.AUTH_BASE_URL!}/api/theauth`, signingSecret: process.env.MCP_SIGNING_SECRET!, // at least 32 characters }, ...mcpStore, }); const handlers = theAuthSvelteKit(theauth, { mcp, authenticate }); export const GET = handlers.GET; export const POST = handlers.POST; export const PATCH = handlers.PATCH; export const DELETE = handlers.DELETE; export const OPTIONS = handlers.OPTIONS; ``` ## Related Compare all available framework adapters and their mount patterns. App Router catch-all with MCP OAuth 2.1 support. Lightweight edge-compatible adapter for Hono apps. OAuth 2.1 authorization server endpoints mounted by the adapter. --- # SolidStart Source: https://docs.theauth.dev/adapters/solidstart `theAuthSolidStart(theauth, options?)` returns named route handlers `{ GET, POST, PATCH, DELETE, OPTIONS }`. Mount them in a catch-all API route file so all theAuth paths are handled. ## Install ```bash pnpm add @glinr/theauth @glinr/theauth-solidstart ``` ## Setup ```typescript // src/lib/theauth.ts import { createTheAuth } from '@glinr/theauth'; import { createMcpModule } from '@glinr/theauth/mcp'; import { mcpStore } from './mcp-store'; // storage callbacks, see the MCP page export const theauth = await createTheAuth({ database: { provider: 'postgres', url: process.env.DATABASE_URL! }, baseUrl: process.env.AUTH_BASE_URL!, mcp: { enabled: true }, // creates the MCP tables }); // baseUrl is the public origin plus the adapter mount path (default /api/theauth) export const mcp = createMcpModule({ config: { enabled: true, issuer: process.env.AUTH_BASE_URL!, baseUrl: `${process.env.AUTH_BASE_URL!}/api/theauth`, signingSecret: process.env.MCP_SIGNING_SECRET!, // at least 32 characters }, ...mcpStore, }); ``` Create `src/routes/api/theauth/[...theauth].ts`. The `[...theauth]` segment catches every sub-path under `/api/theauth/`. ```typescript // src/routes/api/theauth/[...theauth].ts import { theAuthSolidStart } from '@glinr/theauth-solidstart'; import { theauth, mcp } from '~/lib/theauth'; const handlers = theAuthSolidStart(theauth, { mcp, authenticate }); export const GET = handlers.GET; export const POST = handlers.POST; export const PATCH = handlers.PATCH; export const DELETE = handlers.DELETE; export const OPTIONS = handlers.OPTIONS; ``` SolidStart API routes receive a standard Web API `Request`, so the adapter delegates directly to the theAuth dispatcher without any conversion. ## Options ```typescript interface AuthSolidStartOptions { mcp?: McpAuthModule; // enables MCP OAuth 2.1 endpoints basePath?: string; // defaults to '/api/theauth' } ``` ## MCP endpoints When `mcp` is passed, the MCP OAuth 2.1 endpoints are available at: ``` GET /api/theauth/.well-known/oauth-authorization-server GET /api/theauth/.well-known/oauth-protected-resource POST /api/theauth/mcp/register GET /api/theauth/mcp/authorize POST /api/theauth/mcp/token ``` ## Endpoint reference | Method | Path | Description | |---|---|---| | `POST` | `/agents` | Create an agent | | `GET` | `/agents` | List agents | | `GET` | `/agents/:id` | Get an agent | | `PATCH` | `/agents/:id` | Update an agent | | `DELETE` | `/agents/:id` | Revoke an agent | | `POST` | `/agents/:id/rotate` | Rotate token | | `POST` | `/authorize` | Authorize by agent ID | | `POST` | `/authorize/token` | Authorize by bearer token | | `POST` | `/delegations` | Create delegation | | `GET` | `/delegations/:agentId` | List delegation chains | | `DELETE` | `/delegations/:id` | Revoke delegation | | `GET` | `/audit` | Query audit logs | | `GET` | `/audit/export` | Export audit logs | ## Full example ```typescript // src/routes/api/theauth/[...theauth].ts import { createTheAuth } from '@glinr/theauth'; import { createMcpModule } from '@glinr/theauth/mcp'; import { mcpStore } from './mcp-store'; // storage callbacks, see the MCP page import { theAuthSolidStart } from '@glinr/theauth-solidstart'; const theauth = await createTheAuth({ database: { provider: 'postgres', url: process.env.DATABASE_URL! }, baseUrl: process.env.AUTH_BASE_URL!, mcp: { enabled: true }, // creates the MCP tables }); // baseUrl is the public origin plus the adapter mount path (default /api/theauth) const mcp = createMcpModule({ config: { enabled: true, issuer: process.env.AUTH_BASE_URL!, baseUrl: `${process.env.AUTH_BASE_URL!}/api/theauth`, signingSecret: process.env.MCP_SIGNING_SECRET!, // at least 32 characters }, ...mcpStore, }); const handlers = theAuthSolidStart(theauth, { mcp, authenticate }); export const GET = handlers.GET; export const POST = handlers.POST; export const PATCH = handlers.PATCH; export const DELETE = handlers.DELETE; export const OPTIONS = handlers.OPTIONS; ``` ## Related Compare all available framework adapters and their mount patterns. Similar splat-route pattern for TanStack Start apps. Another Web-standard Request adapter with no conversion layer. OAuth 2.1 authorization server endpoints mounted by the adapter. --- # TanStack Start Source: https://docs.theauth.dev/adapters/tanstack `theAuthTanStack(theauth, options?)` returns named route handlers `{ GET, POST, PATCH, DELETE, OPTIONS }`. Mount them in a splat API route file so all theAuth paths are handled. ## Install ```bash pnpm add @glinr/theauth @glinr/theauth-tanstack ``` ## Setup ```typescript // app/lib/theauth.ts import { createTheAuth } from '@glinr/theauth'; import { createMcpModule } from '@glinr/theauth/mcp'; import { mcpStore } from './mcp-store'; // storage callbacks, see the MCP page export const theauth = await createTheAuth({ database: { provider: 'postgres', url: process.env.DATABASE_URL! }, baseUrl: process.env.AUTH_BASE_URL!, mcp: { enabled: true }, // creates the MCP tables }); // baseUrl is the public origin plus the adapter mount path (default /api/theauth) export const mcp = createMcpModule({ config: { enabled: true, issuer: process.env.AUTH_BASE_URL!, baseUrl: `${process.env.AUTH_BASE_URL!}/api/theauth`, signingSecret: process.env.MCP_SIGNING_SECRET!, // at least 32 characters }, ...mcpStore, }); ``` Create `app/routes/api/theauth.$.ts`. The `$` splat segment catches every sub-path under `/api/theauth/`. ```typescript // app/routes/api/theauth.$.ts import { theAuthTanStack } from '@glinr/theauth-tanstack'; import { theauth, mcp } from '~/lib/theauth'; const handlers = theAuthTanStack(theauth, { mcp, authenticate }); export const GET = handlers.GET; export const POST = handlers.POST; export const PATCH = handlers.PATCH; export const DELETE = handlers.DELETE; export const OPTIONS = handlers.OPTIONS; ``` TanStack Start API routes receive a standard Web API `Request`, so the adapter delegates directly to the theAuth dispatcher without any conversion. ## Options ```typescript interface AuthTanStackOptions { mcp?: McpAuthModule; // enables MCP OAuth 2.1 endpoints basePath?: string; // defaults to '/api/theauth' } ``` ## MCP endpoints When `mcp` is passed, the MCP OAuth 2.1 endpoints are available at: ``` GET /api/theauth/.well-known/oauth-authorization-server GET /api/theauth/.well-known/oauth-protected-resource POST /api/theauth/mcp/register GET /api/theauth/mcp/authorize POST /api/theauth/mcp/token ``` ## Endpoint reference | Method | Path | Description | |---|---|---| | `POST` | `/agents` | Create an agent | | `GET` | `/agents` | List agents | | `GET` | `/agents/:id` | Get an agent | | `PATCH` | `/agents/:id` | Update an agent | | `DELETE` | `/agents/:id` | Revoke an agent | | `POST` | `/agents/:id/rotate` | Rotate token | | `POST` | `/authorize` | Authorize by agent ID | | `POST` | `/authorize/token` | Authorize by bearer token | | `POST` | `/delegations` | Create delegation | | `GET` | `/delegations/:agentId` | List delegation chains | | `DELETE` | `/delegations/:id` | Revoke delegation | | `GET` | `/audit` | Query audit logs | | `GET` | `/audit/export` | Export audit logs | ## Full example ```typescript // app/routes/api/theauth.$.ts import { createTheAuth } from '@glinr/theauth'; import { createMcpModule } from '@glinr/theauth/mcp'; import { mcpStore } from './mcp-store'; // storage callbacks, see the MCP page import { theAuthTanStack } from '@glinr/theauth-tanstack'; const theauth = await createTheAuth({ database: { provider: 'postgres', url: process.env.DATABASE_URL! }, baseUrl: process.env.AUTH_BASE_URL!, mcp: { enabled: true }, // creates the MCP tables }); // baseUrl is the public origin plus the adapter mount path (default /api/theauth) const mcp = createMcpModule({ config: { enabled: true, issuer: process.env.AUTH_BASE_URL!, baseUrl: `${process.env.AUTH_BASE_URL!}/api/theauth`, signingSecret: process.env.MCP_SIGNING_SECRET!, // at least 32 characters }, ...mcpStore, }); const handlers = theAuthTanStack(theauth, { mcp, authenticate }); export const GET = handlers.GET; export const POST = handlers.POST; export const PATCH = handlers.PATCH; export const DELETE = handlers.DELETE; export const OPTIONS = handlers.OPTIONS; ``` ## Related Compare all available framework adapters and their mount patterns. Similar Web-standard Request adapter using a splat-route pattern. App Router catch-all handler with MCP OAuth 2.1 support. OAuth 2.1 authorization server endpoints mounted by the adapter. --- # Astro Source: https://docs.theauth.dev/adapters/astro `theAuthAstro(theauth, options?)` returns named route handlers `{ GET, POST, PATCH, DELETE, OPTIONS, ALL }`. Mount them in a catch-all API page so all theAuth paths are handled. Use individual named exports or the `ALL` catch-all handler. ## Install ```bash pnpm add @glinr/theauth @glinr/theauth-astro ``` ## Setup ```typescript // src/lib/theauth.ts import { createTheAuth } from '@glinr/theauth'; import { createMcpModule } from '@glinr/theauth/mcp'; import { mcpStore } from './mcp-store'; // storage callbacks, see the MCP page export const theauth = await createTheAuth({ database: { provider: 'postgres', url: import.meta.env.DATABASE_URL }, baseUrl: import.meta.env.AUTH_BASE_URL, mcp: { enabled: true }, // creates the MCP tables }); // baseUrl is the public origin plus the adapter mount path (default /api/theauth) export const mcp = createMcpModule({ config: { enabled: true, issuer: import.meta.env.AUTH_BASE_URL, baseUrl: `${import.meta.env.AUTH_BASE_URL}/api/theauth`, signingSecret: process.env.MCP_SIGNING_SECRET!, // at least 32 characters }, ...mcpStore, }); ``` Create `src/pages/api/theauth/[...path].ts`. The `[...path]` spread catches every sub-path under `/api/theauth/`. ```typescript // src/pages/api/theauth/[...path].ts import { theAuthAstro } from '@glinr/theauth-astro'; import { theauth, mcp } from '@/lib/theauth'; const handlers = theAuthAstro(theauth, { mcp, authenticate }); export const GET = handlers.GET; export const POST = handlers.POST; export const PATCH = handlers.PATCH; export const DELETE = handlers.DELETE; export const OPTIONS = handlers.OPTIONS; ``` Or use the `ALL` handler to catch every HTTP method in one export: ```typescript export const ALL = handlers.ALL; ``` Astro requires `output: 'server'` or `output: 'hybrid'` in `astro.config.mjs` to enable API routes. Static output mode does not support server-side route handlers. ## Options ```typescript interface AuthAstroOptions { mcp?: McpAuthModule; // enables MCP OAuth 2.1 endpoints basePath?: string; // defaults to '/api/theauth' } ``` ## MCP endpoints When `mcp` is passed, the MCP OAuth 2.1 endpoints are available at: ``` GET /api/theauth/.well-known/oauth-authorization-server GET /api/theauth/.well-known/oauth-protected-resource POST /api/theauth/mcp/register GET /api/theauth/mcp/authorize POST /api/theauth/mcp/token ``` ## Endpoint reference | Method | Path | Description | |---|---|---| | `POST` | `/agents` | Create an agent | | `GET` | `/agents` | List agents | | `GET` | `/agents/:id` | Get an agent | | `PATCH` | `/agents/:id` | Update an agent | | `DELETE` | `/agents/:id` | Revoke an agent | | `POST` | `/agents/:id/rotate` | Rotate token | | `POST` | `/authorize` | Authorize by agent ID | | `POST` | `/authorize/token` | Authorize by bearer token | | `POST` | `/delegations` | Create delegation | | `GET` | `/delegations/:agentId` | List delegation chains | | `DELETE` | `/delegations/:id` | Revoke delegation | | `GET` | `/audit` | Query audit logs | | `GET` | `/audit/export` | Export audit logs | ## Full example ```typescript // src/pages/api/theauth/[...path].ts import { createTheAuth } from '@glinr/theauth'; import { createMcpModule } from '@glinr/theauth/mcp'; import { mcpStore } from './mcp-store'; // storage callbacks, see the MCP page import { theAuthAstro } from '@glinr/theauth-astro'; const theauth = await createTheAuth({ database: { provider: 'postgres', url: import.meta.env.DATABASE_URL }, baseUrl: import.meta.env.AUTH_BASE_URL, mcp: { enabled: true }, // creates the MCP tables }); // baseUrl is the public origin plus the adapter mount path (default /api/theauth) const mcp = createMcpModule({ config: { enabled: true, issuer: import.meta.env.AUTH_BASE_URL, baseUrl: `${import.meta.env.AUTH_BASE_URL}/api/theauth`, signingSecret: process.env.MCP_SIGNING_SECRET!, // at least 32 characters }, ...mcpStore, }); const handlers = theAuthAstro(theauth, { mcp, authenticate }); export const GET = handlers.GET; export const POST = handlers.POST; export const PATCH = handlers.PATCH; export const DELETE = handlers.DELETE; export const OPTIONS = handlers.OPTIONS; ``` ## Related Compare all available framework adapters and their mount patterns. Edge-compatible adapter using +server.ts catch-all routes. App Router catch-all handler with MCP OAuth 2.1 support. OAuth 2.1 authorization server endpoints mounted by the adapter. --- # TypeScript client Source: https://docs.theauth.dev/client-sdk ## Overview `@glinr/theauth-client` is a typed HTTP client for the theAuth REST API. It covers agents, authorization, delegations, audit logs, and MCP server registration. Zero dependencies. No Node.js builtins. Works in browser, Node.js, Deno, and Bun, anywhere the Fetch API is available. The client and the adapters in this repository do not fully line up yet. The adapters wrap every success body as `{ "data": ... }` and the client returns the parsed body as is, without unwrapping it. The adapters answer a denied authorization with HTTP `403`, which the client throws as a `TheAuthApiError` instead of returning `allowed: false`. And four client calls target paths the adapters do not serve: `authorize` (`POST /agents/:id/authorize`), `authorizeByToken` (`POST /authorize` with a bearer header; the adapters take `agentId` there and use `POST /authorize/token` for tokens), `delegations.getEffectivePermissions` (`GET /delegations/:agentId/permissions`), and all of `mcp.*` (`/mcp/servers`). The examples below show the API the client is typed for. Check it against your server before relying on it, or call the [REST endpoints](/api) directly. ## Installation ```bash npm install @glinr/theauth-client ``` ## Creating a client ```typescript import { createTheAuthClient } from '@glinr/theauth-client'; const client = createTheAuthClient({ baseUrl: 'https://yourapp.com/api/theauth', token: process.env.THEAUTH_API_TOKEN, }); ``` Base URL for the theAuth API, without a trailing slash. Bearer token sent as `Authorization: Bearer ` on every request. `} default="{}">Extra headers merged into every request. Useful for API gateways that require custom headers. ## Agents ```typescript // Create const agent = await client.agents.create({ ownerId: 'user-123', name: 'data-pipeline-bot', type: 'autonomous', permissions: [ { resource: 'db:reports:*', actions: ['read'] }, ], }); console.log(agent.token); // kv_..., save this now // List with optional filters const active = await client.agents.list({ userId: 'user-123', status: 'active', type: 'autonomous', }); // Get by ID (returns null for 404) const found = await client.agents.get('agt_abc123'); // Update name and permissions (a new permissions array replaces the old one) const updated = await client.agents.update('agt_abc123', { name: 'data-pipeline-bot-v2', permissions: [ { resource: 'db:reports:*', actions: ['read', 'export'] }, ], }); // Rotate the token const rotated = await client.agents.rotate('agt_abc123'); console.log(rotated.token); // new token, old one is now invalid // Revoke await client.agents.revoke('agt_abc123'); ``` ## Authorization Two paths: by agent ID (when you manage the agent directly) or by raw bearer token (when the token comes in from an HTTP request). ```typescript // By agent ID, useful in admin or backend code const byId = await client.authorize('agt_abc123', { action: 'read', resource: 'db:reports:monthly', arguments: { reportId: 'r-456' }, }); if (!byId.allowed) { console.error(byId.reason); // free-text explanation of the denial } // By bearer token, useful in middleware const bearerToken = request.headers.get('Authorization')?.replace('Bearer ', '') ?? ''; const byToken = await client.authorizeByToken(bearerToken, { action: 'read', resource: 'db:reports:monthly', }); ``` ## Delegations ```typescript // Delegate from one agent to another with a subset of permissions const chain = await client.delegations.create({ fromAgent: 'agt_parent', toAgent: 'agt_child', permissions: [ { resource: 'db:reports:*', actions: ['read'] }, ], expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(), // 1 hour }); // List delegations where the agent is the source (fromAgent) const delegations = await client.delegations.list('agt_parent'); // Effective permissions: the permissions the agent currently holds through // active, unexpired delegations (its own permissions are not included) const effective = await client.delegations.getEffectivePermissions('agt_child'); // Revoke a delegation await client.delegations.revoke(chain.id); ``` ## Audit log ```typescript // Query with filters const entries = await client.audit.query({ agentId: 'agt_abc123', since: '2024-01-01T00:00:00Z', until: '2024-02-01T00:00:00Z', result: 'denied', limit: 100, offset: 0, }); // Export as CSV for billing or compliance (returns the file contents as a string) const csv = await client.audit.export({ format: 'csv', since: '2024-01-01T00:00:00Z', until: '2024-02-01T00:00:00Z', }); // Export as JSON const json = await client.audit.export({ format: 'json' }); ``` ## Error handling All methods throw `TheAuthApiError` when the server returns a non-2xx response or the network call fails. ```typescript import { TheAuthApiError } from '@glinr/theauth-client'; try { const agent = await client.agents.rotate('agt_nonexistent'); } catch (err) { if (err instanceof TheAuthApiError) { console.error(err.code); // e.g. 'NOT_FOUND', 'BAD_REQUEST', 'INTERNAL_ERROR' console.error(err.message); // human-readable message console.error(err.status); // HTTP status code } } ``` `TheAuthApiError` also covers network errors: if `fetch` itself throws, you get `code: 'NETWORK_ERROR'` and `status: 0`. `client.agents.get` and `client.mcp.get` return `null` for 404 rather than throwing. All other methods throw `TheAuthApiError` on any error response. ## MCP servers These calls hit `/mcp/servers`, which the bundled adapters do not serve (see the warning above). In your own server code, use the in-process registry `theauth.mcp.register`, `list`, and `get` instead. ```typescript // Register an MCP server const server = await client.mcp.register({ name: 'github-mcp', endpoint: 'https://mcp.yourapp.com/github', tools: ['list_repos', 'get_issue', 'create_comment'], authRequired: true, rateLimit: { rpm: 60 }, }); // List all registered servers const servers = await client.mcp.list(); // Get one by ID (returns null for 404) const found = await client.mcp.get(server.id); ``` ## Next steps Understand the agent model before building integrations. Querying and exporting the audit trail. How delegated permissions work. --- # React hooks Source: https://docs.theauth.dev/react ## Overview `@glinr/theauth-react` provides React hooks and a context provider for building auth UIs on top of theAuth. It works with Next.js App Router, Next.js Pages Router, Vite, and any React 18+ setup (the package declares React 18 or later as a peer dependency). The package itself has no Node.js dependencies, it runs entirely in the browser. Your theAuth API handler can sit behind Next.js Edge Runtime, Cloudflare Workers, Deno Deploy, or any other edge runtime, and the hooks talk to it over standard `fetch`. All hooks must be rendered inside `TheAuthProvider`. The provider talks to your theAuth API route, no direct database access from the browser. ## Installation ```bash pnpm add @glinr/theauth-react ``` ## Provider setup Wrap your app with `TheAuthProvider`. In Next.js App Router, create a client component and import it from your root layout. ```tsx // app/providers.tsx 'use client'; import { TheAuthProvider } from '@glinr/theauth-react'; export function Providers({ children }: { children: React.ReactNode }) { return ( {children} ); } ``` ```tsx // app/layout.tsx import { Providers } from './providers'; export default function RootLayout({ children }: { children: React.ReactNode }) { return ( {children} ); } ``` Path to your theAuth API handler. Must match the route you mounted in the adapter. Delegate authentication to an external API instead of the managed mode described below. See "External auth mode". Enable verbose auth state logging (also enabled when `localStorage.DEBUG === "theauth"`). In managed mode (no `external` prop) the session is stored in `localStorage` under the key `theauth_session`, and the provider restores it from there on page load. It does not re-verify the session with the server on load. The managed provider signs in and up by calling `POST {basePath}/auth/sign-in` and `POST {basePath}/auth/sign-up` with a JSON body of `{ email, password }` (plus `name` for sign-up). theAuth core does not ship email-and-password routes at those paths (its built-in password module is [username based](/auth/username)), so you need to serve them from your own handler, returning `{ user, session: { token, expiresAt } }` for sign-in and `{ user, token }` for sign-up, or use `external` mode instead. ## useSession Returns the raw session object. Useful when you need the session token or expiry directly. ```tsx import { useSession } from '@glinr/theauth-react'; function SessionDebug() { const { session, isLoading } = useSession(); if (isLoading) return

Loading...

; if (!session) return

No active session.

; return (

Session expires: {session.expiresAt ? new Date(session.expiresAt).toLocaleString() : 'unknown'}

); } ``` The current session (`{ token, user, expiresAt? }`). Null when unauthenticated or still loading. True during the initial session restore. Promise`}>Re-read the session. In managed mode this re-reads `localStorage`, in external mode it re-fetches the current user. ## useUser Returns the authenticated user and a boolean flag. This is the most common hook for protecting UI. ```tsx import { useUser } from '@glinr/theauth-react'; function ProfileCard() { const { user, isAuthenticated, isLoading } = useUser(); if (isLoading) return ; if (!isAuthenticated || !user) return ; return (

{user.name}

{user.email}

); } ``` The authenticated user (`{ id, email?, name?, image? }`). Null when unauthenticated. True when a valid session with a user is present. True during the initial load. Avoid rendering auth-gated UI until this is false. ## useSignIn Handles email and password sign-in. Returns `signIn(email, password)` plus `isLoading` and `error` (a string or `null`). The hook posts to `POST {basePath}/auth/sign-in` (see the warning above about the server route). ```tsx import { useSignIn } from '@glinr/theauth-react'; import { useRouter } from 'next/navigation'; function SignInForm() { const { signIn, isLoading, error } = useSignIn(); const router = useRouter(); async function handleSubmit(e: React.FormEvent) { e.preventDefault(); const form = new FormData(e.currentTarget); const result = await signIn( form.get('email') as string, form.get('password') as string, ); if (result.success) router.push('/dashboard'); } return (
{error &&

{error}

}
); } ``` ## useSignUp Handles new account registration with `signUp(email, password, name?)`. Posts to `POST {basePath}/auth/sign-up`. ```tsx import { useSignUp } from '@glinr/theauth-react'; function SignUpForm() { const { signUp, isLoading, error } = useSignUp(); async function handleSubmit(e: React.FormEvent) { e.preventDefault(); const form = new FormData(e.currentTarget); await signUp( form.get('email') as string, form.get('password') as string, form.get('name') as string, ); } return (
{error &&

{error}

}
); } ``` ## useSignOut Signs the user out. `signOut()` takes no arguments. In managed mode it clears the stored session from React state and `localStorage` and does not call the server, so revoke the session server-side separately if you need to. In external mode it calls your logout path. It does not redirect, navigate yourself afterwards. ```tsx import { useSignOut } from '@glinr/theauth-react'; function NavBar() { const { signOut } = useSignOut(); return ( ); } ``` ## useAgents Lets your UI list, create, revoke, and rotate agents through your theAuth API. `useAgents(basePath = '/api/theauth')` takes its own base path and reads the user from the provider. ```tsx import { useAgents, useUser } from '@glinr/theauth-react'; function Dashboard() { const { user, isAuthenticated } = useUser(); const { agents, create, revoke, isLoading: agentsLoading } = useAgents(); if (!isAuthenticated || !user) return null; async function handleCreate() { await create({ ownerId: user.id, name: 'my-bot', type: 'autonomous', permissions: [ { resource: 'reports:*', actions: ['read'] }, ], }); } return (
{agentsLoading &&

Loading agents...

} {agents.map((agent) => (
{agent.name}
))}
); } ``` The hook calls `GET {basePath}/agents?userId=` (expecting `{ data: Agent[] }`), `POST {basePath}/agents`, `DELETE {basePath}/agents/:id`, and `POST {basePath}/agents/:id/rotate`, all with `credentials: 'include'`. Single-agent responses are expected as `{ data: Agent }`. Current list of agents for the authenticated user. Promise>`}>Create a new agent. `CreateAgentInput` requires `ownerId`, `name`, `type`, and `permissions`. The list reloads on success. Promise`}>Revoke an agent by ID. The list reloads on success. Promise>`}>Rotate an agent's token. The returned agent carries the new token. Promise`}>Re-fetch the agent list manually. True while the agent list is loading. The last load error message. `ActionResult` is `{ success: true, data: T } | { success: false, error: string }`. ## External auth mode Pass `external` to delegate authentication to an API you already run, for example one that manages httpOnly cookies. The provider then fetches the user from `apiUrl + mePath` (default `/api/auth/me`), sends login redirects to `loginPath` (default `/auth/github`), and calls `logoutPath` (default `/auth/logout`, method `POST`) on sign out. No session is stored in `localStorage`. ```tsx {children} ``` `useRotateSession()` returns `{ rotate, status, isOnline }` for the token rotation flow. It only does work when `refreshPath` is set, otherwise `rotate()` resolves with `{ success: false, code: 'network_error' }`. ## Next steps Server-side and Node.js usage with @glinr/theauth-client. How agents are modelled and what fields they carry. Mount the theAuth handler in Next.js, Express, Hono, and others. --- # Vue Source: https://docs.theauth.dev/vue `@glinr/theauth-vue` provides Vue 3 composables and an app plugin for building auth UIs. It works with Vite, Nuxt, and any Vue 3 setup. ## Installation ```bash pnpm add @glinr/theauth-vue ``` ## Setup Register the plugin in your Vue app: ```typescript title="main.ts" import { createApp } from 'vue'; import { createTheAuthPlugin } from '@glinr/theauth-vue'; // [!code highlight] import App from './App.vue'; const app = createApp(App); app.use(createTheAuthPlugin({ // [!code highlight] basePath: '/api/theauth', // [!code highlight] })); // [!code highlight] app.mount('#app'); ``` ### Nuxt setup In Nuxt, create a plugin file: ```typescript title="plugins/theauth.client.ts" import { createTheAuthPlugin } from '@glinr/theauth-vue'; export default defineNuxtPlugin((nuxtApp) => { nuxtApp.vueApp.use(createTheAuthPlugin({ basePath: '/api/theauth', })); }); ``` ## useSession Returns the current session state. Reactive, updates when the user signs in or out. ```vue title="components/NavBar.vue" ``` ## useUser Returns the current user object, or `null` if signed out. ```vue ``` ## useSignIn / useSignUp / useSignOut ```vue title="pages/sign-in.vue" ``` ```typescript title="Sign up" import { useSignUp } from '@glinr/theauth-vue'; const { signUp, isLoading, error } = useSignUp(); await signUp({ email, password, name }); ``` ```typescript title="Sign out" import { useSignOut } from '@glinr/theauth-vue'; const { signOut } = useSignOut(); await signOut(); ``` ## useAgents Lists and manages agent identities for the current user. ```typescript import { useAgents } from '@glinr/theauth-vue'; const { agents, createAgent, revokeAgent, isLoading } = useAgents(); // Create a new agent await createAgent({ name: 'My AI assistant', permissions: ['read:data'] }); ``` ## Plugin configuration reference Base path where your theAuth handler is mounted. Options passed to every internal fetch call (e.g. custom headers). ## Related Server-side theAuth handler for Nuxt server routes and middleware. Svelte stores and SvelteKit hooks integration. Hooks and provider component for React and Next.js apps. Framework-agnostic fetch client underlying all framework SDKs. --- # Svelte Source: https://docs.theauth.dev/svelte `@glinr/theauth-svelte` provides reactive Svelte stores for sessions and agents. It works with Svelte 4+, SvelteKit, and plain Vite setups. ## Installation ```bash pnpm add @glinr/theauth-svelte ``` ## Setup Create a client instance once and export its stores from a shared module: ```typescript title="src/lib/theauth.ts" import { createTheAuthClient } from '@glinr/theauth-svelte'; export const { session, user, isAuthenticated, isLoading, signIn, signUp, signOut, refresh } = createTheAuthClient({ basePath: '/api/theauth' }); ``` The client fetches the session from your theAuth handler in the browser. There is no SvelteKit hooks helper in this package, so `locals.session` is not populated for you. To mount the server routes, use the [SvelteKit adapter](/adapters/sveltekit) (`@glinr/theauth-sveltekit`). ## Session store `session` holds the session object or `null`. `user` and `isAuthenticated` are derived from it, and `isLoading` is true until the first fetch settles. ```svelte title="src/routes/+page.svelte" {#if $isLoading}

Loading...

{:else if $session}

Signed in as {$user?.email}

{:else} Sign in {/if} ``` ## Sign in, sign up, sign out `signIn` and `signUp` take positional arguments and resolve to `{ success: true, data }` or `{ success: false, error }`, where `error` is a string. ```svelte title="src/routes/sign-in/+page.svelte" ``` ```typescript title="Sign up and sign out" import { signUp, signOut } from '$lib/theauth'; await signUp(email, password, name); await signOut(); ``` ## createAgentStore Manage agent identities in reactive Svelte stores. Pass the `user` store from your client and the agent list loads when a user is present, or call `load(userId)` yourself. ```svelte {#each $agents as agent}
{agent.name}
{/each} ``` The store also exposes `rotate(agentId)`. ## Client configuration reference Base path where your theAuth handler is mounted. ## Related Server-side theAuth handler for SvelteKit routes and hooks. Composables and plugin for Vue 3 and Nuxt setups. Hooks and provider component for React and Next.js. Framework-agnostic fetch client underlying all framework SDKs. --- # Expo / React Native Source: https://docs.theauth.dev/expo `@glinr/theauth-expo` brings theAuth auth to React Native and Expo apps. It stores session tokens in any storage adapter you choose. AsyncStorage, SecureStore, or your own, and sends them via `Authorization` header rather than cookies. ## Installation ```bash npm install @glinr/theauth-expo # or pnpm add @glinr/theauth-expo ``` You also need a storage library. The most common choices: ```bash npx expo install @react-native-async-storage/async-storage # or for encrypted storage npx expo install expo-secure-store ``` ## Setup ### Wrap your app ```tsx // app/_layout.tsx (Expo Router) or App.tsx import { TheAuthExpoProvider } from '@glinr/theauth-expo'; import AsyncStorage from '@react-native-async-storage/async-storage'; export default function RootLayout() { return ( ); } ``` The `storage` prop accepts any object with `getItem`, `setItem`, and `removeItem` methods, the same interface as `AsyncStorage` and `expo-secure-store`. ### Use the hooks ```tsx import { useSignIn, useUser } from '@glinr/theauth-expo'; export function LoginScreen() { const { signIn, isLoading, error } = useSignIn(); async function handleLogin() { const result = await signIn('user@example.com', 'password'); if (result.success) { router.replace('/home'); } } return ( ); } return (

Signed in as {user?.email}

); } ``` `signIn` and `signUp` authenticate against the email/password endpoints on your theAuth API. For OAuth providers, trigger `openOAuthWindow` from the main process (see above), then call `refresh()` from the renderer once the window resolves to pick up the new session. ## Features | Feature | Description | |---------|-------------| | Secure storage | Tokens encrypted via Electron's `safeStorage` API | | OAuth popup | Opens provider auth in a new `BrowserWindow` | | IPC bridge | Secure main/renderer communication via `setupTheAuthIpc` / `createIpcStorage` | | Session persistence | `ElectronTheAuthProvider` restores and re-verifies sessions on launch | Token storage uses Electron's `safeStorage.encryptString()` which uses the OS keychain (Keychain on macOS, DPAPI on Windows, Secret Service on Linux). If encryption is unavailable, `createElectronStorage` falls back to plaintext and logs a warning. --- # UI components Source: https://docs.theauth.dev/ui-components `@glinr/theauth-ui` provides drop-in React components for auth flows. Each component renders inside `TheAuthProvider` from `@glinr/theauth-react` and wires up to your theAuth API route automatically, no manual fetch calls required. Components require `@glinr/theauth-react` to be installed and a `TheAuthProvider` wrapping your app. See the [React hooks](/react) page for provider setup. ## Installation ```bash pnpm add @glinr/theauth-ui ``` Components are unstyled by default and rely on Tailwind CSS for layout and spacing. Add `@glinr/theauth-ui` to your `tailwind.config.ts` content paths: ```ts // tailwind.config.ts export default { content: [ './src/**/*.{ts,tsx}', './node_modules/@glinr/theauth-ui/src/**/*.{ts,tsx}', ], }; ``` ## Available components | Component | Description | |-----------|-------------| | `SignIn` | Email/password and magic-link sign-in form | | `SignUp` | New account registration form | | `UserButton` | Avatar dropdown with sign-out and custom menu items | | `ForgotPassword` | Password reset request form | | `TwoFactorVerify` | TOTP/backup-code entry for 2FA flows | | `OAuthButtons` | Social login buttons (Google, GitHub, Discord, and more) | | `AuthCard` | Generic card shell for building custom auth pages | ## SignIn Renders an email/password form. Pass `showMagicLink` to add a passwordless tab. Pass `providers` to show OAuth buttons above the form. ```tsx import { SignIn } from '@glinr/theauth-ui'; import { OAUTH_PROVIDERS } from '@glinr/theauth-ui'; export default function LoginPage() { return ( window.location.href = '/dashboard'} /> ); } ``` OAuth providers to show above the form. Add a magic-link tab alongside password sign-in. URL for the "Sign up" link in the footer. URL for the "Forgot password?" link. Called on successful sign-in. Heading text. Must match your theAuth API route. ## SignUp ```tsx import { SignUp } from '@glinr/theauth-ui'; export default function RegisterPage() { return ( window.location.href = '/dashboard'} /> ); } ``` Include a name field. Add a confirm-password field. URL for the "Sign in" link. Called on successful registration. ## UserButton Renders an avatar that opens a dropdown menu. Includes sign-out by default. Pass `menuItems` to add custom actions. ```tsx import { UserButton } from '@glinr/theauth-ui'; export default function Nav() { return ( router.push('/settings') }, { label: 'Delete account', onClick: handleDelete, danger: true }, ]} onSignOut={() => router.push('/sign-in')} /> ); } ``` ## OAuthButtons Use standalone when you want social login without the full sign-in card. ```tsx import { OAuthButtons, OAUTH_PROVIDERS } from '@glinr/theauth-ui'; ``` `OAUTH_PROVIDERS` ships with metadata and icons for Google, GitHub, GitLab, Discord, Twitter, Facebook, Microsoft, Apple, LinkedIn, Slack, Notion, Reddit, Spotify, and Twitch. You can also pass a custom provider object matching `OAuthProviderMeta`. ## Customization ### Class name overrides Every component accepts a `classNames` prop with keys for each sub-element. Pass a string to append classes, or a function that receives the default class string. ```tsx `${defaults} bg-violet-600 hover:bg-violet-500`, input: 'rounded-none border-b border-zinc-300', }} /> ``` ### Slot replacement Pass a `components` prop to replace any primitive (input, button, link, divider, error). Useful when you need to use your own design system components. ```tsx import { SignIn } from '@glinr/theauth-ui'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; ( ), Input: ({ label, error, ...props }) => (
{error &&

{error}

}
), }} /> ``` The `cx` utility is exported from `@glinr/theauth-ui` for merging class names in your own slot components. ## Framework examples ### Next.js App Router ```tsx // app/(auth)/sign-in/page.tsx import { SignIn } from '@glinr/theauth-ui'; export default function SignInPage() { return (
{ // Client navigation. Wrap in 'use client' if needed. window.location.href = '/dashboard'; }} />
); } ``` ### Vite + React Router ```tsx // src/pages/sign-in.tsx import { useNavigate } from 'react-router-dom'; import { SignIn } from '@glinr/theauth-ui'; export function SignInPage() { const navigate = useNavigate(); return (
navigate('/dashboard')} />
); } ``` ## Next steps useSession, useUser, useSignIn, and more. Mount the theAuth handler in Next.js, Express, Hono, and others. Configure email/password, magic links, OAuth, and 2FA. --- # Admin dashboard Source: https://docs.theauth.dev/dashboard ## Overview The theAuth dashboard is a visual interface for everything the SDK manages through code: creating and revoking agents, reviewing audit logs, inspecting delegation chains, and monitoring permissions. It is optional. You can use the SDK entirely through code without the dashboard. It ships in two forms: 1. A React component (`@glinr/theauth-dashboard`) you embed in an existing app 2. A standalone CLI server you run without any frontend code ## Quick start (demo mode) The fastest way to see the dashboard in action: ```bash npx theauth dashboard ``` This starts a full theAuth instance with in-memory SQLite, seeds sample data (3 agents, permissions, audit entries, a delegation chain), and serves the dashboard on `http://localhost:3100` (set another port with `--port`). To add a login screen: ```bash THEAUTH_DASHBOARD_SECRET=your-secret npx theauth dashboard ``` When `THEAUTH_DASHBOARD_SECRET` is set, the dashboard shows a password prompt before granting access. Without it, the server prints a warning and the dashboard login accepts anyone (suitable for local development only). Demo mode uses an in-memory database. All data is lost when the server stops. For persistent data, embed the dashboard in your app and point it at a real database. ## Installation Install the package: ```bash pnpm add @glinr/theauth-dashboard ``` Render `TheAuthDashboard` inside a protected route in your app. (`AuthDashboard` is a deprecated alias for the same component.) ```tsx import { TheAuthDashboard } from '@glinr/theauth-dashboard'; export default function AdminPage() { return ( ); } ``` ### Component props Base URL of the API. The dashboard calls `${apiUrl}/api/...` (for example `/api/agents`, `/api/audit`, `/api/dashboard/stats`), see the endpoint list below. Initial color scheme. Defaults to `'dark'`. A choice made with the header toggle is saved in localStorage and takes precedence on later visits. There is no `'system'` option. When true, shows a banner indicating sample data. The component peer-depends on React 19 or later. ### Backend The dashboard needs an HTTP backend that serves the paths listed under "API endpoints" below, all under an `/api` prefix on `apiUrl`. The framework adapters (`theAuthNextjs`, `theAuthHono`, `theAuthExpress`, and the others) serve the agent, authorization, delegation, audit, and `dashboard/*` routes of the core API (mounted under `/api/theauth` by default), but they do not serve every route the dashboard calls: `/users`, `/permissions/templates`, `/settings`, `/mcp/servers` (list), and `/agents/:id/permissions` are only implemented in the CLI's demo server. The dashboard also revokes agents with `POST /agents/:id/revoke`, while the Hono adapter exposes revocation as `DELETE /agents/:id`, so that route needs a small shim of your own. The adapter paths also have no `/api` prefix of their own, so mount the adapter so that its routes resolve under `/api` on the host you pass as `apiUrl`. Treat the embedded component as something you wire to your own `/api` routes, and use the standalone CLI if you only want to look at the UI. ```typescript import { Hono } from 'hono'; import { createTheAuth } from '@glinr/theauth'; import { theAuthHono } from '@glinr/theauth-hono'; const theauth = await createTheAuth({ database: { provider: 'sqlite', url: 'theauth.db' }, }); const app = new Hono(); app.route('/api', theAuthHono(theauth, { authenticate })); // serves /api/agents, /api/audit, /api/dashboard/stats, ... ``` ### Login gate The component always renders its own login gate. On load it checks a secret stored in `sessionStorage` against `GET ${apiUrl}/api/dashboard/auth` (with an `Authorization: Bearer ` header), and shows a password screen when that check fails. Your backend must implement `/api/dashboard/auth` (return `200` when the bearer secret is valid) or the dashboard never gets past the login screen. The core adapters do not implement it. The CLI servers implement it with `THEAUTH_DASHBOARD_SECRET`. ```typescript app.get('/api/dashboard/auth', (c) => { const provided = c.req.header('Authorization')?.replace('Bearer ', ''); return provided === process.env.DASHBOARD_SECRET ? c.json({ ok: true }) : c.json({ code: 'UNAUTHORIZED', message: 'Invalid dashboard secret.' }, 401); }); ``` ### Route protection That secret gate is not a replacement for your app's own access control. Wrap the page with your own auth check as well: ```tsx // app/admin/page.tsx import { redirect } from 'next/navigation'; import { getSession } from '@/lib/auth'; import { TheAuthDashboard } from '@glinr/theauth-dashboard'; export default async function AdminPage() { const session = await getSession(); if (!session?.user.isAdmin) redirect('/'); return ; } ``` Do not render the dashboard on a public route. It gives full read/write access to all agents, permissions, and audit data. Run the dashboard without writing any frontend code: ```bash npx theauth dashboard --port 3100 ``` This starts a demo server with in-memory SQLite and sample data. The data is not persisted. For a persistent setup, embed the React component in your app and point it at your own backend. ### CLI options | Option | Description | |---|---| | `--port` | Port to listen on. Defaults to `3100`. | | `--static` | Static-only mode. Serves the dashboard UI without a backend, and serves only the `/api/dashboard/auth` check itself. Use `--api` to point at your own API server. | | `--api` | API URL when using `--static` mode. Defaults to `http://localhost:3000`. | ### Environment variables | Variable | Description | |---|---| | `THEAUTH_DASHBOARD_SECRET` | When set, the dashboard requires this password to log in. Without it, the server warns and login is open. | ### Example with auth ```bash THEAUTH_DASHBOARD_SECRET=my-admin-password npx theauth dashboard --port 3100 ``` Open `http://localhost:3100` and enter the password to access the dashboard. ## Dashboard pages The dashboard has nine pages accessible from the sidebar. **Overview**: Active agent count, authorization rate (allowed vs denied), recent audit entries with live refresh, and quick action buttons. **Agents**: List all agents with status badges. Create new agents with initial permissions, rotate tokens, and revoke agents. Click an agent to see its permissions, recent audit entries, and delegation chains. **Users**: List human users who own agents, with agent counts. **Permissions**: Create and manage permission templates. Templates let you define a permission set once and apply it to multiple agents. Supports visual and raw JSON editing modes. **Delegations**: View all active delegation chains. Shows the from/to agents, delegated permissions, depth, and expiry countdown. **MCP Servers**: Register MCP servers with their endpoints, tools, and auth requirements. Monitor status and token validation activity. **Audit Log**: Full queryable log of every authorization decision. Filter by agent, action, resource, result, and date range. Export as JSON or CSV for compliance. **Security**: Security-focused view showing rate-limited agents, recent denials, revoked agents, and expired tokens. **Settings**: Database connection info, token expiry policy, rate limit defaults, and audit retention settings. ## Light and dark mode The dashboard supports both light and dark themes. A toggle button in the top-right header switches between them. The preference is saved to localStorage and persists across sessions. When embedded as a React component, pass `theme="light"`, `theme="dark"`, or `theme="system"` to set the initial mode. ## API endpoints The dashboard calls these REST endpoints on `${apiUrl}`, each under an `/api` prefix (for example `/api/agents`). If you are building a custom dashboard or integrating with other tools, here is the list the client uses. Paths are shown without the prefix: | Method | Path | Description | |---|---|---| | GET | `/dashboard/auth` | Validate the dashboard secret (Bearer header), used by the login gate | | GET | `/dashboard/stats` | Agent counts, audit stats, delegation counts | | GET | `/agents` | List all agents | | POST | `/agents` | Create an agent | | POST | `/agents/:id/revoke` | Revoke an agent | | POST | `/agents/:id/rotate` | Rotate an agent's token | | GET | `/agents/:id/permissions` | Get an agent's permissions | | GET | `/audit` | Query audit logs (supports filters) | | GET | `/audit/export` | Export logs as JSON or CSV | | GET | `/delegations` | List delegation chains | | DELETE | `/delegations/:id` | Revoke a delegation | | GET | `/permissions/templates` | List permission templates | | POST | `/permissions/templates` | Create a template | | PATCH | `/permissions/templates/:id` | Update a template | | DELETE | `/permissions/templates/:id` | Delete a template | | GET | `/settings` | Get system settings | | PATCH | `/settings` | Update settings | | GET | `/users` | List users | | GET | `/mcp/servers` | List MCP servers | | POST | `/mcp/servers` | Register an MCP server | ## Related Create, rotate, and revoke agents from code instead of the dashboard. Query and export the same audit data the dashboard surfaces. React components for sign-in forms and session management. Manage agents and permissions as code for reproducible environments. --- # Terraform provider Source: https://docs.theauth.dev/terraform **Status: experimental, built from source only.** The provider lives in this repository at `sdks/terraform` (version `0.1.0`, Terraform SDK v2). It is not published to the Terraform Registry, so the `theauth/theauth` source only resolves through the local plugin directory or a dev override as shown below. Only `theauth_agent`, `theauth_permission`, and the two `theauth_agent` data sources are backed by REST routes that the TypeScript server ships (`/agents`, `/agents/:id`, `/agents/:id/rotate` in the framework adapters, via the Go SDK in `sdks/go`). `theauth_api_key` calls `/api-keys` with a `scopes` body, and `theauth_organization` calls `/organizations` with `slug`, `plan`, and `domains`. The server has no such routes: the API key plugin serves `/auth/api-keys` (user session auth, body field `permissions`) and the organization plugin serves `/auth/org/*`. Treat those two resources as unsupported until the server and provider agree. All resource and attribute names below are taken from the provider schema in `sdks/terraform`. ## Why IaC for auth Auth config drifts in ways application code doesn't. An agent spun up by hand in a dashboard has no peer review, no version history, and no automated rollback. When the production incident happens and you need to know why an agent had write access to the deploy tool, "someone added it last Tuesday" is not an audit trail. Treating agents and permissions as Terraform resources fixes this. Every grant goes through a pull request. Every change is versioned in git. Destroying a staging environment tears down the access alongside the infrastructure, no orphaned tokens floating around. ## Installation The provider requires Go 1.21 to build. It depends on `github.com/glincker/theauth-go` (the Go SDK in `sdks/go`) at `v0.1.0`, so building needs network access to that module or a local `replace` directive in `sdks/terraform/go.mod`. From a checkout of this repository, build the binary: ```bash cd sdks/terraform go build -o terraform-provider-theauth . ``` Install into the local plugin cache: ```bash OS=$(go env GOOS) ARCH=$(go env GOARCH) PLUGIN_DIR=~/.terraform.d/plugins/registry.terraform.io/theauth/theauth/0.1.0/${OS}_${ARCH} mkdir -p "$PLUGIN_DIR" mv terraform-provider-theauth "$PLUGIN_DIR/" ``` Declare the provider in your Terraform configuration: ```hcl terraform { required_version = ">= 1.5" required_providers { theauth = { source = "theauth/theauth" version = "~> 0.1" } } } ``` ## Provider setup ```hcl provider "theauth" { base_url = "https://your-app.com/api/theauth" token = var.theauth_token } ``` Both arguments can be set via environment variables, which is preferred in CI: ```bash export THEAUTH_BASE_URL=https://your-app.com/api/theauth export THEAUTH_TOKEN= ``` Base URL of your theAuth deployment, including the path where the adapter is mounted (for example `https://your-app.com/auth`). Falls back to the `THEAUTH_BASE_URL` env var. Bearer token sent on every request. Falls back to the `THEAUTH_TOKEN` env var. Marked sensitive. Both are required; the provider errors at configure time if neither the argument nor the env var is set. The token is sent as `Authorization: Bearer `. How the server authenticates that token is up to your adapter configuration, theAuth does not mint a "provider token" for you. ## Resources ### theauth_agent Manages an agent identity, the primary entity in theAuth. Destroying the resource revokes the agent (`DELETE /agents/:id`), it does not remove the row. `owner_id` and `type` force replacement when changed. ```hcl resource "theauth_agent" "github_reader" { owner_id = "user-123" name = "github-reader" type = "autonomous" permission { resource = "mcp:github:*" actions = ["read"] } permission { resource = "mcp:deploy:production" actions = ["execute"] constraints { require_approval = true max_calls_per_hour = 10 ip_allowlist = ["10.0.0.0/8"] } } expires_at = "2026-12-31T23:59:59Z" } ``` The `token` attribute is computed on creation and marked sensitive. Expose it with an output block, then read it: ```hcl output "github_reader_token" { value = theauth_agent.github_reader.token sensitive = true } ``` ```bash terraform output -raw github_reader_token ``` The token is also stored in Terraform state, so protect the state backend. The raw token is only returned once, at creation time. theAuth does not store it in recoverable form, and the provider leaves `token` empty for imported agents. If you lose it, rotate the agent's token with `POST /agents/:id/rotate` (the provider has no rotation attribute, so the new token will not appear in Terraform state). ID of the user who owns this agent. Human-readable name. Agent type: autonomous, delegated, or service. One or more permission grant blocks. RFC 3339 expiry timestamp. Removing the argument does not clear an expiry that is already set. Computed attributes: `token` (sensitive), `status`, `created_at`, `updated_at`. Permissions are evaluated in list order and only the first permission whose resource and actions match is used (see [Permissions](/permissions)), so order the `permission` blocks from most to least specific. **Permission block arguments** Resource pattern, e.g. mcp:github:* or mcp:deploy:production. Allowed actions, e.g. ["read"] or ["execute"]. Optional block limiting usage. **Constraints block arguments** Require human approval before the action executes. Rate limit: maximum calls allowed per hour. Regular expressions (not globs, despite the provider's schema description). Every string argument must match every pattern. IPv4 addresses or CIDR blocks from which this permission may be used. The provider does not expose the `timeWindow` constraint. --- ### theauth_permission Grants a single permission to an existing agent from a separate module. If you control both the agent and all its permissions in one place, use inline `permission` blocks on `theauth_agent` instead. ```hcl resource "theauth_permission" "staging_deploy" { agent_id = theauth_agent.deploy_bot.id resource = "mcp:deploy:staging" actions = ["execute"] max_calls_per_hour = 20 } ``` ID of the target agent. Resource pattern. Allowed actions. Require human approval. Rate limit per hour. Regular expressions for argument values (see above). IPv4 addresses or CIDR blocks allowed to exercise the permission. This resource edits the agent's permission list (read, modify, `PATCH /agents/:id`) and identifies a permission by `resource`, so two `theauth_permission` resources with the same `resource` on one agent collide, and concurrent applies against the same agent can overwrite each other. It does not support `terraform import`. Do not combine it with inline `permission` blocks on the same agent: the next apply of the agent resource replaces the whole list. --- ### theauth_api_key Manages an API key for server-to-server requests. See the status warning at the top: the provider sends `POST /api-keys` with a `scopes` field, and the TypeScript server does not serve that route. ```hcl resource "theauth_api_key" "ci_pipeline" { name = "ci-pipeline" scopes = ["agents:read", "agents:write"] } output "ci_key" { value = theauth_api_key.ci_pipeline.key sensitive = true } ``` Valid scopes: `agents:read`, `agents:write`, `audit:read`, `delegation:read`, `delegation:write`, `organizations:read`, `organizations:write`, `admin`. The `key` attribute is only populated immediately after creation. Read it with `terraform output -raw ci_key` and store it in your secret manager before the session ends. --- ### theauth_organization Manages an organization for multi-tenant isolation. See the status warning at the top: the provider sends `POST /organizations`, and the TypeScript server does not serve that route. ```hcl resource "theauth_organization" "engineering" { name = "Engineering" slug = "engineering" plan = "pro" } ``` `slug` is immutable after creation (lowercase alphanumeric with hyphens). Change it by deleting and recreating the organization. `plan` accepts `free`, `pro`, or `enterprise`; `domains` is an optional list. `member_count`, `created_at`, and `updated_at` are computed. --- ## Data sources ### theauth_agent Reads an agent that was not created by this Terraform configuration. ```hcl data "theauth_agent" "legacy" { id = "agent-id-from-dashboard" } output "status" { value = data.theauth_agent.legacy.status } ``` ### theauth_agents Lists agents, optionally filtered. ```hcl data "theauth_agents" "active_bots" { owner_id = "user-123" status = "active" type = "autonomous" } output "bot_count" { value = length(data.theauth_agents.active_bots.agents) } ``` Filter arguments: `owner_id`, `status` (`active` | `revoked` | `expired`), `type` (`autonomous` | `delegated` | `service`). --- ## Import existing state Resources provisioned outside Terraform can be brought under management: ```bash # Import an agent terraform import theauth_agent.my_bot agent-abc123 # Import an API key terraform import theauth_api_key.ci # Import an organization terraform import theauth_organization.eng # theauth_permission has no importer ``` After importing, run `terraform plan`. Terraform will show a diff for any attributes that differ from your configuration. Update the HCL to match, or let Terraform converge. Importing an API key restores the ID and metadata but not the raw key value, that was only available at creation time. --- ## GitOps workflow Manage theAuth configuration alongside your application infrastructure: ``` infra/ ├── main.tf # Provider and state backend ├── organizations.tf # theauth_organization resources ├── agents.tf # Agent definitions ├── api_keys.tf # API keys for CI and services └── variables.tf ``` A minimal GitHub Actions workflow: ```yaml name: Terraform on: push: branches: [main] paths: ['infra/**'] pull_request: paths: ['infra/**'] jobs: terraform: runs-on: ubuntu-latest permissions: pull-requests: write steps: - uses: actions/checkout@v4 - uses: hashicorp/setup-terraform@v3 with: terraform_version: "1.8" - run: terraform -chdir=infra init - name: Plan id: plan env: THEAUTH_BASE_URL: ${{ secrets.THEAUTH_BASE_URL }} THEAUTH_TOKEN: ${{ secrets.THEAUTH_TOKEN }} run: terraform -chdir=infra plan -out=tfplan -no-color - name: Apply if: github.ref == 'refs/heads/main' && github.event_name == 'push' env: THEAUTH_BASE_URL: ${{ secrets.THEAUTH_BASE_URL }} THEAUTH_TOKEN: ${{ secrets.THEAUTH_TOKEN }} run: terraform -chdir=infra apply tfplan ``` Every permission change ships as a pull request diff. The plan output shows what will change before it is applied, and the apply only runs on merge to main. The same controls you use for code changes (required reviewers, branch protection, signed commits) then apply to auth config changes. Note that the plan file and state contain agent tokens, so restrict access to both. ## Related The agent lifecycle managed as Terraform resources. Resource-action permission model that Terraform permission resources declare. Visual alternative to Terraform for one-off agent management. Log of authorization decisions. Terraform changes are not audited by theAuth, so keep the Terraform history alongside it. --- # Prisma adapter Source: https://docs.theauth.dev/prisma theAuth ships a Drizzle-based database layer by default. If your project already uses [Prisma](https://www.prisma.io), the `@glinr/theauth-prisma` adapter lets you query the core theAuth tables through your existing `PrismaClient`, without using Drizzle in your own code. The Prisma adapter is a standalone query layer. It does not replace or wrap the core theAuth instance. Use it to read and write theAuth data from the parts of your codebase that already work with Prisma. ## When to use it - You have an existing Prisma project and want to avoid running two ORM clients. - You need to query theAuth tables inside Prisma transactions that span your own models. - You prefer generated Prisma types over Drizzle's schema for editor autocomplete. - Your team finds Prisma's DSL easier to maintain than raw SQL DDL. The adapter has methods for users, agents, permissions, delegation chains, audit logs, sessions, rate limits, OAuth clients, tokens and authorization codes, MCP servers, API keys, organizations, members and invitations, JWT refresh tokens, trust scores, and approval requests. The reference Prisma schema defines more models than the adapter has methods for (for example magic links, passkeys, and SSO connections), so for those tables use Prisma models directly. ## Installation ```bash pnpm add @glinr/theauth-prisma @prisma/client ``` The package declares `@glinr/theauth` and `@prisma/client` (5.0 or later) as peer dependencies. The adapter code itself does not import the core package at runtime. ## Setup ### Add the theAuth models to your Prisma schema Copy the models from `node_modules/@glinr/theauth-prisma/src/schema.prisma` into your `prisma/schema.prisma`, or use it as a reference to add only the tables you need. The schema declares no relations, so add relations and `onDelete` rules yourself if you want them. The schema targets Postgres by default. Switch the `datasource` provider to `sqlite` or `mysql` as appropriate. ```prisma // prisma/schema.prisma generator client { provider = "prisma-client-js" } datasource db { provider = "postgresql" url = env("DATABASE_URL") } // ... your existing models ... model TheAuthUser { id String @id email String @unique name String? banned Boolean @default(false) createdAt DateTime @map("created_at") updatedAt DateTime @map("updated_at") // ... (see the full model in @glinr/theauth-prisma/src/schema.prisma) @@map("theauth_users") } model TheAuthAgent { id String @id ownerId String @map("owner_id") name String type String status String @default("active") tokenHash String @map("token_hash") tokenPrefix String @map("token_prefix") expiresAt DateTime? @map("expires_at") createdAt DateTime @map("created_at") updatedAt DateTime @map("updated_at") // ... (tenantId, lastActiveAt and metadata are in the full model) @@map("theauth_agents") } // ... add remaining models from schema.prisma ... ``` ### Run migrations ```bash npx prisma migrate dev --name add-theauth ``` Or push to an existing database without a migration file: ```bash npx prisma db push ``` ### Create the adapter ```typescript import { PrismaClient } from '@prisma/client'; import { createPrismaAdapter } from '@glinr/theauth-prisma'; const prisma = new PrismaClient(); export const theAuthDb = createPrismaAdapter(prisma); ``` ## Usage ### Agent operations ```typescript import { theAuthDb } from './lib/theauth-db'; // Look up an agent by ID const agent = await theAuthDb.findAgentById('agent-123'); // Look up by token hash (for auth middleware) const agent = await theAuthDb.findAgentByTokenHash(tokenHash); // List all active agents owned by a user const agents = await theAuthDb.listAgents({ ownerId: 'user-456', status: 'active', }); // Create an agent const agent = await theAuthDb.createAgent({ id: crypto.randomUUID(), ownerId: 'user-456', name: 'my-agent', type: 'autonomous', tokenHash: hash, tokenPrefix: prefix, createdAt: new Date(), updatedAt: new Date(), }); // Revoke an agent await theAuthDb.updateAgent('agent-123', { status: 'revoked' }); ``` ### User operations ```typescript // Find by email (for sign-in) const user = await theAuthDb.findUserByEmail('alice@example.com'); // Create a user const user = await theAuthDb.createUser({ id: crypto.randomUUID(), email: 'alice@example.com', name: 'Alice', createdAt: new Date(), updatedAt: new Date(), }); // Update user fields (name, username, externalId, externalProvider, metadata, email) await theAuthDb.updateUser(userId, { name: 'Alice B.', }); ``` ### Session management ```typescript // Create a session record (a row only, it does not mint a session token) const session = await theAuthDb.createSession({ id: crypto.randomUUID(), userId: 'user-456', expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), createdAt: new Date(), }); // Validate a session const session = await theAuthDb.findSessionById(sessionId); if (!session || session.expiresAt < new Date()) { throw new Error('Session expired'); } // Clean up expired sessions const deleted = await theAuthDb.deleteExpiredSessions(); ``` ### Audit log queries ```typescript // Query recent actions for an agent const logs = await theAuthDb.queryAuditLogs({ agentId: 'agent-123', since: new Date(Date.now() - 24 * 60 * 60 * 1000), limit: 50, }); // Query denied actions for investigation const denials = await theAuthDb.queryAuditLogs({ userId: 'user-456', result: 'denied', since: new Date('2026-01-01'), }); // Write an audit log entry await theAuthDb.createAuditLog({ id: crypto.randomUUID(), agentId: 'agent-123', userId: 'user-456', action: 'execute', resource: 'mcp:github:create_issue', result: 'allowed', durationMs: 42, timestamp: new Date(), }); ``` ### Permissions ```typescript // Get all permissions for an agent const perms = await theAuthDb.findPermissionsByAgentId('agent-123'); // Grant a permission await theAuthDb.createPermission({ id: crypto.randomUUID(), agentId: 'agent-123', resource: 'mcp:github:*', actions: ['read', 'write'], createdAt: new Date(), }); // Revoke all permissions for an agent await theAuthDb.deletePermissionsByAgentId('agent-123'); ``` ### Transactions The adapter wraps Prisma's `$transaction` to let you compose theAuth operations with your own Prisma queries: ```typescript import { PrismaClient } from '@prisma/client'; import { createPrismaAdapter } from '@glinr/theauth-prisma'; const prisma = new PrismaClient(); const theAuthDb = createPrismaAdapter(prisma); // TheAuth operations inside a transaction await theAuthDb.transaction(async (tx) => { const agent = await tx.createAgent({ ... }); await tx.createPermission({ agentId: agent.id, ... }); }); // Mix with your own Prisma models await prisma.$transaction(async (tx) => { const theauth = createPrismaAdapter(tx); await theauth.createAgent({ ... }); // Your own model: await tx.subscription.create({ data: { ... } }); }); ``` ### Trust scores ```typescript // Get the trust score for an agent const trust = await theAuthDb.findTrustScore('agent-123'); console.log(trust?.level); // "trusted" | "limited" | ... // Upsert after recomputing await theAuthDb.upsertTrustScore({ agentId: 'agent-123', score: 85, level: 'trusted', factors: { successRate: 0.99, age: 30 }, computedAt: new Date(), }); ``` ### Approval requests ```typescript // List pending approvals for a human to review const pending = await theAuthDb.listPendingApprovals('agent-123'); // Approve a request await theAuthDb.updateApprovalRequest(requestId, { status: 'approved', respondedAt: new Date(), respondedBy: 'user-456', }); ``` ## Migration from Drizzle If you started with the built-in Drizzle backend and want to switch to Prisma: 1. Keep the same table names, all theAuth tables use the `theauth_` prefix and the same column names in both Drizzle and Prisma. 2. Run `npx prisma db pull` against your existing database to generate a Prisma schema from the Drizzle-created tables, or copy the reference schema. 3. Replace calls to the Drizzle `db` directly with `createPrismaAdapter(prisma)`. 4. Set `database.skipMigrations: true` in `createTheAuth(...)` if the core theAuth instance is still used for other features (agents, MCP, etc.), so Drizzle does not conflict with Prisma. ```typescript const theauth = await createTheAuth({ database: { provider: 'postgres', url: process.env.DATABASE_URL!, skipMigrations: true, // Prisma owns the schema now }, }); // Use the Prisma adapter for direct table access const db = createPrismaAdapter(prisma); ``` The core theAuth instance (`createTheAuth`) still uses Drizzle internally for agent creation, authorization, MCP flows, and other built-in features. `@glinr/theauth-prisma` gives you a Prisma-native way to read and write the same tables, it does not replace `createTheAuth`. ## Reference schema The full reference schema is in `node_modules/@glinr/theauth-prisma/src/schema.prisma`. It includes 39 theAuth models with column mappings, indexes, and default values. Every model name follows `TheAuthPascalCase` and maps to a `theauth_snake_case` table, keeping your Prisma namespace clean and your SQL tables clearly scoped. ## Related Core database configuration and provider options. Full createTheAuth() options including database connection setup. Test auth-dependent code with in-memory mock factories. Migration guide for moving from better-auth. --- # Compliance Source: https://docs.theauth.dev/compliance TheAuth writes an audit record for every authorization decision (when `agents.auditAll` is on, which is the default) and gives you access-control features that map to themes in common AI governance frameworks. This page maps those features to framework language and shows how to export the records. This page is a control mapping and a reporting aid. It is not legal advice, and using TheAuth does not make a system certified or compliant with any framework or regulation. Whether your system meets a given obligation depends on your own deployment, processes, and legal review. For EU AI Act obligations, check the text published in the Official Journal or ask your legal counsel which provisions apply to you. ## What TheAuth provides | Requirement | TheAuth feature | |-------------|-----------------| | Append-only audit log | `theauth_audit_logs` table with result, reason, duration, IP, user-agent. The SDK only inserts rows; the one delete path is `theauth.audit.cleanup()`, which you call yourself | | Human oversight | Approval flows (CIBA), delegation depth limits, permission constraints | | Access control | Resource+action permission model with constraints (IP, time window, rate) | | Identity traceability | Every record links `agentId`, `userId`, resource, action, parameters | | Export | `theauth.audit.export()` as JSON or CSV (capped at 10,000 rows per call), or [as Verifiable Credentials](/compliance/verifiable-credentials) | | Behavior review | Query denials with `theauth.audit.query()`; trust scoring counts privilege-escalation style denials | ## Frameworks The EU AI Act imposes obligations on providers and deployers of high-risk AI systems. These are the articles most relevant to agentic AI and the TheAuth features that relate to them. TheAuth supplies some of the technical building blocks; the obligations themselves stay with you. **Article 9 - Risk management system** TheAuth supports Article 9 through: Permission constraints (`maxCallsPerHour`, `timeWindow`, `ipAllowlist`) that enforce operational boundaries Audit queries you can run to review denied and rate-limited calls Trust scoring that adjusts agent autonomy based on track record **Article 12 - Record-keeping** Article 12 concerns automatic recording of events (logs) over a high-risk system's lifetime. TheAuth logs every authorization decision (allowed, denied, rate_limited) with: Agent and user identity Resource and action requested Full parameters IP address and user-agent Duration in milliseconds Optional token cost (for LLM calls) Records are written to `theauth_audit_logs`. The SDK never updates rows. It deletes rows only when you call `theauth.audit.cleanup({ retentionDays })`. IP address and user-agent are stored in the table but are not included in `audit.export()` output. **Article 14 - Human oversight** Article 14 concerns designing high-risk systems so natural persons can effectively oversee them. TheAuth provides: `requireApproval: true` permission constraint to gate sensitive actions behind human approval (CIBA flow) Delegation depth limits (`maxDepth`) to prevent unbounded agent-to-agent chains Revocation (`theauth.agent.revoke()`), after which the agent's status is `revoked` and its token no longer validates **Article 15 - Accuracy, robustness, cybersecurity** TheAuth supports Article 15 through: Token rotation (`theauth.agent.rotate()`) to limit credential exposure Expiry management (`expiresAt`) for time-limited agent identities IP allowlists and time window constraints at the permission level NIST has an AI Agent Standards Initiative focused on AI agent identity and access management. The themes below are how TheAuth features line up with that work. They are not NIST requirements that TheAuth certifies against. **Identity provenance** A recurring theme is that every agent action should be traceable to a specific identity. TheAuth links `agentId` and `userId` on every audit entry, creating a complete provenance chain. **Least privilege access** Another theme is that agents should operate with the minimum permissions necessary. TheAuth enforces this through: Fine-grained resource+action permission model `allowedArgPatterns` constraints to limit argument patterns Scope-limited delegation that cannot exceed the delegating agent's own permissions **Revocation and expiry** Agent credentials should be revocable. TheAuth supports both immediate revocation (`theauth.agent.revoke()`) and time-based expiry via `expiresAt`. **Audit trail** Logs should be tamper-evident. TheAuth writes entries to a database table and the SDK never updates them, but it does not provide cryptographic tamper-evidence by itself (apart from the optional [Verifiable Credential export](/compliance/verifiable-credentials)). For tamper-evidence in production, use your database's audit log features or ship logs to an immutable store (e.g. AWS CloudTrail, Loki with object storage). SOC 2 Trust Service Criteria relevant to AI agent access: **CC6.1 - Logical and physical access controls** TheAuth addresses CC6.1 through: Agent identity management with unique IDs per agent Permission-based access control (resource + action + constraints) Token hashing (only the SHA-256 hash is stored in `theauth_agents.token_hash`) Multi-tenant isolation via `tenantId` **CC6.2 - User registration and authorization** `theauth.agent.create()` enforces `maxPerUser` limits Every agent has an `ownerId` linking it to an authenticated user Permissions are explicit and auditable at creation time **CC6.3 - Role-based access** Permission model supports resource-scoped action grants Delegation chains allow scoped sub-delegation with depth limits Default permissions can be configured globally via `agents.defaultPermissions` **CC7.1 - System monitoring** Audit records let you review denied calls and escalation-style denials, and trust scoring derives a score from denial history All authorization decisions are logged regardless of outcome **CC7.2 - Evaluation of security events** `theauth.audit.query()` supports filtering by agent, user, actions, result, and time range `theauth.audit.export()` produces JSON or CSV that you can feed to a SIEM ISO/IEC 42001 is the AI management system standard. It is a management system standard, so most of it concerns your organization's processes rather than software features. The TheAuth features below can serve as evidence for operational and monitoring controls. Check clause numbers against your copy of the standard. **Inputs** TheAuth logs the `parameters` field of every authorized action, giving you a record of what inputs were provided to agent-invoked tools. **Operational controls** Rate limiting via `maxCallsPerHour` permission constraints Time window restrictions via `timeWindow` constraints IP allowlisting via `ipAllowlist` constraints **Outcomes** The audit log captures `result` (allowed, denied, rate_limited) and `reason` for every action, supporting review of outcomes. **Monitoring** Duration tracking (`durationMs`) on every audit entry Token cost tracking (`tokensCost`) for LLM operations, which you can sum from `theauth.audit.query()` results ## Generating reports The audit module exports records you can attach to your own compliance evidence. `export()` returns a string: ```typescript // Export audit records for a time range as JSON const json = await theauth.audit.export({ format: 'json', since: new Date('2026-01-01'), until: new Date('2026-03-31'), }); // Export as CSV for spreadsheet review or SIEM ingestion const csv = await theauth.audit.export({ format: 'csv', since: new Date('2026-01-01'), }); // Query specific events for a review const denials = await theauth.audit.query({ result: 'denied', since: new Date('2026-01-01'), limit: 1000, }); // Sum token cost yourself from the same records const entries = await theauth.audit.query({ since: new Date('2026-01-01') }); const totalTokens = entries.reduce((sum, e) => sum + (e.tokensCost ?? 0), 0); ``` `export()` returns at most 10,000 records per call, so page through large periods with narrower `since` and `until` ranges. The CSV contains `id`, `agentId`, `userId`, `action`, `resource`, `result`, `reason`, `durationMs`, `tokensCost`, and `timestamp`. The JSON output also includes `parameters`. ## Retention TheAuth does not delete audit records on its own. Retention is up to you: use your database's partitioning or TTL features, or call `theauth.audit.cleanup({ retentionDays })`, which permanently deletes rows older than the cutoff. The EU AI Act sets its own log retention rules for high-risk systems. Confirm the period that applies to your system with counsel and set `retentionDays` to match. Do not delete audit records during a live review or audit period. Expire old records only after your retention obligations have been met. ## Related Query, export, and stream the immutable audit log. Data export and account deletion tools for GDPR obligations. Automatic detection of unusual agent behavior patterns. Export audit evidence as W3C Verifiable Credentials. --- # Verifiable credential exports Source: https://docs.theauth.dev/compliance/verifiable-credentials TheAuth can sign audit records as [W3C Verifiable Credentials](https://www.w3.org/TR/vc-data-model-2.0/), giving you exports that a third party can check against your issuer's public key without querying your database. This is a reporting aid, not a certification or a statement that your system is compliant with anything. ## Why this matters A JSON or CSV export is plain data that anyone could have edited. A signed credential lets a verifier confirm that the record was signed by the holder of a specific DID key and has not changed since signing. That can be useful as supporting evidence for logging and monitoring controls (for example EU AI Act Article 12 or SOC 2 CC7.2), but it does not by itself satisfy either. ## Quick start ```typescript import { exportAuditAsVC, listAuditRecords } from '@glinr/theauth/vc'; import { generateDidKey } from '@glinr/theauth'; import { createVCVerifier } from '@glinr/theauth/vc'; // Generate or load your issuer keypair const keyPair = await generateDidKey(); // Fetch audit records for the period you want to export const records = await theauth.audit.query({ since: new Date('2025-01-01'), until: new Date('2025-03-31'), }); // Export as individual JSON-LD credentials const result = await exportAuditAsVC({ since: new Date('2025-01-01'), until: new Date('2025-03-31'), issuerDid: keyPair.did, issuerConfig: { issuerDid: keyPair.did, privateKeyJwk: keyPair.privateKeyJwk, publicKeyJwk: keyPair.publicKeyJwk, }, records, }); console.log(`Exported ${result.count} credentials`); console.log(JSON.stringify(result.credentials[0], null, 2)); // Verify any credential independently const verifier = createVCVerifier({ resolveDidKey: async () => keyPair.publicKeyJwk, }); const verified = await verifier.verifyCredential( result.credentials[0], keyPair.publicKeyJwk, ); console.log(verified.success); // true ``` ## Export options ### Format `ldp_vc` (default), JSON-LD with an embedded proof of type `JsonWebSignature2020` that carries a compact EdDSA JWS over the credential body. Pass the credential object to `verifyCredential()`. `jwt_vc`, JWT-encoded credential. The `result.jwts` array contains the compact JWT strings. Pass those to `verifyCredential()`. ```typescript // JWT format const jwtResult = await exportAuditAsVC({ ...options, format: 'jwt_vc', }); for (const jwt of jwtResult.jwts ?? []) { const verified = await verifier.verifyCredential(jwt, keyPair.publicKeyJwk); console.log(verified.success); } ``` ### Output shape `individual` (default), one credential per audit record. `presentation`, a single Verifiable Presentation wrapping all credentials. Useful when submitting a batch to an auditor as a single signed document. ```typescript const vpResult = await exportAuditAsVC({ ...options, output: 'presentation', }); const vp = vpResult.presentation; // vp.verifiableCredential contains every exported credential // vp.proof is a JsonWebSignature2020 proof over the whole presentation (ldp_vc only) const vpVerified = await verifier.verifyPresentation(vp, keyPair.publicKeyJwk); console.log(vpVerified.success); // true ``` With `format: 'jwt_vc'` the presentation is returned unsigned; only the individual JWTs are signed. ### Filtering Pass a `filter` function to select a subset of records before signing. Useful for exporting only denials, or records for a specific agent. ```typescript const denyExport = await exportAuditAsVC({ ...options, filter: (r) => r.result === 'denied', }); ``` ## Credential subject schema Each credential carries a `TheAuthAuditCredential` type with the following subject: | Field | Type | Description | |-------|------|-------------| | `id` | `string` | Audit record ID | | `agentId` | `string` | The agent that triggered the action | | `principalId` | `string?` | The user who owns the agent | | `operation` | `string` | Action attempted (e.g. `execute`, `read`) | | `target` | `string` | Resource identifier (e.g. `mcp:github:create_issue`) | | `decision` | `"allow" \| "deny"` | `allowed` maps to `allow`; `denied` and `rate_limited` both map to `deny` | | `policyName` | `string?` | Denial reason or policy reference | | `timestamp` | `string` | ISO 8601 timestamp of the original audit event | | `traceId` | `string?` | Reserved. The exporter does not currently set it | | `theauthVersion` | `string` | Version string embedded by the exporter (currently a fixed value, not read from the installed package) | The `@context` array includes both `https://www.w3.org/ns/credentials/v2` and `https://theauth.dev/contexts/audit/v1.jsonld`. The theauth context URL is a stable identifier for this schema, it does not need to resolve at runtime. ## Limits to know about Credentials are issued with a 24 hour expiry, and the verifier rejects expired credentials. Export close to the time you hand evidence to a reviewer, or re-export when needed. A signature shows who signed a record and that it is unchanged since signing. It does not show that the underlying audit log was complete, and it does not replace your own controls, auditor review, or legal advice. ## Control mapping The export can serve as supporting evidence for EU AI Act Article 12 (record-keeping) and SOC 2 CC7.2 (evaluating security events). See the [compliance overview](/compliance) for the mapping and its caveats. ## Related - [Audit trail](/audit), how audit logging works and how to query records - [Compliance](/compliance), control mapping for EU AI Act, SOC 2, and ISO 42001 - [DID (Decentralized Identifiers)](/did), issuer identity and key management - SOC 2 compliance report, coming soon - EU AI Act conformity assessment, coming soon --- # GDPR data rights Source: https://docs.theauth.dev/gdpr theAuth includes built-in tools for three data subject requests that commonly require custom code: exporting a user's data, deleting their account on request, and anonymizing their account. Deleting an account can also anonymize the user's audit rows. These tools cover part of the technical layer and do not make a deployment compliant by themselves. Your privacy policy, data processing agreements, and response timelines are your responsibility. ## Setup The `gdpr` plugin takes no options. It registers three self-service endpoints scoped to the authenticated user. The user is resolved through the same mechanism as other plugin endpoints (your configured auth adapter or session); unauthenticated requests get a 401. ```typescript title="lib/theauth.ts" import { createTheAuth } from '@glinr/theauth'; import { gdpr } from '@glinr/theauth/auth'; // [!code highlight] const theauth = await createTheAuth({ database: { provider: 'postgres', url: process.env.DATABASE_URL! }, agents: { enabled: true }, plugins: [ gdpr(), // [!code highlight] ], }); ``` ## Export user data `GET /auth/gdpr/export` Returns a JSON bundle covering the authenticated user's profile, agents, sessions, audit events, delegations, organization memberships, and API keys (GDPR Article 20). It is not a dump of every table: it omits, for example, TOTP records, passkeys, OAuth tokens, approval requests, and budget policies, and each section contains a few summary fields only (API keys: id, name, createdAt; sessions: id and timestamps). Audit events and delegations are only included if the user owns at least one agent. ```typescript title="Request export (client)" const res = await fetch('/auth/gdpr/export', { method: 'GET', credentials: 'include', }); const data = await res.json(); // data.user, data.agents, data.sessions, data.auditLogs, data.delegations, data.organizations, data.apiKeys, data.exportedAt ``` ## Delete account `DELETE /auth/gdpr/delete` Deletes the user account and associated data (GDPR Article 17). Requires an explicit confirmation string in the request body: ```typescript title="Delete account (client)" const res = await fetch('/auth/gdpr/delete', { method: 'DELETE', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ confirm: 'delete my account', // literal string, required // [!code highlight] keepAuditLogs: true, // default: true, anonymizes rather than deletes audit rows // [!code highlight] deleteOrganizations: false, // default: false, removes memberships but leaves owned orgs // [!code highlight] }), }); const { success, deletedAgents, deletedSessions, deletedDelegations, deletedApiKeys, anonymizedAuditLogs } = await res.json(); ``` What the delete does, in order: revokes the user's agents; deletes sessions, delegation chains involving those agents, and API keys; anonymizes the audit rows of those agents (or deletes the rows of this user when `keepAuditLogs` is `false`); deletes approval requests, budget policies, OAuth tokens and codes, magic links, email OTPs, TOTP records, passkeys, and org memberships; optionally deletes owned organizations; then deletes the user row. Note that with `keepAuditLogs: true` the agent rows are kept with status `revoked` (so `deletedAgents` counts agents revoked, not rows removed), and they still carry their name and metadata. With `keepAuditLogs: false` the agent rows are deleted. The module toggles `PRAGMA foreign_keys` while deleting and anonymizing audit rows. That statement is SQLite syntax, so on Postgres or MySQL the `keepAuditLogs: true` path and the final user deletion will fail with a database error unless the statement is accepted by your driver. Test deletion against your production database engine before relying on it. Deletion is irreversible. Any app data you store outside theAuth that references the user ID will become orphaned, the plugin has no `onBeforeDelete` hook, cascade those deletes yourself before calling this endpoint. ## Anonymize account `POST /auth/gdpr/anonymize` Replaces the email with a deterministic anonymous value (`deleted-@anon.invalid`) and clears name, external ID, and metadata, while keeping the account row, agents, and audit history. It also deletes the user's TOTP records and passkeys. It does not revoke sessions, and it does not touch audit rows (those still reference the same user ID). Use this instead of deletion when org membership or audit referential integrity must be preserved. ```typescript title="Anonymize account (client)" const res = await fetch('/auth/gdpr/anonymize', { method: 'POST', credentials: 'include', }); const { success } = await res.json(); ``` ## Using the module directly The plugin wraps `createGdprModule`, which you can also call directly (for background jobs, admin tooling, or CLI scripts) without going through the HTTP endpoints: ```typescript import { createGdprModule } from '@glinr/theauth/auth'; const gdprModule = createGdprModule(theauth.db); const exportData = await gdprModule.exportUserData(userId); const result = await gdprModule.deleteUser(userId, { keepAuditLogs: true }); await gdprModule.anonymizeUser(userId); ``` ## Related Framework mapping and evidence export (JSON, CSV, verifiable credentials). The audit log that GDPR anonymization targets. Lifecycle hooks for authorization and agent events. There is no account deletion hook. Session revocation that runs as part of account deletion. --- # Password breach checking Source: https://docs.theauth.dev/hibp theAuth integrates with the [HaveIBeenPwned Pwned Passwords API](https://haveibeenpwned.com/API/v3#PwnedPasswords) to detect compromised passwords at sign-up and password change. It uses the k-anonymity model, only the first 5 characters of the SHA-1 hash are sent to the API. Your users' actual passwords never leave your server. `createHibpModule` is standalone, it does not hook automatically into the [username](/auth/username) module or any other password flow. Call `check()` or `enforce()` yourself wherever you accept a new password. ## Setup ```typescript title="lib/theauth.ts" import { createTheAuth } from '@glinr/theauth'; import { createHibpModule } from '@glinr/theauth/auth'; // [!code highlight] const hibp = createHibpModule({ threshold: 0 }); // reject any breach count above 0 // [!code highlight] const theauth = await createTheAuth({ database: { provider: 'postgres', url: process.env.DATABASE_URL! }, auth: { session: { secret: process.env.SESSION_SECRET! } }, username: { password: { minLength: 8 } }, }); ``` Call `hibp.enforce()` before delegating to the username module's `signUp`: ```typescript title="Sign-up handler" async function handleSignUp(username: string, password: string) { await hibp.enforce(password); // throws HibpBreachedError if compromised // [!code highlight] return theauth.username?.signUp({ username, password }); } ``` ## check() and enforce() The module exposes two methods with different failure modes. `check()` does not apply `threshold`, it always returns the raw breach count: ```typescript title="Manual usage" const hibp = createHibpModule(); // Returns the breach count as a number. 0 means clean (or the API was // unreachable and onError is 'allow', the default). const count = await hibp.check('user-entered-password'); if (count > 0) { console.warn(`Password seen ${count} times in breaches`); } // Throws HibpBreachedError if count exceeds the configured threshold await hibp.enforce('user-entered-password'); ``` `check()` is useful when you want to warn the user without blocking them. `enforce()` throws a typed `HibpBreachedError` (with a `count` property) so you can catch it and return a friendly message: ```typescript import { HibpBreachedError } from '@glinr/theauth/auth'; try { await hibp.enforce(password); } catch (err) { if (err instanceof HibpBreachedError) { return { error: `This password has appeared in ${err.count} known breaches. Choose another.` }; } throw err; } ``` ## How k-anonymity works ``` 1. SHA-1 hash the password → "5BAA61E4C9B93F3F0682250B6CF8331B7EE68FD8" 2. Send only the first 5 chars → GET /range/5BAA6 3. API returns ~500 hashes with that prefix 4. Check if the rest of the hash appears in the list 5. If yes, the password has been breached. The full hash is never sent. ``` The full password hash is never transmitted. The API response contains partial hashes from other users' passwords, so the provider cannot determine which password you were checking. ## Configuration The Pwned Passwords API is free and does not require an API key. Point `apiUrl` at a self-hosted mirror if you run one: ```typescript title="lib/theauth.ts" const hibp = createHibpModule({ threshold: 0, // reject any breach count greater than this // [!code highlight] apiUrl: 'https://api.pwnedpasswords.com', // default, override for a self-hosted mirror // [!code highlight] timeoutMs: 5000, // ms before the check is abandoned // [!code highlight] onError: 'allow', // 'allow' | 'block', behavior when the API is unreachable // [!code highlight] }); ``` If the HIBP API is unreachable and `onError` is `'allow'` (the default), `check()` returns `0` and `enforce()` passes through, a slow network does not block your users from registering. Set `onError: 'block'` to fail closed instead, `check()` throws `HibpApiError` and `enforce()` propagates it. ## Configuration reference Reject passwords seen in more than this many breaches. Default 0 rejects any breach at all. Base URL for the Pwned Passwords range API. Milliseconds to wait for the HIBP API before giving up. Behavior when the HIBP API is unreachable or returns an error. ## Related Add TOTP as a second layer of protection beyond password quality. Skip 2FA on devices you have marked as trusted. theAuth's built-in password module that HIBP enforcement typically guards. Audit trail, GDPR tooling, and the other compliance-related features. --- # Trusted devices Source: https://docs.theauth.dev/trusted-device The trusted device module lets you mark a device as trusted after a user completes two-factor authentication, then skip 2FA on subsequent sign-ins from that device. Trust is stored against an HMAC-signed fingerprint, not a plain device ID, so it cannot be guessed or forged. It is a standalone module, you call it yourself in your own sign-in flow, it does not wire into the `twoFactor` plugin automatically. ## Setup ```typescript title="lib/theauth.ts" import { createTheAuth } from '@glinr/theauth'; import { createTrustedDeviceModule } from '@glinr/theauth/auth'; // [!code highlight] const theauth = await createTheAuth({ database: { provider: 'postgres', url: process.env.DATABASE_URL! }, }); const trustedDevice = createTrustedDeviceModule( // [!code highlight] { secret: process.env.TRUSTED_DEVICE_SECRET, // separate from the session secret // [!code highlight] trustDurationSeconds: 30 * 24 * 60 * 60, // 30 days, this is the default // [!code highlight] maxDevices: 10, // [!code highlight] }, theauth.db, // [!code highlight] ); ``` ## Fingerprint a request `generateFingerprint` derives a stable, HMAC-protected fingerprint from the `user-agent`, `accept-language`, `accept-encoding`, and `accept` request headers. The same header values always produce the same fingerprint, and a browser update that changes them produces a new one: ```typescript const fingerprint = await trustedDevice.generateFingerprint(request); ``` ## Trust a device After the user completes 2FA, call `trustDevice` to mark their device. It returns the trust record's id (calling it again for the same fingerprint refreshes the expiry and returns the same id). Note that `isTrusted` looks devices up by user and fingerprint, not by this id, so the id is only useful if you want to keep a handle on the record, for example in a long-lived cookie: ```typescript title="Trust after 2FA (server)" // After verifying the 2FA code: const fingerprint = await trustedDevice.generateFingerprint(request); const deviceId = await trustedDevice.trustDevice(user.id, fingerprint); response.setCookie('td_id', deviceId, { maxAge: 30 * 24 * 60 * 60, httpOnly: true }); ``` ## Check whether a device is trusted On sign-in, check `isTrusted` before prompting for a 2FA code: ```typescript title="Check on sign-in (server)" const fingerprint = await trustedDevice.generateFingerprint(request); const trusted = await trustedDevice.isTrusted(user.id, fingerprint); if (trusted) { // Skip 2FA, proceed with session creation } ``` ## Revoke devices ```typescript title="Revoke one device / all devices (server)" await trustedDevice.revokeDevice(user.id, fingerprint); await trustedDevice.revokeAllDevices(user.id); ``` ## List trusted devices ```typescript title="List devices (server)" const devices = await trustedDevice.listDevices(user.id); // [{ id, fingerprint, label, trustedAt, expiresAt }, ...] // label is always "Trusted device" ``` ## Configuration reference HMAC key used to sign device fingerprints. Rotate this to invalidate all trusted devices instantly. If omitted, a random per-process key is used and fingerprints won't survive a restart. How long a device stays trusted, in seconds. Default is 30 days. Maximum number of trusted devices per user. The oldest records are evicted when exceeded. ## Related TOTP plugin whose second-factor check you can skip on trusted devices in your own sign-in flow. List and revoke a user's sessions. Audit trail, GDPR tooling, and the other compliance-related features. Cookie and JWT sessions you issue after sign-in. --- # Multi-session Source: https://docs.theauth.dev/multi-session By default, theAuth allows unlimited concurrent sessions per user. The multi-session module adds a cap with a configurable overflow strategy, plus functions for listing and revoking sessions, useful for building an "active devices" settings page. It is a plain module exported from `@glinr/theauth`, not a plugin. It registers no HTTP routes and does not hook into sign-in on its own: you call `enforceSessionLimit` before creating a session, and you expose the list and revoke operations from your own routes. ## Setup ```typescript title="lib/theauth.ts" import { createTheAuth, createMultiSessionModule } from '@glinr/theauth'; // [!code highlight] const theauth = await createTheAuth({ database: { provider: 'postgres', url: process.env.DATABASE_URL! }, auth: { session: { secret: process.env.SESSION_SECRET! } }, }); export const multiSession = createMultiSessionModule( // [!code highlight] { maxSessions: 5, // [!code highlight] overflowStrategy: 'evict-oldest', // [!code highlight] }, theauth.db, theauth.auth.session!, // [!code highlight] ); ``` The third argument is the low-level session manager (`theauth.auth.session`, or `cookieSessionManager.raw` if you use `createCookieSessionManager`). ## Enforcing the cap Call `enforceSessionLimit` before you create a session, and use `buildSessionMetadata` to record device and IP: ```typescript title="Sign-in handler" import { buildSessionMetadata, MultiSessionLimitError } from '@glinr/theauth'; try { await multiSession.enforceSessionLimit(userId); } catch (err) { if (err instanceof MultiSessionLimitError) { return new Response(JSON.stringify({ error: err.code }), { status: 429 }); } throw err; } const { token } = await theauth.auth.session!.create(userId, buildSessionMetadata(request)); ``` ## Overflow strategies | Strategy | Behavior | |----------|----------| | `evict-oldest` | Revokes the oldest sessions (by creation time) until there is room for the new one | | `reject` | Throws `MultiSessionLimitError` with code `SESSION_LIMIT_REACHED`. Turning that into an HTTP status is up to your handler | ## List sessions `listSessions(userId)` returns all non-expired sessions for a user, newest first. Wire it to your own route to build an "active devices" UI. ```typescript title="List sessions (server)" const sessions = await multiSession.listSessions(userId); ``` **Result shape** ```json [ { "id": "5f0c2d1e-...", "createdAt": "2026-06-01T10:00:00.000Z", "expiresAt": "2026-06-08T10:00:00.000Z", "device": "Chrome on macOS", "ip": "203.0.113.1" } ] ``` `device` and `ip` are only present when the session was created with metadata from `buildSessionMetadata` (or your own metadata with those keys). `device` is a short string parsed from the `User-Agent` header, such as `Chrome on macOS`. IP addresses are stored as-is, apply your own masking if required. Any other metadata keys appear under `metadata`. The result has no `current` flag, compare `id` with the caller's session ID yourself. ## Revoke a session ```typescript title="Revoke session (server)" await multiSession.revokeSession(sessionId); ``` `revokeSession` deletes the session by ID. It does not check who owns it, so verify that the session belongs to the signed-in user before calling it. ## Revoke all other sessions ```typescript title="Sign out all other devices (server)" const revoked = await multiSession.revokeOtherSessions(userId, currentSessionId); ``` Revokes every non-expired session for the user except `currentSessionId` and returns the number revoked. `getSessionCount(userId)` returns the number of active sessions. ## Configuration reference Maximum concurrent sessions per user. What to do when a new sign-in would exceed maxSessions. ## Related Core session model with cookie and JWT session details. Cookie attributes and sliding expiry that affect session lifetime. Mark specific devices as trusted so your sign-in flow can skip 2FA. Erase or export user data on request. --- # Lifecycle hooks Source: https://docs.theauth.dev/hooks ## What hooks are Hooks are async callbacks you register at startup. theAuth calls them at specific points in the authorization and agent lifecycle. They let you add custom logic without patching the SDK: log denials to Slack, block agents from running in unsandboxed environments, fire webhooks, or update your own database. Hooks are async. `beforeAuthorize` and `beforeAgentCreate` are awaited and can block requests by returning `{ allow: false }`; an exception thrown inside them propagates to the caller. All other hooks (`afterAuthorize`, `afterAgentCreate`, `onAgentRevoke`, `onViolation`) are fire-and-forget: theAuth does not await them and does not catch their errors, so a throw becomes an unhandled promise rejection (which can terminate a Node.js process). Wrap their bodies in `try/catch`. Authorization hooks run only for calls to `theauth.authorize()`. They do not run for `theauth.authorizeByToken()` or `theauth.policy.evaluate()`. ## Available hooks Promise<{ allow: boolean; reason?: string } | void>`}>Fires before every `theauth.authorize()` call. Return `{ allow: false, reason }` to block: the call returns `{ allowed: false, reason, auditId: "" }` without touching the database, so no audit row is written. Return nothing or `{ allow: true }` to proceed. Promise`}>Fires after authorize() completes with the final result (`{ allowed, reason?, auditId }`). It does not fire when `beforeAuthorize` blocked the call or when the agent was not found or is not active. Promise<{ allow: boolean; reason?: string } | void>`}>Fires before `theauth.agent.create()`. Return `{ allow: false }` to reject the creation; `create()` then throws an `Error` carrying your reason. Promise`}>Fires after an agent is successfully created. Promise`}>Fires after `theauth.agent.revoke()` succeeds. Promise`}>Fires when `theauth.authorize()` returns a denial (including a `beforeAuthorize` block). Budget policies are not part of `authorize()`, so they do not trigger it. ### Violation types The `onViolation` hook receives a typed `type` field so you can route each category to a different handler. | Type | When it fires | |------|---------------| | `permission_denied` | Default when no other category matches (for example, no permission grants the agent the action) | | `rate_limited` | The denial reason contains "rate" | | `ip_blocked` | The denial reason contains "ip" or "allowlist" (the request IP is missing or not in the permission's `ipAllowlist`) | | `time_restricted` | The denial reason contains "time" or "window" (outside the permission's `timeWindow`) | | `approval_required` | The denial reason contains "approval" (the permission has `requireApproval`) | The type is derived by substring matching on the denial reason text, checked in the order above. A reason that happens to contain one of those substrings, for example an agent or resource name containing "rate" or "time", can be classified as that category. ## Registering hooks Pass a `hooks` object to `createTheAuth`: ```typescript import { createTheAuth } from '@glinr/theauth'; const theauth = await createTheAuth({ database: { provider: 'sqlite', url: 'theauth.db' }, hooks: { async beforeAuthorize({ agentId, action, resource }) { // Example: block calls outside business hours const hour = new Date().getUTCHours(); if (hour < 8 || hour >= 20) { return { allow: false, reason: `Agent ${agentId} cannot run outside business hours (08:00 to 20:00 UTC)`, }; } // Return nothing (or { allow: true }) to let the request proceed }, async afterAuthorize({ agentId, action, resource, result }) { if (!result.allowed) { console.warn(`[theauth] Denied: agent=${agentId} action=${action} resource=${resource} reason=${result.reason}`); } }, }, }); ``` ## Logging every denial ```typescript const theauth = await createTheAuth({ database: { provider: 'sqlite', url: 'theauth.db' }, hooks: { async afterAuthorize({ agentId, action, resource, result }) { if (!result.allowed) { await logger.warn('Authorization denied', { agentId, action, resource, reason: result.reason, auditId: result.auditId, }); } }, }, }); ``` The `result.auditId` links this log line to the audit entry, so you can correlate your own logs with the theAuth audit trail. When `agents.auditAll` is `false`, the permission engine returns an `auditId` but does not write a row for it. Blocked-by-hook and unknown-agent denials return an empty `auditId`. ## Enforcing a sandbox check ```typescript const theauth = await createTheAuth({ database: { provider: 'sqlite', url: 'theauth.db' }, hooks: { async beforeAgentCreate(input) { // Require sandbox metadata on all autonomous agents if (input.type === 'autonomous' && !input.metadata?.sandboxId) { return { allow: false, reason: 'Autonomous agents must declare a sandboxId in metadata', }; } }, }, }); ``` Returning `{ allow: false }` from `beforeAgentCreate` causes the `theauth.agent.create()` call to throw an `Error` whose message is the reason you provided. ## Reacting to violations The `onViolation` hook fires for every denial returned by `theauth.authorize()`, classified as described above. Use it to send alerts or update your observability platform. ```typescript const theauth = await createTheAuth({ database: { provider: 'sqlite', url: 'theauth.db' }, hooks: { async onViolation({ type, agentId, action, resource, reason }) { // Send to your alerting system await alerts.send({ severity: type === 'rate_limited' ? 'warning' : 'error', title: `TheAuth violation: ${type}`, fields: { agentId, action, resource, reason }, }); // For repeat offenders, you could revoke here if (type === 'ip_blocked') { await theauth.agent.revoke(agentId); } }, }, }); ``` ## Cleaning up after revocation ```typescript import { createDiscoveryModule } from '@glinr/theauth-plugin-discovery'; // Create it once, after the instance exists, using the same database let discovery: ReturnType; const theauth = await createTheAuth({ database: { provider: 'sqlite', url: 'theauth.db' }, hooks: { async onAgentRevoke(agentId) { // Remove the agent's discovery card. The discovery module is the separate // @glinr/theauth-plugin-discovery package; it is not on the theauth instance. await discovery.removeCard(agentId); // Notify your own systems await internalApi.notifyAgentRevoked(agentId); }, }, }); discovery = createDiscoveryModule(theauth.db); ``` ## Next steps Track spend and call limits. These are a separate module and are not enforced by authorize(). Stream authorization events to Kafka, NATS, or Webhooks. Query the record of authorization decisions. --- # Webhooks Source: https://docs.theauth.dev/webhooks ## What webhooks do Webhooks push signed HTTP POST requests to a URL you control. Use them to sync user records, trigger onboarding flows, alert on suspicious logins, or feed events into your analytics pipeline. Webhooks are a delivery mechanism, not an event source. The theAuth core does not call `emit` for you when a user signs up or an agent is created. Your code (or a lifecycle hook) calls `theauth.webhooks?.emit(event, payload)` at the points where you want a delivery to happen. ## Setup Pass an array of endpoints as `webhooks` to `createTheAuth`. Each endpoint has its own URL, secret, and event list. The instance then exposes `theauth.webhooks` (`null` when no endpoints were configured). ```typescript import { createTheAuth } from '@glinr/theauth'; const theauth = await createTheAuth({ database: { provider: 'sqlite', url: 'theauth.db' }, webhooks: [ { url: 'https://myapp.com/webhooks/theauth', secret: process.env.THEAUTH_WEBHOOK_SECRET!, events: ['user.created', 'agent.created'], }, ], }); // Emit from anywhere in your app. Delivery is fire-and-forget. theauth.webhooks?.emit('user.created', { userId: 'user_123' }); ``` Store the webhook secret in an environment variable, not in source code. theAuth uses it to sign every request with HMAC-SHA256. ### Endpoint options Destination URL for the POST request. Signing secret used for the HMAC-SHA256 signature. Event types this endpoint receives. There is no `'*'` wildcard: list every event you want. Retry attempts after the first failed delivery. Defaults to 3. Per-request timeout in milliseconds. Defaults to 10000. You can add endpoints at runtime with `theauth.webhooks?.addEndpoint({ ... })` and read them back with `listEndpoints()`. ## Event reference These are the event names accepted by `emit` and by an endpoint's `events` list: | Event | Intended meaning | |-------|-----------| | `user.created` | A new human user registers | | `user.updated` | A user account changes | | `user.deleted` | A user account is deleted | | `session.created` | A session is created | | `session.revoked` | A session is revoked | | `agent.created` | A new agent identity is created | | `agent.revoked` | An agent is revoked | | `agent.rotated` | An agent token is rotated | | `auth.sign-in` | A user signs in | | `auth.sign-up` | A user signs up | | `auth.password-reset` | A password is reset | | `auth.email-verified` | An email address is verified | The meaning column describes what you would emit the event for. The library does not emit these automatically. Delegation and approval events are not part of this list. ## Payload The request body is JSON: ```json { "event": "user.created", "timestamp": "1767225600", "data": { "userId": "user_123" } } ``` `timestamp` is Unix time in seconds, as a string, and `data` is the payload you passed to `emit`. ## Request headers Every webhook delivery includes these headers: | Header | Value | |--------|-------| | `X-TheAuth-Signature` | `sha256=`, the HMAC-SHA256 of `.` using your secret | | `X-TheAuth-Event` | Event type, e.g. `user.created` | | `X-TheAuth-Timestamp` | Unix timestamp (seconds) of delivery, the same value as `timestamp` in the body | ## Verifying signatures The signature covers the timestamp and the raw body joined by a dot, not the body alone. Always verify it before trusting the payload, and reject old timestamps to limit replays. ```typescript import { createHmac, timingSafeEqual } from 'node:crypto'; function verifyWebhook( rawBody: Buffer, timestamp: string, signature: string, secret: string, ): boolean { const expected = 'sha256=' + createHmac('sha256', secret).update(`${timestamp}.${rawBody.toString('utf8')}`).digest('hex'); const a = Buffer.from(expected); const b = Buffer.from(signature); return a.length === b.length && timingSafeEqual(a, b); } // Express handler app.post('/webhooks/theauth', express.raw({ type: 'application/json' }), (req, res) => { const sig = req.headers['x-theauth-signature'] as string; const ts = req.headers['x-theauth-timestamp'] as string; if (!verifyWebhook(req.body, ts, sig, process.env.THEAUTH_WEBHOOK_SECRET!)) { return res.status(401).send('Invalid signature'); } const event = JSON.parse(req.body.toString()); // handle event... res.sendStatus(200); }); ``` ```typescript async function verifyWebhook( rawBody: string, timestamp: string, signature: string, secret: string, ): Promise { const key = await crypto.subtle.importKey( 'raw', new TextEncoder().encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'], ); const mac = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(`${timestamp}.${rawBody}`)); const expected = 'sha256=' + Array.from(new Uint8Array(mac)).map(b => b.toString(16).padStart(2, '0')).join(''); return expected === signature; } ``` ## Retry behavior If your endpoint returns a non-2xx status, errors, or times out, theAuth retries up to `retries` times (default 3) after the first attempt, with exponential backoff: | Retry | Delay before it | |---------|-------| | 1 | 1 second | | 2 | 2 seconds | | 3 | 4 seconds | After the last retry the delivery is dropped silently. There is no persisted delivery record, no `failed` state, and no replay: retries run in memory in the same process, so a restart loses pending retries. ## Standalone subscription module `@glinr/theauth` also exports a separate `createWebhookModule({ secret, maxRetries?, timeoutMs? })` with runtime subscriptions kept in memory: `subscribe(url, events)`, `unsubscribe(id)`, `list()`, `dispatch(event, payload)`, and `test(subscriptionId)`. It is independent of `theauth.webhooks`, signs the raw body only (`X-TheAuth-Signature: sha256=` of the body, plus `X-TheAuth-Delivery` and an ISO `X-TheAuth-Timestamp`), tries up to `maxRetries` attempts in total, and accepts a different event list that includes `delegation.created`, `delegation.revoked`, `auth.login`, `auth.logout`, `auth.failed`, and `org.*` events. Verify its deliveries with the exported `verifyWebhookSignature(secret, rawBody, signature)`. Subscriptions are lost on restart. ```typescript import { createWebhookModule } from '@glinr/theauth'; const webhooks = createWebhookModule({ secret: process.env.THEAUTH_WEBHOOK_SECRET! }); const sub = await webhooks.subscribe('https://myapp.com/webhooks/theauth', ['agent.created']); webhooks.dispatch('agent.created', { agentId: 'agt_123' }); const ping = await webhooks.test(sub.id); // { success, statusCode?, error? } ``` ## Next steps Run async callbacks on auth events inside the SDK process. Query the full record of every authorization decision. --- # Event streaming Source: https://docs.theauth.dev/event-streaming ## What event streaming is Event streaming gives you a persistent, real-time connection to theAuth events via [Server-Sent Events (SSE)](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events). Whenever your code calls `stream.emit()` (an agent is revoked, a budget is exceeded, and so on), connected clients receive it, no polling required. Events are also persisted to the database so you can replay anything you missed. ## Streaming vs webhooks Webhooks and SSE are separate mechanisms with separate event sets: the webhook module delivers the events you pass to `theauth.webhooks.emit()`, and the stream delivers the events you pass to `stream.emit()`. You can emit the same occurrence to both. | | Webhooks | Event streaming | |---|---|---| | Transport | HTTP POST to your server | Persistent HTTP connection to your browser or service | | Latency | Network latency plus retries | Near-instant | | Auth | Signed with an HMAC signature header | Bearer token | | Missed events | Retried up to 3 times by default, then dropped | Replay via the `since` timestamp | | Best for | Backend integrations, data pipelines | Dashboards, SOC tooling, live monitoring | Use webhooks when you need durable delivery to an external service. Use event streaming when you need a live view, a security dashboard, an admin feed, or a CI script watching for `budget.exceeded`. ## Setup ```typescript import { createTheAuth } from '@glinr/theauth'; import { createEventStreamModule } from '@glinr/theauth/auth'; const theauth = await createTheAuth({ database: { provider: 'sqlite', url: 'theauth.db' }, }); const stream = createEventStreamModule({ db: theauth.db, requireAuth: true, validateToken: async (token) => { // Return a subscriber ID (userId or agentId) on success, null on failure. // Here: a TheAuth-managed session (requires `auth.session` in the config). const session = await theauth.auth.session?.validate(token); return session?.userId ?? null; }, }); // Mount in a Web-standard (Request/Response) handler, for example a Next.js route export async function GET(request: Request): Promise { return stream.handleRequest(request) ?? new Response('Not found', { status: 404 }); } // Emit from anywhere in your application stream.emit({ id: crypto.randomUUID(), type: 'agent.created', timestamp: new Date(), data: { agentId: agent.id, name: agent.name }, }); ``` `handleRequest` takes a Web `Request` and returns a Web `Response`, or `null` for any request that is not an SSE request: the method must be GET, the path must end in `/events/stream`, and the `Accept` header must include `text/event-stream`. This makes it safe to call inside a catch-all handler. Node `(req, res)` handlers such as Express need a small adapter to convert to and from `Request` and `Response`. ## Connecting from a browser The browser's built-in `EventSource` API handles reconnection automatically. ```typescript const source = new EventSource( '/api/theauth/events/stream?token=your-bearer-token', ); // Each message is named by its type and carries { id, type, timestamp, data, agentId?, userId? } source.addEventListener('agent.created', (event) => { const payload = JSON.parse((event as MessageEvent).data); console.log('New agent:', payload.data.agentId); }); source.addEventListener('budget.exceeded', (event) => { const payload = JSON.parse((event as MessageEvent).data); console.warn('Budget exceeded:', payload.data); }); source.addEventListener('error', (event) => { // Server-sent error events carry JSON; connection errors have no data const raw = (event as MessageEvent).data; if (typeof raw === 'string') { console.error('Stream error:', JSON.parse(raw).code); } }); // Clean up source.close(); ``` If you pass the token via the `Authorization` header instead, use the `eventsource` package or a fetch-based polyfill, since the native `EventSource` does not support custom headers. ## Connecting from Node.js ```typescript import { EventSource } from 'eventsource'; const source = new EventSource('https://your-app.com/api/theauth/events/stream', { headers: { Authorization: 'Bearer your-bearer-token', }, }); // Messages are named events, so onmessage never fires. Listen per type. source.addEventListener('agent.revoked', (event) => { const payload = JSON.parse(event.data); console.log('Event:', payload.type, payload.data); }); source.onerror = () => { console.error('Connection lost, reconnecting...'); }; ``` ## Event types The stream module is standalone. The theAuth core does not call `stream.emit()` for you, so none of these events fire automatically: you emit the ones you need from your own code, hooks, or webhook handlers. `anomaly.detected` is only a reserved type name, since theAuth has no anomaly detector. The type column below describes the intended meaning of each type. | Type | When it fires | |---|---| | `audit` | Any agent action logged to the audit trail | | `agent.created` | A new agent identity is registered | | `agent.revoked` | An agent is permanently revoked | | `agent.rotated` | An agent token is rotated | | `auth.signin` | A user signs in | | `auth.signout` | A user signs out | | `auth.failed` | A sign-in attempt fails | | `delegation.created` | A new delegation chain is created | | `delegation.revoked` | A delegation is revoked | | `budget.exceeded` | An agent exceeds its cost budget | | `anomaly.detected` | Reserved name. theAuth has no anomaly detector, so this only appears if your own code emits it | | `cost.recorded` | A cost event is attributed to an agent | ## Filtering events Pass a `types` query parameter with a comma-separated list to receive only the events you care about. ``` GET /api/theauth/events/stream?types=agent.revoked,budget.exceeded ``` ```typescript // From a browser const source = new EventSource( '/api/theauth/events/stream?token=tok&types=agent.created,agent.revoked', ); ``` Unknown type names in `types` are ignored; if none are valid, the connection receives all types. You can also restrict the types at the module level, which filters live delivery. ```typescript const stream = createEventStreamModule({ db: theauth.db, requireAuth: true, validateToken, eventTypes: ['audit', 'budget.exceeded'], // only these types are delivered live }); ``` ## Replay and cursor Events passed to `emit()` are persisted in the `theauth_stream_events` table (a failed write is ignored). If a client disconnects and reconnects, pass `since` to receive everything it missed, oldest first, after the `connected` event. `since` must be an ISO 8601 timestamp with an offset or `Z`; anything else is rejected with a 400. ``` GET /api/theauth/events/stream?since=2026-01-15T10:00:00Z ``` The module also reads the `Last-Event-ID` header as a fallback cursor, but it parses the value as a date. Each SSE message uses your event `id` as its `id:` field, so the header only works for replay if your event IDs are timestamps (for example ISO strings). With UUID IDs, as in the examples here, an automatic browser reconnect replays nothing, so track the last timestamp yourself and pass `since`. Replay applies the connection's `types` filter, or the module-level `eventTypes` if the client sent none. A client that passes its own `types` can replay types outside the module-level list, and the programmatic `replay()` method does not apply the module-level list at all. ```typescript // Programmatic replay without an open connection const result = await stream.replay(new Date('2026-01-15T10:00:00Z'), ['audit', 'auth.failed']); if (result.success) { for (const event of result.data) { console.log(event.type, event.data); } } ``` `replay` returns up to 1000 events in descending order (newest first). Apply your own pagination on top if you need to page through large windows. ## Auth requirements By default `requireAuth: true`. Every connection must present a Bearer token, either in the `Authorization` header or as the `token` query parameter. ``` Authorization: Bearer GET /api/theauth/events/stream?token= ``` When the token is missing or `validateToken` returns `null`, the stream sends a single `error` event and closes (the HTTP status is still 200). ```json { "code": "UNAUTHORIZED", "message": "Invalid token" } ``` If you set `requireAuth: true` but do not provide `validateToken`, any non-empty token is accepted. Always supply `validateToken` in production. To disable auth for local development or internal-only deployments: ```typescript const stream = createEventStreamModule({ db: theauth.db, requireAuth: false, }); ``` Never disable auth in production. The stream exposes audit events and agent lifecycle data. ## Configuration ```typescript interface EventStreamConfig { db: Database; // Maximum concurrent connections. Returns 503 when exceeded (default: 100). maxConnections?: number; // Interval between heartbeat comments in ms (default: 30000) heartbeatIntervalMs?: number; // Module-level event type allow-list. Clients cannot exceed this (default: all). eventTypes?: EventType[]; // Require a Bearer token to connect (default: true) requireAuth?: boolean; // Validate the token and return a subscriber ID (userId or agentId), or null. // If omitted while requireAuth is true, any non-empty token is accepted. validateToken?: (token: string) => Promise; } ``` ## Connection limits The stream rejects connections beyond `maxConnections` with a `503 Too many connections` response. Size this based on your deployment: a single process can comfortably handle hundreds of concurrent SSE connections; above that, consider a pub/sub layer (Redis, NATS) in front of the module. ## Heartbeat The server sends `: heartbeat` comments on the configured interval (default 30 seconds) to keep load balancers and proxies from closing idle connections. No action is required on the client side, `EventSource` ignores comment lines. ## Emitting events from plugins Any part of your application can emit to the stream. ```typescript // After revoking an agent await theauth.agent.revoke(agentId); stream.emit({ id: crypto.randomUUID(), type: 'agent.revoked', timestamp: new Date(), data: { agentId, reason: 'manual-revocation' }, agentId, userId: currentUser.id, }); ``` Integrate with the [webhooks](/webhooks) module to fire both a webhook and a stream event from the same action. ## Module API ```typescript interface EventStreamModule { // Emit an event to all connected clients and persist for replay emit(event: StreamEvent): void; // Handle an SSE connection request. Returns a Response or null. handleRequest(request: Request): Response | null; // Number of currently active connections getConnectionCount(): number; // Replay persisted events since a date, optionally filtered by type. // Up to 1000 events, newest first. replay(since: Date, types?: EventType[]): Promise>; // Close all connections and stop the heartbeat timer close(): void; } ``` ## Related Durable HTTP delivery for the same events to external services. The audit log. Replay reads from the separate stream events table, not from audit entries. Call `stream.emit()` from lifecycle hooks such as `onViolation` and `onAgentRevoke`. Visual monitoring of agents and audit entries. --- # Custom session fields Source: https://docs.theauth.dev/custom-session The `customSession` plugin lets you store application-specific data on a session without adding database columns. Fields live in `session.metadata.custom`, and you read and update them with the module or the REST endpoints below. `defaultFields` and `onSessionCreate` are registered as a plugin `onSessionCreate` hook, but theAuth's session managers and sign-in modules do not currently invoke plugin hooks. Creating a session through `theauth.auth.session.create(...)` does not populate `metadata.custom` by itself. Until that wiring exists, write the initial fields yourself with `updateSessionFields` right after you create the session. ## Install The plugin ships inside `@glinr/theauth/auth`, no extra packages needed. ## Setup ```typescript import { createTheAuth } from '@glinr/theauth'; import { customSession } from '@glinr/theauth/auth'; const theauth = await createTheAuth({ database: { provider: 'sqlite', url: 'theauth.db' }, auth: { session: { secret: process.env.SESSION_SECRET! }, }, plugins: [ customSession({ // Merged into every session automatically defaultFields: { theme: 'system', beta: false }, // Called once per session creation to compute dynamic fields onSessionCreate: async (userId) => ({ createdAt: Date.now(), plan: await fetchUserPlan(userId), }), }), ], }); ``` ## Reading and writing fields After setup, access the module through the plugin context (it is not a property of the instance): ```typescript import type { CustomSessionModule } from '@glinr/theauth/auth'; const mod = theauth.plugins.getContext().customSession as CustomSessionModule; // Read custom fields (null when the session is missing or has none) const fields = await mod.getSessionFields(session.id); // => { theme: 'system', beta: false, createdAt: 1234567890, plan: 'pro' } // Update fields on an existing session await mod.updateSessionFields(session.id, { beta: true, lastPage: '/dashboard' }); ``` `updateSessionFields` merges with existing data. Keys not in the update are left untouched. It throws when the session does not exist. ## Hook behaviour The plugin declares an `onSessionCreate` hook that merges `defaultFields` and the result of your `onSessionCreate` callback (callback wins on a key clash) into `{ custom: ... }`. The hook is collected into the plugin registry, but theAuth does not call it when sessions are created today (see the warning above). Treat `defaultFields` and `onSessionCreate` as inert until it is wired up. ## REST endpoints | Method | Path | Description | |--------|------|-------------| | `GET` | `/auth/session/fields?sessionId=` | Read custom fields for a session | | `PATCH` | `/auth/session/fields` | Update custom fields on a session | ### GET example ```http GET /auth/session/fields?sessionId=sess_abc123 ``` ```json { "fields": { "theme": "dark", "plan": "pro" } } ``` ### PATCH example ```http PATCH /auth/session/fields Content-Type: application/json { "sessionId": "sess_abc123", "fields": { "theme": "light" } } ``` ```json { "updated": true } ``` Returns `404` when the session does not exist. Both endpoints require an authenticated user, but they do not check that the `sessionId` belongs to that user, so any signed-in caller who knows a session ID can read or change its custom fields. Treat session IDs as private, or put your own ownership check in front of these routes. ## Config reference ```typescript interface CustomSessionConfig { defaultFields?: Record; onSessionCreate?: (userId: string, request?: Request) => Promise>; } ``` | Option | Type | Description | |--------|------|-------------| | `defaultFields` | `Record` | Fields intended to be merged into every new session (see the warning above). | | `onSessionCreate` | `async (userId, request?) => Record` | Async callback; return value is intended to be merged over `defaultFields`. | ## Storage No migrations required. Custom data is stored in the `metadata` JSON column of `theauth_sessions` under the `custom` key. Existing metadata keys (such as those written by other plugins) are not affected. ## Related Core session model: cookie sessions, JWT tokens, and lifecycle management. Attach custom columns to users instead of sessions. Lifecycle hooks you can attach to your instance. Cookie attributes for cross-subdomain and production setups. --- # Additional fields Source: https://docs.theauth.dev/additional-fields The `additionalFields` plugin lets you attach typed custom data to users and sessions without writing migrations. Fields are stored in the `metadata` JSON column of `theauth_users` and `theauth_sessions`. ## Install Ships inside `@glinr/theauth/auth`, no extra packages needed. ## Setup Define a schema once when creating your theAuth instance: ```typescript import { createTheAuth } from '@glinr/theauth'; import { additionalFields } from '@glinr/theauth/auth'; const theauth = await createTheAuth({ database: { provider: 'sqlite', url: 'theauth.db' }, plugins: [ additionalFields({ user: { plan: { type: 'string', required: false, defaultValue: 'free' }, credits: { type: 'number', required: false, defaultValue: 0 }, verified: { type: 'boolean', required: false }, settings: { type: 'json', required: false }, }, session: { ipCountry: { type: 'string', required: false, defaultValue: 'unknown' }, deviceType: { type: 'string', required: false }, }, }), ], }); ``` ## Reading and writing user fields ```typescript import type { AdditionalFieldsModule } from '@glinr/theauth/auth'; const mod = theauth.plugins.getContext().additionalFields as AdditionalFieldsModule; // Write await mod.setUserFields(userId, { plan: 'pro', credits: 100 }); // Read (missing fields get their defaultValue) const fields = await mod.getUserFields(userId); // => { plan: 'pro', credits: 100 } // Fields that were never written and have no defaultValue are absent from the result. ``` `setUserFields` merges with existing fields. Previously stored keys not present in the update are preserved. It throws if validation fails or the user does not exist. ## Reading and writing session fields ```typescript await mod.setSessionFields(session.id, { ipCountry: 'DE', deviceType: 'mobile' }); const sessionFields = await mod.getSessionFields(session.id); // => { ipCountry: 'DE', deviceType: 'mobile' } ``` ## Validation Validate a field map before writing, or in a request handler: ```typescript const result = mod.validate({ plan: 42 }, 'user'); // => { valid: false, errors: ['Field "plan" must be of type string'] } const ok = mod.validate({ plan: 'pro', credits: 10 }, 'user'); // => { valid: true } ``` Rules enforced during `validate()` (and automatically on every `setUserFields` / `setSessionFields` call): - Required fields must be present in the map you pass. Because `setUserFields` and `setSessionFields` validate the update itself (not the merged result), a schema with `required: true` fields means every write must include them. - Field values must match the declared type. - Fields not in the schema are rejected. ## Field types | Type | Accepted values | |------|----------------| | `string` | Any `typeof v === 'string'` | | `number` | Any `typeof v === 'number'` | | `boolean` | `true` or `false` | | `json` | Any non-null value | ## REST endpoints | Method | Path | Description | |--------|------|-------------| | `GET` | `/auth/users/fields?userId=` | Read additional fields for a user | | `PUT` | `/auth/users/fields` | Set additional fields on a user | | `POST` | `/auth/fields/validate` | Validate fields against the schema | ### GET example ```http GET /auth/users/fields?userId=usr_abc ``` ```json { "fields": { "plan": "pro", "credits": 100 } } ``` ### PUT example ```http PUT /auth/users/fields Content-Type: application/json { "userId": "usr_abc", "fields": { "plan": "enterprise" } } ``` ```json { "updated": true } ``` Returns `422` when validation fails. Returns `404` when the user does not exist. The endpoints require an authenticated user, but they do not check that the `userId` is the caller's own, so any signed-in caller can read or write another user's fields. Put your own ownership or admin check in front of them. `GET` returns the schema defaults for an unknown user ID rather than a `404`. ### Validate example ```http POST /auth/fields/validate Content-Type: application/json { "schema": "user", "fields": { "plan": 42 } } ``` ```json { "valid": false, "errors": ["Field \"plan\" must be of type string"] } ``` The status is `200` when valid and `422` when not. `schema` must be `"user"` or `"session"`. Only the user fields have REST read and write endpoints, session fields are read and written through the module (`getSessionFields`, `setSessionFields`). ## Schema reference ```typescript interface FieldDefinition { type: 'string' | 'number' | 'boolean' | 'json'; required?: boolean; defaultValue?: unknown; } interface AdditionalFieldsConfig { user?: Record; session?: Record; } ``` ## Storage No database migrations are needed. User fields are stored under `user.metadata.additionalFields` and session fields under `session.metadata.additionalFields`. Other metadata keys written by the core system or other plugins are not modified. ## Related Lifecycle hooks you can attach to your instance. Store extra data on a session record. Supported databases and schema overview for theAuth. Organizations with roles and per-org metadata. --- # Email templates Source: https://docs.theauth.dev/email-templates ## Overview `createEmailTemplates` returns a renderer for six pre-built HTML email templates (verification, password reset, magic link, email OTP, invitation, and welcome). `render(name, vars)` produces a `{ subject, text, html }` object you pass to your mail provider. ## Setup ```typescript import { createEmailTemplates } from '@glinr/theauth'; const templates = createEmailTemplates({ appName: 'MyApp', // default: 'TheAuth' appUrl: 'https://myapp.com', // default: 'http://localhost:3000' }); ``` `appName` appears in the subject lines and the email header and footer. `appUrl` is used for the default verification and reset links and for the welcome email button. ## Built-in templates | Template | Variables (all strings) | When to use | |----------|-------------------------|------------| | `verification` | `email`, `verifyUrl` (or `token`) | Email address verification on signup | | `passwordReset` | `email`, `resetUrl` (or `token`) | Password reset request | | `magicLink` | `email`, `url` | Passwordless login link | | `emailOtp` | `email`, `code` | One-time passcode for email OTP auth | | `invitation` | `email`, `orgName`, `inviteUrl` | Invite a user to an organization | | `welcome` | `email`, `name` | Post-signup welcome message | When `verifyUrl` or `resetUrl` is missing, the template builds `{appUrl}/verify?token={token}` or `{appUrl}/reset-password?token={token}`. Missing variables become empty strings, so check you pass what the template needs. ## Using a template `render(name, vars)` takes the template name and a flat map of string variables and returns `{ subject, text, html }`. ```typescript const { subject, text, html } = templates.render('magicLink', { url: 'https://myapp.com/api/theauth/auth/magic-link/verify?token=abc123', email: 'alice@example.com', }); await mailer.send({ to: 'alice@example.com', subject, text, html }); ``` Variables are inserted into the HTML without escaping. If a value can contain user-controlled text (a name, an organization name), escape it before you pass it in. ## Template expiry text The expiry sentence in each template is fixed text, it does not read your configuration: verification says 24 hours, password reset says 1 hour, magic link says 15 minutes (matching the magic link default), and email OTP says 10 minutes. The email OTP module expires codes after 5 minutes by default, so either set `codeExpiry: 600` or provide an override with matching text. ## Integrating with auth modules The magic link and email OTP modules take a send callback and leave delivery to you. Pass template output to your mail provider inside it. ```typescript import { createTheAuth, createEmailTemplates } from '@glinr/theauth'; import { magicLink } from '@glinr/theauth/auth'; const templates = createEmailTemplates({ appName: 'MyApp', appUrl: 'https://myapp.com' }); const theauth = await createTheAuth({ database: { provider: 'sqlite', url: 'theauth.db' }, auth: { session: { secret: process.env.SESSION_SECRET! } }, plugins: [ magicLink({ appUrl: 'https://myapp.com/api/theauth', async sendMagicLink(email, _token, url) { const mail = templates.render('magicLink', { url, email }); await mailer.send({ to: email, ...mail }); }, }), ], }); ``` ```typescript import { emailOtp } from '@glinr/theauth/auth'; emailOtp({ codeExpiry: 600, // match the 10 minutes in the template text async sendOtp(email, code) { const mail = templates.render('emailOtp', { code, email }); await mailer.send({ to: email, ...mail }); }, }); ``` ```typescript // passwordReset config key on createTheAuth, see the password-reset module const mail = templates.render('passwordReset', { resetUrl, email }); await mailer.send({ to: email, ...mail }); ``` ## Customizing templates Override any template with the `templates` option. Each override is a function that receives the same `vars` map as the built-in and must return `{ subject, text, html }`. ```typescript const templates = createEmailTemplates({ appName: 'MyApp', appUrl: 'https://myapp.com', templates: { welcome(vars) { const name = vars.name ?? vars.email; return { subject: `Welcome to MyApp, ${name}`, text: `Hey ${name}, your account is ready.`, html: `

Hey ${name}, your account is ready. Get started.

`, }; }, }, }); ``` Overrides only replace the templates you provide. All other templates continue to use the defaults. ## Next steps Passwordless auth via email links. One-time passcodes delivered by email. Locale and message helpers. --- # Internationalization Source: https://docs.theauth.dev/i18n ## Overview `createI18n` gives you typed access to theAuth's built-in translation strings. Use it to return localized error messages to users, translate email subjects, or power a multi-language UI. It is a standalone helper, theAuth's own endpoints return English error text and do not call it for you. ## Setup ```typescript import { createI18n, es, fr } from '@glinr/theauth'; const i18n = createI18n({ defaultLocale: 'en', translations: { es, fr }, // register the locales you need }); ``` Only English is registered by default. The other built-in locale bundles are exported separately (`es`, `fr`, `de`, `ja`, `zh`) so you only bundle what you use, and you register them through `translations` or `addLocale`. `defaultLocale` is the locale used when you do not pass one to `t`. ## Built-in locales | Export | Language | Registered by default | |--------|----------|-----------------------| | `en` | English | Yes | | `es` | Spanish | No | | `fr` | French | No | | `de` | German | No | | `ja` | Japanese | No | | `zh` | Chinese | No | ## Translating a string The locale is the second argument of `t`: ```typescript const message = i18n.t('auth.invalidCredentials'); // "Invalid email or password." const localized = i18n.t('auth.invalidCredentials', 'fr'); // "Adresse e-mail ou mot de passe incorrect." ``` `t(key, locale?)` and `t(key, vars, locale?)` are the two call shapes. Locale lookup tries an exact match (`fr-CA`), then the language prefix (`fr`), then `defaultLocale`, then `en`. ## Variable interpolation Pass a variables object as the second argument and the locale as the third. Variables are referenced with `{{name}}` in translation strings. Unknown variables are left as `{{name}}`. ```typescript i18n.t('email.welcome.subject', { appName: 'MyApp' }); // "Welcome to MyApp" i18n.t('auth.rateLimited', { retryAfter: '30' }, 'fr'); // "Trop de tentatives. Réessayez dans 30 secondes." ``` ## Per-request locale Detect the user's locale from the `Accept-Language` header and pass it to each call: ```typescript function getLocale(req: Request): string { const header = req.headers.get('accept-language') ?? 'en'; const tag = (header.split(',')[0] ?? 'en').split('-')[0]?.trim() ?? 'en'; return i18n.getLocales().includes(tag) ? tag : 'en'; } export async function POST(req: Request): Promise { const locale = getLocale(req); // ...your sign-in logic fails with invalid credentials return Response.json({ error: i18n.t('auth.invalidCredentials', locale) }, { status: 401 }); } ``` ## Adding a custom locale Pass a `translations` map, or call `addLocale` at runtime: ```typescript const i18n = createI18n({ defaultLocale: 'en', translations: { pt: { 'auth.invalidCredentials': 'E-mail ou senha inválidos.', 'auth.rateLimited': 'Muitas tentativas. Tente novamente em {{retryAfter}} segundos.', 'email.welcome.subject': 'Bem-vindo ao {{appName}}', }, }, }); i18n.addLocale('it', { 'auth.invalidCredentials': 'Email o password non validi.' }); i18n.getLocales(); // ['en', 'pt', 'it'] ``` Translations you supply merge over any keys already registered for that locale. A key missing from a locale falls back to the English string, and an unknown key returns the key itself. Partial translations are safe to ship. ## Translation key reference | Key | Variables | Description | |-----|-----------|-------------| | `auth.invalidCredentials` |, | Wrong email or password | | `auth.emailNotVerified` |, | Email address not yet verified | | `auth.accountLocked` |, | Account locked | | `auth.rateLimited` | `retryAfter` | Too many requests | | `auth.emailAlreadyExists` |, | Account with that email exists | | `auth.weakPassword` |, | Password too weak | | `auth.tokenExpired` |, | Link or token expired | | `auth.tokenInvalid` |, | Link or token invalid or already used | | `auth.unauthorized` |, | Not authorized to perform the action | | `agent.notFound` |, | Agent does not exist | | `agent.revoked` |, | Agent has been revoked | | `agent.limitExceeded` |, | Agent limit reached | | `agent.permissionDenied` |, | Agent lacks permission | | `twoFactor.invalidCode` |, | Invalid 2FA code | | `twoFactor.alreadyEnabled` |, | 2FA already enabled | | `twoFactor.notEnabled` |, | 2FA not enabled | | `email.verification.subject` |, | Email verification subject line | | `email.passwordReset.subject` |, | Password reset subject line | | `email.magicLink.subject` |, | Magic link subject line | | `email.otp.subject` |, | OTP delivery subject line | | `email.invitation.subject` | `orgName` | Organization invitation subject | | `email.welcome.subject` | `appName` | Welcome email subject line | | `general.serverError` |, | Unexpected server error | | `general.badRequest` |, | Bad request | | `general.notFound` |, | Resource not found | ## Next steps Built-in HTML templates for auth emails. theAuth error codes and their meanings. --- # W3C DID identity Source: https://docs.theauth.dev/did ## What are DIDs W3C Decentralized Identifiers give agents a portable, cryptographic identity that works across services. Instead of an opaque token tied to one theAuth instance, an agent gets a DID like `did:key:z6Mk...` backed by an Ed25519 keypair. The agent proves its identity by signing a payload with its private key, and a verifier checks the signature against the agent's public key. No shared secrets are needed. Note the current scope: `theauth.did.verify()` and `theauth.did.verifyPresentation()` look the public key up in the theAuth database, so they only verify DIDs generated by the same theAuth instance. A third party that only has a DID string cannot verify with these methods; see [Sign and verify payloads](#sign-and-verify-payloads). DIDs are optional. Regular agent bearer tokens (`kv_...`) work fine for single-service deployments. Use DIDs when agents need to prove identity across organizational boundaries. ## Two DID methods theAuth supports two W3C DID methods: | Method | Format | Best for | |---|---|---| | `did:key` | `did:key:z6Mk...` | Self-contained identity. Key is embedded in the identifier. | | `did:web` | `did:web:auth.example.com:agents:agt_123` | Organization-backed identity. DID document hosted by you at an HTTPS URL. | Each agent can hold one DID: the record is keyed by agent ID, so calling `generateKey()` or `generateWeb()` a second time for the same agent fails. ## Generate a DID for an agent ```typescript const { agentDid, privateKeyJwk } = await theauth.did.generateKey(agent.id); console.log(agentDid.did); // did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK // Store privateKeyJwk securely. It's shown once and never stored in the database. ``` Configure your domain first: ```typescript const theauth = await createTheAuth({ database: { provider: 'sqlite', url: 'theauth.db' }, did: { web: { domain: 'auth.example.com', path: 'agents' }, }, }); const { agentDid, privateKeyJwk } = await theauth.did.generateWeb(agent.id); console.log(agentDid.did); // did:web:auth.example.com:agents:agt_abc123 ``` theAuth stores the DID document but does not serve it. Publish it yourself (for example from `agentDid.didDocument`) at the resolved URL: ``` did:web:auth.example.com:agents:agt_abc123 → https://auth.example.com/agents/agt_abc123/did.json ``` A `did:web` with no path (only a domain) resolves to `https:///.well-known/did.json`. The private key is returned once and never stored in the database. Only the public key and DID document are persisted. Treat the private key like a bearer token. ## Sign and verify payloads An agent can sign a payload to prove it authored a request. The signature is an EdDSA JWT whose `iss` is the agent's DID and whose `iat` is set automatically: ```typescript // Agent signs a payload const signed = await theauth.did.sign(agent.id, { action: 'deploy', environment: 'staging', timestamp: new Date().toISOString(), }, privateKeyJwk); console.log(signed.jws); // compact JWS string console.log(signed.issuer); // did:key:z6Mk... ``` A service that shares the theAuth database verifies the signature by DID: ```typescript const result = await theauth.did.verify(signed.jws, agentDid.did); if (result.valid) { console.log(result.payload); // { action: 'deploy', ... } (standard JWT claims removed) console.log(result.issuer); // did:key:z6Mk... } else { console.log(result.error); } ``` `verify` looks up the stored public key for the DID you pass; it requires the DID argument and does not check that it matches the token's `iss`, and it does not enforce an expiry unless the payload carries a standard `exp` claim. A verifier without database access must obtain the agent's public JWK by its own means and call the lower-level `verifyPayload(jws, publicKeyJwk)`, exported from `@glinr/theauth`. `theauth.did.resolve()` is not enough for that: for `did:key` it returns a document whose `publicKeyJwk` is only a placeholder (`{ kty, crv }` without `x`), because the key is not decoded from the identifier. ## Verifiable presentations A presentation is a signed JWT that bundles an agent's identity with its capabilities. Use this when an agent needs to prove both who it is and what it can do. ```typescript // Create a presentation (the issuer DID is read from the agent's stored DID) const jwt = await theauth.did.createPresentation({ agentId: agent.id, privateKeyJwk, capabilities: ['mcp:github:read', 'mcp:linear:write'], audience: 'https://mcp.partner.com', expiresIn: 300, // seconds, default 300 }); // Verify on the receiving end const result = await theauth.did.verifyPresentation(jwt); if (result.valid) { console.log(result.issuer); // the agent's DID console.log(result.capabilities); // ['mcp:github:read', 'mcp:linear:write'] } ``` `verifyPresentation` returns `{ valid, issuer?, capabilities?, error? }` with no `agentId` field (the agent ID is in the JWT's `sub` and `agentId` claims, which the module does not return). It finds the public key by the JWT's `iss` DID in the database, checks the signature and `exp`, and does not check the `audience` you set when creating it: if you rely on audience binding, verify the `aud` claim yourself. The `capabilities` are only assertions the agent signed, they are not checked against the agent's actual permissions. ## DID document structure Every agent DID resolves to a W3C DID document: ```json { "@context": ["https://www.w3.org/ns/did/v1"], "id": "did:key:z6MkhaXg...", "controller": "did:key:z6MkhaXg...", "verificationMethod": [{ "id": "did:key:z6MkhaXg...#z6MkhaXg...", "type": "JsonWebKey2020", "controller": "did:key:z6MkhaXg...", "publicKeyJwk": { "kty": "OKP", "crv": "Ed25519", "x": "..." } }], "authentication": ["did:key:z6MkhaXg...#z6MkhaXg..."], "assertionMethod": ["did:key:z6MkhaXg...#z6MkhaXg..."], "capabilityInvocation": ["did:key:z6MkhaXg...#z6MkhaXg..."], "capabilityDelegation": ["did:key:z6MkhaXg...#z6MkhaXg..."] } ``` ## Retrieve a stored DID ```typescript const agentDid = await theauth.did.getAgentDid(agent.id); if (agentDid) { console.log(agentDid.did); // did:key:z6Mk... console.log(agentDid.method); // 'key' or 'web' } ``` ## Resolve any DID ```typescript // Resolve did:key (local, no network). The document's publicKeyJwk is a placeholder. const keyDoc = await theauth.did.resolve('did:key:z6Mk...'); // Resolve did:web (fetches https://auth.example.com/.well-known/did.json) const webDoc = await theauth.did.resolve('did:web:auth.example.com'); ``` `resolve` returns `null` for a malformed DID, a failed fetch, or an unsupported method. The fields of a stored DID record (`AgentDid`): The agent this DID belongs to. The full DID string (did:key:... or did:web:...). Which DID method was used. Ed25519 public key in JWK format. The full W3C DID document. When the DID was generated. ## Related Issue W3C Verifiable Credentials backed by agent DIDs. Cross-service agent authentication using signed federation tokens. Bearer token identity and the agent lifecycle managed by theAuth. IETF agentic JWT claim names and what theAuth emits today. --- # Verifiable credentials Source: https://docs.theauth.dev/verifiable-credentials ## Why verifiable credentials matter for agents When agent A calls agent B, how does B know what A is allowed to do? Today, most systems answer this with a network call back to a central auth server. That works inside one organization, but falls apart when agents cross trust boundaries. W3C Verifiable Credentials solve this. A credential is a signed JSON document that says "agent X has permissions Y, issued by authority Z." The agent carries the credential and presents it directly. The verifier checks the cryptographic signature and reads the permissions. No network call needed. This matters for three reasons: 1. **Offline verification** -- an agent can prove its identity and permissions without the issuing server being reachable 2. **Cross-organization trust** -- if you trust the issuer's DID, you trust the credential, regardless of where the agent lives 3. **Delegation chains** -- a credential can encode a chain of delegations, so a sub-agent can prove it was authorized by its parent VCs build on top of theAuth DID support. If you have not set up DIDs yet, see the [DID identity](/did) page first. ## Issuing credentials Create an issuer bound to a DID keypair. The issuer can produce credentials in JWT or JSON-LD format. ```typescript import { generateDidKey } from '@glinr/theauth'; import { createVCIssuer } from '@glinr/theauth/vc'; const keyPair = await generateDidKey(); const issuer = createVCIssuer({ issuerDid: keyPair.did, privateKeyJwk: keyPair.privateKeyJwk, publicKeyJwk: keyPair.publicKeyJwk, defaultTtl: 86400, // 24 hours }); ``` ### Agent identity credential Encodes who the agent is, what it can do, and how much you trust it. ```typescript const result = await issuer.issueAgentCredential({ agentId: 'agent-data-processor', name: 'Data Processor', agentType: 'autonomous', permissions: ['read:datasets', 'write:reports'], trustLevel: 0.85, format: 'jwt', }); if (result.success) { // Send the JWT to the agent -- it carries this as a bearer credential const jwt = result.data.jwt; } ``` ```typescript const result = await issuer.issueAgentCredential({ agentId: 'agent-data-processor', name: 'Data Processor', agentType: 'autonomous', permissions: ['read:datasets', 'write:reports'], trustLevel: 0.85, format: 'json-ld', }); if (result.success) { // The credential includes an embedded proof const vc = result.data.credential; console.log(vc.proof.type); // "JsonWebSignature2020" } ``` ### Permission credential Grants specific permissions to an agent without encoding the full identity. ```typescript const result = await issuer.issuePermissionCredential({ agentId: 'agent-helper', permissions: ['read:files', 'execute:tools'], ttl: 3600, // 1 hour }); ``` ### Delegation credential Encodes a chain of delegations so a sub-agent can prove it was authorized by its parent, which was authorized by its parent, and so on. ```typescript const result = await issuer.issueDelegationCredential({ agentId: 'sub-agent', chain: [ { delegator: 'did:key:z6MkRoot...', delegatee: 'did:key:z6MkMiddle...', permissions: ['read:data', 'write:data'], createdAt: new Date().toISOString(), }, { delegator: 'did:key:z6MkMiddle...', delegatee: 'sub-agent', permissions: ['read:data'], createdAt: new Date().toISOString(), }, ], delegationScope: ['read:data'], }); ``` ## Verifying credentials Create a verifier to check credentials from any source. The verifier validates the signature, checks expiry, and optionally checks revocation status. ```typescript import { createVCVerifier } from '@glinr/theauth/vc'; const verifier = createVCVerifier({ // Optional: resolve DIDs to public keys automatically resolveDidKey: async (did) => { // Look up the DID document and return the public key const doc = await myDidResolver.resolve(did); return doc?.verificationMethod[0]?.publicKeyJwk ?? null; }, // Optional: check revocation checkRevocationStatus: async (status) => { const list = await fetch(status.statusListCredential); // Check if the credential at statusListIndex is revoked return isRevoked(await list.json(), status.statusListIndex); }, }); ``` ### Verify a JWT credential ```typescript const result = await verifier.verifyCredential(jwtString); if (result.success) { console.log(result.data.issuer); // "did:key:z6Mk..." console.log(result.data.format); // "jwt" console.log(result.data.expiresAt); // Date or null } ``` ### Verify a JSON-LD credential ```typescript const result = await verifier.verifyCredential(credentialObject); if (result.success) { console.log(result.data.format); // "json-ld" } ``` ### Verify a presentation (multiple credentials) An agent might present several credentials at once to prove both identity and permissions. ```typescript const presentation = { '@context': ['https://www.w3.org/ns/credentials/v2'], type: ['VerifiablePresentation'], holder: 'did:key:z6MkAgent...', verifiableCredential: [agentCredential, permissionCredential], }; const result = await verifier.verifyPresentation(presentation); if (result.success) { for (const verified of result.data.credentials) { console.log(verified.issuer, verified.credential.type); } } ``` ### Extract permissions After verification, pull out the theAuth-specific permissions. ```typescript const extracted = verifier.extractPermissions(verifiedCredential); console.log(extracted.agentId); // "agent-data-processor" console.log(extracted.permissions); // ["read:datasets", "write:reports"] console.log(extracted.trustLevel); // 0.85 console.log(extracted.delegationScope); // [] ``` ## Portable identity The key advantage of VCs is portability. An agent issued a credential by theAuth instance A can present it to service B without B ever talking to A. ``` Agent Service B | | |-- present VC ----------->| | |-- check signature (local) | |-- check expiry (local) | |-- extract permissions | | |<-- authorized -----------| ``` The only requirement is that service B trusts the issuer's DID. This can be configured as a simple allowlist of trusted DIDs, or through a more formal trust framework. ## Integration with DID support VCs work with both `did:key` and `did:web` identifiers. The issuer DID is embedded in the credential's `issuer` field. When verifying, the verifier resolves the DID to get the public key. For `did:key`, resolution is local (the key is embedded in the identifier). For `did:web`, the verifier fetches the DID document from the well-known URL. ```typescript import { generateDidKey } from '@glinr/theauth'; import { createVCIssuer, createVCVerifier } from '@glinr/theauth/vc'; // Issuer creates credentials with their did:key const issuerKeys = await generateDidKey(); const issuer = createVCIssuer({ issuerDid: issuerKeys.did, privateKeyJwk: issuerKeys.privateKeyJwk, publicKeyJwk: issuerKeys.publicKeyJwk, }); // Verifier trusts this issuer's DID const trustedKeys = new Map([[issuerKeys.did, issuerKeys.publicKeyJwk]]); const verifier = createVCVerifier({ resolveDidKey: async (did) => trustedKeys.get(did) ?? null, }); ``` ## Related Ed25519 keypairs that back the issuer DID for credential signing. Embed Verifiable Credentials in federation tokens for offline verification. Export audit evidence as Verifiable Credentials for AI governance frameworks. IETF draft claims and W3C standards theAuth tracks. --- # Agent identity federation Source: https://docs.theauth.dev/federation ## What federation does When two services both run theAuth, an agent created at Service A can authenticate at Service B without creating a new account. The agent's identity, trust score, permissions, and delegation scope travel with it in a short-lived, signed JWT called a federation token. This is useful when: - A user has agents in multiple SaaS apps that need to talk to each other - An agent orchestrator delegates sub-tasks to agents registered at different services - You run a multi-service architecture and want unified agent identity Federation tokens are not long-lived sessions. They are short-lived credentials (5 minutes by default) that prove "this agent exists at Service A, with these permissions." The module does not track used tokens, so a token can be presented more than once until it expires. Use audience restriction and short TTLs to limit replay. ## How it works The module only signs and verifies tokens, and it does not look agents up in your database. The `agentId`, permissions and trust score you pass to `issueFederationToken` are exactly what the token carries. 1. Service A issues a federation token for one of its agents 2. The agent presents that token to Service B 3. Service B fetches Service A's public key from `/.well-known/theauth-federation.json` 4. Service B verifies the token signature, checks expiry, and applies trust level rules 5. Service B now has a `FederatedAgent` object with the agent's identity and (possibly downgraded) permissions The source instance signs tokens with an EdDSA keypair. The target instance only needs the public key to verify. ## Setup ### Generate a keypair for each instance Each theAuth instance needs its own signing key. ```typescript import { generateKeyPair } from "jose"; const { publicKey, privateKey } = await generateKeyPair("EdDSA"); ``` ### Create the federation module ```typescript import { createFederationModule } from "@glinr/theauth/auth"; const federation = createFederationModule({ instanceId: "service-a", instanceUrl: "https://a.example.com", signingKey: privateKey, tokenTtlSeconds: 300, // 5 minutes (default) }); ``` `signingKey` must be a `CryptoKey` for an EdDSA (Ed25519) key. Other options: `trustedInstances` (a list of pre-configured instances) and `autoTrust`. ### Expose the well-known endpoint Serve the instance identity at `/.well-known/theauth-federation.json` so other instances can discover your public key. ```typescript // In your HTTP server app.get("/.well-known/theauth-federation.json", async (req, res) => { const identity = await federation.getInstanceIdentity(); res.json(identity); }); ``` ### Configure trust between instances ```typescript // Service B trusts Service A federation.addTrustedInstance({ instanceId: "service-a", instanceUrl: "https://a.example.com", publicKey: serviceAPublicJwk, // from discovery or manual config trustLevel: "full", }); ``` `trustLevel` defaults to `full` when omitted. Or use discovery to fetch the public key automatically: ```typescript const discovered = await federation.discoverInstance("https://a.example.com"); if (discovered.success) { // Discovered instances default to "verify-only" trust // Upgrade if you want to accept their permissions federation.addTrustedInstance({ ...discovered.data, trustLevel: "full", }); } ``` ## Trust levels When you add a trusted instance, you choose how much to trust its claims. | Level | Permissions | Trust score | Use case | |-------|------------|-------------|----------| | `full` | Accepted as-is | Accepted as-is | Internal services you control | | `limited` | Write and admin stripped | Capped at 0.5 | Partners, third-party services | | `verify-only` | All stripped | Set to 0 | Identity verification only | With `limited` trust, any permission containing "write" or "admin" is removed from the federated agent's permissions. The trust score is capped at 0.5 regardless of what the source instance claims. With `verify-only` trust, you only confirm the agent exists at the source instance. No permissions or trust are transferred. Useful when you want to check identity before assigning local permissions. ## Issuing federation tokens Service A issues a token for one of its agents: ```typescript const result = await federation.issueFederationToken({ agentId: "agent-123", permissions: ["read:data", "write:reports"], trustScore: 0.85, delegationScope: ["tool:github"], targetInstance: "service-b", // optional audience restriction }); if (result.success) { // Give result.data.token to the agent console.log(result.data.token); console.log(result.data.expiresAt); } ``` The token is a JWT containing: - `sub`: agent ID - `iss`: source instance ID - `aud`: target instance ID (if specified) - `exp`: expiration time - `permissions`: agent permissions array - `trust_score`: source instance's trust assessment (0-1) - `delegation_scope`: what the agent is allowed to delegate - `credential`: optional embedded Verifiable Credential JWT ## Verifying federation tokens Service B verifies the token: ```typescript const result = await federation.verifyFederationToken(token); if (result.success) { const agent = result.data; console.log(agent.agentId); // "agent-123" console.log(agent.sourceInstance); // "service-a" console.log(agent.permissions); // ["read:data", "write:reports"] console.log(agent.trustScore); // 0.85 console.log(agent.delegationScope); // ["tool:github"] } ``` Verification checks: 1. Token signature matches the source instance's public key 2. Token has not expired 3. Source instance is in the trusted list and has a public key configured 4. Audience matches this instance (if audience is set in the token) 5. Trust level rules are applied to downgrade permissions if needed ## Embedding Verifiable Credentials You can attach a W3C Verifiable Credential to the federation token for offline verification. This is useful when the target instance needs cryptographic proof of the agent's capabilities that does not depend on the source instance's availability. ```typescript import { createVCIssuer } from "@glinr/theauth/vc"; const vcIssuer = createVCIssuer({ issuerDid, // e.g. a did:key for this instance privateKeyJwk, publicKeyJwk, }); // Issue a VC for the agent const vcResult = await vcIssuer.issueAgentCredential({ agentId: "agent-123", permissions: ["read:data"], trustLevel: 0.85, }); // Embed it in the federation token if (vcResult.success) { const fedResult = await federation.issueFederationToken({ agentId: "agent-123", permissions: ["read:data"], trustScore: 0.85, credential: vcResult.data.jwt, }); } ``` On the receiving side, `result.data.credential` will contain the VC JWT, which you can verify independently using `createVCVerifier`. ## Discovery protocol Each theAuth instance can publish its identity at: ``` GET /.well-known/theauth-federation.json ``` Response: ```json { "instanceId": "service-a", "instanceUrl": "https://a.example.com", "publicKeyJwk": { "kty": "OKP", "crv": "Ed25519", "x": "..." }, "protocolVersion": "1.0", "features": ["federation-tokens", "vc-embedding", "auto-discovery"] } ``` Use `discoverInstance` to fetch and parse this: ```typescript const result = await federation.discoverInstance("https://a.example.com"); ``` Discovered instances default to `verify-only` trust. You must explicitly upgrade the trust level after discovery. ## Security considerations Federation tokens carry permissions across trust boundaries. Think carefully about trust levels. - **Short TTLs**: Default is 5 minutes. Keep tokens short-lived to limit blast radius. - **Audience restriction**: Use `targetInstance` to prevent token replay at unintended services. - **Key rotation**: Rotate signing keys periodically. When you rotate, update the well-known endpoint and notify trusted instances. - **Discovery vs. pre-configuration**: Discovery is convenient for dev, but pre-configure public keys in production for stronger security guarantees. - **Auto-trust**: `autoTrust` only skips the trusted-list check. Verification still needs a public key for the issuing instance, so in practice you still add each instance (with its key) via `addTrustedInstance` or `trustedInstances`. Do not rely on it in production. - **Trust levels are enforced by the verifier**: The source instance cannot override the target's trust level setting. When the target marks the source `limited`, permissions containing `write` or `admin` are always stripped regardless of what the source claims. ## Full example Two services federating agents: ```typescript import { createFederationModule } from "@glinr/theauth/auth"; import { generateKeyPair, exportJWK } from "jose"; const { publicKey, privateKey } = await generateKeyPair("EdDSA"); const federation = createFederationModule({ instanceId: "service-a", instanceUrl: "https://a.example.com", signingKey: privateKey, }); // Expose well-known endpoint app.get("/.well-known/theauth-federation.json", async (req, res) => { const identity = await federation.getInstanceIdentity(); res.json(identity); }); // When an agent needs to talk to Service B app.post("/agents/:id/federate", async (req, res) => { const result = await federation.issueFederationToken({ agentId: req.params.id, permissions: agent.permissions, trustScore: agent.trustScore, targetInstance: "service-b", }); if (!result.success) { return res.status(500).json(result.error); } res.json({ federationToken: result.data.token }); }); ``` ```typescript import { createFederationModule } from "@glinr/theauth/auth"; import { generateKeyPair } from "jose"; const { privateKey } = await generateKeyPair("EdDSA"); const federation = createFederationModule({ instanceId: "service-b", instanceUrl: "https://b.example.com", signingKey: privateKey, trustedInstances: [ { instanceId: "service-a", instanceUrl: "https://a.example.com", publicKey: serviceAPublicJwk, trustLevel: "full", }, ], }); // Verify federation tokens on incoming requests app.use("/agents/federated/*", async (req, res, next) => { const token = req.headers.authorization?.replace("Bearer ", ""); if (!token) return res.status(401).json({ error: "Missing token" }); const result = await federation.verifyFederationToken(token); if (!result.success) { return res.status(403).json(result.error); } req.federatedAgent = result.data; next(); }); ``` ## Related Ed25519-backed DIDs for agents. Federation tokens are signed with the instance's own EdDSA key. Embed W3C Verifiable Credentials in federation tokens for offline proof. Delegate permissions within a single theAuth instance. Isolate tenants within a single instance instead of federating. --- # Multi-tenant isolation Source: https://docs.theauth.dev/multi-tenant ## What tenants are A tenant represents an organization or workspace that shares one theAuth deployment. The tenant module stores tenant records (name, slug, settings, status), and agents and budget policies can carry a `tenantId`. Tenants are a data model, not an enforcement layer. What exists today: `agent.create({ tenantId })` stores the tenant on the agent, and `agent.list({ tenantId })` filters by it. Nothing else reads it: `authorize()` does not compare tenants, a suspended tenant does not block authorization, the `settings` values are not applied when agents or delegations are created, and the audit log and REST endpoints have no tenant filter. If you need isolation, enforce it in your own code, for example by checking `agent.tenantId` against the caller's tenant before acting. `tenantId` is nullable everywhere it appears. Existing agents, policies, and audit entries created before you start using tenants continue to work without modification. ## Data model Stable identifier with a tnt_ prefix, e.g. tnt_acme. Display name for the tenant. URL-safe identifier. Lowercase letters, numbers, and hyphens only. Must be unique. Per-tenant configuration values, stored on the tenant record and not enforced by the SDK (see the note above). Recorded by `suspend()` and `activate()`. The SDK does not currently act on it. When the tenant was created. When the tenant was last modified. ### TenantSettings Intended cap on active agents in this tenant. Not enforced; the cap that applies is `agents.maxPerUser`. Intended delegation depth limit. Not enforced; delegation uses the `maxDepth` passed to `delegate()`. Intended audit retention for this tenant, in days. Not enforced; call `theauth.audit.cleanup({ retentionDays })` yourself. Intended restriction on agent types. Not enforced. ## Creating a tenant Slugs must be unique and match `^[a-z0-9]+(?:-[a-z0-9]+)*$`. theAuth rejects duplicate slugs at creation time. ```typescript const tenant = await theauth.tenant.create({ name: 'Acme Corp', slug: 'acme', settings: { maxAgents: 200, auditRetentionDays: 365, allowedAgentTypes: ['autonomous', 'service'], }, }); console.log(tenant.id); // tnt_... console.log(tenant.slug); // acme ``` ## Creating an agent inside a tenant Pass `tenantId` when creating an agent with `theauth.agent.create`. The agent stores that tenant ID, and the tenant must already exist (foreign key). The REST endpoint `POST /agents` ignores `tenantId`. ```typescript const agent = await theauth.agent.create({ ownerId: 'user-456', name: 'acme-data-bot', type: 'autonomous', tenantId: tenant.id, permissions: [ { resource: 'reports:*', actions: ['read', 'export'] }, ], }); ``` ## Listing agents by tenant Available on `theauth.agent.list` only; `GET /agents` has no `tenantId` parameter. ```typescript const agents = await theauth.agent.list({ tenantId: tenant.id, status: 'active', }); ``` ## Fetching and updating a tenant ```typescript // By ID const t = await theauth.tenant.get('tnt_abc123'); // By slug (useful when the slug comes from a URL path) const t2 = await theauth.tenant.getBySlug('acme'); // Update settings const updated = await theauth.tenant.update(tenant.id, { settings: { maxAgents: 500, auditRetentionDays: 730, }, }); ``` Settings are merged, not replaced. Fields you omit in the update keep their existing values. ## Listing all tenants ```typescript const tenants = await theauth.tenant.list(); ``` Useful for admin dashboards. Returns all tenants regardless of status. ## Suspending and reactivating `suspend` and `activate` flip the tenant's `status` between `suspended` and `active`. The SDK does not check that status when authorizing, so suspending a tenant does not stop its agents by itself. To cut off a tenant today, check `tenant.status` in your own code, or revoke its agents with `theauth.agent.revoke`. ```typescript // Suspend await theauth.tenant.suspend(tenant.id); // Reactivate await theauth.tenant.activate(tenant.id); ``` ## Budget policies per tenant Budget policies store a `tenantId`, but `checkBudget` and `recordUsage` match policies by `agentId` only, so a tenant-only policy applies to every agent, not just the tenant's. See [Budget policies](/budget-policies) for the full reference and how to enforce limits. ```typescript await theauth.policies.create({ tenantId: tenant.id, limits: { maxTokensCostPerMonth: 5000, maxCallsPerMonth: 100_000, }, action: 'block', }); ``` ## Next steps Per-agent and global cost limits, with the check and enforcement steps you call yourself. Create agents scoped to a tenant. Query the audit trail by agent or user. --- # Standards alignment Source: https://docs.theauth.dev/standards theAuth tracks the two emerging IETF drafts for agent authorization: `draft-goswami-agentic-jwt-00` (agentic JWT claims) and `draft-liu-agent-operation-authorization-01` (three-layer user-workload-token binding). Claim names are defined in a single file so future audits are a one-file review. ## The claim constants Every claim name lives in `packages/core/src/standards/claims.ts` as `AGENTIC_JWT_CLAIMS`. Each constant has a JSDoc reference to the relevant draft section. ```ts import { AGENTIC_JWT_CLAIMS } from "@glinr/theauth/standards"; AGENTIC_JWT_CLAIMS.AGENT_ID; // "agent_id" AGENTIC_JWT_CLAIMS.AGENT_TYPE; // "agent_type" AGENTIC_JWT_CLAIMS.ON_BEHALF_OF; // "on_behalf_of" AGENTIC_JWT_CLAIMS.ACT; // "act" AGENTIC_JWT_CLAIMS.MAY_ACT; // "may_act" AGENTIC_JWT_CLAIMS.TRUST_TIER; // "trust_tier" AGENTIC_JWT_CLAIMS.AUDIT_REF; // "audit_ref" AGENTIC_JWT_CLAIMS.TOOL_CONSTRAINTS; // "tool_constraints" AGENTIC_JWT_CLAIMS.WORKLOAD_BINDING; // "wit" AGENTIC_JWT_CLAIMS.OPERATION; // "operation" ``` ## Turning claim emission on Claim emission is off by default and is configured on the two token issuers that support it, not through a single global switch. `TheAuthConfig` declares a top-level `emitAgenticJwtClaims` field, but `createTheAuth()` does not read it, so setting it there has no effect. **MCP access tokens.** Set `emitAgenticJwtClaims: true` on the `mcp` config and supply `getAgenticContext`, which returns the values to embed. Fields you leave out are omitted rather than invented. ```ts import { createTheAuth } from "@glinr/theauth"; const theauth = await createTheAuth({ database: { provider: "postgres", url: process.env.DATABASE_URL! }, mcp: { enabled: true, signingSecret: process.env.THEAUTH_MCP_SECRET, emitAgenticJwtClaims: true, getAgenticContext: async (userId) => ({ agentId: "agent_42", agentType: "delegated", trustTier: "standard", }), }, }); ``` **JWT sessions.** Set `emitAgenticJwtClaims: true` in the JWT session config, and pass an `agenticContext` (`{ agentId?, agentType?, trustTier? }`) on the `SessionUser` you create the session for. See [JWT sessions](/jwt-sessions). With emission on and a context supplied, a token looks like this (abridged): ```json { "sub": "user_123", "iss": "https://your-app.example.com", "exp": 1700000000, "agent_id": "agent_42", "agent_type": "delegated", "trust_tier": "standard" } ``` ## What is populated today Only three claims are ever written, and only from the context you provide: | Claim | Populated when | Source | |---|---|---| | `agent_id` | The context has `agentId` | `getAgenticContext` (MCP) or `SessionUser.agenticContext` (JWT sessions) | | `agent_type` | The context has `agentType` (`autonomous`, `delegated`, or `supervised`) | Same | | `trust_tier` | The context has `trustTier` (`unverified`, `low`, `standard`, `elevated`, or `high`) | Same | | `on_behalf_of`, `act`, `may_act`, `audit_ref`, `tool_constraints`, `wit`, `operation` | Never. The names are defined as constants and typed in `AgenticJwtClaims`, but no issuer emits them | None | theAuth does not derive these values for you. The trust module is not wired into `getAgenticContext`, and the `TrustTier` bands used for the claim (`unverified`, `low`, `standard`, `elevated`, `high`) are a separate vocabulary from the trust levels returned by `theauth.trust` (`untrusted`, `limited`, `standard`, `trusted`, `elevated`). Map between them in your own `getAgenticContext`. ## Roadmap Not implemented, listed so you do not look for them: - Three-layer binding (`wit`, `operation`): needs workload identity support. - `act` and `may_act` for RFC 8693 delegation chains. - Automatic `trust_tier` from the trust module. ## What this does not aim at - OpenID for Verifiable Presentations (OID4VP). Out of scope for agent sign-in. Verifiable Credentials theAuth issues are for audit, not for sign-in. - SPIFFE URI scheme. Agent identity uses DIDs (`did:key`, `did:web`) which fit the hosted path better. - Post-quantum signatures (ML-DSA). Tracked, not scheduled. ## Related PKCE, RFC 9728, RFC 8414, RFC 7591 implementation details. Ed25519 DIDs that back agentic JWT claims like agent_id. W3C VC issuance for audit export and cross-org trust. Where agent_id and trust_tier claims originate. --- # Migration guides Source: https://docs.theauth.dev/migrate/index Pick the guide that matches your current setup. Concepts map, step-by-step code diffs, data migration SQL, and rollback strategy. Concepts map, hooks and middleware diff, Clerk data export SQL, rollout plan. Concepts map, delegation chain shape, cascading revocation, authorizeByToken migration. Concepts map, M2M-to-agent migration, rules-to-hooks, user export import, rollback plan. One command renames Kavach exports, env vars and imports to TheAuth. --- # Migrate from better-auth Source: https://docs.theauth.dev/migrate/from-better-auth better-auth is a solid human-auth library. If your product now needs AI agents as first-class entities, an MCP OAuth 2.1 server, GDPR data export tooling, or trust scoring per agent, theAuth is worth the switch. If you rely on an OAuth provider that better-auth covers but theAuth does not ship first-class (we have 17 plus a generic OIDC factory as of 2026-04), you may want to wait. ## Concepts map | better-auth | theAuth | |---|---| | `auth = betterAuth({...})` | `theauth = await createTheAuth({...})` | | `User` | `User` + `AgentIdentity` (agents are a first-class entity, not an extension) | | `Session` | `Session`, plus ephemeral agent sessions (`createEphemeralSessionModule` from `@glinr/theauth/auth`) | | `organization` plugin | `organization()` plugin for HTTP endpoints, plus `org: {...}` on `createTheAuth` to get the server-side `theauth.org` API (it is `null` otherwise) | | `admin` plugin | `admin` plugin and `theauth.admin` (ban, impersonation with a TTL) | | `two-factor`, `passkey`, `magic-link`, `username`, `email-otp`, `phone-number`, `anonymous`, `siwe`, `device-authorization`, `one-tap` | All present, mostly as `twoFactor()`, `passkey()`, `magicLink()` style plugins or config keys. The shapes differ, check each page under Auth. | | `api-key` plugin | `apiKeys` config key and `theauth.apiKeys` | | `mcp` plugin (thin wrapper) | MCP OAuth 2.1 server from `createMcpModule` in `@glinr/theauth/mcp`, passed to your framework adapter. You supply the storage callbacks. | | `@better-auth/agent-auth` | theAuth core (`theauth.agent`), no separate package | | `sso`, `saml`, `scim`, `oidc-provider`, `openapi`, `jwt`, `custom-session`, `additional-fields`, `bearer` | Present under similar names (`sso`, `scim()`, `createOidcProviderModule`, `createOpenApiModule`, `createJwtSessionModule`, `additionalFields`, `bearerAuth`) | ## Server migration ### Next.js App Router ```ts // BEFORE: lib/auth.ts (better-auth) import { betterAuth } from 'better-auth'; import { organization, twoFactor } from 'better-auth/plugins'; export const auth = betterAuth({ database: { provider: 'postgresql', url: process.env.DATABASE_URL, }, emailAndPassword: { enabled: true }, plugins: [organization(), twoFactor()], }); ``` ```ts // AFTER: lib/theauth.ts (TheAuth) import { createTheAuth } from '@glinr/theauth'; import { organization, twoFactor } from '@glinr/theauth/auth'; export const theauth = await createTheAuth({ database: { provider: 'postgres', url: process.env.DATABASE_URL! }, secret: process.env.THEAUTH_SECRET!, baseUrl: process.env.AUTH_BASE_URL!, auth: { session: { secret: process.env.SESSION_SECRET! } }, username: { password: { minLength: 8 } }, // TheAuth's password auth is username-based, see /auth/username plugins: [organization(), twoFactor()], }); ``` theAuth has no `emailAndPassword` config key. Its built-in password module authenticates by username, see [Username and password](/auth/username) for the real shape and how to pair it with email verification if you need an email-first flow. ```ts // BEFORE: app/api/auth/[...all]/route.ts (better-auth) import { auth } from '@/lib/auth'; import { toNextJsHandler } from 'better-auth/next-js'; export const { GET, POST } = toNextJsHandler(auth); ``` ```ts // AFTER: app/api/theauth/[...theauth]/route.ts (TheAuth) import { theAuthNextjs } from '@glinr/theauth-nextjs'; import { theauth } from '@/lib/theauth'; const handlers = theAuthNextjs(theauth, { authenticate }); export const GET = handlers.GET; export const POST = handlers.POST; export const PATCH = handlers.PATCH; export const DELETE = handlers.DELETE; export const OPTIONS = handlers.OPTIONS; ``` ### Hono ```ts // BEFORE: src/index.ts (better-auth) import { Hono } from 'hono'; import { auth } from './lib/auth.js'; const app = new Hono(); app.on(['GET', 'POST'], '/api/auth/*', (c) => auth.handler(c.req.raw)); ``` ```ts // AFTER: src/index.ts (TheAuth) import { Hono } from 'hono'; import { theAuthHono } from '@glinr/theauth-hono'; import { theauth } from './lib/theauth.js'; const app = new Hono(); app.route('/api/theauth', theAuthHono(theauth, { authenticate })); ``` ## Database adapter better-auth supports Prisma, Drizzle, Mongoose, and others. `createTheAuth` always runs on its own Drizzle layer. `@glinr/theauth-prisma` lets you query theAuth tables through an existing `PrismaClient`, but it is a standalone query layer, not a database backend for `createTheAuth`, see [Prisma adapter](/prisma). ```ts // BEFORE: better-auth with Drizzle adapter import { betterAuth } from 'better-auth'; import { drizzleAdapter } from 'better-auth/adapters/drizzle'; import { db } from './db.js'; export const auth = betterAuth({ database: drizzleAdapter(db, { provider: 'pg' }), }); ``` ```ts // AFTER: TheAuth (Drizzle is built in, no separate adapter import) import { createTheAuth } from '@glinr/theauth'; export const theauth = await createTheAuth({ database: { provider: 'postgres', url: process.env.DATABASE_URL! }, }); ``` theAuth runs its own schema migrations. You do not pass your Drizzle `db` instance; pass the connection URL and theAuth manages its own tables. See [data migration](#data-migration) below for how to move existing rows. ## Client SDK ```ts // BEFORE: better-auth client import { createAuthClient } from 'better-auth/client'; import { organizationClient } from 'better-auth/client/plugins'; export const authClient = createAuthClient({ baseURL: 'http://localhost:3000', plugins: [organizationClient()], }); const { data: session } = await authClient.useSession(); ``` ```ts // AFTER: TheAuth client import { createTheAuthClient } from '@glinr/theauth-client'; export const client = createTheAuthClient({ baseUrl: 'http://localhost:3000/api/theauth', }); ``` For React, swap the hook import: ```tsx // BEFORE (better-auth React hooks) import { useSession } from 'better-auth/react'; // verify against current better-auth docs // AFTER (TheAuth React hooks) import { useSession } from '@glinr/theauth-react'; // Wrap your app: import { TheAuthProvider } from '@glinr/theauth-react'; {children} ``` ## OAuth providers GitHub, Google, and Discord side-by-side: ```ts // BEFORE: better-auth import { betterAuth } from 'better-auth'; export const auth = betterAuth({ socialProviders: { github: { clientId: process.env.GITHUB_CLIENT_ID!, clientSecret: process.env.GITHUB_CLIENT_SECRET!, }, google: { clientId: process.env.GOOGLE_CLIENT_ID!, clientSecret: process.env.GOOGLE_CLIENT_SECRET!, }, discord: { clientId: process.env.DISCORD_CLIENT_ID!, clientSecret: process.env.DISCORD_CLIENT_SECRET!, }, }, }); ``` ```ts // AFTER: TheAuth import { createTheAuth } from '@glinr/theauth'; import { oauth, createGithubProvider, createGoogleProvider, createDiscordProvider, } from '@glinr/theauth/auth'; export const theauth = await createTheAuth({ database: { provider: 'postgres', url: process.env.DATABASE_URL! }, secret: process.env.THEAUTH_SECRET!, baseUrl: process.env.AUTH_BASE_URL!, auth: { session: { secret: process.env.SESSION_SECRET! } }, plugins: [ oauth({ // A map of provider id to a provider instance, not an array of config objects. providers: { github: createGithubProvider({ clientId: process.env.GITHUB_CLIENT_ID!, clientSecret: process.env.GITHUB_CLIENT_SECRET!, }), google: createGoogleProvider({ clientId: process.env.GOOGLE_CLIENT_ID!, clientSecret: process.env.GOOGLE_CLIENT_SECRET!, }), discord: createDiscordProvider({ clientId: process.env.DISCORD_CLIENT_ID!, clientSecret: process.env.DISCORD_CLIENT_SECRET!, }), }, }), ], }); ``` `clientId` and `clientSecret` belong to the `createXProvider(...)` call. The `oauth()` plugin itself takes `providers` as a record of ready-made `OAuthProvider` instances, and the `oauth` plugin requires `auth.session` so it can issue a session on callback. The default callback URL is `{baseUrl}/auth/oauth/callback/{provider}`, so include your adapter mount path in `baseUrl` (or pass `buildRedirectUri`). For a provider without a `createXProvider` function, use the generic OIDC factory: ```ts import { genericOIDC } from '@glinr/theauth/auth'; const linear = genericOIDC({ id: 'linear', name: 'Linear', issuer: 'https://linear.app', clientId: process.env.LINEAR_CLIENT_ID!, clientSecret: process.env.LINEAR_CLIENT_SECRET!, scopes: ['read'], authorizationUrl: 'https://linear.app/oauth/authorize', tokenUrl: 'https://api.linear.app/oauth/token', userinfoUrl: 'https://api.linear.app/graphql', }); // then: oauth({ providers: { linear } }) ``` ## Breaking differences ### Session tokens better-auth and theAuth use different token structures. An existing session token from better-auth will not be accepted by theAuth and vice versa. `auth.adapter` only accepts a single adapter, there is no built-in multi-adapter "accept both" mode, so bridge the transition window yourself with `customAuth`, which wraps a function that reads the old better-auth cookie: ```ts import { createTheAuth } from '@glinr/theauth'; import { customAuth } from '@glinr/theauth/auth'; import { auth as legacyAuth } from './lib/better-auth-legacy'; // your old better-auth instance, kept around during cutover const legacyAdapter = customAuth(async (request) => { const session = await legacyAuth.api.getSession({ headers: request.headers }); if (!session?.user) return null; return { id: session.user.id, email: session.user.email, name: session.user.name }; }); export const theauth = await createTheAuth({ database: { provider: 'postgres', url: process.env.DATABASE_URL! }, secret: process.env.THEAUTH_SECRET!, baseUrl: process.env.AUTH_BASE_URL!, auth: { adapter: legacyAdapter, session: { secret: process.env.SESSION_SECRET! }, }, }); ``` You can configure both. For the endpoints that theAuth plugins register, the user is resolved by trying `legacyAdapter` first and then falling back to a theAuth session (`auth.session`) cookie. Note that `theauth.auth.resolveUser(request)` consults only the adapter, so in your own server code check `theauth.auth.session` too if you need to accept both. Once traffic drops to near-zero on the old cookie, drop the adapter and keep `auth.session` only. ### Cookie defaults | Setting | better-auth default | theAuth default | |---|---|---| | Cookie name | `better-auth.session_token` | `theauth_session` (change with `auth.session.cookieName`) | | `Secure` | Depends on `NODE_ENV` | Depends on the code path (see below) | | `SameSite` | `Lax` | `Lax` | | `HttpOnly` | `true` | `true` | `Secure` is not one global setting. The OAuth callback sets it when `baseUrl` starts with `https://`. The standalone cookie session manager defaults it to on only when `NODE_ENV` is `production`, and you can override it with `cookieOptions.secure`. If your better-auth app set `secure: false` in a staging environment, check your `baseUrl` and `NODE_ENV` there. ### `@better-auth/agent-auth` If you use `@better-auth/agent-auth`, none of its config maps 1:1. That package is a thin wrapper; theAuth replaces it with a native `AgentIdentity` entity, built-in delegation, ephemeral sessions, and the MCP OAuth server. Start with the [agents quickstart](/agents) rather than trying to adapt your existing `agent-auth` config. ## Data migration theAuth keeps its own tables, all prefixed `theauth_`. Run `createTheAuth` once so it creates them, then copy rows across. Users go into `theauth_users` and provider links into `theauth_oauth_accounts`. Adjust the source column names to your better-auth schema (Postgres setups often use camelCase such as `"emailVerified"` and `"createdAt"`). ```sql -- Users INSERT INTO theauth_users (id, email, name, email_verified, created_at, updated_at) SELECT id, lower(email), name, "emailVerified", "createdAt", "updatedAt" FROM "user" -- better-auth default table name ON CONFLICT (id) DO NOTHING; -- Accounts (OAuth connections). Skip the password ("credential") rows. INSERT INTO theauth_oauth_accounts ( id, user_id, provider, provider_account_id, access_token, refresh_token, expires_at, created_at, updated_at ) SELECT id, "userId", "providerId", "accountId", COALESCE("accessToken", ''), -- access_token is NOT NULL in TheAuth "refreshToken", "accessTokenExpiresAt", "createdAt", "updatedAt" FROM account WHERE "providerId" <> 'credential' ON CONFLICT (id) DO NOTHING; ``` Column names in better-auth can vary depending on your adapter and any custom fields you added. Run `SELECT column_name FROM information_schema.columns WHERE table_name = 'user'` against your database to confirm the exact names before running the migration. Two things do not migrate: - **Sessions.** A theAuth session row (`theauth_sessions`) holds only `id`, `user_id`, `expires_at`, and metadata. The token is a signed JWT that references that row, so better-auth session tokens cannot be copied across. Use the `legacyAdapter` above to keep people signed in, or have them sign in again. - **Passwords.** The `username` module verifies PBKDF2 hashes in the format `pbkdf2:::` only. better-auth's `credential` account hashes are not in that format and will not verify. Follow the forced reset or lazy rehash approach in [Migrate from Auth0](/migrate/from-auth0#user-data-migration), which applies the same way here. ## Rollback Keep better-auth running behind a feature flag while you roll out theAuth to a percentage of traffic. ```ts // middleware.ts (Next.js) import { NextRequest, NextResponse } from 'next/server'; export function middleware(req: NextRequest) { const userId = req.cookies.get('user_id')?.value ?? ''; // Hash the ID and check if it falls in the rollout bucket const bucket = hashToPercent(userId); const rolloutPercent = Number(process.env.THEAUTH_ROLLOUT ?? '0'); if (bucket < rolloutPercent) { // Route to TheAuth handler return NextResponse.rewrite(new URL(req.url.replace('/api/auth', '/api/theauth'), req.url)); } // Fall through to better-auth return NextResponse.next(); } function hashToPercent(str: string): number { let hash = 0; for (let i = 0; i < str.length; i++) { hash = (hash * 31 + str.charCodeAt(i)) >>> 0; } return hash % 100; } export const config = { matcher: '/api/auth/:path*' }; ``` Set `THEAUTH_ROLLOUT=10` to start at 10%, then raise it over days as you validate sessions in the theAuth tables. The `customAuth`-based `legacyAdapter` described above keeps existing users resolvable during the overlap. ## FAQ **Does theAuth support all the OAuth providers better-auth has?** No. As of 2026-04 we ship 17 first-class providers: Apple, Atlassian, Discord, Dropbox, Figma, GitHub, GitLab, Google, LinkedIn, Microsoft, Notion, Reddit, Slack, Spotify, Twitch, Twitter/X, Zoom. Any provider with a standard OAuth 2.0 authorization code flow works via the generic provider factory, but you write the config by hand. If a specific provider matters to you, open an issue. **Is Prisma supported?** Partly. `createTheAuth` itself takes `database: { provider, url }` and runs on Drizzle. `@glinr/theauth-prisma` gives you `createPrismaAdapter(prisma)` to read and write theAuth tables from code that already uses Prisma, it does not replace the core instance. See the [Prisma adapter docs](/prisma). **Do I need to install an MCP plugin?** No separate package. Build the OAuth server with `createMcpModule` from `@glinr/theauth/mcp` and pass it to your adapter as the `mcp` option, see [MCP](/mcp). The `mcp` key on `createTheAuth` only creates the MCP tables. **Can I run better-auth and theAuth side by side?** Yes. Use `customAuth` to wrap your existing better-auth instance's session check as a `legacyAdapter`, and route traffic with a feature flag as shown above. **Will my users have to sign in again?** With the `legacyAdapter`, not right away: theAuth plugin endpoints keep resolving the better-auth session. The adapter does not mint theAuth sessions, so users move onto theAuth sessions when they next sign in through theAuth. Without the adapter, yes, existing tokens will be rejected. ## Related Feature-by-feature comparison before committing to a migration. Configure the Drizzle-based database layer theAuth uses. Query theAuth tables from an existing PrismaClient. The agent primitives that motivated the switch from better-auth. --- # Migrate from the better-auth agent plugin Source: https://docs.theauth.dev/migrate/from-better-auth-agent-plugin `@better-auth/agent-auth` is better-auth's attempt to add AI-agent support alongside a human-first auth library. It is marked "heavy development, not yet stable" in their repo as of 2026-03. If you started there to get agent identity with the broader better-auth feature set, moving to theAuth gives you a single codebase where agents are a first-class entity instead of a separate package bolted onto the user model. Both libraries share the same ancestor on the human-auth side. Most of the familiar hooks keep their names. The agent story is the part that changes materially. If you use the human-auth half of better-auth and not the agent plugin yet, read the [general better-auth migration guide](/migrate/from-better-auth) first, this page only covers the agent-plugin deltas. ## Where the model diverges better-auth treats an agent as an OAuth client attached to a user. A single-hop delegation is possible. There is no delegation chain tracking, no trust score, no cost attribution, no ephemeral session type, and no MCP OAuth 2.1 authorization server built in. theAuth treats an agent as a primary database entity with its own lifecycle: - `AgentIdentity` row in the schema, owned by a user, with status transitions (`active`, `revoked`, `expired`). - Multi-hop delegation chains with a per-call depth limit and cascading revocation of chains. - Trust scoring, 5 levels, computed from the audit log. - Cost attribution per agent, tool, and chain. - An MCP OAuth 2.1 authorization server (`createMcpModule`) that sits alongside the same instance and adapter. - Ephemeral agent sessions for one-off tasks (`createEphemeralSessionModule`). ## Concepts map | `@better-auth/agent-auth` | theAuth | |---|---| | `agentPlugin()` added to `betterAuth` | Built into `createTheAuth`. No plugin needed for the core agent surface. | | `auth.api.createAgent({ userId, scopes })` | `theauth.agent.create({ ownerId, name, type, permissions })` | | `agent.accessToken` | `agent.token` (`kv_` followed by base64url of 32 random bytes, returned once, SHA-256 hashed at rest) | | `agent.scopes: string[]` | `agent.permissions: Permission[]` (resource patterns + actions + constraints) | | `agent.kind: 'service' \| 'user-agent'` | `agent.type: 'autonomous' \| 'delegated' \| 'service'` | | `auth.api.verifyAgentToken(token)` | `theauth.agent.validateToken(token)` (returns the agent or `null`) or `theauth.authorizeByToken(token, req)` | | Single-hop delegation via second `createAgent` call | `theauth.delegate({ fromAgent, toAgent, permissions, expiresAt, maxDepth })` | | No cascading revocation | `theauth.delegation.revoke(chainId)` revokes that link and every downstream link. Immediate. | | No audit module (relies on generic logs) | `theauth.audit.query()` and `theauth.audit.export()` | | No trust score | `theauth.trust.computeScore(agentId)` | | No MCP server | OAuth 2.1 authorization server from `createMcpModule` (`@glinr/theauth/mcp`), PKCE S256, RFC 9728 / 8707 / 8414 / 7591 | | No per-agent rate cap | Permission constraint: `maxCallsPerHour` | | No approval gate | Permission constraint `requireApproval: true` denies the call with a "requires human approval" reason. Create and resolve requests with `theauth.approval`. | | No ephemeral sessions | `createEphemeralSessionModule({ db: theauth.db }).createSession({ ownerId, permissions, ttlSeconds })`, see [Ephemeral sessions](/ephemeral-sessions) | ## Server setup ```ts // BEFORE: lib/auth.ts (better-auth + agent plugin) import { betterAuth } from 'better-auth'; import { agent } from '@better-auth/agent-auth'; export const auth = betterAuth({ database: { provider: 'postgresql', url: process.env.DATABASE_URL }, emailAndPassword: { enabled: true }, plugins: [agent()], }); ``` ```ts // AFTER: lib/theauth.ts import { createTheAuth } from '@glinr/theauth'; export const theauth = await createTheAuth({ database: { provider: 'postgres', url: process.env.DATABASE_URL! }, secret: process.env.THEAUTH_SECRET!, baseUrl: process.env.AUTH_BASE_URL!, auth: { session: { secret: process.env.SESSION_SECRET! } }, username: { password: { minLength: 8 } }, // TheAuth's password auth is username-based, see /auth/username // No agent plugin, it is core. }); ``` theAuth has no `emailAndPassword` config key, its built-in password module authenticates by username (see [Username and password](/auth/username)). The rest of the human-auth config (`organization`, `twoFactor`, etc.) keeps a similar plugin shape, imported from `@glinr/theauth/auth` rather than `@glinr/theauth/plugins`. Check each feature's page, the option names are not identical. ## Creating an agent ```ts // BEFORE (better-auth + agent plugin) const agent = await auth.api.createAgent({ userId: user.id, name: 'github-reader', kind: 'service', scopes: ['github:read'], }); // agent.accessToken is the bearer. Store it. ``` ```ts // AFTER (TheAuth) const agent = await theauth.agent.create({ ownerId: user.id, name: 'github-reader', type: 'service', permissions: [ { resource: 'mcp:github:*', actions: ['read'], constraints: { maxCallsPerHour: 500 }, }, ], }); // agent.token (kv_ plus base64url of 32 random bytes) is the bearer. Returned once, // only its SHA-256 hash is stored. Rotate with theauth.agent.rotate(agent.id) to cut // a new one; the old token stops validating as soon as the new hash is written. ``` The shape of the call is close. The substantive difference is the permission model. A `scopes: ['github:read']` list maps to `{ resource: 'mcp:github:*', actions: ['read'] }`. The extra structure pays for itself when you need to add rate caps, argument allowlists, or time windows. In a resource pattern, `*` matches the rest of the path from that position on, so `mcp:github:*` also covers `mcp:github:repos:list`. `ownerId` must be the id of an existing user (`theauth_users`). ## Authorizing a tool call ```ts // BEFORE (better-auth + agent plugin) const result = await auth.api.verifyAgentToken(token); if (!result.valid) return new Response('Unauthorized', { status: 401 }); if (!result.scopes.includes('github:read')) { return new Response('Forbidden', { status: 403 }); } ``` ```ts // AFTER (TheAuth) const result = await theauth.authorizeByToken(token, { action: 'read', resource: 'mcp:github:repos', }); if (!result.allowed) { return new Response(result.reason ?? 'Forbidden', { status: 403 }); } // Every call is audited. result.auditId points at the row. ``` One call. One audit row. One place to reason about rate caps and constraints. ## Delegation ```ts // BEFORE: single-hop only. You create a new agent whose scopes are a subset of the parent. const child = await auth.api.createAgent({ userId: user.id, name: 'child-agent', kind: 'service', scopes: ['github:read'], // must be a subset of parent }); ``` ```ts // AFTER: explicit chain with depth and expiry const chain = await theauth.delegate({ fromAgent: parent.id, toAgent: child.id, permissions: [{ resource: 'mcp:github:*', actions: ['read'] }], maxDepth: 2, expiresAt: new Date(Date.now() + 60 * 60 * 1000), }); // Revoke the link, and every link downstream of `child`, in one call: await theauth.delegation.revoke(chain.id); ``` The delegated permissions must be a subset of the parent agent's own permissions, otherwise `delegate` throws. `expiresAt` is required. Revocation works on chains, not on the parent agent. `theauth.agent.revoke(parent.id)` marks that agent revoked so its own calls are denied, but it does not touch the chains it already issued. Call `theauth.delegation.revoke(chain.id)` to cut the child off, which also revokes the chains the child delegated onward. Use `theauth.delegation.listChains(agentId)` to find them. `maxDepth` is checked on each call to `theauth.delegate`: the new link's depth must be less than or equal to the `maxDepth` you pass on that call (default 3). Depth is one more than the deepest active chain that ends at `fromAgent`. So `maxDepth: 2` stops a third hop only if you pass it when creating that third link. Nothing stores a root-level cap that later calls inherit, so pass the same value from your delegation helper every time. ## Trust scoring `@better-auth/agent-auth` does not ship scoring. theAuth computes a 0-100 score from the audit history, mapped to five named levels. Useful as a gate for sensitive actions that should only run for agents with a clean track record. ```ts const score = await theauth.trust.computeScore(agent.id); if (score.level === 'untrusted' || score.level === 'limited') { // Route through the approval flow instead of auto-execute. await theauth.approval.request({ agentId: agent.id, userId: agent.ownerId, action: 'delete', resource: 'file:prod-data/*', }); return { queued: true }; } ``` ## MCP server If you were running a separate MCP OAuth server alongside better-auth (because the `@better-auth/mcp` plugin is a thin OIDC wrapper without agent semantics), you can retire it. theAuth's OAuth 2.1 server is a module you create with `createMcpModule` from `@glinr/theauth/mcp` and pass to your framework adapter. It does not take the `theauth` instance, and it has no built-in database: you supply the storage callbacks. [MCP](/mcp) has a complete in-memory example of those callbacks. ```ts import { createTheAuth } from '@glinr/theauth'; import { createMcpModule } from '@glinr/theauth/mcp'; import { theAuthNextjs } from '@glinr/theauth-nextjs'; import { mcpStore } from './mcp-store'; // storeClient, findClient, storeAuthorizationCode, ... const theauth = await createTheAuth({ database: { provider: 'postgres', url: process.env.DATABASE_URL! }, mcp: { enabled: true }, // creates the MCP server registry table only }); const mcp = createMcpModule({ config: { enabled: true, issuer: 'https://auth.yourapp.com', // Public origin plus the adapter mount path baseUrl: 'https://auth.yourapp.com/api/theauth', signingSecret: process.env.MCP_SIGNING_SECRET!, // at least 32 characters }, ...mcpStore, }); export const { GET, POST, PATCH, DELETE, OPTIONS } = theAuthNextjs(theauth, { mcp, authenticate }); ``` `mcpStore` supplies `storeClient`, `findClient`, `storeAuthorizationCode`, `consumeAuthorizationCode`, `storeToken`, `findTokenByRefreshToken`, `revokeToken`, and `resolveUserId`. The adapter serves `/mcp/register`, `/mcp/authorize`, and `/mcp/token`, plus the two `.well-known` documents, all relative to its mount path (`/api/theauth` by default). MCP clients look for the `.well-known` documents at the root of your origin, so add root-level routes or rewrites when you mount under a prefix, see [MCP](/mcp). ## Data migration Agent rows in better-auth live in whatever table the plugin writes to (check the plugin's schema migration). theAuth stores agents in `theauth_agents` and their permissions in `theauth_permissions`, and it keeps only the SHA-256 hash of the full `kv_...` token. A better-auth token hash cannot be reused: the bearer would have to be a `kv_` token whose SHA-256 matches, which no old token is. So do not copy token hashes with SQL. Recreate each agent through the API and hand the owner a new token: ```ts // scripts/import-agents.ts import { theauth } from '../lib/theauth.js'; interface LegacyAgent { userId: string; name: string; kind: 'service' | 'user-agent'; scopes: string[]; // e.g. ['github:read'] } export async function importAgent(legacy: LegacyAgent) { const agent = await theauth.agent.create({ ownerId: legacy.userId, // must already exist in theauth_users name: legacy.name, type: legacy.kind === 'service' ? 'service' : 'autonomous', permissions: legacy.scopes.map((scope) => { // 'github:read' becomes resource 'mcp:github:*' with action 'read' const idx = scope.lastIndexOf(':'); const resource = scope.slice(0, idx); const action = scope.slice(idx + 1); return { resource: `mcp:${resource}:*`, actions: [action] }; }), metadata: { importedFrom: 'better-auth-agent-plugin' }, }); // agent.token is shown once. Deliver it to the service that used the old accessToken. return agent; } ``` The scope mapping above is only a starting point. Decide per scope what resource pattern and actions it should map to, and test the result in staging, the mapping is the load-bearing piece. ## Cutover plan 1. Stand up theAuth next to better-auth against the same database (or a shadow copy). Do not delete the better-auth schema yet. 2. Import agents with `theauth.agent.create`. Validate one new service token with `theauth.authorizeByToken(token, {...})`. 3. Give a single non-critical service its new token and switch it to `theauth.authorizeByToken` instead of `auth.api.verifyAgentToken`. 4. Watch the audit log for `allowed: false` entries. Fix permission mappings. 5. Expand the theAuth-guarded agent surface one service at a time. 6. Remove the `@better-auth/agent-auth` plugin from `lib/auth.ts` once no service still calls it. The human-auth side can move in the same PR or stay on better-auth during the transition, see the [general better-auth migration guide](/migrate/from-better-auth). ## Rollback Keep the better-auth agent plugin wired for at least one rotation cycle after the cut-over. If you need to revert: 1. Re-enable the `agent()` plugin in `lib/auth.ts`. 2. Point services back at `auth.api.verifyAgentToken`. 3. theAuth agent rows remain, they just stop being read. Old better-auth bearers keep working on better-auth because you never revoked them there, new `kv_` tokens only work against theAuth. ## Runnable example [`examples/migrate-from-better-auth-agent-plugin`](https://github.com/glincker/theauth/tree/main/examples/migrate-from-better-auth-agent-plugin) runs the AFTER patterns end-to-end: AgentIdentity creation, multi-hop delegation with `maxDepth`, authorize-falls-back-to-chain semantics, and cascading chain revocation. The script uses the monorepo `workspace:*` version so it tracks the released package, and a vitest smoke suite runs it in CI. ```bash pnpm --filter @glinr/theauth-example-migrate-from-better-auth-agent-plugin start pnpm --filter @glinr/theauth-example-migrate-from-better-auth-agent-plugin test ``` ## Next steps The primary entity model and lifecycle. Multi-hop delegation with depth and cascading revocation. Graduated autonomy by audit history. The authorization server the better-auth plugin does not ship. --- # Migrate from Clerk Source: https://docs.theauth.dev/migrate/from-clerk Clerk is a hosted identity service with a strong Next.js story and paid tiers that scale with MAU. theAuth is open source, self-hosted, and treats AI agents as a first-class entity next to users. The two trade-offs are real: you give up Clerk's hosted sign-in UI and org management console, you take back your data, your cookie domain, your rate limits, and your bill. Read this before you cut over. When the switch makes sense: - You are hitting Clerk's MAU pricing tiers and the cost stops matching the value. - Your product now needs AI agents as first-class entities, an MCP OAuth 2.1 server, or per-agent trust scoring. Clerk does not model any of that. - You want full control over session cookies, token issuance, and audit logs. - Data-handling needs (GDPR export tooling, self-hosted data residency) are easier with a service you run yourself. theAuth supplies building blocks, it does not certify your deployment. When to wait: - You rely heavily on Clerk's hosted sign-in / sign-up components and have no designer bandwidth to rebuild them. theAuth ships headless building blocks, not a dashboard you drop in. - You use Clerk's B2B Organizations extensively with their admin UI. theAuth has an organization plugin but no hosted admin console yet. - You need Clerk-specific features like device attestation at the sign-in edge or their waitlist product. ## Concepts map | Clerk | theAuth | |---|---| | `clerkClient` (server) | instance from `createTheAuth({...})` | | `` | `` from `@glinr/theauth-react` | | `useUser()` | `useUser()` from `@glinr/theauth-react` | | `useAuth()` | `useSession()` + `useSignOut()` from `@glinr/theauth-react` | | `useSession()` | `useSession()` from `@glinr/theauth-react` | | `useSignIn()`, `useSignUp()` | `useSignIn()`, `useSignUp()` from `@glinr/theauth-react` | | `auth()` in server components | Read the `theauth_session` cookie and call `theauth.auth.session.validate(token)` (returns a `Session` or `null`) | | `currentUser()` | `theauth.auth.resolveUser(request)` when you configured an `auth.adapter`, or `GET /auth/session` over HTTP | | `clerkMiddleware()` | No drop-in. Read the session cookie in your own `middleware.ts` (sample below). | | `app/api/webhooks/clerk/route.ts` | Not applicable. Use the `webhooks` config on `createTheAuth` or the event stream, see [Webhooks](/webhooks). | | Hosted sign-in at `/sign-in` | Build your own page against `useSignIn()`. | | Hosted org switcher | Build your own against the `organization` plugin. | | JWT templates | `createJwtSessionModule` with the `customClaims` option. | | Clerk backend SDK `@clerk/backend` | theAuth server instance directly. | | `Organization`, `Membership` | `org: {...}` config and `theauth.org` (plus the `organization()` plugin for HTTP endpoints). Same model, similar shape. | ## Server setup ```ts // BEFORE: lib/auth.ts (Clerk) // Clerk is initialised via env vars; there is no explicit constructor. // CLERK_SECRET_KEY and NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY are read at runtime. ``` ```ts // AFTER: lib/theauth.ts (TheAuth) import { createTheAuth } from '@glinr/theauth'; import { organization } from '@glinr/theauth/auth'; let instance: Awaited> | null = null; export async function getTheAuth() { if (!instance) { instance = await createTheAuth({ database: { provider: 'postgres', url: process.env.DATABASE_URL!, }, secret: process.env.THEAUTH_SECRET!, baseUrl: process.env.AUTH_BASE_URL!, auth: { session: { secret: process.env.SESSION_SECRET! } }, username: { password: { minLength: 8 } }, // TheAuth's password auth is username-based, see /auth/username org: {}, // makes `theauth.org` available plugins: [organization()], }); } return instance; } ``` ### Next.js App Router route handler Clerk hides its route handling behind the middleware. theAuth exposes an explicit handler so you can see and control what runs. ```ts // BEFORE: no route handler needed. Clerk handles /sign-in, /sign-up, and session refresh // on its own domain, then sets a cookie for yours. ``` ```ts // AFTER: app/api/auth/[...theauth]/route.ts (TheAuth) import { theAuthNextjs } from '@glinr/theauth-nextjs'; import { getTheAuth } from '@/lib/theauth'; const theauth = await getTheAuth(); // theAuthNextjs takes the instance, not a function // basePath must match the folder the catch-all route lives in (the default is /api/theauth) export const { GET, POST, PATCH, DELETE, OPTIONS } = theAuthNextjs(theauth, { basePath: '/api/auth', authenticate, // see /adapters#authenticate-the-management-routes }); ``` ### middleware.ts Clerk's `clerkMiddleware` does three things: verifies the session cookie, attaches `auth` to the request, and optionally protects routes. theAuth does not ship an equivalent wrapper. Next.js middleware runs on the Edge runtime by default, where you cannot open a Postgres connection to validate a session. So keep middleware to a cheap cookie presence check, and validate the session in server code (pages, route handlers, server actions). ```ts // BEFORE: middleware.ts (Clerk) import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server'; const isProtectedRoute = createRouteMatcher(['/dashboard(.*)', '/api/private(.*)']); export default clerkMiddleware(async (auth, req) => { if (isProtectedRoute(req)) { await auth.protect(); } }); export const config = { matcher: ['/((?!_next|[^?]*\\.(?:html?|css|js|png|jpg|jpeg|gif|svg|ico)).*)'], }; ``` ```ts // AFTER: middleware.ts (TheAuth), cookie presence only import { NextRequest, NextResponse } from 'next/server'; export function middleware(req: NextRequest) { // Default cookie name, change it with auth.session.cookieName if (req.cookies.get('theauth_session')) return NextResponse.next(); const signInUrl = new URL('/sign-in', req.url); signInUrl.searchParams.set('next', req.nextUrl.pathname); return NextResponse.redirect(signInUrl); } export const config = { matcher: ['/dashboard/:path*', '/api/private/:path*'], }; ``` ```ts // AFTER: lib/require-user.ts (TheAuth), the real check, in Node server code import { cookies } from 'next/headers'; import { redirect } from 'next/navigation'; import { getTheAuth } from '@/lib/theauth'; export async function requireUser() { const theauth = await getTheAuth(); const token = (await cookies()).get('theauth_session')?.value; const session = token ? await theauth.auth.session?.validate(token) : null; if (!session) redirect('/sign-in'); return session; // { id, userId, expiresAt, createdAt, metadata? } } ``` `session.validate(token)` returns a `Session` or `null`, it does not throw. The `username` module methods (`signIn`, `signUp`) throw an `Error` on failure instead of returning a result object. ## Client SDK ```tsx // BEFORE: app/layout.tsx (Clerk) import { ClerkProvider } from '@clerk/nextjs'; export default function RootLayout({ children }: { children: React.ReactNode }) { return ( {children} ); } ``` ```tsx // AFTER: app/layout.tsx (TheAuth) import { TheAuthProvider } from '@glinr/theauth-react'; export default function RootLayout({ children }: { children: React.ReactNode }) { return ( {children} ); } ``` ### Hooks ```tsx // BEFORE (Clerk) import { useUser, useAuth } from '@clerk/nextjs'; function Profile() { const { user, isLoaded } = useUser(); const { signOut } = useAuth(); if (!isLoaded) return null; return (

{user?.emailAddresses[0]?.emailAddress}

); } ``` ```tsx // AFTER (TheAuth) import { useUser, useSignOut } from '@glinr/theauth-react'; function Profile() { const { user, isLoading } = useUser(); const { signOut } = useSignOut(); if (isLoading) return null; return (

{user?.email}

); } ``` ## Sign-in and sign-up pages Clerk ships `` and `` components that render their hosted flow inside your layout. theAuth gives you hooks and expects you to build the form. The `useSignIn` hook POSTs `{ email, password }` to `${basePath}/auth/sign-in`, and `useSignUp` POSTs to `${basePath}/auth/sign-up`. The TypeScript core and adapters do not register those two routes. The `username` module serves `/auth/username/sign-in` and `/auth/username/sign-up` with `{ username, password }` instead, and you have to call `theauth.username.handleRequest(request)` from your own route handler. Until you add a route that answers the paths the hook calls, build the form against the username endpoints (or `theauth.username.signIn` in a server action). ```tsx // BEFORE: app/sign-in/[[...sign-in]]/page.tsx (Clerk) import { SignIn } from '@clerk/nextjs'; export default function Page() { return ; } ``` ```tsx // AFTER: app/sign-in/page.tsx (TheAuth) 'use client'; import { useState } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; import { useSignIn } from '@glinr/theauth-react'; export default function SignInPage() { const router = useRouter(); const params = useSearchParams(); const { signIn } = useSignIn(); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [error, setError] = useState(null); async function onSubmit(e: React.FormEvent) { e.preventDefault(); setError(null); const result = await signIn(email, password); if (!result.success) { setError(result.error); // a string return; } router.push(params.get('next') ?? '/dashboard'); } return (
setEmail(e.target.value)} /> setPassword(e.target.value)} /> {error ?

{error}

: null}
); } ``` If you were relying on Clerk's first-party passkey UI or phone OTP UI, you will rebuild the forms. `useSignIn` only handles email and password. Passkeys and phone OTP are server modules with their own endpoints, see the pages under Auth (for example [passkey](/auth/passkey) and [phone](/auth/phone)). ## Organizations Clerk's Organizations model ports cleanly. `theauth.org` uses the same three concepts: organization, membership, role. It exists only when you pass `org: {...}` to `createTheAuth` (it is `null` otherwise). Built-in roles are `owner`, `admin`, `member`, and `viewer`. ```ts // BEFORE (Clerk backend) import { clerkClient } from '@clerk/nextjs/server'; const org = await clerkClient().organizations.createOrganization({ name: 'Acme', createdBy: userId, }); await clerkClient().organizations.createOrganizationMembership({ organizationId: org.id, userId: otherUserId, role: 'org:member', }); ``` ```ts // AFTER (TheAuth) import { getTheAuth } from '@/lib/theauth'; const theauth = await getTheAuth(); const org = await theauth.org?.create({ name: 'Acme', slug: 'acme', ownerId: userId, }); if (org) { await theauth.org?.addMember(org.id, otherUserId, 'member'); } ``` Clerk's `org:admin`, `org:member` role prefix becomes plain role strings in theAuth. If you have code reading `orgMembership.role`, search for `org:` and strip the prefix. ## OAuth providers Clerk configures OAuth providers in its dashboard. theAuth does it in code. Side by side for GitHub and Google: ```ts // BEFORE (Clerk dashboard) // No code. Configure providers in the Clerk dashboard, set your OAuth app // redirect URI to https://.clerk.accounts.dev/v1/oauth_callback/ ``` ```ts // AFTER (TheAuth) import { createTheAuth } from '@glinr/theauth'; import { oauth, createGithubProvider, createGoogleProvider } from '@glinr/theauth/auth'; export const theauth = await createTheAuth({ database: { provider: 'postgres', url: process.env.DATABASE_URL! }, secret: process.env.THEAUTH_SECRET!, // Include the adapter mount path: the OAuth redirect URI is built from baseUrl baseUrl: 'https://app.example.com/api/auth', auth: { session: { secret: process.env.SESSION_SECRET! } }, // required by the oauth plugin plugins: [ oauth({ // A map of provider id to a provider instance providers: { github: createGithubProvider({ clientId: process.env.GITHUB_CLIENT_ID!, clientSecret: process.env.GITHUB_CLIENT_SECRET!, }), google: createGoogleProvider({ clientId: process.env.GOOGLE_CLIENT_ID!, clientSecret: process.env.GOOGLE_CLIENT_SECRET!, }), }, }), ], }); ``` Update each OAuth app in the provider's console: change the callback URL from `https://.clerk.accounts.dev/v1/oauth_callback/` to `{baseUrl}/auth/oauth/callback/`. With the config above and the adapter mounted at `/api/auth`, that is `https://app.example.com/api/auth/auth/oauth/callback/github`. If you want a different URL, pass `buildRedirectUri` to `oauth()`. Do this before cutover so the first sign-in after the switch works. ## Data migration Clerk's data lives in Clerk's cloud. You export it, then import it. Clerk exposes a Backend API and an SDK. Pagination caps at 500 per page. ### Step 1: export users from Clerk ```ts // scripts/export-clerk-users.ts import { clerkClient } from '@clerk/backend'; import { writeFileSync } from 'node:fs'; const clerk = clerkClient({ secretKey: process.env.CLERK_SECRET_KEY! }); const out: unknown[] = []; let offset = 0; const limit = 500; while (true) { const { data } = await clerk.users.getUserList({ limit, offset }); if (!data.length) break; out.push(...data); offset += data.length; if (data.length < limit) break; } writeFileSync('./clerk-users.json', JSON.stringify(out, null, 2)); console.log(`exported ${out.length} users`); ``` ### Step 2: import into the theAuth tables theAuth uses PBKDF2-SHA256 for password hashing. Clerk uses bcrypt. The hashes are not compatible, so password sign-in will not work for imported users until they go through a password reset or a rehash that you write yourself (see [next sign-in rehash](#next-sign-in-rehash) below). OAuth sign-ins work once the provider is configured. theAuth's tables are named `theauth_users`, `theauth_oauth_accounts`, and `theauth_username_accounts`, and `createTheAuth` creates them for you. ```sql -- Users. Run after you have loaded clerk-users.json into a staging table -- called clerk_user_import with the fields below. INSERT INTO theauth_users ( id, email, name, email_verified, created_at, updated_at ) SELECT id, -- keep Clerk's id so foreign keys survive lower(email_addresses->0->>'email_address'), NULLIF(trim(COALESCE(first_name, '') || ' ' || COALESCE(last_name, '')), ''), (email_addresses->0->'verification'->>'status') = 'verified', to_timestamp(created_at / 1000.0), to_timestamp(updated_at / 1000.0) FROM clerk_user_import ON CONFLICT (id) DO NOTHING; -- External OAuth connections, one row per provider link. -- theauth_users has no image column, so Clerk's image_url is not imported. INSERT INTO theauth_oauth_accounts ( id, user_id, provider, provider_account_id, access_token, refresh_token, created_at, updated_at ) SELECT gen_random_uuid()::text, u.id, replace(e.value->>'provider', 'oauth_', ''), -- Clerk prefixes ids, e.g. oauth_github e.value->>'provider_user_id', '', -- access_token is NOT NULL, Clerk does not export tokens NULL, to_timestamp(u.created_at / 1000.0), to_timestamp(u.updated_at / 1000.0) FROM clerk_user_import u, jsonb_array_elements(u.external_accounts) e; ``` Clerk exports historical metadata, not live OAuth tokens. Users will re-authorise the provider the first time they sign in after migration; the refresh token will be fetched fresh. If you rely on the access token for third-party API calls, plan for a grace period where those calls fail until users sign in. ### Next sign-in rehash theAuth's [username module](/auth/username) has no legacy-hash bridging hook, `verifyLegacyHash` and similar options do not exist, and there is no API to set a password hash for an existing user. `theauth.username.signUp` always creates a brand new user row, so calling it for an imported user would create a duplicate account. If you want password sign-in to keep working without a forced reset, you have to check the Clerk hash yourself in front of theAuth and write a PBKDF2 hash into `theauth_username_accounts` yourself. Import each password user with a placeholder row in `theauth_username_accounts` (any value that is not in `pbkdf2:::` form never verifies), set `theauth_users.force_password_reset`, and keep Clerk's bcrypt hash in your own `legacy_password_hashes` table. [Migrate from Auth0](/migrate/from-auth0#user-data-migration) shows that import script. Then verify the legacy hash in your own sign-in handler: ```ts import { compare as bcryptCompare } from 'bcrypt'; import { pbkdf2Sync, randomBytes } from 'node:crypto'; import { Pool } from 'pg'; import { getTheAuth } from '@/lib/theauth'; const pool = new Pool({ connectionString: process.env.DATABASE_URL }); function pbkdf2Format(password: string): string { const iterations = 600_000; const salt = randomBytes(16); const hash = pbkdf2Sync(password, salt, iterations, 32, 'sha256'); return `pbkdf2:${iterations}:${salt.toString('hex')}:${hash.toString('hex')}`; } export async function signInWithLegacyBridge(username: string, password: string) { const theauth = await getTheAuth(); const { rows } = await pool.query( 'SELECT user_id, bcrypt_hash FROM legacy_password_hashes WHERE username = $1', [username.toLowerCase()], ); const legacy = rows[0]; if (legacy && (await bcryptCompare(password, legacy.bcrypt_hash))) { // Legacy hash verified: store a TheAuth-format hash and lift the reset flag. await pool.query( 'UPDATE theauth_username_accounts SET password_hash = $1, updated_at = now() WHERE user_id = $2', [pbkdf2Format(password), legacy.user_id], ); await pool.query( 'UPDATE theauth_users SET force_password_reset = FALSE, updated_at = now() WHERE id = $1', [legacy.user_id], ); await pool.query('DELETE FROM legacy_password_hashes WHERE user_id = $1', [legacy.user_id]); } return theauth.username?.signIn({ username, password }); } ``` After 30 to 60 days, drop the `legacy_password_hashes` table and force a password reset (the `passwordReset` module) for any stragglers. ## Session cookies Clerk sets the `__session` cookie signed by Clerk's keys. theAuth cannot read those cookies without calling Clerk. That means one of two things: 1. **Hard cutover.** All signed-in users sign in once on the theAuth flow. Simpler. Set an expectation with a banner for a few days. 2. **Side-by-side with a rollout flag.** Keep Clerk running for a percentage of traffic while theAuth takes the rest. Each user picks one stack until you flip them to 100% theAuth. Sample middleware below. Unlike a library-to-library migration, there is no cookie adapter that verifies Clerk tokens locally. Clerk session tokens require round-trips to Clerk's backend. ## Rollback Keep Clerk wired up on a different subdomain while you roll out. ```ts // middleware.ts (Next.js) import { NextRequest, NextResponse } from 'next/server'; export function middleware(req: NextRequest) { const userHint = req.cookies.get('migration_cohort')?.value ?? ''; const onTheAuth = userHint === 'theauth' || hashBucket(userHint) < Number(process.env.THEAUTH_ROLLOUT ?? '0'); if (onTheAuth) { return NextResponse.next(); } // Redirect to the Clerk subdomain, preserving the path. const url = new URL(req.nextUrl.pathname + req.nextUrl.search, 'https://legacy.example.com'); return NextResponse.redirect(url); } function hashBucket(s: string): number { let h = 0; for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0; return h % 100; } ``` Start at `THEAUTH_ROLLOUT=10`, watch error rates and sign-in conversions, raise to 100 over a week or two. Cut the Clerk app once traffic is zero. ## FAQ **Will my users be signed out on cutover day?** Yes, unless you run a side-by-side rollout. Clerk session cookies cannot be verified without Clerk, so theAuth cannot accept them. A one-time sign-in is the simplest story. Tell users ahead of time and keep the old subdomain alive as a fallback. **Do OAuth tokens survive the migration?** No. Clerk does not export live OAuth access or refresh tokens. Users re-authorise the provider on first sign-in after migration. If your app calls provider APIs using those tokens, plan for a grace period. **Does theAuth have an equivalent of Clerk's Organizations UI?** Not hosted. The `organization` plugin has the same model (organization, membership, role). You build the UI. The [example app](https://github.com/glincker/theauth/tree/main/packages/create-theauth-app/templates/next-saas) has a minimal organization page to copy. **What about Clerk's waitlist, age gate, or impersonation features?** - Waitlist: not in theAuth core. Gate sign-up in your own route handler. - Age gate: the `additionalFields` module (`createAdditionalFieldsModule`) can define and validate a date-of-birth field. Enforce the rule in your own sign-up handler. - Impersonation with TTL: the `admin` config on `createTheAuth` enables `theauth.admin.impersonate(adminUserId, targetUserId)`, with `impersonationTtlSeconds` to set the lifetime. **Will my Clerk webhooks still fire?** No. Clerk webhooks are driven by Clerk's internal events. theAuth can send signed webhooks and stream events from your own process, see [Webhooks](/webhooks) and [event streaming](/event-streaming). If you used Clerk webhooks to sync users to Stripe, port that to a webhook consumer or a call in your own sign-up handler. **I use Clerk's JWT templates. What replaces them?** The JWT session module (`createJwtSessionModule`) takes a `customClaims` callback that runs at token issuance. It receives `{ id, email, name }` for the user and returns any extra claims you want in the access token. See [JWT sessions](/jwt-sessions). **Can I keep Clerk for B2B and use theAuth for B2C in the same app?** Technically yes, but you will spend more time on the boundary than on the migration itself. Pick one. ## Related Structural comparison of theAuth versus Clerk and Auth0. Migration guide if you are coming from better-auth instead. Data export and deletion helpers that self-hosting makes straightforward. The agent layer Clerk does not model natively. --- # Migrate from Auth0 Source: https://docs.theauth.dev/migrate/from-auth0 Auth0 is a hosted identity platform with a deep enterprise feature set, Rules, Actions, Hooks, Organizations, and Machine-to-Machine (M2M) clients. theAuth is open source, self-hosted, and models AI agents as a first-class entity next to human users. The trade is real: you give up Auth0's hosted Universal Login and rules marketplace, you take back full control of your token issuer, your cookie domain, your audit trail, and your bill. When the switch makes sense: - Your M2M client count (or Auth0 MAU) has outgrown what the tier price pays for. - You are building AI agents and Auth0's per-connection M2M model does not give you delegation chains, per-agent rate caps, trust scoring, or cost attribution. - You want an MCP OAuth 2.1 server wired into the same auth instance rather than a second system. - You want GDPR data export tooling and self-hosted data residency on the same platform that issues your tokens. theAuth gives you the building blocks, it does not certify your deployment against any regulation. When to wait: - You rely on Auth0's Universal Login branding, device enrolment flow, or the Guardian MFA app. theAuth ships headless building blocks, not a hosted page. - Your Rules or Actions logic runs heavy third-party integrations (Okta, Duo, Risk signals). You can port that logic into your own route handlers, but it is not a copy-paste job. - You are on Enterprise with dedicated support SLAs. We do not have a 24/7 hotline. ## Concepts map | Auth0 | theAuth | |---|---| | Tenant | A `createTheAuth` instance. One per deployment. | | Application (Regular Web, SPA, Native) | `@glinr/theauth-react`, `@glinr/theauth-vue`, etc., plus the adapter for your framework. | | API (resource server) | The HTTP handler mounted via an adapter. To protect your own API, resolve the caller with `theauth.auth.resolveUser(request)` (humans) or `theauth.authorizeByToken(token, ...)` (agents). | | Machine-to-Machine client | `AgentIdentity` with `type: 'service'`. Scoped permissions, rotatable token, revocable. | | Connection (database, social, enterprise) | A provider in the `oauth` plugin config (`createGithubProvider`, `genericOIDC`, and others), or the built-in username and password module. | | Rule | Deprecated in Auth0 as well. Replace with code in your own route handlers. theAuth has no post-login hook. | | Action (`onExecutePostLogin`, etc.) | There is no sign-in or sign-up hook. Run the logic in your own handler after `theauth.username.signIn`, or add claims with the `customClaims` option of the JWT session module. The real `hooks` cover agents only, see below. | | Post-login trigger on agent tokens | The `beforeAuthorize` and `afterAuthorize` hooks, run on every `authorize()` call. | | Organizations | `organization` plugin for the HTTP endpoints, plus `org: {...}` on `createTheAuth` for the server-side `theauth.org` API. | | Roles, Permissions | Agent `Permission` objects with resource patterns, the [policy engine](/policy-engine), and org roles via `theauth.org`. | | `authorize` endpoint (`/oauth/authorize`) | `GET /mcp/authorize` from `createMcpModule` (PKCE S256 only), served relative to the adapter mount path. | | `userinfo` endpoint | `theauth.auth.resolveUser(request)` on the server, or `GET /auth/session` over HTTP. | | Management API | Server-side instance methods directly, no separate API. | | Refresh token rotation | The optional JWT session module (`createJwtSessionModule`) rotates the refresh token on each refresh, see [JWT sessions](/jwt-sessions). | | Tenant logs | Audit trail via `theauth.audit.query()` and the audit export. | | Custom domain, cookie name | Set `baseUrl` on `createTheAuth` and `auth.session.cookieName` (default `theauth_session`). | | Hooks (Pre-User Registration, Post-User Registration, Send Phone Message) | No equivalent hooks. Run the logic in your own handlers, or supply the `sendSms` callback in the `phone` config. | ## Server setup ```ts // BEFORE: server.ts (Auth0) // Auth0 is not constructed locally. You create a tenant in the dashboard, // then hit their API with AUTH0_CLIENT_ID, AUTH0_CLIENT_SECRET, AUTH0_DOMAIN. ``` ```ts // AFTER: lib/theauth.ts (TheAuth) import { createTheAuth } from '@glinr/theauth'; import { organization } from '@glinr/theauth/auth'; export const theauth = await createTheAuth({ database: { provider: 'postgres', url: process.env.DATABASE_URL! }, secret: process.env.THEAUTH_SECRET!, baseUrl: process.env.AUTH_BASE_URL!, // e.g. https://auth.example.com auth: { session: { secret: process.env.SESSION_SECRET! } }, username: { password: { minLength: 8 } }, // TheAuth's password auth is username-based, see /auth/username org: {}, // makes `theauth.org` available, see below plugins: [organization()], }); ``` Role-based access is handled by the built-in policy engine (`theauth.policy.evaluate`), there is no separate `rbac` plugin to opt into, see [Policy engine](/policy-engine). The MCP OAuth 2.1 server is not a `plugins: [...]` entry and not just a config key: you build it with `createMcpModule` from `@glinr/theauth/mcp` and pass it to the adapter, see [MCP](/mcp). `theauth.org` exists only when you pass `org: {...}` to `createTheAuth`; the `organization()` plugin on its own only adds the HTTP endpoints. ## Mounting the handler Auth0 hides its auth server behind the hosted domain. theAuth exposes an explicit handler on your own domain. Pick your framework adapter. ```ts // BEFORE (Auth0 + Next.js, using nextjs-auth0) // app/api/auth/[auth0]/route.ts import { handleAuth } from '@auth0/nextjs-auth0'; export const GET = handleAuth(); ``` ```ts // AFTER (TheAuth + Next.js) // app/api/theauth/[...theauth]/route.ts import { theAuthNextjs } from '@glinr/theauth-nextjs'; import { theauth } from '@/lib/theauth'; const handlers = theAuthNextjs(theauth, { authenticate }); export const { GET, POST, PATCH, DELETE, OPTIONS } = handlers; ``` Hono, Express, Fastify, SvelteKit, Nuxt, Astro, NestJS, SolidStart, and TanStack Start all have their own adapters with the same shape. ## Client code ```tsx // BEFORE (Auth0 React SDK) import { useUser } from '@auth0/nextjs-auth0/client'; export function Nav() { const { user, isLoading } = useUser(); if (isLoading) return null; return user ? Sign out {user.name} : Sign in; } ``` ```tsx // AFTER (@glinr/theauth-react) import { useUser, useSignOut } from '@glinr/theauth-react'; export function Nav() { const { user, isLoading } = useUser(); const { signOut } = useSignOut(); if (isLoading) return null; return user ? : Sign in; } ``` Wrap the root in ``, then the hooks work the same way they do in the Auth0 SDK. ## Rules and Actions Auth0 Rules ran in a VM on their side. Actions replaced them with a saner runtime. theAuth has no equivalent of the post-login trigger: there is no sign-in, sign-up, or session hook. The `hooks` option on `createTheAuth` is for agents only, and the real hooks are `beforeAuthorize`, `afterAuthorize`, `beforeAgentCreate`, `afterAgentCreate`, `onAgentRevoke`, and `onViolation` (see [Lifecycle hooks](/hooks)). For the common "add a custom claim to the token" Action, use the `customClaims` option of the JWT session module: ```ts // BEFORE: Auth0 Action // onExecutePostLogin: enrich the token with a custom claim exports.onExecutePostLogin = async (event, api) => { if (event.user.email_verified) { api.accessToken.setCustomClaim('tenantId', event.user.app_metadata.tenantId); } }; ``` ```ts // AFTER: JWT session module with customClaims import { createJwtSessionModule } from '@glinr/theauth/auth'; const sessions = createJwtSessionModule( { secret: process.env.JWT_SESSION_SECRET!, // >= 32 chars customClaims: (user) => ({ tenantId: lookupTenantId(user.id) }), }, theauth.db, ); ``` `customClaims` receives `{ id, email, name }` and returns extra claims for the access token. Anything else an Action did after login (provisioning, notifications, blocking a sign-in) goes in your own route handler around `theauth.username.signIn`. For agents, `hooks.beforeAuthorize` can veto a call by returning `{ allow: false, reason }`: ```ts import { createTheAuth } from '@glinr/theauth'; export const theauth = await createTheAuth({ database: { provider: 'postgres', url: process.env.DATABASE_URL! }, hooks: { beforeAuthorize: async ({ agentId, action, resource }) => { if (resource.startsWith('mcp:billing:') && action !== 'read') { return { allow: false, reason: 'billing is read-only for agents' }; } return undefined; }, }, }); ``` ## Machine-to-Machine clients Auth0's M2M clients issue long-lived tokens against an API. They have a flat scope list, no delegation, no per-client rate cap. ```ts // BEFORE (Auth0 M2M) // Each M2M client has a client_id and client_secret. Scopes are assigned per-API. // Rotation means generating a new secret in the dashboard. ``` ```ts // AFTER (TheAuth agent) const agent = await theauth.agent.create({ ownerId: opsUser.id, // the id of an existing row in theauth_users name: 'billing-pipeline', type: 'service', permissions: [ { resource: 'mcp:stripe:*', // `*` matches the rest of the path from that position on actions: ['read'], constraints: { maxCallsPerHour: 1000 }, }, { resource: 'mcp:stripe:refund', actions: ['execute'], constraints: { requireApproval: true }, }, ], }); // agent.token is a kv_... bearer (kv_ plus base64url of 32 random bytes). // It is returned once and only its SHA-256 hash is stored. Hand it to the service. // Rotate with theauth.agent.rotate(agent.id) when you need to. // Revoke with theauth.agent.revoke(agent.id). Authorization decisions are audited. ``` Two differences to call out: 1. Rotation is atomic. Old token dies the moment the new one is issued. No gap. 2. Per-agent rate caps (`maxCallsPerHour`), approval gates (`requireApproval`), time windows (`timeWindow`), and IP allowlists (`ipAllowlist`) are part of the permission's `constraints`, not a separate rule. ## User data migration Auth0 exports users via the Management API bulk export, which delivers JSON lines. theAuth users live in your own Postgres, SQLite, MySQL, or D1, in the `theauth_users` table that `createTheAuth` creates for you. Do not create your own `users` table. There is no user import API (`theauth.auth.importUser` does not exist), and theAuth cannot verify Auth0 bcrypt hashes. Password verification in the `username` module is PBKDF2 only, in the format `pbkdf2:::` (SHA-256). A bcrypt hash stored in `theauth_username_accounts.password_hash` is simply rejected at sign-in. Plan for a password reset or a lazy rehash that you write yourself, both shown below. Three things to know about the shape of the data: - Human users are rows in `theauth_users` (`id`, `email`, `name`, `email_verified`, `metadata`, `force_password_reset`, timestamps). - Password credentials are rows in `theauth_username_accounts` (`user_id`, `username`, `password_hash`). Sign-in looks up the `username` (lowercased by default) and does not apply the sign-up pattern check, so storing the lowercased email as the username lets users keep signing in with their email. - Social links are rows in `theauth_oauth_accounts` (`provider`, `provider_account_id`, `access_token` which is `NOT NULL`, so use an empty string for imported links). A small Node script that inserts directly with SQL is enough. It keeps each user's bcrypt hash in your own `legacy_password_hashes` table and writes an unusable placeholder into theAuth, with `force_password_reset` set so nobody can sign in until they either reset or are rehashed: ```ts // scripts/import-auth0.ts import fs from 'node:fs'; import readline from 'node:readline'; import { randomUUID } from 'node:crypto'; import { Pool } from 'pg'; const pool = new Pool({ connectionString: process.env.DATABASE_URL }); const stream = readline.createInterface({ input: fs.createReadStream('auth0-users.ndjson'), }); for await (const line of stream) { const u = JSON.parse(line); const email = String(u.email).toLowerCase(); // Pass Auth0's id instead of randomUUID() if your foreign keys depend on it. const inserted = await pool.query( `INSERT INTO theauth_users (id, email, name, email_verified, metadata, force_password_reset, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, now(), now()) ON CONFLICT (email) DO UPDATE SET updated_at = now() RETURNING id`, [randomUUID(), email, u.name ?? null, Boolean(u.email_verified), JSON.stringify(u.app_metadata ?? {}), Boolean(u.passwordHash)], ); const userId: string = inserted.rows[0].id; if (u.passwordHash) { // Auth0 releases password hashes through a support request. Social-only users have none. await pool.query( `INSERT INTO theauth_username_accounts (id, user_id, username, password_hash, created_at, updated_at) VALUES ($1, $2, $3, 'imported:unusable', now(), now()) ON CONFLICT DO NOTHING`, [randomUUID(), userId, email], ); await pool.query( `INSERT INTO legacy_password_hashes (user_id, username, bcrypt_hash) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`, [userId, email, u.passwordHash], ); } } await pool.end(); ``` Choose one of two ways to let password users back in: 1. **Forced reset (simplest).** Configure the `passwordReset` module on `createTheAuth` (it needs `resetUrl` and `sendResetEmail`), then send each user through `POST /auth/forgot-password`. The placeholder account row is what lets the reset flow find them, and a successful reset clears `force_password_reset`. 2. **Lazy rehash in your own sign-in handler.** Verify the bcrypt hash yourself, write a PBKDF2 hash in theAuth's format, then call `theauth.username.signIn`: ```ts import { compare as bcryptCompare } from 'bcrypt'; import { pbkdf2Sync, randomBytes } from 'node:crypto'; import { Pool } from 'pg'; import { theauth } from '../lib/theauth.js'; const pool = new Pool({ connectionString: process.env.DATABASE_URL }); function pbkdf2Format(password: string): string { const iterations = 600_000; const salt = randomBytes(16); const hash = pbkdf2Sync(password, salt, iterations, 32, 'sha256'); return `pbkdf2:${iterations}:${salt.toString('hex')}:${hash.toString('hex')}`; } export async function signInWithLegacyBridge(email: string, password: string) { const username = email.toLowerCase(); const { rows } = await pool.query( 'SELECT user_id, bcrypt_hash FROM legacy_password_hashes WHERE username = $1', [username], ); const legacy = rows[0]; if (legacy && (await bcryptCompare(password, legacy.bcrypt_hash))) { // Legacy hash verified: store a TheAuth-format hash and lift the reset flag. await pool.query( 'UPDATE theauth_username_accounts SET password_hash = $1, updated_at = now() WHERE user_id = $2', [pbkdf2Format(password), legacy.user_id], ); await pool.query( 'UPDATE theauth_users SET force_password_reset = FALSE, updated_at = now() WHERE id = $1', [legacy.user_id], ); await pool.query('DELETE FROM legacy_password_hashes WHERE user_id = $1', [legacy.user_id]); } return theauth.username?.signIn({ username, password }); } ``` After a grace period, drop `legacy_password_hashes` and force a reset for anyone who never signed in. Social-only users have no hash: wire up the same provider in the `oauth` plugin before the cutover, and insert their `theauth_oauth_accounts` link (Auth0 ids look like `google-oauth2|1234`, the part after the pipe is the `provider_account_id`). ## Cutover plan 1. Stand up theAuth next to Auth0. Point at the same user database schema. Do not turn off Auth0 yet. 2. Import users. Validate a sample with a test sign-in. 3. Point a single route (for example `/api/admin`) at the theAuth handler. Keep everything else on Auth0. 4. Watch the audit log for authorization failures. Fix mismatches. 5. Expand the theAuth-guarded surface one route at a time. M2M clients cut over last, after you have confirmed rate caps and approval gates in staging. 6. Final DNS or reverse-proxy switch: all auth traffic hits theAuth. Decommission the Auth0 tenant after the grace period. ## Rollback Keep the Auth0 tenant active for at least one full session-refresh cycle after the cutover (default 7 days). If you need to revert: 1. Flip the reverse proxy or middleware back to Auth0. 2. Users stay signed in using Auth0 cookies. New sessions come from Auth0 again. 3. theAuth sessions remain valid locally until they expire. No user-facing disruption. ## What Auth0 does that theAuth does not (yet) - Hosted Universal Login. theAuth ships React, Vue, and Svelte components you host yourself. - Guardian push-MFA mobile app. TOTP and passkeys are supported, push MFA is not. - Attack Protection suite (bot detection, breached-password detection on their edge). theAuth has HIBP integration and IP rate limiting, but no managed bot detection. - A hosted B2B customer-admin console. theAuth has an embeddable `@glinr/theauth-dashboard` React component for operators, not a hosted SaaS console. If any of those are hard requirements, keep Auth0 on the books and read the [competitor notes](/compare) before you commit to a switch. ## Runnable example A minimal, in-memory version of the AFTER patterns lives at [`examples/migrate-from-auth0`](https://github.com/glincker/theauth/tree/main/examples/migrate-from-auth0). It pulls `@glinr/theauth` via the monorepo `workspace:*` protocol so the script stays pinned to the same version the docs describe, and the smoke test runs in CI on every push. ```bash pnpm --filter @glinr/theauth-example-migrate-from-auth0 start pnpm --filter @glinr/theauth-example-migrate-from-auth0 test ``` ## Next steps Model M2M clients as agents with delegation and trust scoring. The authorization server Auth0 does not ship. The real hook points (agent authorization and creation) that replace per-agent Actions. Tenant logs, queryable and exportable, written for every authorize() call. --- # Upgrade from Kavach names Source: https://docs.theauth.dev/migrate/from-kavach The legacy `Kavach*` names are gone. Exports are `TheAuth*`, env vars are `THEAUTH_*`, webhook headers are `X-TheAuth-*`, and database tables are `theauth_*`. The `theauth codemod rename` command updates your source for the part that is mechanical. ## Run the codemod The command is a dry run by default. It lists what would change and writes nothing. ```bash npx @glinr/theauth-cli codemod rename src ``` Review the summary, then apply it. ```bash npx @glinr/theauth-cli codemod rename src --write ``` | Option | Effect | |---|---| | `[paths...]` | Files or folders to scan. Defaults to the current directory. | | `--write` | Apply the changes. Without it nothing is written. | | `--include-env` | Also rewrite `KAVACH_*` to `THEAUTH_*` in `.env*` files and `.md` or `.mdx` docs. | ## What it changes It scans `.ts`, `.tsx`, `.js`, `.jsx`, `.mjs`, `.cjs`, `.mts`, `.cts`, `.vue` and `.svelte` files and skips `node_modules`, `dist`, `build` and other generated folders. - Identifiers such as `createKavach`, `KavachConfig` and `useKavachContext` in imports, usages, type references and comments, matched on word boundaries only. `KavachProvider` becomes `TheAuthProvider` in JSX opening and closing tags. - Old package names in import paths (`kavachos`, `@kavachos/react`) become `@glinr/theauth` and `@glinr/theauth-react`. - `process.env.KAVACH_X` and `import.meta.env.KAVACH_X` become `THEAUTH_X`. - Other string literals are never touched. The mapping lives in one data file, `packages/cli/src/rename-map.ts`, and a test checks that every target name is a real export in this repo. ## What it only reports Some mentions need a human, so the summary lists them with file and line and leaves them alone. - `X-Kavach-*` header strings. Update your webhook receivers and senders together. - `kavach_*` table or cookie names. `createTables` renames existing `kavach_*` tables in place, so you rarely need to edit these. - Any other `Kavach` mention, for example a variable named `kavachos_notes`. Running the command again after `--write` changes nothing, so it is safe to repeat. ## Auth names `Auth*` names, such as `createAuth`, remain as deprecated aliases and are not rewritten. Switch them to `TheAuth*` at your own pace. --- # REST API Source: https://docs.theauth.dev/api All REST endpoints are mounted by the framework adapter (Hono, Express, Next.js, etc.). Paths below are relative to the mount path. The Next.js, Astro, Fastify, and NestJS adapters default to `/api/theauth` (set with their `basePath` or `prefix` option); with Hono and Express you pick the mount path yourself, for example `app.route('/api/theauth', theAuthHono(theauth, { authenticate }))`. The adapters do not authenticate callers. Only `POST /authorize/token` reads a bearer token (the agent's own `kv_` token). Every other endpoint, including agent creation, audit queries, and the dashboard routes, is open to whoever can reach the mount path, so put your own authentication in front of it (middleware or a gateway). Successful responses wrap the payload as `{ "data": ... }`. Errors use `{ "error": { "code": "...", "message": "..." } }` with codes such as `BAD_REQUEST` (400), `UNAUTHORIZED` (401), `NOT_FOUND` (404), and `INTERNAL_ERROR` (500). Examples below show the payload inside `data`. Dates are ISO 8601 strings and IDs are UUIDs (the `agt_...` IDs in the examples are illustrative). The MCP OAuth endpoints are the exception: they return bare OAuth JSON. ## Agent endpoints ### Create agent ``` POST /agents ``` Creates a new agent identity. **Request body** ```json { "ownerId": "user_01abc", "name": "GitHub Automation", "type": "autonomous", "permissions": [ { "resource": "mcp:github:*", "actions": ["read", "write"], "constraints": { "maxCallsPerHour": 100 } } ], "expiresAt": "2026-12-31T23:59:59Z", "metadata": {} } ``` `ownerId`, `name`, `type`, and at least one permission are required. `expiresAt` is optional: when omitted, the agent expires after `agents.tokenExpiry` (default `24h`). The owner must already exist as a row in `theauth_users`. Fields not listed here, such as `tenantId`, are ignored by the REST endpoint. **Response** `201 Created` ```json { "id": "agt_01abc", "ownerId": "user_01abc", "name": "GitHub Automation", "type": "autonomous", "token": "kv_...", "status": "active", "permissions": [...], "expiresAt": "2026-12-31T23:59:59.000Z", "createdAt": "2026-03-21T10:00:00.000Z", "updatedAt": "2026-03-21T10:00:00.000Z" } ``` The `token` (`kv_` followed by 43 base64url characters) is returned only here and from the rotate endpoint. Every other endpoint returns `"token": ""`. Validation failures return `400`. ### List agents ``` GET /agents ``` Returns agents, optionally filtered. With no filters it returns every agent. **Query parameters** | Parameter | Type | Description | |-----------|------|-------------| | `userId` | `string` | Filter by owner | | `status` | `active \| revoked \| expired` | Filter by status | | `type` | `autonomous \| delegated \| service` | Filter by agent type | Unrecognized `status` or `type` values are ignored rather than rejected. **Response** `200 OK` - Array of agent objects. ### Get agent ``` GET /agents/:id ``` Returns a single agent by ID. **Response** `200 OK` - Agent object. `404` when not found. ### Update agent ``` PATCH /agents/:id ``` Updates name, permissions, expiry, or metadata. **Request body** ```json { "name": "Updated Name", "permissions": [...], "expiresAt": "2027-01-01T00:00:00Z", "metadata": { "env": "production" } } ``` All fields are optional. Passing `permissions` replaces the full permission list. **Response** `200 OK` - Updated agent object, or `404` when not found. ### Revoke agent ``` DELETE /agents/:id ``` Permanently revokes an agent. Revoked agents cannot be reactivated. **Response** `204 No Content`, or `404` when not found. ### Rotate agent token ``` POST /agents/:id/rotate ``` Issues a new token and invalidates the previous one. Use this for credential rotation. Only active agents can be rotated. **Response** `200 OK` - Agent object with a new `token` value. `404` when not found; `500` with the message `Cannot rotate token for agent.` when the agent is revoked or expired. --- ## Authorization endpoints ### Authorize a request ``` POST /authorize ``` Checks whether an agent has permission to perform an action on a resource. The agent's own permissions are checked first, then permissions it holds through delegation. Budget policies are not checked here. **Request body** ```json { "agentId": "agt_01abc", "action": "write", "resource": "mcp:github:create_issue", "arguments": { "repo": "org/repo", "title": "Bug fix" } } ``` `agentId`, `action`, and `resource` are required. The client IP (first entry of `X-Forwarded-For`, else `X-Real-IP`) and `User-Agent` are read from the request headers and stored on the audit entry. A `context` object in the body is ignored. **Response** `200 OK` when allowed ```json { "allowed": true, "auditId": "7b0c1d52-..." } ``` **Response** `403 Forbidden` when denied. The body is still wrapped in `data`, and `reason` is free text, not a code: ```json { "allowed": false, "reason": "Rate limit exceeded: 100/100 calls per hour for resource \"mcp:github:create_issue\"", "auditId": "7b0c1d52-..." } ``` For an unknown or inactive agent the response is also `403`, with an empty `auditId` because no audit entry is written. ### Authorize by agent token ``` POST /authorize/token Authorization: Bearer kv_... ``` Same check, but the agent is identified by its bearer token instead of `agentId`. The body takes `action`, `resource`, and optional `arguments`. A missing or malformed `Authorization` header returns `401 UNAUTHORIZED`. An invalid, revoked, or expired token returns `403` with `reason: "Invalid or expired agent token"`. This path checks only the agent's own permissions, not delegated ones. --- ## Delegation endpoints ### Create delegation ``` POST /delegations ``` Delegates a subset of permissions from one agent to another. The permissions must be a subset of what `fromAgent` holds itself. **Request body** ```json { "fromAgent": "agt_01abc", "toAgent": "agt_02def", "permissions": [ { "resource": "mcp:github:*", "actions": ["read"] } ], "expiresAt": "2026-06-01T00:00:00Z", "maxDepth": 2 } ``` `fromAgent`, `toAgent`, at least one permission, and `expiresAt` are required. `maxDepth` defaults to `3`; the new link's depth must be less than or equal to it, otherwise the call fails with `400`. An unknown parent agent returns `404`. A permission set that is not a subset of the parent's currently surfaces as `500 INTERNAL_ERROR` with the explanatory message. **Response** `201 Created` ```json { "id": "del_01abc", "fromAgent": "agt_01abc", "toAgent": "agt_02def", "permissions": [...], "depth": 1, "expiresAt": "2026-06-01T00:00:00.000Z", "createdAt": "2026-03-21T10:00:00.000Z" } ``` ### List delegations ``` GET /delegations/:agentId ``` Returns the delegation chains where `:agentId` is the source agent (`fromAgent`). There are no query parameters, and there is no endpoint that lists every delegation. **Response** `200 OK` - Array of delegation objects. ### Revoke delegation ``` DELETE /delegations/:id ``` Revokes a delegation chain immediately, and cascades to chains the target agent has delegated onward. **Response** `204 No Content`, or `404` when not found. --- ## Audit endpoints ### Query audit log ``` GET /audit ``` Returns audit log entries matching the specified filters. **Query parameters** | Parameter | Type | Description | |-----------|------|-------------| | `agentId` | `string` | Filter by agent | | `userId` | `string` | Filter by user | | `since` | `ISO 8601` | Start of time range | | `until` | `ISO 8601` | End of time range | | `actions` | `string` (comma-separated) | Filter by action names | | `result` | `allowed \| denied \| rate_limited` | Filter by outcome | | `limit` | `number` | Max results (no default; all matches when omitted) | | `offset` | `number` | Pagination offset | Entries are returned newest first. Invalid dates, non-positive `limit`, and negative `offset` values are ignored. The `actions` filter is applied after `limit` and `offset`, so a page can come back shorter than `limit`. Authorization decisions are recorded only as `allowed` or `denied`, so `result=rate_limited` currently matches nothing. **Response** `200 OK` ```json [ { "id": "aud_01xyz", "agentId": "agt_01abc", "userId": "user_01abc", "action": "write", "resource": "mcp:github:create_issue", "parameters": { "repo": "org/repo" }, "result": "allowed", "durationMs": 3, "timestamp": "2026-03-21T10:00:00.000Z" } ] ``` Denied entries also carry a free-text `reason`. `tokensCost` appears only on entries that recorded one. The IP address and user agent are stored but not returned. ### Export audit log ``` GET /audit/export?format=json&since=2026-01-01 ``` Exports the audit log as JSON or CSV, as evidence for compliance work. The export contains at most the 10,000 most recent matching entries. **Query parameters** | Parameter | Type | Description | |-----------|------|-------------| | `format` | `json \| csv` | Output format. Defaults to `json`; any other value returns `400`. | | `since` | `ISO 8601` | Start of time range | | `until` | `ISO 8601` | End of time range | **Response** `200 OK` - File download (`audit-export.json` or `audit-export.csv`) with `Content-Disposition: attachment`. The body is not wrapped in `data`. CSV columns are `id, agentId, userId, action, resource, result, reason, durationMs, tokensCost, timestamp`; request `parameters` appear only in the JSON export. --- ## MCP endpoints These endpoints implement the MCP OAuth 2.1 specification. They are served only when you pass an MCP module to the adapter (`theAuthHono(theauth, { mcp, authenticate })`, built with `createMcpModule` from `@glinr/theauth/mcp`); otherwise they return `404` with `MCP module not configured`. Responses are bare OAuth JSON (no `data` wrapper), errors look like `{ "error": "invalid_request", "error_description": "..." }`, and CORS is open (`Access-Control-Allow-Origin: *`). ### Authorization Server Metadata ``` GET /.well-known/oauth-authorization-server ``` Returns OAuth 2.0 Authorization Server Metadata (RFC 8414). Public endpoint, no auth required. ### Protected Resource Metadata ``` GET /.well-known/oauth-protected-resource ``` Returns Protected Resource Metadata (RFC 9728). Public endpoint, no auth required. ### Dynamic Client Registration ``` POST /mcp/register ``` Registers a new OAuth client (RFC 7591). Registration is open: the endpoint itself does not authenticate callers. **Request body** - See RFC 7591 for the full schema. Minimum: ```json { "redirect_uris": ["https://my-mcp-client.example.com/callback"], "client_name": "My MCP Client" } ``` **Response** `201 Created` (with `Cache-Control: no-store`) - Client metadata including `client_id`. A `client_secret` is included for confidential clients, which is the default (`token_endpoint_auth_method` of `client_secret_basic`); clients that register with `none` get no secret. Invalid metadata returns `400` with `invalid_client_metadata`. ### Authorization request ``` GET /mcp/authorize?response_type=code&client_id=...&redirect_uri=...&code_challenge=...&code_challenge_method=S256 ``` Starts the OAuth authorization code flow. Requires PKCE (`code_challenge` + `code_challenge_method=S256`). Responds with a `302` redirect: to the module's `loginPage` (with a `returnTo` parameter) if `resolveUserId` finds no signed-in user, to `consentPage` when one is configured, and otherwise to your `redirect_uri` with the authorization code. Request errors return `400` with an OAuth `error` code. ### Token exchange ``` POST /mcp/token ``` Exchanges an authorization code or refresh token for an access token. Confidential clients authenticate with `client_secret_basic` or `client_secret_post`. **Request body** (`application/x-www-form-urlencoded`) For `authorization_code` grant: ``` grant_type=authorization_code &code= &redirect_uri= &client_id= &code_verifier= ``` For `refresh_token` grant: ``` grant_type=refresh_token &refresh_token= &client_id= ``` **Response** `200 OK` (with `Cache-Control: no-store`) ```json { "access_token": "eyJ...", "token_type": "Bearer", "expires_in": 3600, "refresh_token": "...", "scope": "mcp:read mcp:execute" } ``` `refresh_token` is present only when the granted scope includes `offline_access`. `expires_in` is the module's `accessTokenTtl`. Failures return `401` for `invalid_client` and `400` for everything else. --- ## Dashboard endpoints ### Stats overview ``` GET /dashboard/stats ``` Returns aggregate statistics for the admin dashboard. The audit counts cover the last 24 hours and are computed from at most the 1,000 most recent entries. **Response** `200 OK` ```json { "agents": { "total": 42, "active": 38, "revoked": 3, "expired": 1 }, "users": { "total": 12 }, "audit": { "last24h": 480, "allowed": 470, "denied": 10, "rateLimited": 0 } } ``` ### Dashboard agents and audit ``` GET /dashboard/agents GET /dashboard/audit ``` Aliases for `GET /agents` and `GET /audit`, with the same query parameters and responses. ## Password reset and email verification ``` POST /auth/forgot-password POST /auth/reset-password POST /auth/verify-email/send POST /auth/verify-email/confirm ``` These forward the request to `theauth.passwordReset` and `theauth.emailVerification`, which exist only when you pass `passwordReset` or `emailVerification` to `createTheAuth` (and, for password reset, `username` and `auth.session`). When the module is not configured the route returns `404`. Request and response bodies are defined by those modules, not by the agent endpoints above. ## Related Framework adapters that mount these endpoints on your server. Core concepts behind the agent endpoints and token lifecycle. Querying and exporting audit logs via the REST API. Error codes and HTTP status reference for all endpoints. --- # OpenAPI spec Source: https://docs.theauth.dev/openapi `generateOpenAPISpec` returns an OpenAPI 3.1 document describing the agent, authorization, audit and delegation REST endpoints. Feed it to a code generator to produce a client for Python, Java, Rust or any other language. ```ts title="openapi.ts" import { generateOpenAPISpec } from '@glinr/theauth'; import { writeFileSync } from 'node:fs'; const spec = generateOpenAPISpec({ baseUrl: 'https://auth.example.com/api/theauth', // default http://localhost:3000 version: '1.0.0', // default 0.0.1 }); writeFileSync('theauth-openapi.json', JSON.stringify(spec, null, 2)); ``` Then generate a client, for example: ```bash npx @openapitools/openapi-generator-cli generate -i theauth-openapi.json -g python -o ./theauth-client ``` ## Serving it The function is pure, so you can expose it from any route: ```ts app.get('/openapi.json', (c) => c.json(generateOpenAPISpec({ baseUrl: new URL(c.req.url).origin }))); ``` ## What it covers | Path | Purpose | |---|---| | `/agents`, `/agents/{id}`, `/agents/{id}/rotate` | Create, read, update, revoke and rotate agents | | `/authorize`, `/authorize/token` | Authorization checks by agent ID or bearer token | | `/audit` | Query the audit trail | | `/delegations` | Delegation chains | Requests authenticate with a bearer token (`BearerAuth` security scheme). The spec describes the core agent API only. Endpoints that plugins register (email, OAuth, passkeys and so on) are not included; see [Writing plugins](/plugins) for how those are mounted. --- # Writing plugins Source: https://docs.theauth.dev/plugins A plugin is a plain object with an `id`, an optional `init` function and optional `hooks`. `@glinr/theauth-email`, the discovery plugin and the telemetry plugin are built this way. You pass plugins to `createTheAuth({ plugins: [...] })`. ## A complete plugin ```ts title="ping-plugin.ts" import { createTheAuth, type TheAuthPlugin } from '@glinr/theauth'; const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); export function ping(): TheAuthPlugin { return { id: 'example-ping', async init(ctx) { ctx.addMigration( 'CREATE TABLE IF NOT EXISTS example_pings (id TEXT PRIMARY KEY, at INTEGER NOT NULL)', ); ctx.addEndpoint({ method: 'GET', path: '/ping/:name', metadata: { description: 'Say hello', rateLimit: { window: 60, max: 30 }, requireAuth: false }, async handler(request) { const name = new URL(request.url).searchParams.get('_param_name'); return json({ hello: name }); }, }); return { context: { pingVersion: 1 } }; }, }; } const theauth = await createTheAuth({ database: { provider: 'sqlite', url: ':memory:' }, plugins: [ping()], }); const res = await theauth.plugins.handleRequest(new Request('http://localhost/ping/ada')); console.log(await res?.json()); // { hello: 'ada' } ``` ## The context passed to `init` | Member | Purpose | |---|---| | `db` | The Drizzle database handle. | | `config` | The resolved `createTheAuth` config. | | `addEndpoint(endpoint)` | Register an HTTP endpoint (below). | | `addMigration(sql)` | Queue a `CREATE TABLE IF NOT EXISTS` statement. Migrations run after all plugins initialize, so keep each statement idempotent. | | `sessionManager` | The shared session manager, or `null` when `auth.session` is not configured. | `init` may return `{ context }`; those values are merged into `theauth.plugins.getContext()`. ## Endpoints An endpoint has a `method`, a `path` relative to the mount point, a `handler(request, ctx)` that takes a Web `Request` and returns a `Response`, and optional `metadata`: - `rateLimit: { window, max }`: `window` is in seconds. Counted per client IP, in memory, per process. Over the limit the router returns 429. - `requireAuth`: when true, the router calls `getUser` first and returns 401 if there is no session. - `description`: used for documentation. Paths support `:param` segments. The router copies captured values into the request URL as `_param_` query parameters, as shown above. The handler `ctx` also gives you `db`, `getUser(request)` and `getSession(token)`. The JSON, body-parsing and cookie helpers that built-in plugins use are internal, so write small equivalents in your own plugin (the `json` function above is one). ## Lifecycle hooks ```ts hooks: { onRequest: async (request) => undefined, // return a Request to replace it, or a Response to short-circuit onAuthenticate: async (user, session) => {}, // after a successful sign-in onSessionCreate: async (userId) => ({ source: 'my-plugin' }), // return metadata to attach onSessionRevoke: async (sessionId) => {}, } ``` ## Serving plugin endpoints `theauth.plugins.handleRequest(request, basePath?)` returns a `Response`, or `null` when no endpoint matches, so you can fall through to the rest of your app. `theauth.plugins.getEndpoints()` lists every registered endpoint, which is what the [framework adapters](/adapters) use to mount routes. Strip your mount prefix by passing it as `basePath`. ```ts const response = await theauth.plugins.handleRequest(request, '/api/theauth'); return response ?? new Response('Not found', { status: 404 }); ``` ## Notes - `schema` on a plugin is for Drizzle type safety only. Tables are created by `addMigration`. - `AuthPlugin` is a deprecated alias of `TheAuthPlugin`. - Plugin rate limits are per process. Behind several instances, enforce limits at your gateway as well. --- # Redirect chains Source: https://docs.theauth.dev/redirect `createRedirectChain` remembers the page a user wanted before they were sent to sign in, lets you queue extra steps (verify email, onboarding), and tells you where to send them next. State lives in one short-lived cookie, so it works on any runtime that has Web `Request` and `Response`. ```ts title="redirects.ts" import { createRedirectChain } from '@glinr/theauth/redirect'; const redirects = createRedirectChain({ defaultPath: '/dashboard' }); // 1. Auth middleware: not signed in. Remember where they were headed. function requireSignIn(request: Request): Response { return new Response(null, { status: 302, headers: { Location: '/sign-in', 'Set-Cookie': redirects.capture(request) }, }); } // 2. After sign-up: add steps that must happen first. const c1 = redirects.push('/verify-email', { label: 'verify' }); // 3. As each step finishes, pop the next destination. function next(request: Request): Response { const { url, done, clearCookie } = redirects.pop(request); const headers = new Headers({ Location: url }); if (done && clearCookie) headers.append('Set-Cookie', clearCookie); return new Response(null, { status: 302, headers }); } ``` `push` returns a `Set-Cookie` value, so send it on the response that moves the user to the next step. The same helpers are also exported from `@glinr/theauth`. ## API | Method | Returns | |---|---| | `capture(request)` | `Set-Cookie` that stores the request URL as the origin. Keeps an existing origin if the user reloads the sign-in page. | | `push(path, { label?, query? })` | `Set-Cookie` with a step added. Steps pop last-in, first-out, then the origin. | | `pop(request)` | `{ url, done, clearCookie }`. `done` is true once the origin has been returned. | | `peek(request)` | `{ url, remaining }` without consuming, or `null`. | | `getOrigin(request)`, `parse(request)` | The origin entry or full chain state, or `null` if absent or expired. | | `clear()` | `Set-Cookie` that deletes the chain. | | `buildUrl(entry)`, `createEntry(url, label?)` | Helpers for entries. | ## Options | Option | Default | |---|---| | `cookieName` | `theauth_redirect` | | `maxAge` (seconds) | `600` | | `defaultPath` | `/` | | `excludePaths` | `/sign-in`, `/sign-up`, `/forgot-password`, `/reset-password`, `/verify-email`, `/api/` | | `preserveQuery`, `preserveHash` | `true` | | `maxDepth` | `10` | | `cookie` | `httpOnly`, `secure`, `sameSite: "lax"`, `path: "/"` | If the captured page is in `excludePaths`, the chain falls back to `defaultPath`, so users never bounce back to the sign-in page. ## Security notes Entries created from a URL keep only its path, query and hash, never the origin. The cookie itself is base64url JSON and is **not signed**, so a user can edit it. Before you redirect to the `url` that `pop` or `peek` returns, check that it starts with a single `/` (and not `//` or `/\`), and validate any `redirectTo` value you accept from users before passing it to `createEntry` or `push`. ```ts const safe = (u: string) => u.startsWith('/') && !u.startsWith('//') && !u.startsWith('/\\'); ``` --- # Error codes Source: https://docs.theauth.dev/errors theAuth returns structured errors in the following shape: ```typescript interface TheAuthError { code: string; // machine-readable error code message: string; // human-readable description details?: Record; // additional context } ``` All SDK functions that can fail return a `Result` discriminated union rather than throwing: ```typescript const result = await theauth.authorize(agentId, { action: 'write', resource: 'mcp:github:*' }); if (!result.allowed) { console.log(result.reason); // e.g. 'Rate limit exceeded: 20/20 calls per hour for resource "mcp:deploy:staging"' } ``` For REST API calls, errors are returned as JSON with the corresponding HTTP status code: ```json { "code": "AGENT_NOT_FOUND", "message": "Agent agt_01abc does not exist or has been revoked." } ``` ## Error code reference `theauth.authorize()` and `theauth.delegate()` do not return the permission and delegation codes in the tables below. `authorize()` returns `{ allowed, reason, auditId }` where `reason` is free-form text (for example `No permission grants agent "x" access to "write" on "mcp:github:repos"`), and `delegate()` throws a plain `Error` with a descriptive message (a subset violation or a depth overrun). Treat the Agent, Permission, Token, and Delegation tables as a reference vocabulary for your own HTTP layer, not as values the TypeScript core emits. The MCP module returns `Result` errors with codes such as `INVALID_CLIENT`, `INVALID_GRANT`, `LOGIN_REQUIRED`, `INVALID_TOKEN`, `TOKEN_EXPIRED`, `INVALID_AUDIENCE`, `INVALID_ISSUER`, `INSUFFICIENT_SCOPE`, `UNAUTHORIZED`, and `SERVER_ERROR`. ### Agent errors | Code | HTTP status | Description | |------|-------------|-------------| | `AGENT_NOT_FOUND` | 404 | No agent with the given ID exists. | | `AGENT_LIMIT_EXCEEDED` | 422 | The user has reached their `maxPerUser` agent limit. | | `AGENT_REVOKED` | 403 | The agent has been explicitly revoked and cannot be used. | | `AGENT_EXPIRED` | 403 | The agent's `expiresAt` is in the past. | ### Permission errors | Code | HTTP status | Description | |------|-------------|-------------| | `PERMISSION_DENIED` | 403 | The agent does not have a permission matching the requested resource and action. | | `RATE_LIMITED` | 429 | The agent has exceeded its `maxCallsPerHour` constraint for this resource. | | `OUTSIDE_TIME_WINDOW` | 403 | The current time falls outside the permission's `timeWindow` constraint. | | `IP_NOT_ALLOWED` | 403 | The request IP is not in the permission's `ipAllowlist`. | | `REQUIRES_APPROVAL` | 202 | The action requires human approval (`requireApproval: true`). An approval request has been created. | ### Token errors | Code | HTTP status | Description | |------|-------------|-------------| | `INVALID_TOKEN` | 401 | The token cannot be verified (bad signature, malformed, or unknown). | | `TOKEN_EXPIRED` | 401 | The token's `exp` claim is in the past. | ### Delegation errors | Code | HTTP status | Description | |------|-------------|-------------| | `DELEGATION_DEPTH_EXCEEDED` | 422 | The delegation would exceed the `maxDepth` limit. | | `INSUFFICIENT_PERMISSIONS` | 403 | The delegating agent is attempting to grant permissions it does not hold. | | `DELEGATION_NOT_FOUND` | 404 | No delegation with the given ID exists. | | `DELEGATION_EXPIRED` | 403 | The delegation chain's `expiresAt` is in the past. | ### MCP / OAuth errors | Code | HTTP status | Description | |------|-------------|-------------| | `MCP_CLIENT_NOT_FOUND` | 401 | The OAuth `client_id` is not registered. | | `MCP_INVALID_GRANT` | 400 | The authorization code or refresh token is invalid, expired, or already consumed. | | `MCP_INVALID_REDIRECT_URI` | 400 | The `redirect_uri` does not match the registered client. | | `MCP_PKCE_FAILED` | 400 | The `code_verifier` does not match the stored `code_challenge`. | | `MCP_SCOPE_INSUFFICIENT` | 403 | The token does not carry the required scopes for this endpoint. | | `MCP_CLIENT_DISABLED` | 403 | The OAuth client has been disabled by an administrator. | ### General errors | Code | HTTP status | Description | |------|-------------|-------------| | `BAD_REQUEST` | 400 | The request body or parameters failed validation. | | `UNAUTHORIZED` | 401 | No valid credential was provided. | | `FORBIDDEN` | 403 | The credential is valid but does not have access to this resource. | | `NOT_FOUND` | 404 | The requested resource does not exist. | | `INTERNAL_ERROR` | 500 | An unexpected error occurred. Check server logs for details. | ## Related Full REST endpoint reference with request and response shapes. Agent creation, rotation, and revocation that produce these error codes. Permission and delegation errors explained in context. Assert on error codes in unit tests without a real database. --- # Test utilities Source: https://docs.theauth.dev/test-utils `@glinr/theauth-test-utils` provides factories, mock servers, and assertion helpers so you can test auth-dependent code without a real database or network. ## Install ```bash pnpm add -D @glinr/theauth-test-utils ``` ## Factories Factory functions create realistic mock entities with sensible defaults. Pass overrides for any fields relevant to the test. ```ts import { createMockUser, createMockSession, createMockAgent, createMockPermission, } from '@glinr/theauth-test-utils'; const user = createMockUser({ email: 'alice@example.com' }); const session = createMockSession({ user }); const agent = createMockAgent({ type: 'service', permissions: [] }); const perm = createMockPermission({ resource: 'files', actions: ['read', 'write'] }); ``` Each call generates unique IDs, so you can create multiple entities in the same test without collisions. ## Mock auth server `createMockAuthServer` returns an in-memory `AuthAdapter` implementation with zero network or database calls. Use it in server-side unit tests that exercise code paths calling `resolveUser`, `getUser`, or `syncUser`. ```ts import { createMockAuthServer, createMockUser } from '@glinr/theauth-test-utils'; const server = createMockAuthServer(); const user = createMockUser(); server.addUser(user); server.setActiveUser(user.id); const resolved = await server.resolveUser(new Request('https://example.com')); // resolved.id === user.id ``` ### Per-request user override Set the `x-mock-theauth-user-id` header on a `Request` to override the active user for that specific request only, without calling `setActiveUser`: ```ts import { MOCK_USER_ID_HEADER } from '@glinr/theauth-test-utils'; const req = new Request('https://example.com', { headers: { [MOCK_USER_ID_HEADER]: user.id }, }); const resolved = await server.resolveUser(req); ``` ### Cleanup ```ts afterEach(() => server.reset()); // clears the store and active session ``` ## Assertions Three typed assertion helpers narrow `ActionResult` (the result type used by `@glinr/theauth-react`, where a failure carries an `error` string) and throw descriptive errors on failure. ```ts import { expectAuthenticated, expectUnauthenticated, expectPermissionDenied, } from '@glinr/theauth-test-utils'; // Passes only when result.success === true expectAuthenticated(result); console.log(result.data); // typed // Passes only when result.success === false expectUnauthenticated(result); // Passes only when result.success === false and error contains "permission" (case-insensitive) expectPermissionDenied(result); // Custom substring match expectPermissionDenied(result, 'not allowed'); ``` ## Mock React provider For component tests, `MockTheAuthProvider` replaces `` with fixed values. It accepts `user`, `session`, `isAuthenticated`, `isLoading`, and optional `signIn`, `signUp`, `signOut`, and `refresh` overrides. The actions default to `vi.fn()` spies, so you can assert on calls. ```tsx import { MockTheAuthProvider, createMockUser, createMockSession } from '@glinr/theauth-test-utils'; const user = createMockUser(); const session = createMockSession({ user }); render( ); ``` The mock server itself has no dependencies. It matches the `AuthAdapter` interface structurally, so TypeScript will accept it anywhere an `AuthAdapter` is expected. The package entry point also exports `MockTheAuthProvider`, which imports `vitest`, `react` and `@glinr/theauth-react` (`react` and `@glinr/theauth-react` are optional peer dependencies), so use the package from a Vitest setup. ## Related How theAuth reports errors. In-memory SQLite for integration tests that need a real database. Agent creation and permission checking to exercise in integration tests. Lifecycle hooks you can attach to your instance. --- # A2A protocol Source: https://docs.theauth.dev/a2a ## What A2A is The Agent-to-Agent (A2A) protocol is an open standard from Google that lets AI agents discover and communicate with each other. Where MCP handles the connection between agents and tools, A2A handles the connection between agents themselves. Think of it this way: MCP is how an agent uses a tool. A2A is how an agent talks to another agent. theAuth implements A2A with built-in authentication, so every agent interaction is identity-verified and audited. ### A2A and MCP compared | | MCP | A2A | |---|---|---| | Purpose | Agent-to-tool communication | Agent-to-agent communication | | Discovery | OAuth metadata endpoints | `/.well-known/agent.json` | | Protocol | Tool-specific RPC | JSON-RPC 2.0 | | Auth | OAuth 2.1 | OAuth 2.1, API keys, OIDC, mTLS | | Lifecycle | Stateless tool calls | Stateful task lifecycle | | Streaming | Not defined | SSE via `message/stream` | ## Setup ### Install ```bash pnpm add @glinr/theauth ``` ### Create an A2A server The A2A server exposes your agent as a JSON-RPC endpoint that other agents can call. ```typescript import { createTheAuth } from '@glinr/theauth'; import { createAgentCard, createA2AServer } from '@glinr/theauth/a2a'; const theauth = await createTheAuth({ database: { provider: 'sqlite', url: 'theauth.db' }, }); // Register an agent identity const agent = await theauth.agent.create({ ownerId: 'user-1', name: 'Code Reviewer', type: 'service', permissions: [{ resource: 'a2a:*', actions: ['execute'] }], }); // Build the A2A Agent Card const card = createAgentCard({ agent: { id: agent.id, name: agent.name, type: agent.type }, url: 'https://your-app.com/a2a', description: 'Reviews pull requests and suggests improvements', version: '1.0.0', skills: [ { id: 'review-code', name: 'Code review', description: 'Analyzes code changes and provides feedback', tags: ['code', 'review', 'quality'], }, ], capabilities: { streaming: true }, securitySchemes: { bearer: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' }, }, security: [{ bearer: [] }], }); // Create the server const server = createA2AServer({ agentCard: card, handler: { onMessage: async (message) => { // Your agent logic here const text = message.parts .filter((p) => p.type === 'text') .map((p) => p.text) .join('\n'); return { id: crypto.randomUUID(), contextId: crypto.randomUUID(), status: { code: 'completed' }, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), artifacts: [ { id: crypto.randomUUID(), parts: [{ type: 'text', text: `Review complete for: ${text}` }], createdAt: new Date().toISOString(), }, ], }; }, }, authenticate: async (request) => { const token = request.headers.get('Authorization')?.replace('Bearer ', ''); if (!token) return null; // Agent bearer tokens (kv_...) are validated against the stored hash const caller = await theauth.agent.validateToken(token); return caller?.id ?? null; }, onAudit: async (event) => { console.log('[A2A audit]', event.method, event.agentId, event.success); }, }); // Mount in your framework // app.all('/a2a', (req) => server.handleRequest(req)); // app.get('/.well-known/agent.json', (req) => server.handleRequest(req)); ``` ### A2A server config The Agent Card that describes this agent. Served at /.well-known/agent.json. Callbacks for handling incoming messages, cancellations, and streaming. Promise`}>Validates the caller. Return an agent/user ID or null to reject. Optional, omit to allow unauthenticated calls. Promise`}>Called after each A2A interaction for audit logging. Custom task persistence. Defaults to an in-memory Map. ## Agent Cards An Agent Card is a JSON document that describes what an agent can do, how to authenticate with it, and where to reach it. Other agents fetch this card to decide whether and how to communicate. ### Creating a card ```typescript import { createAgentCard } from '@glinr/theauth/a2a'; const card = createAgentCard({ agent: { id: 'agent-1', name: 'Translator', type: 'service' }, url: 'https://translator.example.com/a2a', description: 'Translates text between languages', version: '2.1.0', skills: [ { id: 'translate', name: 'Translate text', description: 'Translates text from one language to another', supportedMediaTypes: ['text/plain'], tags: ['translation', 'i18n'], }, ], provider: { name: 'Acme Corp', url: 'https://acme.com' }, defaultInputModes: ['text/plain'], defaultOutputModes: ['text/plain'], }); ``` ### Validating a card ```typescript import { validateAgentCard } from '@glinr/theauth/a2a'; const result = validateAgentCard(incomingJson); if (!result.success) { console.error('Bad card:', result.error.message); } ``` ### Signing and verifying Agent Cards can be signed with a private key so that consumers can verify the card hasn't been tampered with. ```typescript import { generateKeyPair } from 'jose'; import { signAgentCard, verifyAgentCard } from '@glinr/theauth/a2a'; const { publicKey, privateKey } = await generateKeyPair('ES256'); // Sign const signed = await signAgentCard({ card, privateKey }); if (!signed.success) throw new Error(signed.error.message); // Verify const verified = await verifyAgentCard({ card: signed.data, publicKey, }); if (verified.success) console.log('Valid:', verified.data.valid); // true ``` ## Calling other agents The A2A client discovers remote agents and sends them tasks. ```typescript import { createA2AClient } from '@glinr/theauth/a2a'; const client = createA2AClient({ agent: 'https://remote-agent.example.com', // The bearer token for the calling agent: the `token` returned by // theauth.agent.create() or theauth.agent.rotate(). It is shown only once, // so keep it in your secret store. getAuthToken: async () => process.env.AGENT_TOKEN!, }); // Discover the remote agent's capabilities const discovery = await client.discover(); if (discovery.success) { console.log('Skills:', discovery.data.skills.map((s) => s.name)); } // Send a message const result = await client.sendMessage({ message: { id: crypto.randomUUID(), role: 'user', parts: [{ type: 'text', text: 'Review this PR: https://github.com/...' }], createdAt: new Date().toISOString(), }, }); if (result.success) { console.log('Task status:', result.data.status.code); console.log('Artifacts:', result.data.artifacts); } ``` ### Retrieving and canceling tasks ```typescript // Get a task by ID const task = await client.getTask({ id: 'task-123' }); // Cancel a running task const canceled = await client.cancelTask({ id: 'task-123' }); ``` ## Authentication between agents theAuth supports all A2A security schemes. You declare them in the Agent Card and enforce them in the server's `authenticate` callback. ```typescript const card = createAgentCard({ // ... securitySchemes: { bearer: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' }, }, security: [{ bearer: [] }], }); const server = createA2AServer({ agentCard: card, handler: { onMessage: handleMessage }, authenticate: async (req) => { const token = req.headers.get('Authorization')?.slice(7); if (!token) return null; const caller = await theauth.agent.validateToken(token); return caller?.id ?? null; }, }); ``` ```typescript const card = createAgentCard({ // ... securitySchemes: { apiKey: { type: 'apiKey', name: 'X-API-Key', in: 'header' }, }, security: [{ apiKey: [] }], }); const server = createA2AServer({ agentCard: card, handler: { onMessage: handleMessage }, authenticate: async (req) => { const key = req.headers.get('X-API-Key'); if (!key) return null; // An agent bearer token sent in a custom header const agent = await theauth.agent.validateToken(key); return agent?.id ?? null; }, }); ``` ```typescript const card = createAgentCard({ // ... securitySchemes: { oauth2: { type: 'oauth2', flows: { clientCredentials: { tokenUrl: 'https://auth.example.com/oauth/token', scopes: { 'a2a:call': 'Call this agent' }, }, }, }, }, security: [{ oauth2: ['a2a:call'] }], }); ``` ## Task lifecycle A2A tasks go through a defined state machine: ``` submitted -> working -> completed \-> failed \-> canceled \-> input-required -> working -> ... \-> auth-required -> working -> ... \-> rejected ``` Each state transition is tracked with timestamps. When the server's `onAudit` callback is set, every interaction is logged with the method name, caller identity, task ID, and outcome. A2A tasks are independent from MCP tool calls. An agent might use MCP to call tools internally while processing an A2A task, but the two protocols operate at different layers. ## JSON-RPC methods | Method | Purpose | |---|---| | `message/send` | Send a message and get a task result | | `message/stream` | Send a message and receive SSE events | | `tasks/get` | Retrieve a task by ID | | `tasks/cancel` | Cancel a running task | ## Related OAuth 2.1 authorization server for MCP tool connections. Create and manage agent identities with scoped permissions. Chain agent permissions across trust boundaries. Query and export the full agent activity trail. --- # Compare theAuth Source: https://docs.theauth.dev/compare/index Auth is a crowded space. These pages are meant to help you pick the right tool, not sell you on any particular one. If a competitor does something better, we say so. Both are MIT TypeScript libraries with a broad OAuth provider list. The split is agent primitives: better-auth treats agents as OAuth clients; theAuth makes them first-class entities with delegation, ephemeral sessions, trust scoring, and compliance exports. Hanko is a passkey-first library with an AGPL backend. If passkeys are your entire auth story and you have no agent workloads, it's worth a look. If you need any agent identity or RBAC, you'll be building on top of it yourself. Casdoor is a deployed Go IAM service with LDAP, CAS, and RADIUS support. It targets employee SSO in Go shops. theAuth is a library, not a service, and it's built for agent-native TypeScript apps. Clerk and Auth0 are polished, fast to start, and expensive at scale. They're also closed-source. This page covers the open-source vs. managed tradeoff without reproducing any pricing tables. --- # theAuth vs better-auth Source: https://docs.theauth.dev/compare/vs-ba better-auth is a solid, well-maintained TypeScript auth library. It has more OAuth providers today, a mature Prisma integration, and a large ecosystem of community plugins. If you're building a standard web app where human auth is the entire story, it gets you there fast. theAuth starts from a different premise: agents are first-class entities, not OAuth clients. The comparison below reflects that split honestly. Where better-auth ships something, we say so. Where it doesn't, we say that too. A migration guide from better-auth is coming soon. ## Feature matrix | Capability | theAuth | better-auth | |---|---|---| | Language | TypeScript, MIT | TypeScript, MIT | | Named OAuth providers | 24 | 37 | | MCP OAuth 2.1 server | Built in with agent identity, delegation, and ephemeral sessions | Thin OIDC wrapper plugin | | Agent identity | First-class `AgentIdentity` entity next to `User` | Treated as an OAuth client | | A2A protocol | Server + client + Agent Cards with JWS signing | Not shipped | | Ephemeral agent sessions | Built in with auto-expiry, action limits, and audit grouping | Not shipped | | Cost attribution per agent/tool/chain | Built in with alerts and budget integration | Not shipped | | Trust scoring | 5-level built in | Not shipped | | Compliance reports (EU AI Act, NIST AI RMF, SOC 2, ISO 42001) | Exports built in | Not shipped | | Unified RBAC + ABAC + ReBAC policy engine | One engine | RBAC only | | Approval flows (CIBA) | Built in | Not shipped | | Verifiable Credentials audit export | On roadmap | Not shipped | | Edge runtime (Workers, Deno, Bun) | Zero `node:crypto` imports, Web Crypto throughout | Partial | | DB adapters | Drizzle (core) plus Prisma (`@glinr/theauth-prisma`) | Prisma, Drizzle, Kysely, Mongo, Redis | | Client libraries | React, Vue, Svelte, Electron, Expo, plain fetch | React, Vue, Svelte, Solid, Electron, Expo | ## Pick theAuth if - Your app runs AI agents with their own identity, permissions, or audit requirements. - You need MCP OAuth 2.1 with proper agent delegation, not just an OIDC wrapper. - You're targeting Cloudflare Workers, Deno, or Bun and need full edge compatibility from day one. ## Pick better-auth if - You're building a human-facing web app with no agent workloads. - You need one of the 13 additional OAuth providers it ships that theAuth doesn't yet cover. - You want a Mongo or Redis adapter and Prisma first-class support right now. Both are MIT, both are TypeScript. The question is whether agents are part of your architecture. ## Related Step-by-step migration with before and after code diffs. How theAuth compares to the passkey-first auth library. theAuth versus Clerk and Auth0 across cost and agent support. The mental model behind agents, permissions, and audit. --- # theAuth vs Hanko Source: https://docs.theauth.dev/compare/vs-hanko Hanko is built around one idea: passkeys should be the default, not the fallback. It's a focused library with a Go backend, official TypeScript bindings, and an AGPL license. If passkeys are your entire auth surface and you have zero agent workloads, it's worth a serious look. theAuth includes passkey support as one method among many. The bigger difference is what happens after the human authenticates: theAuth was designed for the agent layer that comes next. A migration guide from Hanko is coming soon. ## Feature matrix | Capability | theAuth | Hanko | |---|---|---| | License | MIT | AGPL (backend), MIT (JS SDK) | | Primary focus | Agent-first auth SDK | Passkey-first auth | | TypeScript SDK | Yes, first-party | Yes, first-party | | Named OAuth providers | 24 | ~3 (Google, Apple, GitHub) | | Passkey support | Yes | Yes, core focus | | MCP OAuth 2.1 server | Built in | Not shipped | | Agent identity | First-class `AgentIdentity` entity | Not shipped | | RBAC / permissions | Unified RBAC + ABAC + ReBAC | Not shipped | | Ephemeral sessions | Built in with auto-expiry and audit grouping | Not shipped | | Edge runtime | Web Crypto throughout | Go backend required | | Self-hostable | Yes | Yes | ## Pick theAuth if - You need more than passkeys: OAuth providers, agent identity, or a policy engine. - You're building AI-powered products where agents need their own auth layer. - You want a permissive MIT license for the full stack, not just the client SDK. ## Pick Hanko if - You want the smallest possible passkey-only library with a tight scope. - You have no agent story and passkey-first is exactly the feature you need. - You're comfortable with AGPL for your backend auth service. Hanko does one thing well. theAuth does more, which is a tradeoff in either direction. ## Related Feature comparison with the TypeScript-native auth library. theAuth as a library versus Casdoor as a standalone Go service. WebAuthn / FIDO2 support in theAuth. What theAuth adds beyond passkey auth for the agent layer. --- # theAuth vs Casdoor Source: https://docs.theauth.dev/compare/vs-casdoor Casdoor is a deployed Go IAM service. You run it as a separate process alongside your app, and it handles SSO, LDAP, CAS, RADIUS, and a native MCP OAuth server. It's designed for organizations that need a standalone identity provider, especially in Go environments. theAuth is a library, not a service. You import it into your TypeScript app and it runs in-process. Same MCP OAuth 2.1 spec, very different deployment model. A migration guide from Casdoor is coming soon. ## Feature matrix | Capability | theAuth | Casdoor | |---|---|---| | Language | TypeScript library | Go service | | First-party TypeScript SDK | Yes | No (third-party only) | | Deployment model | In-process library | Standalone IAM server | | MCP OAuth 2.1 server | Built in with agent delegation | Built in | | Agent identity | First-class `AgentIdentity` entity with delegation and audit | Not shipped | | LDAP / CAS / RADIUS | Not shipped | Yes | | RBAC | Unified RBAC + ABAC + ReBAC | RBAC via Casbin | | Ephemeral agent sessions | Built in | Not shipped | | Cost attribution | Built in | Not shipped | | Trust scoring | 5-level built in | Not shipped | | Edge runtime | Web Crypto throughout | Go, not applicable | | Self-hostable | Yes | Yes | | License | MIT | Apache 2.0 | ## Pick theAuth if - You're building a TypeScript or edge-native app and want auth in-process, not as a sidecar. - You need first-class agent primitives: delegation, ephemeral sessions, trust scoring, and cost attribution. - Your MCP OAuth story needs to know which agent made which call, not just which client. ## Pick Casdoor if - You want a deployed IAM service that your whole organization can log into, including non-TypeScript services. - You need LDAP, CAS, or RADIUS compatibility for employee SSO. - You're in a Go shop and want to own the full server. The clearest signal: if you're writing `import { createTheAuth }`, theAuth. If you're writing `docker run casdoor`, Casdoor. ## Related How theAuth compares to the TypeScript-native auth library. theAuth versus the passkey-first Go auth library. How theAuth implements the MCP authorization server spec. First-class agent identities with delegation and audit. --- # theAuth vs paid auth platforms Source: https://docs.theauth.dev/compare/vs-paid Clerk and Auth0 are polished products. The onboarding is fast, the UI components are good, and you can be in production in an afternoon. That's a real advantage worth naming before anything else. The tradeoff is real too. You don't own the code, self-hosting is not an option, and the bill scales with your user count. Both have free tiers that feel generous until you hit the ceiling. Check their pricing pages directly, [Clerk pricing](https://clerk.com/pricing) and [Auth0 pricing](https://auth0.com/pricing), before making a spreadsheet. ## The structural differences **Open source vs. closed.** theAuth is MIT. You can read every line, fork it, run it anywhere. Paid platforms are proprietary. You're trusting their security posture, their uptime SLA, and their product roadmap. **Self-hosted vs. vendor.** Running theAuth means you control the database, the logs, and the data residency. That matters if you're in healthcare, finance, or working under GDPR data locality requirements. Vendor platforms handle the infrastructure but own the logs too. **Agent-native vs. bolted on.** Neither Clerk nor Auth0 was designed with AI agents in mind. Both can issue tokens that an agent can use, but there's no concept of `AgentIdentity`, delegation chains, ephemeral sessions, or cost attribution. You build that layer yourself on top of their APIs. theAuth ships it. ## When managed platforms make sense Managed auth is the right call when speed matters more than cost or control. If you're validating a product idea, a startup in week two, or a solo developer who doesn't want to run a database, the vendor handles the operational complexity. It's also worth considering if your team has no one who wants to own auth infrastructure. theAuth is simple to run, but it still runs on your stack. ## When open source makes sense Once you have a stable user base, the cost curve of managed platforms typically exceeds what it costs to run a database and a self-hosted SDK. Beyond cost, open source gives you the ability to audit the code for compliance, patch issues without waiting on a vendor, and keep sensitive auth data inside your own infrastructure. For any product with AI agents, theAuth is the only open-source option that treats them as first-class entities rather than an afterthought. ## Related How to move a Clerk app to theAuth with a concrete migration plan. Comparing theAuth to the open-source better-auth library. Built-in data export and deletion for self-hosted data residency. The agent primitives that paid platforms don't ship. ---