-
Notifications
You must be signed in to change notification settings - Fork 2
Rapid Auth
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.
-
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/middlewaresnever pulls it in. -
Anything else — write the middleware: read your credential, verify it,
ctx.setAuth(...); guard with a middleware that throwsRAPID_UNAUTHENTICATED/RAPID_ACCESS_DENIED. See 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.
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);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.authunset) —authorizestill rejects it.optional: falsemakes 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/loginitself — so it is cleared (Set-CookiewithMax-Age=0) and the request continues anonymous (or is aNO_CREDENTIALS401 underoptional: false). A header credential never gets that treatment. - Every 401
authenticateraises for a presented or missing header credential, and every 401 fromauthorize, includes aWWW-Authenticatechallenge listing the accepted schemes (challenge: falseto suppress,realmto name one). The stale-cookie 401 underoptional: 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
authenticatebefore anything that reads the request body: the HMAC body digest and JWE decryption read the raw bytes once (ctx.rawPayload), andctx.payloadthen parses from those same bytes. -
ctx.authholds the pact object by reference; pick the fields you return from a JSON handler (grantsare BigInts).
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.
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 } };
});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 } };
});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: {} 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.
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'.
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.