-
Notifications
You must be signed in to change notification settings - Fork 2
Pact
Permissions, Authentication, Control & Tokens — a transport-agnostic
authentication and authorization toolkit for Deno, Bun, Node, Cloudflare
Workers, and the browser. It covers RBAC authorization over unbounded
BigInt bitmask permissions; four credential schemes — password (Basic,
PBKDF2), Bearer sessions, API keys, and HMAC request signatures;
JWT and opaque sessions with refresh-token rotation and reuse
detection; passkeys (WebAuthn / FIDO2) for passwordless sign-in;
TOTP as a second factor (MFA / 2FA); an OAuth 2.0 /
OpenID Connect client with PKCE, state, and JWKS id_token
verification; and drop-in middleware for express, fastify, oak, and hono.
Identity, credentials, and sessions live behind a flat set of optional
storage hooks — plain functions, no adapter, no base class, no schema
ownership — a lighter, bring-your-own-storage alternative to Passport, Lucia,
or better-auth. All cryptography is delegated to
@tundralibs/crypt; OAuth HTTP runs on
@tundralibs/restler.
- Transport belongs to your framework. pact never parses headers or cookies, performs redirects, or owns routes. The framework extracts values and passes them in; pact checks and validates. The shipped middleware adapters do the extraction for the common frameworks.
- Storage belongs to your app. Users, sessions, and API keys live in your database under your schema. pact reaches them through flat optional hooks — implement only what the features you enable need. A suggested table structure covers every capability if you'd rather not design the schema yourself.
- Crypto belongs to crypt. Password hashing (salted PBKDF2), JWTs, HMAC, TOTP, sha-256 — pact orchestrates, crypt computes.
| Topic | Description |
|---|---|
| Hooks | The storage seam — stored shapes, every hook, what each feature needs |
| Storage | A suggested table structure covering every pact capability |
| Sessions | Opaque vs JWT, refresh rotation, reuse detection, cache-only mode |
| OAuth | Provider presets, PKCE/state/nonce, JIT provisioning, id_token policy |
| Multi-tenant OAuth | Per-tenant IdPs registered at runtime: updateOAuth/removeOAuth, propagation across instances |
| Tenants | Tenant-scoped grants (acme::Post): building, checking and refreshing them; account models |
| Caching | Opt-in caches, the instance name, TTLs, invalidation |
| Security | The error contract, enumeration resistance, bound principals, threat notes |
| Middleware | express / fastify / oak / hono adapters and the neutral core |
| Passkeys | WebAuthn registration and login, usernameless sign-in, clone detection |
| Roadmap | Known limitations and planned work |
Deno:
deno add @tundralibs/pactBun:
bunx jsr add @tundralibs/pactNode.js:
npx jsr add @tundralibs/pactimport { Pact } from '@tundralibs/pact';
import type { PactStoredSession, PactStoredUser } from '@tundralibs/pact/types';
// ── your app's own data layer (any database, any schema) ────────────
declare const db: {
users: {
byEmail(email: string): Promise<PactStoredUser | null>;
byId(id: string): Promise<PactStoredUser | null>;
insert(draft: unknown): Promise<PactStoredUser>;
};
sessions: {
insert(s: PactStoredSession): Promise<void>;
get(id: string): Promise<PactStoredSession | null>;
del(id: string): Promise<void>;
};
};
const pact = Pact.create({
// Authorization: atomic permission bits and per-module ceilings.
// Modules are derived from the modulePermissions keys.
bits: { READ: 1n, EDIT: 2n, DELETE: 4n, PUBLISH: 8n },
modulePermissions: {
Post: ['READ', 'EDIT', 'DELETE', 'PUBLISH'],
Billing: ['READ'],
},
// The storage seam: flat, optional, promise-friendly functions.
hooks: {
getUser: (q) =>
q.by === 'ID'
? db.users.byId(q.id)
: q.by === 'IDENTIFIER'
? db.users.byEmail(q.identifier)
: Promise.resolve(null),
createUser: (draft) => db.users.insert(draft),
saveSession: (s) => db.sessions.insert(s),
getSession: (id) => db.sessions.get(id),
deleteSession: (id) => db.sessions.del(id),
},
});
// ── register → login → authenticate → authorize ─────────────────────
await pact.register({
identifier: 'a@x.io',
password: 'hunter2!hunter2!',
grants: { Post: 1n | 2n }, // READ | EDIT
});
const login = await pact.login({
identifier: 'a@x.io',
password: 'hunter2!hunter2!',
}); // throws typed errors on failure — never returns null
const ctx = await pact.authenticate({
scheme: 'BEARER',
token: login.session.token,
});
// The bound principal checks against already-resolved grants: no
// store round-trip per check.
await ctx.principal.assert('Post', 'EDIT');
const canPublish = await ctx.principal.hasPermission('Post', 'PUBLISH');
// Or by id, from anywhere:
await pact.hasPermission('user-42', 'Billing', 'READ'); // boolean
await pact.logout(login.session.token);
console.log(canPublish);Failure semantics are part of the contract: authentication failures throw
typed PactErrors with stable codes (map PACT_AUTH_FAILURE_CODES to 401),
authorization answers are booleans, and assert throws PERMISSION_DENIED.
See Security.
The ./middleware subpaths ship the transport half for the common
frameworks: an authentication handler that extracts the credential, calls
authenticate, and attaches the context, plus a per-route permission guard.
import { Pact } from '@tundralibs/pact';
import { oakPact } from '@tundralibs/pact/middleware/oak';
declare const pact: Pact<{ READ: 1n }, 'Projects'>;
declare const router: {
get: (path: string, ...handlers: unknown[]) => void;
};
const { authenticate, authorize } = oakPact(pact);
router.get('/projects', authenticate, authorize('Projects', 'READ'), () => {
// ctx.state.pact.principal is the authenticated, bound principal
});One factory per framework — expressPact, fastifyPact, oakPact,
honoPact — each returning { authenticate, authorize } over one instance
and one options bag; authorize is typed by the instance's catalog and
checked at the call site. Every carrier (header, scheme prefix) defaults to
the standard and is configurable; the HMAC scheme verifies a templated,
timestamped request signature and signs the response back; API-key callers
can exchange JWE-encrypted payloads. The neutral core
(createPactMiddleware) makes an adapter for any other stack a few lines.
See Middleware.
-
Four credential schemes through one
authenticate()—BASIC(identifier + password),BEARER(session token, opaque or JWT),APIKEY(key id + presented secret),HMAC(request signature; the secret never travels). Junk input collapses to a 401, never a crash.signFor/encryptFor/decryptForuse a key's secret server-side without exposing it. -
Bound principals —
authenticateandprincipalOf(id)return a principal whosehasPermission/assertevaluate in memory, re-resolving only when stale or after a revocation call. Hand-built objects have no working methods, and the capability does not survive serialization. See Security. -
A login seam you can compose —
verifyCredentialsproves identity (and reports MFA enrollment),createSessionmints by id;loginis the two glued together. MFA-gated logins, magic links, and impersonation are app flows, not framework features. -
Two session strategies, one surface — store-backed
OPAQUE(instantly revocable) orJWTwith a rotating refresh family: every refresh bumps a generation, agracewindow absorbs concurrent refreshes, and replaying a stale token revokes the whole family and firesrefreshReused. See Sessions. - Bitmask authorization — module × permission over unbounded BigInt masks. Definition typos throw at construction; per-request junk fails closed. Grants serialize through a prototype-pollution-safe codec.
-
Tenant-scoped grants — a check on
acme::Postpasses on the principal'sacme::Postgrant or its globalPostgrant, so one grant map covers tenant users and platform admins. See Tenants. -
OAuth as helpers, not a framework —
oauthRedirect()(URL, state, PKCE verifier, nonce) andoauthLogin()feeding the standard session pipeline. Seven presets plus generic OIDC discovery; inboundid_tokens are JWKS-verified with the algorithm pinned to the key. See OAuth. -
Opt-in caching with a named namespace — no config means every check
hits your hooks; per-type TTLs opt in, and the instance
namekeys the cache namespace so two apps on one Redis can never read each other's grants. See Caching. - Passkeys — WebAuthn registration and login (identifier-first or usernameless), verified server-side with attestation policy 'none' and counter-based clone detection; the minted session is an ordinary bearer. See Passkeys.
-
TOTP as plain secondary verification —
generateMFASecret()/generateMFAAuthURL()for enrollment,verifyMFA()to check; the app decides when to demand the second step. Codes are single-use and attempts are limited (5 per 15 minutes by default). See Security. -
Content signing —
sign()/verifySignature()for webhook payloads and signed URLs, keyed by an HKDF-derived, JWT-domain-separated secret or your own explicit key. -
Events everywhere —
login,loginFailed,logout,authenticateFailed,refreshReused,passkeyCloneSuspected,idTokenUnverifiedvia.on()or_on<event>options. Listener faults never alter an outcome.
| Import | Contents |
|---|---|
@tundralibs/pact |
Pact, the grants codec, errors, all types |
@tundralibs/pact/middleware |
The neutral core + every framework adapter |
@tundralibs/pact/middleware/express |
express adapter only (same for fastify, oak, hono) |
@tundralibs/pact/types |
The type surface |
@tundralibs/pact/errors |
PactError, codes, PACT_AUTH_FAILURE_CODES
|
Three runnable mini-apps live in packages/pact/examples/, each with its own
README and per-runtime run commands:
- orbit — a project-management API on oak exercising the whole surface: register + email verification, login/logout/refresh (JWT strategy), password reset, API keys, HMAC, MFA, per-route authorization, and the audit-trail events.
- passkey-signin — passwordless sign-up and sign-in with passkeys in a real browser, including usernameless login and the oak middleware.
- oauth-signin — "Sign in with Google/GitHub" end to end: redirect with PKCE and state, callback exchange, JIT provisioning, and the shipped oak middleware. Bring your own provider credentials via env variables.
Header/cookie parsing, redirects, routes, CSRF, and every other transport
concern (the framework's — though the middleware
covers the common cases); user/session storage (yours, via hooks — no schema,
no adapters, no migrations); token delivery (the reset / verification email
is yours to send) and the status write a verified email unlocks; per-instance
authorization ("edit this post" is app
logic); group/role membership resolution (compose effective grants in your
getUser).
See the Roadmap for known limitations and planned work.
MIT