Skip to content

Rapid Auth

GitHub Actions edited this page Sep 27, 2026 · 3 revisions

Authentication & authorization

Rapid owns one thing here: the auth bag. ctx.auth is undefined until an authentication middleware calls ctx.setAuth(identity) — once per request, any transport — and every guard downstream reads it. The @tundralibs/pact adapter is the shipped way to fill it; any other identity system is a short middleware over the same seam.


TL;DR

  • Using pact — @tundralibs/rapid/middlewares/pact: one factory over your instance, const { authenticate, authorize } = pactAuth(pact, options); authorize('Module', 'PERMISSION') is typed by that instance. pact is a real dependency of this subpath only — importing @tundralibs/rapid/middlewares never pulls it in.
  • Anything else — write the middleware: read your credential, verify it, ctx.setAuth(...); guard with a middleware that throws RAPID_UNAUTHENTICATED / RAPID_ACCESS_DENIED. See Bring your own auth.

Bring your own auth

The seam is the context, not a helper. An identifying middleware never rejects (anonymous requests flow through so public routes keep working), skips jobs (no client), and reads the upgrade request on a socket frame; a guard throws rapid's own codes so the error pipeline (JSON envelope, HTML on the UI surface, logs) handles the rest:

import {
  Application,
  RapidError,
  type RapidMiddleware,
} from '@tundralibs/rapid';

declare function verify(
  token: string,
): Promise<{ id: string; role: string } | null>;

const identify: RapidMiddleware = async (ctx, next) => {
  if (ctx.type !== 'JOB') {
    const headers = ctx.type === 'HTTP' ? ctx.headers : ctx.connection.headers;
    const token = headers.get('authorization')?.replace(/^Bearer\s+/i, '');
    const identity = token ? await verify(token) : null;
    if (identity !== null) ctx.setAuth(identity);
  }
  return next();
};

const admins: RapidMiddleware = (ctx, next) => {
  const auth = ctx.auth as { role: string } | undefined;
  if (auth === undefined) throw new RapidError('RAPID_UNAUTHENTICATED');
  if (auth.role !== 'admin') throw new RapidError('RAPID_ACCESS_DENIED');
  return next();
};

const app = await Application.initialize({ name: 'demo' });
app.use(identify);
app.get('/admin', admins, (ctx) => ({ content: { auth: ctx.auth } }));

setAuth is write-once (a second call is RAPID_CONFIG), so two identity middlewares cannot silently overwrite each other.


Using the pact adapter

Requires @tundralibs/pact (deno add @tundralibs/pact). Create the instance and the two middlewares once, at module load, in an auth.ts:

import { Pact } from '@tundralibs/pact';
import { pactAuth } from '@tundralibs/rapid/middlewares/pact';

declare const hooks: Parameters<typeof Pact.create>[0]['hooks'];

export const pact = Pact.create({
  bits: { READ: 1n, EDIT: 2n },
  modulePermissions: { Posts: ['READ', 'EDIT'], Admin: ['READ'] },
  hooks, // getUser / getApiKey / saveSession / … — your storage
});

export const { authenticate, authorize } = pactAuth(pact, {
  schemes: ['BEARER', 'APIKEY'], // default: BEARER, BASIC, APIKEY
  bearer: { cookie: 'session' }, // browser UIs: the cookie login({ cookie }) set
  apiKey: { keyHeader: 'x-api-key', secretHeader: 'x-api-secret' },
});

The options are pact's own PactMiddlewareOptions — every carrier (header and scheme prefix) defaults to its standard and is overridable, plus hmac, encryption, challenge and realm — with rapid's additions, bearer.cookie and session (below), and one changed default: optional is true here. The full option and wire contract lives in @tundralibs/pact's Middleware guide; rapid's adapter is glue over the same neutral core as pact's express/fastify/oak/hono adapters, so a client written for one works against all of them.

Then wire them wherever routes are registered — authenticate once (global, or onlyApi(authenticate) on a split surface), authorize per route:

import { authenticate, authorize } from './auth.ts';

app.use(authenticate);
app.get('/posts', authorize('Posts', 'READ'), list);
app.post('/posts', authorize('Posts', 'EDIT'), create);

What authenticate does

It looks for a credential — Authorization: Bearer <token> (or the configured bearer header/prefix), Authorization: Basic … (a user, or an API key with basic.credential: 'apiKey'), Authorization: ApiKey <key>:<secret> or the two-header apiKey form, HMAC (x-key-id + x-signature + x-timestamp, when hmac is configured), or the bearer.cookie — runs pact.authenticate(), and sets ctx.auth to pact's PactAuthContext: { principal, via, sessionId? }, where principal is the BOUND principal (id, kind: 'USER' | 'APIKEY', grants, hasPermission(), assert()).

  • No credential → the request continues anonymous (ctx.auth unset) — authorize still rejects it. optional: false makes it a 401 instead.
  • A credential that fails → 401, never anonymous. A wrong password, an unknown key and a disabled account are ONE answer on the wire (the distinction is in the server log): RAPID_UNAUTHENTICATED, "invalid credential". A stale HMAC timestamp says so (details.reason: 'STALE_TIMESTAMP') — the caller's own clock is not a secret. The one exception is a stale bearer cookie: a browser keeps sending it after the session ended, and a 401 would lock the user out of /login itself — so it is cleared (Set-Cookie with Max-Age=0) and the request continues anonymous (or is a NO_CREDENTIALS 401 under optional: false). A header credential never gets that treatment.
  • Every 401 authenticate raises for a presented or missing header credential, and every 401 from authorize, includes a WWW-Authenticate challenge listing the accepted schemes (challenge: false to suppress, realm to name one). The stale-cookie 401 under optional: false, me()'s 401 and the session handlers' 401s carry no challenge.
  • Socket frames authenticate from the UPGRADE request's headers/cookies with the header-only schemes (no HMAC, no encryption); jobs pass through (there is no client) — a guard on a job fails closed.
  • Register authenticate before anything that reads the request body: the HMAC body digest and JWE decryption read the raw bytes once (ctx.rawPayload), and ctx.payload then parses from those same bytes.
  • ctx.auth holds the pact object by reference; pick the fields you return from a JSON handler (grants are BigInts).

What authorize(module, permission) does

await principal.assert(module, permission) on the authenticated principal — no store round-trip. 401 (with the challenge) when ctx.auth is unset, 403 when the grant is missing. Both arguments are typed by the instance ('Posts', 'READ'), and a JS caller's typo is a RAPID_CONFIG at the call site, not on the first request.

Tenant-scoped permissions

pact grants can be scoped to a tenant (acme::Posts), with a bare Posts grant applying in every tenant; see Pact-Tenants. authorize(module, permission) is fixed per route, and the tenant comes from each request, so check a tenant-scoped key in the handler. Throw RAPID_ACCESS_DENIED for the 403: an uncaught pact error in a handler is a 500.

import { Application, RapidError } from '@tundralibs/rapid';
import type { PactAuthContext } from '@tundralibs/pact';

const app = await Application.initialize({ name: 'tenants' });

app.post('/orgs/:org:/posts', async (ctx) => {
  const auth = ctx.auth as PactAuthContext<'Posts'> | undefined;
  if (auth === undefined) throw new RapidError('RAPID_UNAUTHENTICATED');
  const org = String(ctx.args.params.org);
  if (!await auth.principal.hasPermission(`${org}::Posts`, 'EDIT')) {
    throw new RapidError('RAPID_ACCESS_DENIED', {
      details: { module: `${org}::Posts`, permission: 'EDIT' },
    });
  }
  // Query and write with `org`, the value just checked, never a second
  // tenant id from the body.
  return { content: { org } };
});

A second factor (TOTP)

pactAuth does not run TOTP; call pact.verifyMFA in your own route. A code works once, and past options.mfa.maxAttempts attempts pact throws MFA_LOCKED even for a correct code. Map it to RAPID_RATE_LIMITED: an uncaught pact error in a handler is a 500.

import { Application, RapidError } from '@tundralibs/rapid';
import { type Pact, PactError } from '@tundralibs/pact';

declare const pact: Pact<{ READ: 1n }, 'Posts'>;
const app = await Application.initialize({ name: 'mfa' });

app.post('/mfa', async (ctx) => {
  const { userId, code } = await ctx.payload as {
    userId: string;
    code: string;
  };
  try {
    if (!await pact.verifyMFA(userId, code)) {
      throw new RapidError('RAPID_UNAUTHENTICATED');
    }
  } catch (e) {
    if (e instanceof PactError && e.code === 'MFA_LOCKED') {
      throw new RapidError('RAPID_RATE_LIMITED');
    }
    throw e;
  }
  return { content: { ok: true } };
});

Sessions — login, logout, refresh, me

The factory also returns the four session handlers, thin HTTP wrappers over pact.login(), pact.logout() and pact.refresh(). Hanging them off pactAuth is what keeps the cookie login sets and the cookie authenticate reads one declaration: bearer.cookie.

import { Application } from '@tundralibs/rapid';
import { pactAuth } from '@tundralibs/rapid/middlewares/pact';
import type { Pact } from '@tundralibs/pact';

declare const pact: Pact<{ READ: 1n }, 'Admin'>;
const app = await Application.initialize({ name: 'sessions' });

const { authenticate, login, logout, refresh, me } = pactAuth(pact, {
  bearer: { cookie: 'session' },
  session: {
    fields: { identifier: 'email' }, // body field names (default identifier/password)
    cookie: { sameSite: 'Lax' }, // + secure: true, path: '/' — always HttpOnly
    refreshCookie: 'refresh', // JWT strategy: refresh token as an HttpOnly cookie
    principal: (p) => ({ id: p.id, kind: p.kind }), // default: { id }
  },
});

app.use(authenticate);
app.post('/login', login());
app.post('/logout', logout());
app.post('/refresh', refresh());
app.get('/me', me());
Handler Does
login() Reads the two body fields → pact.login() → 200 { token, expiresAt, refreshToken?, principal } and, when bearer.cookie is set, the session cookie (Max-Age = remaining session life, capped at 400 days). refreshToken is in the body only when the JWT strategy issued one AND no refreshCookie is configured — with the cookie it travels there (Max-Age = session.refreshMaxAge, default 7 days). Malformed body → 400. Every pact authentication failure → one 401 invalid credentials; anything else is a real 500.
logout() Ends the presented session (header or cookie) via pact.logout(), clears the session and refresh cookies, answers 204. Idempotent — an unknown or already-ended token still clears the cookie.
refresh() JWT strategy only: pact.refresh() with the token from refreshCookie (or refreshToken in the body) → the same reply as login with rotated tokens. A reused or expired token is 401 and clears the refresh cookie; an OPAQUE instance is a RAPID_CONFIG 500.
me() { principal, via } for the current credential through the same projection; 401 when anonymous. Mount it after authenticate.

Rules worth knowing: the principal projection defaults to { id } on purpose (grants, status and metadata are yours to expose field by field, and grants are BigInts); sameSite: 'None' needs secure: true; the handlers are ordinary routes, so csrf() applies to the POSTs if installed and idempotency() should not sit in front of login (set-cookie is never replayed). API clients ignore the cookie and send the same token as Authorization: Bearer. The API reference's sign-in form posts here too: docs(app, { tryIt: { login: { path: '/login' } } }) reads token from the reply and the cookie comes along for free — see OpenAPI and the API reference.

HMAC — signed both ways

hmac: {} turns on pact's signed exchange with its standard defaults: the client signs ${@method}\n${@path}${@query}\n${x-timestamp}\n${content-digest} with the API key's secret (hex HMAC; the digest is RFC 9530 sha-256=:…: over the exact body bytes), sends x-key-id, x-signature and x-timestamp, and rapid verifies it — a timestamp outside maxSkew (300 s) is a 401 before the body is even hashed. After the handler, rapid signs the response over ${@status}\n${x-timestamp}\n${content-digest} with the same key: a JSON reply is serialized by the adapter so the signed bytes are the sent bytes; a streamed body (ctx.serve, SSE) goes out unsigned, and so does an error response. Templates, header names, the algorithm and the frozen RFC 9421 key set are pact's — see its Middleware guide.

Encrypted payloads

encryption: {} lets an API-key or HMAC caller send its body as a compact JWE (Content-Type: application/jose, alg: dir + AES-GCM under a key derived from the API key's secret — pact.encryptFor/decryptFor are the reference). Rapid decrypts it before the handler runs, so ctx.payload and payload(schema) see the plaintext (JSON when it parses, else text), and encrypts the reply back for a caller that sent a JWE, sends Accept: application/jose, or when required is on. A JWE that cannot be opened is a 400 RAPID_VALIDATION_FAILED with details.reason: 'ENCRYPTION_INVALID'.


Norm + pact

pact owns no storage — its hooks are just queries. For the full pattern (sharing one pool, backing getUser/getApiKey with norm repos, caching getUser safely), see Database access & connection pooling; a runnable version lives in examples/blog/auth.ts and examples/blog/main.ts's /login + /admin/* routes.

Clone this wiki locally