Auth Plugin
Authentication and authorization plugin for Setu-TS: JWT and API-key authentication, local credential verification, RBAC authorization with role hierarchy, and short-circuiting route guards.
All cryptography (HS256/RS256 JWT signing/verification and PBKDF2-SHA256 password hashing) runs
through Web Crypto via IRuntimeServices (runtime.subtle / runtime.randomBytes), so no npm
package is involved in issuing or verifying a token, or in hashing a password, and every one of
those paths is cross-runtime (Deno / Node 20+ / Bun).
The package declares two optional drivers, which npm therefore installs alongside it:
RedisRateLimitStore lazy-loads ioredis, and a saml sign-in provider lazy-loads
@node-saml/node-saml (which needs the nodejs_compat flag on Cloudflare Workers). Nothing imports
either unless you construct that store or configure that provider — the defaults need no driver. See
Optional npm drivers
for what that means on npm versus Deno.
The plugin always registers JWT and authentication services. It registers authorization only when
the optional rbac configuration is supplied:
| Service | Token | Interface |
|---|---|---|
| JWT sign/verify/decode | 'jwt' | IJwtService |
| Authentication | CAPABILITIES.AUTH | IAuthService |
| Authorization (RBAC) | 'authorization' | IAuthorizationService (when rbac is configured) |
The authentication capability is named CAPABILITIES.AUTH (value 'authentication') — there is no
CAPABILITIES.AUTHENTICATION member.
Without rbac, the four authorization guards answer 501 Not Implemented rather than throwing
— see Guards. A configured policy that the caller fails answers
403 Insufficient privileges; it never names the required role or permission.
Refresh tokens and rate limiting ship in this package.
IJwtServiceitself exposes onlysign/verify/decode, but do not hand-roll a refresh token assign({ expiresIn: '7d' })— that has no rotation and no replay rejection. UseRefreshTokenServiceandrateLimitMiddleware, both covered below.
Installation
deno add npm:@setu-ts/auth-plugin
Usage
import { AuthPlugin } from '@setu-ts/auth-plugin';
app.register(
AuthPlugin({
jwt: {
secret: config.get('JWT_SECRET'), // HS256; use privateKey/publicKey PEMs for RS256
audience: 'my-app-users', // expected `aud`, enforced on verify
issuer: 'my-app', // expected `iss`, enforced on verify
},
apiKey: {
header: 'X-API-Key',
validate: (key) => apiKeyService.validate(key), // (key) => Promise<IPrincipal | null>
},
local: {
// (identifier, secret) => Promise<IPrincipal | null>
verify: (identifier, secret) => userService.checkPassword(identifier, secret),
},
rbac: {
roles: {
admin: { permissions: ['*'], inherits: ['manager'] },
manager: { permissions: ['users:read', 'users:write'], inherits: ['user'] },
user: { permissions: ['profile:read', 'profile:write'] },
},
},
}),
);
// AuthPlugin registers passive authentication globally at priority 300.
Login (Issue Token)
IAuthService.verifyCredentials({ identifier, secret }) resolves to an IPrincipal | null; mint a
JWT with the separate IJwtService resolved from 'jwt'.
import { CAPABILITIES } from '@setu-ts/common';
import type { IAuthService, IJwtService } from '@setu-ts/common';
app.router.post('/auth/login', async (ctx) => {
const auth = ctx.services.get<IAuthService>(CAPABILITIES.AUTH);
const jwt = ctx.services.get<IJwtService>('jwt');
const { username, password } = await ctx.request.json<{ username: string; password: string }>();
const principal = await auth.verifyCredentials({ identifier: username, secret: password });
if (!principal) {
return ctx.response.status(401).json({ error: 'Invalid credentials' });
}
const accessToken = await jwt.sign(
{ sub: principal.id, roles: principal.roles },
{ expiresIn: '1h', audience: 'my-app-users', issuer: 'my-app' },
);
return ctx.response.json({ accessToken });
});
JWT time boundaries
The first-party JwtService.verify (the IJwtService registered under 'jwt') reads time as
fractional seconds, runtime.now() / 1000, with no clock tolerance. It rejects with Token expired
once now >= exp, and with Token not yet valid while now < nbf. At nbf the token passes the
not-before check; at exp it fails the expiry check. Previously, a token could be accepted for up
to one second after exp; clients must refresh before exp.
The outside-issuer verifier configured through issuers also uses fractional seconds, but applies
clockToleranceSec: it rejects as expired once now >= exp + clockToleranceSec (previously >),
and as not yet valid while now + clockToleranceSec < nbf (unchanged). The tolerance defaults to 30
seconds and is at most 300 seconds.
Strategies
- JwtStrategy — passive bearer-token authentication. Extracts
Authorization: Bearer <token>, callsIJwtService.verify, and maps the claims to anIPrincipal. - IssuerStrategy — passive bearer-token authentication for tokens an outside identity
provider issued (Auth0, Entra ID, Google, Keycloak, Cognito). Configured by the
issuersoption, runs immediately after the JWT strategy, reads the same header and scheme, and verifies against the provider’s published key set. See Accepting tokens from an identity provider. - ApiKeyStrategy — passive API-key authentication. Reads the key from a configurable header
(default
X-API-Key) and calls the app-suppliedapiKey.validate(key)callback. - SessionStrategy — passive cookie-session authentication. Reads the session cookie through
ISessionService.fromHeadersand maps the openedSessionViewto anIPrincipalthrough the requiredsession.toPrincipalcallback; it returnsnullwhen no session opened or the session carries no identity, so the chain continues. Configured by thesessionoption and never barrel-exported, like the other two; it requiresSessionPluginto be registered, orregister()throws naming both plugins.toPrincipalis synchronous and reads only the session, so this strategy cannot re-check the account on each request; for a deactivation or a “sign out everywhere” to take effect before the session ends, usesignIn: { providers: [] }withrefreshPrincipalandsessionStampinstead (see Ending every session of a user). - LocalStrategy — explicit credentials verification. Not passive; reached only via
IAuthService.verifyCredentialsfrom a login handler.
Passive strategies run in a fixed order during IAuthService.authenticate — jwt → issuers →
api-key → session → caller-supplied, in declaration order — and the first non-null principal wins,
with null returned when none match. A request carrying both a bearer header and a session cookie
is therefore authenticated by the JWT, because the explicit credential runs first. Caller-supplied
strategies come from the strategies option and run last; a name colliding with any other
strategy in the assembled chain makes register() throw, because a strategy’s name is its only
identity.
Every strategy resolves to an IPrincipal:
| Field | Type | Meaning |
|---|---|---|
id | string | Stable subject identifier (required). |
roles | readonly string[] | Role names held by the principal. |
permissions | readonly string[] | Permission names held by the principal. |
claims | Readonly<Record<string, unknown>> | Additional claims from the credential. |
RBAC
IAuthorizationService (the 'authorization' service) resolves a transitive role hierarchy before
checking. A principal with admin satisfies requireRole('user') when admin inherits user
(directly or transitively). Hierarchy resolution is cycle-safe (a self/cyclic inherits is
ignored). The wildcard permission '*' — held directly by the principal or granted by any of its
(direct or inherited) roles — satisfies every hasPermission/hasAllPermissions check.
import type { IAuthorizationService } from '@setu-ts/common';
const authz = ctx.services.get<IAuthorizationService>('authorization');
authz.hasRole(principal, 'user'); // true when principal is admin and admin inherits user
authz.hasPermission(principal, 'users:write');
authz.hasAnyRole(principal, ['admin', 'manager']);
authz.hasAllPermissions(principal, ['users:read', 'users:write']);
Authorization Policies
Roles answer “does this principal hold this role, anywhere”. A policy answers “may this
principal do this, to this target” — asynchronously, with the target as an argument. Attribute rules
(“the author of this post”, “an approver of this amount”) are written as policies; there is no
attribute engine. AuthPlugin always registers an IAuthorizationPolicyService under
CAPABILITIES.AUTHORIZATION_POLICIES, independent of rbac.
import { createApplication } from '@setu-ts/kernel';
import { RuntimePlugin } from '@setu-ts/runtime';
import { AuthPlugin, definePolicy, requirePolicy } from '@setu-ts/auth-plugin';
interface Post {
readonly authorId: string;
}
const posts = new Map<string, Post>();
const postPolicy = definePolicy({
name: 'post',
abilities: {
update: (principal, post: Post | undefined) => post?.authorId === principal.id,
read: { anonymous: true, check: () => true },
},
before: (principal) => (principal.roles?.includes('admin') === true ? true : undefined),
});
const app = createApplication({
plugins: [
RuntimePlugin(),
AuthPlugin({ jwt: { secret: 'replace-with-a-secret-of-32-chars!' }, policies: [postPolicy] }),
],
});
app.router.patch('/posts/:id', {
middleware: [requirePolicy(postPolicy, 'update', (ctx) => posts.get(ctx.params.id ?? ''))],
handler: (ctx) => ctx.response.json({ ok: true }),
});
The rules are fixed: only a literal true allows; a throwing or rejecting check denies and is
logged once (never the target); an anonymous request is refused 401 before a check runs unless the
ability is declared { anonymous: true, check }; for a signed-in principal before runs first —
true allows, undefined falls through, anything else denies; a denied signed-in principal gets
403 with the same body requireRole writes; with no policy service the guard answers 501. A
guard naming a policy or ability that is not registered makes app.start() fail, naming the route —
except on a route added after start() or a guard added as global middleware, where it fails closed
per request.
Inside a handler, can(principal, policy, ability, target) resolves a boolean and authorize(...)
rejects a denial with AuthorizationDeniedError, which errorHandler answers with the guard’s own
401/403 body. An unknown policy or ability rejects both with UnknownPolicyError. Class-form
policies and the @RequirePolicy decorator live in @setu-ts/decorator-plugin. The full guide is
Authorization.
Scoped Roles
A role held in a scope — one tenant, organisation, team or region — rather than everywhere.
scopedRbac (requires rbac) defines one built-in policy, scoped-rbac, on the policy evaluator:
every catalogue permission and role is checkable, and the target is the scope.
import { scopeFromParam } from '@setu-ts/common';
import { createApplication } from '@setu-ts/kernel';
import { RuntimePlugin } from '@setu-ts/runtime';
import { AuthPlugin, requireScopedPermission, requireScopedRole } from '@setu-ts/auth-plugin';
const app = createApplication({
plugins: [
RuntimePlugin(),
AuthPlugin({
jwt: { secret: 'replace-with-a-secret-of-32-chars!' },
rbac: {
roles: {
approver: { permissions: ['invoices:approve'] },
'org-admin': { permissions: [] },
},
},
scopedRbac: {
sources: [
{
kind: 'static',
grants: [{ subject: 'ann', role: 'approver', scope: { type: 'tenant', id: 'acme' } }],
},
],
// A child tenant inherits its parent's grants.
inheritsFrom: (scope) => (scope.id === 'acme-eu' ? [{ type: 'tenant', id: 'acme' }] : []),
},
}),
],
});
// Default scope: the resolved request tenant (`MultiTenancyPlugin`).
app.router.post('/invoices/:id/approve', {
middleware: [requireScopedPermission('invoices:approve')],
handler: (ctx) => ctx.response.json({ ok: true }),
});
app.router.get('/orgs/:orgId', {
middleware: [requireScopedRole('org-admin', { scope: scopeFromParam('orgId', 'organisation') })],
handler: (ctx) => ctx.response.json({ ok: true }),
});
Several roles are any-of, several permissions all-of. A guard naming a permission or role outside
the catalogue fails app.start(). Grant sources (static, claims, custom — including
createDatabaseGrantSource from @setu-ts/database-plugin) are unioned, and one failing source
denies the whole check. A route scope naming another tenant than the resolved request tenant
denies. grantableIn limits where a role may be granted; customRoles lets a tenant define roles
that bundle catalogue permissions; timing is 'request' (default — a revocation applies on the
next request), { kind: 'cache', ttlMs, maxEntries } (within ttlMs) or 'sign-in' (stored in the
auth session — until sign-out); a custom source sees only the principal’s id and iss, so its
answer is safe to share, while a claims source is mapped from the credential on every check and
never cached, since two tokens for one principal can carry different claims. Only grants decide a
scoped check: the roles and permissions a principal carries itself (a JWT roles claim, a
'*' permission) count in no scope unless principalAuthorityIsGlobal: true, which makes each one
global — the behaviour before 0.10.0. The unscoped requireRole/requirePermission guards read the
principal either way. A direct policies.can(...) call made while serving a request passes the
request as the target’s context, or the tenant comparison is skipped. Every bound
(sourceTimeoutMs, maxGrantsPerPrincipal, maxScopeDepth, maxScopeNodes, maxCustomRoles,
maxPermissionsPerRole) refuses an out-of-range value at construction, and exceeding one denies.
The decorator form is @ScopedRoles/@ScopedPermissions in @setu-ts/decorator-plugin. Full
guide:
Scoped Roles.
Guards
Guards are free MiddlewareFunction factories (imported from the plugin, not methods on
IAuthService). The authorization guards resolve IAuthorizationService from 'authorization',
return 401 when no principal is attached and 403 when the check fails, and short-circuit
(they do not call next()). authMiddleware always calls next(), so an unauthenticated
request still reaches the guard.
import {
publicRoute,
requireAllPermissions,
requireAnyRole,
requireAuth,
requirePermission,
requireRole,
} from '@setu-ts/auth-plugin';
app.router.get('/profile', { middleware: [requireAuth()], handler });
app.router.delete('/users/:id', { middleware: [requireAuth(), requireRole('admin')], handler });
app.router.post('/users', {
middleware: [requireAuth(), requirePermission('users:write')],
handler,
});
app.router.get('/reports', {
middleware: [requireAuth(), requireAnyRole(['admin', 'manager'])],
handler,
});
app.router.post('/bulk', {
middleware: [requireAuth(), requireAllPermissions(['users:read', 'users:write'])],
handler,
});
app.router.get('/health', { middleware: [publicRoute()], handler });
publicRouteis used instead ofpublicbecausepublicis a reserved word.
Sending a browser to the sign-in page
requireAuth({ signInPage }) answers an unauthenticated page navigation with a 302 to the
sign-in page, carrying the requested path and query as returnTo. Every other unauthenticated
request — fetch(), an API client, a form POST — keeps the 401.
import { requireAuth } from '@setu-ts/auth-plugin';
const signedIn = requireAuth({ signInPage: '/login' });
app.router.get('/account', { middleware: [signedIn], handler });
// GET /account from a browser tab → 302 Location: /login?returnTo=%2Faccount
// GET /account from fetch() or curl → 401
A request is a navigation when it is a GET or HEAD, is not X-Requested-With: XMLHttpRequest,
and carries Sec-Fetch-Mode: navigate — or, from a client that sends no Sec-Fetch-Mode, an
Accept that names text/html or application/xhtml+xml. A bare */* is not enough: curl and
most HTTP clients send it. This is the rule Laravel, Devise, ASP.NET Core and Spring Security apply.
A POST is never redirected, because the redirect would turn it into a GET and drop its body.
signInPagemust be a same-origin absolute path (safeReturnTokeeps it unchanged); anything else throwsAuthPluginConfigurationErrorwhen the guard is built. Its own query and fragment are kept, andreturnTois added beside them.returnTois the requested path and query passed throughsafeReturnTo, so a path longer than 256 bytes becomes/. Your login handler should applysafeReturnToagain before it redirects back: the query string is the client’s to change.- Caching. Both unauthenticated answers append
Accept,Sec-Fetch-ModeandX-Requested-WithtoVary, keeping anyVaryalready set (CORS appendsOrigin), so a shared cache cannot serve a browser’s redirect to an API client. - No loop. A request for the sign-in page’s own path answers
401, so a sign-in page placed behind the guard by mistake cannot redirect to itself. - Role and permission guards stay
401. ListrequireAuth({ signInPage })first:[requireAuth({ signInPage: '/login' }), requireRole('admin')]. A decorated controller takes the guard through@UseGuards(requireAuth({ signInPage: '/login' }));@RequirePolicyanswers its own401.
Guards without rbac
rbac is optional, and a JWT-only registration provides no authorization service. In that
composition the four authorization guards — requireRole, requirePermission, requireAnyRole,
requireAllPermissions — answer 501 with the detail Authorization is not configured,
short-circuiting before the handler. requireAuth() and publicRoute() resolve nothing and are
unaffected.
The guards answer through the error-responder seam, so the status and the detail text are the
invariant while the body’s shape is whatever the application configured. With no errorHandler
registered you get the framework-default fallback; with one, its format:
// no errorHandler
{ "error": "Not Implemented", "detail": "Authorization is not configured" }
// errorHandler()
{ "statusCode": 501, "message": "Not Implemented", "details": { "detail": "Authorization is not configured" } }
// errorHandler({ format: 'rfc9457' })
{ "type": "about:blank", "title": "Not Implemented", "status": 501, "detail": "Authorization is not configured", "instance": "/reports" }
501 rather than 403 because nothing is wrong with the caller: the deployment cannot evaluate the
policy at all, and the condition is permanent for that deployment. A principal that genuinely fails
a policy check still gets 403. The guards fail closed either way — supply rbac to make them
evaluate.
Password Hashing
PasswordHasher is an exported utility for provisioning passwords and verifying them inside a
local.verify callback. It draws a random 16-byte salt and derives a 32-byte key with PBKDF2-SHA256
via runtime.subtle / runtime.randomBytes, comparing with a fixed-time check. New hashes use
600,000 iterations (OWASP’s 2023 guidance) unless iterations says otherwise — an integer from
100,000 to 10,000,000, refused at construction otherwise. verify reads the count stored in each
hash, so a hash made at another count keeps verifying, and needsRehash(stored) reports a hash made
with fewer iterations than configured, so it can be replaced the next time its password is known.
import { PasswordHasher } from '@setu-ts/auth-plugin';
const hasher = new PasswordHasher(runtime); // IRuntimeServices resolved from the 'runtime' token
const stored = await hasher.hash('correct horse battery staple');
const ok = await hasher.verify(stored, 'correct horse battery staple'); // true
// On a successful sign-in, upgrade a hash made at a lower count:
if (ok && hasher.needsRehash(stored)) {
const upgraded = await hasher.hash('correct horse battery staple');
// …persist `upgraded` in place of `stored`
}
A hash costs about 80 ms on Node at 600,000 iterations (15 ms at 100,000), so keep a sign-in route
behind rateLimitMiddleware. A deployed Cloudflare Worker is reported to refuse PBKDF2 counts above
100,000 (local workerd accepts 600,000; this is not verified against a deployed Worker), so a
Workers application passes new PasswordHasher(runtime, { iterations: 100_000 }).
Options
| Option | Type | Default | Description |
|---|---|---|---|
jwt | JwtOptions | - | Optional JWT service and passive bearer strategy. |
jwt.secret | string | Uint8Array | - | HS256 key. Required for HS256. |
jwt.privateKey | string (PEM) | - | RS256 private key. Required for RS256. |
jwt.publicKey | string (PEM) | - | RS256 public key. Required for RS256. |
jwt.algorithm | 'HS256' | 'RS256' | inferred | Inferred from which key material is provided. |
jwt.audience | string | - | Expected aud; enforced on verify. |
jwt.issuer | string | - | Expected iss; enforced on verify. |
jwt.header | string | 'authorization' | Header name for bearer extraction. |
jwt.scheme | string | 'bearer' | Token scheme prefix. |
jwt.accessTokenRevocationStore | IAccessTokenRevocationStore | - | Shared store that rejects revoked typed access tokens. |
apiKey.header | string | 'X-API-Key' | Header holding the API key. |
apiKey.validate | (key) => Promise<IPrincipal | null> | - | App-supplied API-key lookup. |
local.verify | (identifier, secret) => Promise<IPrincipal | null> | - | App-supplied credential check. |
rbac.roles | Record<string, RoleDefinition> | - | Role → permissions + inherits hierarchy. |
policies | readonly PolicyDefinition[] | [] | Authorization policies from definePolicy, checked when AuthPlugin(...) is called (malformed or duplicate names refuse). The policy service is registered whether or not this is set. See Authorization Policies. |
scopedRbac | ScopedRbacOptions | - | Scoped RBAC: grant sources, scope inheritance, custom roles, timing and bounds. Requires rbac. |
session.toPrincipal | (view: SessionView) => IPrincipal | null | - | Maps the opened session to its principal; null continues the chain. Requires SessionPlugin. |
strategies | readonly IAuthStrategy[] | - | Caller-supplied strategies, appended after every built-in in declaration order. |
middleware | false | AuthMiddlewareOption | { priority: 300 } | Move, exclude paths from, or disable the global authentication middleware. |
issuers | readonly TrustedIssuer[] | - | Outside identity providers whose access tokens are accepted. |
http | IAuthHttp | fetch-based | Outbound HTTP for issuer key sets, discovery documents, and the sign-in token exchange. |
signIn | SignInConfig | - | Sign-in with outside providers; registers IAuthSessionService. Requires SessionPlugin. |
At least one passive strategy must be configured through jwt, issuers, apiKey, session, or
strategies; local alone is a login verifier and cannot authenticate a later request. For
backend-backed strategies on public operational routes, exclusions can be explicit:
import { AuthPlugin, DEFAULT_RATE_LIMIT_EXCLUDED_PATHS } from '@setu-ts/auth-plugin';
AuthPlugin({
apiKey: { validate: async () => null },
middleware: { exclude: DEFAULT_RATE_LIMIT_EXCLUDED_PATHS },
});
Pass middleware: false only when attaching authMiddleware() at route level yourself. Existing
applications that added a global copy should remove it; two copies remain correct but run the
strategy chain twice.
When jwt is supplied, omitting both jwt.secret (HS256) and jwt.privateKey + jwt.publicKey
(RS256) throws at construction.
Accepting tokens from an identity provider
jwt verifies tokens this application issued, against one key it holds. issuers verifies
tokens an outside identity provider issued, against the provider’s published key set, which
rotates. The two coexist: a self-issued token is recognized by the JWT strategy first, and a token
whose iss names a configured provider is recognized by the issuer strategy next.
import { AuthPlugin } from '@setu-ts/auth-plugin';
AuthPlugin({
issuers: [
{
name: 'keycloak',
issuer: 'https://id.example.com/realms/acme',
audience: 'orders-api',
keys: { discovery: true },
toPrincipal: (claims) =>
typeof claims.sub === 'string'
? {
// Namespaced: `sub` is unique only within its issuer.
id: `${claims.iss}|${claims.sub}`,
roles: Array.isArray(claims.roles) ? claims.roles.map(String) : [],
}
: null,
},
],
});
- Routing. A token’s
issis read without trusting the token, only to choose the configured entry whoseissuerequals it exactly. A token from an unconfigured issuer leaves the chain to continue; nothing else from an unverified token is used. - Keys.
keysis{ jwksUri }or{ discovery: true }, which readsjwks_urifrom<issuer>/.well-known/openid-configurationand requires that document’sissuerto match. Both URLs must behttps(orhttpon a loopback host) and written in printable ASCII — punycode an IDN host, percent-encode a non-ASCII path. Keys are filtered bykty,crv,use,alg,key_opsandkid, so an encryption key in the same set is never used to verify a signature. - Algorithms. RS256, PS256, ES256, ES384 and EdDSA (also spelled
Ed25519), narrowed per issuer withalgorithms.noneand everyHS*algorithm are refused before any key is looked up, so a token signed with HMAC using the provider’s public key as the secret cannot pass. - Claims.
issexact,audmust containaudience,exprequired;exp,nbfand a futureiatallowclockToleranceSec(default 30, at most 300). Expiry is refused atnow >= exp + clockToleranceSec; not-before is refused whilenow + clockToleranceSec < nbf.toPrincipalreceives the full verified claims and decides where roles live — the plugin never guesses. Namespace the id whenever this issuer is not the only identity source —subis unique only within its issuer, and a self-issuedjwt, another issuer, an API key, a session or a custom strategy can each produce the same id.{ id: `${claims.iss}|${claims.sub}` }(as in the example) separates an outsidesubfrom the other sources only if none of them can produce an id of that form — a self-issuedsubor a user-chosen username could — so namespace every source (for example{ id: `local|${id}` }) or keep the other sources’ ids free of the separator. - Rotation. The key set is cached for
keySet.ttlMs(default 10 minutes). A token naming an unknownkidtriggers at most one refetch perkeySet.minRefreshIntervalMs(default 60 s), and concurrent refetches share one request, so forgedkids cannot turn requests into outbound fetches. If fetching fails the last good set stays usable forkeySet.maxStaleMs(default 24 h) after it was last confirmed, then is dropped — a key the provider removed cannot authenticate indefinitely while its endpoint is unreachable. Each fetch is bounded bykeySet.fetchTimeoutMs(default 5 s), 64 KiB and 64 keys, and does not follow redirects.ttlMsmay not exceedmaxStaleMs. Fetches in flight are aborted when the application begins stopping. - Health. The
authindicator staysup;data.currentis true only when every issuer’s key set is current.data.issuerscarriesstale,expiredandunfetchedstates. Shared issuer faults do not withdraw this replica from/ready. It reads cached state and performs no I/O, so it reportsunfetcheduntil the first token from that issuer arrives. - Failures return no principal and are logged at
debugwith a fixed reason code, never the token. A key-set refresh failure — a provider the operator cannot reach — is the exception: it logsauth-plugin: issuer '<name>' key-set refresh failed (<reason>)atwarnonce per change of reason per issuer, and repeats atdebug; a successful refresh resets it. Both lines carry the fixed reason code, never the token.
Give the API its own audience. Access-token type (typ: at+jwt, RFC 9068) is not enforced, so a
provider’s ID token whose aud equals audience could verify as an access token. That happens
when an API reuses a sign-in client’s id as its audience; configure a distinct resource identifier
for the API instead. A token carrying Keycloak’s typ: "ID" claim is refused either way, but other
providers do not mark their ID tokens. Multi-tenant Entra ID (common/organizations) is not
supported, because its discovery document carries a {tenantid} issuer template that cannot equal a
configured issuer; a single-tenant Entra issuer works.
Signing a user in
issuers accepts a token some other client obtained. signIn makes this application the
client: it sends the user to an outside provider (Google, Microsoft Entra ID, GitHub, Keycloak) over
the OAuth 2.0 authorization-code flow with PKCE, and comes back with a session the rest of the
application recognises. It requires SessionPlugin, and register() refuses to start without it.
Configuring signIn does three things:
- registers
IAuthSessionServiceunderCAPABILITIES.AUTH_SESSION— the ONE place that records “this session is signed in as this principal”; - adds an internal
auth-sessionstrategy after thesessionstrategy, so a signed-in session authenticates every later request through the global authentication middleware with no hand-written middleware; the principal carriesclaims.amrfrom the recorded methods (RFC 8176), overwriting anyamrthe stored principal held; - registers, per
oidc/oauth2provider,GET <basePath>/<name>/loginandGET <basePath>/<name>/callback, and onePOST <basePath>/logout(basePathdefaults to/auth); asamlprovider gets its own three routes — see SAML 2.0 single sign-on.
import { createApplication } from '@setu-ts/kernel';
import { RuntimePlugin } from '@setu-ts/runtime';
import { SessionPlugin } from '@setu-ts/session-plugin';
import { AuthPlugin } from '@setu-ts/auth-plugin';
const app = createApplication({
plugins: [
RuntimePlugin(),
SessionPlugin({ secret: 'replace-with-at-least-32-characters!!', store: 'memory' }),
AuthPlugin({
signIn: {
providers: [
{
kind: 'oidc',
name: 'keycloak',
issuer: 'https://id.example.com/realms/acme',
clientId: 'web',
clientSecret: 'from-your-secret-store',
scopes: ['openid', 'profile', 'email'],
redirectUri: 'https://app.example.com/auth/keycloak/callback',
// Namespaced: `sub` is unique only within its issuer.
toPrincipal: (claims) =>
typeof claims.sub === 'string' ? { id: `keycloak|${claims.sub}` } : null,
rpInitiatedLogout: { postLogoutRedirectUri: 'https://app.example.com/' },
},
{
// GitHub issues no ID token: the profile comes from a userinfo endpoint.
kind: 'oauth2',
name: 'github',
clientId: 'Iv1.abc',
clientSecret: 'from-your-secret-store',
tokenEndpointAuth: 'client_secret_post',
scopes: ['read:user'],
authorizationEndpoint: 'https://github.com/login/oauth/authorize',
tokenEndpoint: 'https://github.com/login/oauth/access_token',
userinfoEndpoint: 'https://api.github.com/user',
redirectUri: 'https://app.example.com/auth/github/callback',
toPrincipal: (profile) => ({ id: `github|${String(profile.id)}` }),
},
],
},
}),
],
});
A link to /auth/keycloak/login?returnTo=/orders starts a sign-in and lands on /orders
afterwards.
- Login mints
stateand a PKCE verifier (32 random bytes each, S256 challenge) and, foroidc, anonce, stores them in the user’s OWN session (at most three attempts, ten minutes each), and redirects to the provider. PKCE is sent for every provider, confidential clients included.returnTois kept only when it is a same-origin path — one leading/, no\, no scheme, no control character, at most 256 bytes with non-ASCII percent-encoded — and otherwise becomes/; it is stored at login and never read from the callback URL, so the redirect after sign-in cannot be steered. When the provider’s discovery document cannot be read, login answers503provider-unavailablerather than redirecting to an endpoint it never read, and logsauth-plugin: signIn['<provider>'] provider unavailable (<reason>)atwarnon the first or a changed reason and atdebugon a repeat — so a persistent outage cannot flood the log from an unauthenticated route. - Callback refuses —
401, or a redirect tofailureRedirectwith?error=<code>— with one of four fixed codes:provider-denied(the provider reported an error),state-invalid(unknown, replayed, expired, issued for another provider, or an RFC 9207issnaming another issuer),exchange-failed(no code, token endpoint refused or unreachable, or the ID token failed verification), andprofile-unavailable(theoauth2userinfo endpoint failed). Nothing the provider said reaches a URL, a body or a log line, and every refusal is written in the application’s configured error format. The attempt is consumed before the code is exchanged, so a replayed callback fails. AnoidcID token is verified by the same verifier asissuers— discovery-checked keys,issexact,audcontainingclientIdand, with several audiences,azpequal toclientId— and itsnoncemust match the attempt.toPrincipalreturningnull(or throwing) answers403principal-refused. - Sign-in records the principal with
methods: ['fed']and regenerates the session id, so a session id planted before authentication does not survive into the authenticated session. Provider tokens are handed toonTokensand stored nowhere else. - Cookies. The callback needs the session cookie on the provider’s cross-site redirect, a
top-level
GET, whichSameSite=Lax(the session default) andNonesend andStrictdoes not: withsameSite: 'strict'every callback fails closed asstate-invalid.
Signing in without a provider. signIn: { providers: [] } is a password-only sign-in: there are
no provider routes, and signIn still supplies the auth-session capability, the per-request
refreshPrincipal and sessionStamp checks, and the logout route. A password login records its
principal through the same contract, so it authenticates exactly as a federated one does, and
safeReturnTo validates the returnTo that requireAuth({ signInPage }) sent:
import { CAPABILITIES } from '@setu-ts/common';
import type { IAuthService, IAuthSessionService } from '@setu-ts/common';
import { safeReturnTo } from '@setu-ts/auth-plugin';
app.router.post('/login', async (ctx) => {
const { username, password } = await ctx.request.json<{ username: string; password: string }>();
const auth = ctx.services.get<IAuthService>(CAPABILITIES.AUTH);
const principal = await auth.verifyCredentials({ identifier: username, secret: password });
if (!principal) {
return ctx.response.status(401).json({ error: 'Invalid credentials' });
}
const authSession = ctx.services.get<IAuthSessionService>(CAPABILITIES.AUTH_SESSION);
await authSession.signIn(ctx, principal, { methods: ['pwd'] });
return ctx.response.redirect(safeReturnTo(ctx.query.returnTo), 303);
});
Logging out. POST /auth/logout ends the local session and redirects to / — or, for the one
oidc provider that sets rpInitiatedLogout, when the session signed in through that provider, to
its advertised end-session endpoint with client_id and post_logout_redirect_uri. A password
sign-in, a sign-in through another provider, or an anonymous request ends the local session only. It
is a POST so form CSRF applies when configured, so the logout form carries the token field;
without it every logout answers 403:
import { csrfTokenField } from '@setu-ts/session-plugin';
app.router.get('/account', (ctx) =>
ctx.response.html(
`<form method="post" action="/auth/logout">${csrfTokenField(ctx)}` +
`<button>Sign out</button></form>`,
),
);
- What sign-out revokes depends on the session strategy. With
SessionPlugin({ store })the stored entry is deleted, so a cookie copied before sign-out stops authenticating at once. With the default encrypted-cookie strategy nothing server-side exists to delete, and a copied cookie keeps authenticating until itsmaxAge. Use the store strategy wherever sign-out must be a revocation. idTokenHint: truestores the ID token at sign-in and sends it asid_token_hint, which lets a provider end its session without a confirmation page. An ID token routinely runs to kilobytes, and past the session cookie’s 4096-byte budget the session plugin throws at commit — breaking the sign-in, not the logout — so pair it with the store strategy.- A stored principal is a snapshot. Roles revoked after sign-in stay in force until the session
ends.
signIn.refreshPrincipal(stored)re-reads it on every request: return the current principal, ornullto make the request anonymous. It runs per request, so it is opt-in; a throwing re-read is treated as anonymous, never as the stale snapshot. - Ending every session of one user is
signIn.sessionStamp— see below. IAuthSessionService.current(ctx)is the stored snapshot: neither re-read runs, so it still returns a user whose sessionrefreshPrincipalor the stamp has ended. Decide who may act fromctx.request.user, which theauth-sessionstrategy re-checked. The plugin’s own passkey registration routes re-check the same way.
Ending every session of a user
Keep a stamp on your user record — a sessionVersion counter, or a prefix of the password hash as
Laravel, Django and Devise do — and give signIn a reader for it. IAuthSessionService.signIn
records a SHA-256 digest of the stamp; every request compares the digest of the current stamp with
it, in constant time. Change the stamp and every session of that user answers anonymous on its next
request — ctx.request.user is unset, and passkey registration refuses it — on the cookie strategy
as well as the store strategy.
import type { IPrincipal } from '@setu-ts/common';
import { AuthPlugin } from '@setu-ts/auth-plugin';
declare const users: {
find(id: string): Promise<{ active: boolean; roles: string[]; sessionVersion: number } | null>;
};
// Principals this request's refreshPrincipal just loaded. A snapshot read back
// from the session is a new object and never in here.
const loaded = new WeakSet<IPrincipal>();
AuthPlugin({
signIn: {
providers: [],
// One user load per request: the re-read carries the stamp in a claim…
refreshPrincipal: async (stored) => {
const user = await users.find(stored.id);
if (user?.active !== true) return null;
const principal: IPrincipal = {
id: stored.id,
roles: user.roles,
claims: { sessionVersion: String(user.sessionVersion) },
};
loaded.add(principal);
return principal;
},
// …and the stamp reader takes it only from a principal refreshPrincipal
// loaded; at sign-in (or for anything else) it reads the user record.
sessionStamp: async (principal) => {
const claim = principal.claims?.sessionVersion;
if (loaded.has(principal) && typeof claim === 'string') return claim;
const user = await users.find(principal.id);
return user === null ? null : String(user.sessionVersion);
},
},
});
The claim shortcut is safe only for a principal refreshPrincipal loaded on this request. A
reader that trusts the claim on any principal also trusts the stored snapshot — which is what it
receives at sign-in, and on every request when refreshPrincipal is not configured — and then a
bump never ends anything. Without refreshPrincipal, read the stamp from the user record.
- The reader runs at sign-in and on every request. At sign-in it receives the principal you pass
to
signIn; on a request, the principalrefreshPrincipalreturned (or the stored snapshot without one). A reader that throws, or returns anything but a string ornull, makes the request anonymous; at sign-in the same failure rejectssignInand records nothing. nullmeans “no stamp” and matches only a session recorded with none. EnablingsessionStampon a running application ends every existing session whose user already has a stamp.- Only the digest is stored. Under
SessionPlugin({ mode: 'sign' })the client can read the cookie, so a password-hash prefix never reaches it as the stamp — but a claim you put on the principal you pass tosignInIS stored. Put a secret-derived stamp in the reader, not in the signed-in principal’s claims; a counter is safe in either. - Sign out everywhere is one write:
UPDATE users SET session_version = session_version + 1. - Keep the current session through a password change by signing it in again after the bump, with
the methods it already recorded — passing only
['pwd']would send an MFA session back to the second-factor step. A re-sign-in also forgets the provider-session keys, so RP-initiated logout no longer reaches the provider for a federated user:
import { CAPABILITIES } from '@setu-ts/common';
import type { AuthMethod, IAuthSessionService } from '@setu-ts/common';
import { requireAuth } from '@setu-ts/auth-plugin';
declare const users: { bumpSessionVersion(id: string): Promise<void> };
app.router.post('/account/password', {
middleware: [requireAuth()],
handler: async (ctx) => {
const principal = ctx.request.user!;
// …verify the current password and store the new hash…
await users.bumpSessionVersion(principal.id);
const methods = (principal.claims?.amr ?? []) as AuthMethod[];
const authSession = ctx.services.get<IAuthSessionService>(CAPABILITIES.AUTH_SESSION);
// Only the id: the request's principal still carries the OLD sessionVersion
// claim, which the stamp reader would record. refreshPrincipal reloads the rest.
await authSession.signIn(ctx, { id: principal.id }, { methods });
return ctx.response.json({ ok: true });
},
});
Two account flows are recipes rather than features:
- Forced password reset. Have
refreshPrincipaladd a claim such asmustResetPassword: trueand register a middleware that refuses every route but the reset page while the claim is set. - Admin impersonation is not provided. An application that needs it signs the admin in as the
target principal with a claim naming the real admin (
impersonatedBy), records both ids through@setu-ts/audit-plugin, and ends the impersonation with an ordinary sign-in as the admin.
Listing a user’s sessions with their IP address and device needs a server-side session index, which
ISessionStore does not have; it is not provided.
Not provided: the implicit and hybrid flows, the device flow, dynamic client registration, front-
and back-channel logout, and account linking — toPrincipal decides what an identity maps to.
SAML 2.0 single sign-on
A saml provider makes the application a SAML 2.0 service provider (SP) for an enterprise
identity provider (Entra ID, Okta, ADFS, Keycloak, Google Workspace). It joins the same
signIn.providers list and lands in the same signed-in session, with methods: ['fed'], so
signIn.mfa and requireMfa() apply to it exactly as to an OpenID Connect sign-in.
import { createApplication } from '@setu-ts/kernel';
import { RuntimePlugin } from '@setu-ts/runtime';
import { SessionPlugin } from '@setu-ts/session-plugin';
import { AuthPlugin } from '@setu-ts/auth-plugin';
const idpSigningCert = '-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----\n';
const app = createApplication({
plugins: [
RuntimePlugin(),
SessionPlugin({ secret: 'replace-with-at-least-32-characters!!', store: 'memory' }),
AuthPlugin({
signIn: {
providers: [
{
kind: 'saml',
name: 'corp',
entityId: 'https://app.example.com/saml',
idp: {
entityId: 'https://sts.example.com/acme',
ssoUrl: 'https://sts.example.com/acme/saml2',
// Several certificates let a signing-key rotation overlap.
certs: [idpSigningCert],
},
acsUrl: 'https://app.example.com/auth/corp/acs',
// A NameID is unique only within its issuer: namespace it.
toPrincipal: (profile) => ({
id: `corp|${profile.nameID}`,
claims: { email: profile.attributes.email },
}),
},
],
},
}),
],
});
Three routes per provider:
GET <basePath>/<name>/login— issues an AuthnRequest over the HTTP-Redirect binding and redirects toidp.ssoUrl.?returnTo=follows the same same-origin rule as the other providers and is stored server-side. If the pending request cannot be stored the route answers503provider-unavailablerather than redirecting.POST <basePath>/<name>/acs— the assertion consumer service, over the HTTP-POST binding.GET <basePath>/<name>/metadata— the SP descriptor (application/samlmetadata+xml) to register with the IdP: the entity id, the ACS URL,AuthnRequestsSigned="false"andWantAssertionsSigned="true".
What the ACS checks. The assertion must be signed by one of idp.certs (a signature only on the
response envelope is not enough, and an unsigned or encrypted assertion is refused); its Issuer
must equal idp.entityId; its Audience must be entityId; every SubjectConfirmationData must
name acsUrl as its Recipient and carry an InResponseTo naming the consumed request (the
response envelope is unsigned when only the assertion is, so its own InResponseTo cannot bind the
assertion); NotBefore/NotOnOrAfter must hold with 60 seconds of skew; the response’s
InResponseTo must name a pending request this server issued for this provider, and that request is
consumed — once; the assertion ID must not have been used before; and the browser must present the
binding cookie set at login. Two posts of one captured response cannot both sign in. The Issuer
and NameID the plugin checks and hands to toPrincipal are read from the signed assertion’s own
<Issuer> and <Subject><NameID> elements — never from the library’s profile object, where a
same-named IdP attribute could stand in for them — and an assertion missing either is refused.
IdP-initiated (unsolicited) login is refused: it has no request to bind to. XML signature
verification, including resistance to signature-wrapping, is delegated to
@node-saml/node-saml rather than hand-written.
Refusals answer 401 — or redirect to failureRedirect with ?error=<code> — with one of two
fixed codes, and the library’s own message reaches only the debug log:
| Code | Meaning |
|---|---|
assertion-invalid | The response did not verify, answered no pending request, or reused an assertion id. |
state-invalid | The response answers a login this browser did not start: another browser’s request, or a missing or wrong binding. |
toPrincipal returning null (or throwing) answers 403 principal-refused. A sign-in held for a
second factor redirects to signIn.mfa.challengePath when set.
Why a second cookie. The IdP returns by a cross-site POST, which the session cookie’s default
SameSite=Lax does not accompany, so the pending request cannot live in the session. It lives in
the provider’s store, and the browser that started the login is bound to it by __Host-setu-saml
(SameSite=None; Secure; HttpOnly; Path=/; Max-Age=600). Without that binding an attacker could
start a login, obtain a valid response for their own account, and make a victim’s browser post it.
Do not set the session cookie to SameSite=None to “make SAML work” — it is unnecessary.
Because the binding is one cookie per browser, a login started in a second tab replaces the first
tab’s, which then fails closed; retrying succeeds.
The ACS runs on a new session. The Lax session cookie is not sent with the IdP’s POST, so
the session middleware opens an empty session, signIn writes the identity into it, and its cookie
replaces the browser’s previous one. Anything the previous session held is gone (on the store
strategy its entry is orphaned until its own expiry). Do not keep state across a SAML sign-in in the
session; returnTo is the supported way to resume.
Several replicas need a shared store. The default MemorySamlRequestStore is correct for ONE
process: a login started on one replica and answered on another is refused. Implement
ISamlRequestStore over a shared backend; consumeRequest and claimAssertionId MUST be atomic.
The login route is unauthenticated, so bound what it can cost. Each GET …/login records a
pending request for its lifetime. MemorySamlRequestStore caps how many it holds
(maxPendingRequests, default DEFAULT_MAX_PENDING_SAML_REQUESTS = 10,000): past the cap the
OLDEST pending request is evicted, so its login fails closed and is retried while memory stays
bounded. A shared store needs its own bound (a TTL and a size limit). Rate-limit the login route as
well, and set RuntimePlugin({ maxBodyBytes }) — the ACS reads a form body, and a SAML response is
a few kilobytes, so a cap of tens of kilobytes is ample.
CSRF composition. The IdP’s POST carries no form token, so with the session plugin’s form CSRF
the ACS path must be in csrf.exclude. The Origin is the subtler half: an IdP that serves
Referrer-Policy: no-referrer (Keycloak does) makes the browser post the ACS with Origin: null,
which no origin allowlist can admit safely — trustedOrigins: ['null'] would admit every
opaque-origin POST on every route. The documented recipe therefore puts the ACS path in
http-security-plugin’s csrf.exclude too, so BOTH checks exempt the path rather than trusting an
origin. Exempting it is sound because the signed assertion, the single-use request and the binding
cookie are the ACS’s own defences. Keep trustedOrigins only for an IdP that sends a real origin.
import { SessionPlugin } from '@setu-ts/session-plugin';
import { HttpSecurityPlugin } from '@setu-ts/http-security-plugin';
SessionPlugin({
secret: 'replace-with-at-least-32-characters!!',
csrf: { exclude: ['/auth/corp/acs'] },
});
HttpSecurityPlugin({ csrf: { exclude: ['/auth/corp/acs'] } });
The library. @node-saml/node-saml is imported lazily when a saml provider is configured,
awaited in register(), so a missing package fails at startup with SamlRuntimeLoadError rather
than at the first login. Pass module to inject it instead. On Cloudflare Workers it needs the
nodejs_compat compatibility flag; without it the library cannot bundle, and the error says so.
Not provided: IdP-initiated login, encrypted assertions, single logout, the Artifact binding, signed AuthnRequests, and acting as an IdP.
Multi-Factor Authentication (TOTP)
signIn.mfa adds a TOTP second factor to any sign-in flow that records its principal through
IAuthSessionService — a federated callback, a password login, or both. It is off by default;
nothing changes until you set signIn.mfa.required.
TotpService is an app-instantiated service (like PasswordHasher and RefreshTokenService) — it
is not an AuthPlugin option and registers nothing. It computes RFC 6238 TOTP codes (HMAC-SHA1,
30-second step, 6 digits, ±1 step window) from a base32 secret, and it verifies them against an
ITotpStore. The shipped MemoryTotpStore is single-process; a multi-instance application supplies
a shared implementation, and must implement stageSecret, confirmSecret, claimStep,
reserveAttempt and consumeRecoveryCode as single atomic operations (a compare-and-set or a
transaction) — each one closes a race that a read followed by a write reopens.
A shared ITotpStore implementation must preserve these operations across replicas:
| Method | Operation / return contract | Race closed |
|---|---|---|
getEnrolment(principalId) | Read the enrolment or null; memory returns a copy | Read without mutating the stored record |
stageSecret(principalId, secret, label) | Atomically create an unconfirmed record with step 0; replace secret/label on an unconfirmed record; on a confirmed record set pending fields, keeping the active secret. Never reset an existing step or confirmation. Returns void | Re-enrolment cannot undo a concurrent step claim |
confirmSecret(principalId, secret, minStep) | Atomically confirm only the still-awaiting secret, clear pending fields, raise the claimed step to at least minStep. true when applied; false without changes if missing or replaced | A code checked for an old candidate cannot confirm a new one |
deleteEnrolment(principalId) | Delete enrolment, recovery codes and attempt counts; void | Remove factor state, including recovery codes |
claimStep(principalId, step) | Atomically raise the claimed step; true only if greater than the previous step, false for replay or missing enrolment | Concurrent uses of one code cannot both claim its step |
reserveAttempt(principalId, now, { limit, windowMs }) | Atomically expire old attempts, count this attempt before verification and return { allowed, count }. Refused attempts are not stored; memory reports the stored count plus one for a refusal | A burst cannot bypass lockout; refused guesses cannot extend it indefinitely |
clearAttempts(principalId) | Clear counted attempts after successful verification; void | Clear the window without writing back a stale count |
saveRecoveryCodes(principalId, digests) | Replace the whole recovery set with SHA-256 hex digests, never plaintext; void | Regeneration replaces the previous set |
consumeRecoveryCode(principalId, digest) | Atomically find and consume; true once, false when absent or already used | Concurrent uses of one recovery code cannot both succeed |
A password-only application sets providers: [] with mfa and records its own principal through
IAuthSessionService.signIn, as the /login route below does; signIn then supplies the
auth-session capability, the pending state and the logout route.
import { CAPABILITIES } from '@setu-ts/common';
import type { IAuthSessionService, IRequestContext } from '@setu-ts/common';
import { AuthPlugin, MemoryTotpStore, requireMfa, TotpService } from '@setu-ts/auth-plugin';
import { createRuntimeServices, RuntimePlugin } from '@setu-ts/runtime';
import { SessionPlugin } from '@setu-ts/session-plugin';
import { createApplication } from '@setu-ts/kernel';
const runtime = createRuntimeServices();
const totp = new TotpService({ store: new MemoryTotpStore(), runtime, issuer: 'MyApp' });
const app = createApplication({
plugins: [
RuntimePlugin(),
SessionPlugin({ secret: 'replace-with-at-least-32-characters!!', store: 'memory' }),
AuthPlugin({
signIn: {
providers: [],
mfa: {
// Typically: does this principal have a confirmed factor (or a policy
// that demands one)? Your own lookup decides.
required: (principal) => principal.id.startsWith('local|'),
pendingTtlMs: 300_000,
challengePath: '/mfa',
},
},
}),
],
});
// Your own credential check — whatever your user store provides.
declare function checkPassword(username: string, password: string): Promise<{ id: string } | null>;
app.router.post('/login', async (ctx) => {
const { username, password } = await ctx.request.json<{ username: string; password: string }>();
const user = await checkPassword(username, password);
if (user === null) return ctx.response.status(401).json({ error: 'invalid credentials' });
const auth = ctx.services.get<IAuthSessionService>(CAPABILITIES.AUTH_SESSION);
const outcome = await auth.signIn(ctx, { id: user.id, roles: [] }, { methods: ['pwd'] });
// 'second-factor-required': send the browser to the code form.
return ctx.response.json(outcome);
});
// Who is enrolling: the signed-in principal (a settings page), or — during a
// sign-in held for its first second factor — the pending one. NEVER an id from
// the request body: that would let anyone enrol a factor on another account.
function enrollingPrincipal(ctx: IRequestContext): string | null {
const auth = ctx.services.get<IAuthSessionService>(CAPABILITIES.AUTH_SESSION);
return ctx.request.user?.id ?? auth.pending(ctx)?.principal.id ?? null;
}
app.router.post('/mfa/enrol', async (ctx) => {
const principalId = enrollingPrincipal(ctx);
if (principalId === null) return ctx.response.status(401).json({ error: 'sign in first' });
const { secret, uri } = await totp.beginEnrolment(principalId, principalId);
return ctx.response.json({ secret, uri });
});
// `proof` is required only when REPLACING a confirmed factor: a code from the
// current authenticator, or an unused recovery code.
app.router.post('/mfa/confirm', async (ctx) => {
const principalId = enrollingPrincipal(ctx);
if (principalId === null) return ctx.response.status(401).json({ error: 'sign in first' });
const { code, proof } = await ctx.request.json<{ code: string; proof?: string }>();
const result = await totp.confirmEnrolment(principalId, code, proof);
if (result.status !== 'ok') return ctx.response.status(401).json({ error: result.status });
// Confirmation mints the recovery codes; show them to the user this once.
return ctx.response.json({ recoveryCodes: result.recoveryCodes });
});
app.router.post('/mfa/complete', async (ctx) => {
const { code } = await ctx.request.json<{ code: string }>();
const result = await totp.completeSignIn(ctx, code);
if (result !== 'signed-in') {
return ctx.response.status(401).json({ error: result });
}
return ctx.response.redirect('/', 302);
});
app.router.post('/mfa/complete-recovery', async (ctx) => {
const { code } = await ctx.request.json<{ code: string }>();
const result = await totp.completeSignInWithRecoveryCode(ctx, code);
if (result !== 'signed-in') {
return ctx.response.status(401).json({ error: result });
}
return ctx.response.redirect('/', 302);
});
// Settings: regenerating codes or disabling the factor takes the factor.
app.router.post('/account/mfa/recovery-codes', {
middleware: [requireMfa()],
handler: async (ctx) => {
const { proof } = await ctx.request.json<{ proof: string }>();
const result = await totp.generateRecoveryCodes(ctx.request.user?.id ?? '', proof);
if (result.status !== 'ok') return ctx.response.status(401).json({ error: result.status });
return ctx.response.json({ recoveryCodes: result.recoveryCodes });
},
});
app.router.post('/account/mfa/disable', {
middleware: [requireMfa()],
handler: async (ctx) => {
const { proof } = await ctx.request.json<{ proof: string }>();
const result = await totp.disable(ctx.request.user?.id ?? '', proof);
return ctx.response.status(result === 'ok' ? 200 : 401).json({ result });
},
});
app.router.get('/account/bank', {
middleware: [requireMfa()],
handler: async (ctx) => ctx.response.json({ ok: true }),
});
- Who may change a factor. Enrolment, confirmation, regeneration and disabling all take a
principal id, and the routes that call them must take it from the session — the signed-in
principal, or the pending one during a sign-in — never from the request. The service then enforces
the rest: confirming a REPLACEMENT for a confirmed factor, regenerating recovery codes, and
disabling a confirmed factor each require
proofof the current factor (a code from it or an unused recovery code), answeringproof-requiredwithout it. So a caller who can name a principal still cannot take over or remove its second factor. A FIRST enrolment needs no proof, because there is nothing to prove: an account with no factor is trust-on-first-use, and whoever holds its password during the pending state can enrol one. If that matters, enrol factors only from a signed-in session, or havemfa.requiredanswertrueonly for principals that already have one. An administrator reset that cannot obtain proof callsITotpStore.deleteEnrolmentdirectly, behind its own authorization. A PASSKEY is a second factor too: withsignIn.mfaconfigured, a one-factor session cannot register one — not even the first — unlessPasskeyOptions.mayRegisteradmits it, so a stolen password cannot add a passkey beside a TOTP factor (see Passkeys). - Pending sign-in. When
mfa.requiredreturnstrue,signIndoes NOT sign the session in. It stores aPendingSignInrecord under a private session key and returnssecond-factor-required. The session is anonymous untilcompleteSignIn(orcompleteSignInWithRecoveryCode) succeeds, at which point the principal is recorded withmethods: ['pwd', 'otp'](or['fed', 'otp']) and the session id is rotated. A pending record expires aftersignIn.mfa.pendingTtlMs(default 300 000 ms; a positive integer, anything else is refused whenAuthPlugin(...)is called). An expired record is not reported bypending(ctx), socompleteSignInanswersno-pendingwithout checking — or spending — the code; that option is the only place the TTL is configured, becauseTotpServicehas no TTL option of its own. The internal promotion (promotePending) is not exported, andTotpServiceis the framework’s only path that completes a pending sign-in — after checking a factor for the pending principal. That is a guarantee about the framework’s surface, not a sandbox: application code holding the request can always callsignInwithmethods: ['pwd', 'otp']itself, and is trusted to record only methods it verified. - Federated sign-ins. A provider callback whose sign-in is held pending redirects to
signIn.mfa.challengePath(a same-origin absolute path) — the code form — instead ofreturnTo; without it, it falls back toreturnTo, where the page must readpending(ctx)to tell the pending state from an anonymous one. The provider-session facts the callback records survive the promotion, so RP-initiated logout still ends the provider session afterwards. - Lockout. Five failed attempts within 15 minutes lock the principal out of verification until
15 minutes after the oldest counted attempt. A refused attempt is not counted, so continued
guessing cannot extend the lock.
MemoryTotpStorekeeps an entry only for a principal with an attempt inside the window — expired entries are swept as the map grows — so its size is bounded by the attempt rate, not by every principal id ever presented. The attempt is reserved BEFORE the code is checked, so the fifth failure is what trips the lock and the sixth answerslockedwithout computing or comparing anything. Recovery codes share that counter: five wrong recovery codes lock out TOTP verification too. A successful verification clears the count. - Re-enrolment is non-destructive. A confirmed factor stays confirmed and stays usable while a
new secret awaits confirmation; confirming swaps the pending secret in and keeps the step counter
monotonic, so a code valid before the swap is still refused afterwards. Confirmation succeeds only
if the secret its code was checked against is STILL the one awaiting confirmation
(
ITotpStore.confirmSecret), so a secret staged concurrently by someone else is never confirmed on the strength of the user’s code — the user’s confirmation answersinvalidinstead. A wrong new code is checked before any recovery code offered as proof, so it does not spend one. - Replay protection. The store’s
claimStepis monotonic: a code whose step is ≤ the last claimed step is refused, so a captured code cannot be replayed within its ±1 window. - Recovery codes. Confirming a factor mints the first set (
confirmEnrolmentreturns it), so codes never exist without a proven factor;generateRecoveryCodes(principalId, proof)replaces the set later. Each set is 10 codes of 16 base32 characters (80 bits each). They are stored as SHA-256 digests and consumed atomically on use — a code works exactly once. The plaintext list is returned to the caller exactly once and is not recoverable. requireMfa()guard. Answers401for an anonymous request and403second-factor-requiredfor a principal whoseclaims.amrlacksotporpop. A session that completed a second factor carriesamr: [..., 'otp']and passes. A non-arrayamr— a bare'otp', a number, an object — is treated as ABSENT rather than coerced, so a string merely containing a factor name cannot satisfy the guard. The guard is brandedAUTHENTICATED, so it composes withrequireRole/requirePermissionon the same route.- Both completion paths append
otp.completeSignInandcompleteSignInWithRecoveryCodeeach appendotpto the pending record’s methods, so a password sign-in that finished with either ends up withmethods: ['pwd', 'otp']andamr: ['pwd', 'otp'].requireMfa()acceptsotporpop.
Passkeys (WebAuthn)
signIn.passkeys registers the four ceremony routes — POST <basePath>/passkeys/register/options,
POST <basePath>/passkeys/register/verify, POST <basePath>/passkeys/login/options and
POST <basePath>/passkeys/login/verify (basePath defaults to /auth) — and lets a passkey
assertion with user verification count as the second factor for signIn.mfa’s step-up model.
Registration and authentication ceremonies are WebAuthn Level 2 with attestation conveyance none;
ES256, RS256 and EdDSA credentials are accepted.
The verifier is zero-dependency: a bounded CBOR/COSE decoder over runtime.subtle. It does not
use @simplewebauthn/server, because importing that library installs a global Reflect.getMetadata
polyfill in every application that enables passkeys (its reflect-metadata dependency) and
advertises ML-DSA-44 on some runtimes only — the framework requires no reflection library, and the
plugin keeps that true. The library appears only in the package’s own differential test, as a test
oracle.
Verification checks clientDataJSON (ceremony type, the session-held challenge, the origin against
an exact allowlist, crossOrigin refused), authenticatorData (RP ID hash, user-present and
user-verified flags), and the signature over authenticatorData ‖ SHA-256(clientDataJSON).
Challenges are held in the session with a 5-minute expiry AND claimed once in the credential store:
on the default encrypted-cookie session strategy an older cookie still carries a consumed challenge,
and a synced passkey’s counter is always 0, so the session alone cannot stop a replayed assertion.
import type { IPrincipal } from '@setu-ts/common';
import { AuthPlugin, MemoryPasskeyStore } from '@setu-ts/auth-plugin';
import { createApplication } from '@setu-ts/kernel';
import { RuntimePlugin } from '@setu-ts/runtime';
import { SessionPlugin } from '@setu-ts/session-plugin';
/** The application's own principal lookup; `null` refuses the sign-in. */
function loadPrincipal(principalId: string): Promise<IPrincipal | null> {
return Promise.resolve(null);
}
const app = createApplication({
plugins: [
RuntimePlugin(),
SessionPlugin({ secret: 'replace-with-at-least-32-characters!!' }),
AuthPlugin({
signIn: {
providers: [],
mfa: { required: (principal) => principal.roles?.includes('admin') === true },
passkeys: {
rpId: 'example.com',
rpName: 'My App',
origins: ['https://example.com'],
store: new MemoryPasskeyStore(),
resolvePrincipal: loadPrincipal,
},
},
}),
],
});
The browser calls the routes with navigator.credentials and the CSRF header token the session
plugin’s form CSRF check requires:
const options = await fetch('/auth/passkeys/login/options', {
method: 'POST',
headers: { 'x-csrf-token': csrfToken },
}).then((r) => r.json());
// The options are JSON (base64url strings); WebAuthn needs ArrayBuffers. Registration
// does the same with `parseCreationOptionsFromJSON` before `navigator.credentials.create`.
const assertion = await navigator.credentials.get({
publicKey: PublicKeyCredential.parseRequestOptionsFromJSON(options),
});
const result = await fetch('/auth/passkeys/login/verify', {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-csrf-token': csrfToken },
body: JSON.stringify(assertion),
}).then((r) => r.json());
// { status: 'signed-in' } — or { status: 'second-factor-required' }
Credentials are the application’s data: PasskeyOptions.store is a required IPasskeyStore, and
the shipped MemoryPasskeyStore is single-process. A multi-instance application supplies a shared
implementation and must implement updateCounter as an atomic compare-and-advance — it stores
the observed counter only when it is greater than the stored one (or both are zero) and reports
whether it did. A read-then-write lets two concurrent assertions both validate against the same
stored value and lets the lower one overwrite the higher, which is exactly how a cloned
authenticator’s stale counter slips through. save must be an atomic compare-and-set — it
stores a NEW credential only when its id is absent AND the principal holds fewer than
options.maxPerPrincipal credentials, both checked in the same atomic step as the insert, and
answers 'saved', 'duplicate' or 'limit'. A blind write lets two concurrent registrations of
the same credential id both succeed and lets the later record silently replace the earlier one,
principalId included; a count read before a separate write lets a burst of concurrent
registrations overshoot the cap. claimChallenge must be atomic too.
Under the default userVerification: 'required' a UV-unset assertion is refused everywhere — the
options told the browser user verification is required, so the server enforces it. Under
'preferred' or 'discouraged' a UV-unset assertion is refused for a username-less sign-in and
accepted only as the second factor after a first one: without user verification it proves possession
alone, and recording pop for it would satisfy requireMfa() with one factor.
A passkey-only application needs no provider and no mfa: signIn: { providers: [], passkeys } is
accepted, and the passkeys option is validated when AuthPlugin(...) is called, so a malformed
origin or rpId refuses before an application exists. The recorded method is always pop (proof of
possession) — never hwk/swk, which the plugin cannot know with attestation unverified.
Registration is gated. Registering a passkey requires a signed-in principal (401 otherwise), and
a principal who ALREADY holds a passkey must have proved a second factor (otp or pop) in the
current session — otherwise a stolen password alone could enrol the attacker’s own authenticator,
whose later assertions are recorded as pop and pass requireMfa(). That is refused with
403 second-factor-required, at both register/options and register/verify.
The FIRST passkey depends on whether signIn.mfa is configured. Without it (a passkey-only
application) the first passkey is trusted on first use. With it, and no mayRegister, the first
passkey needs a proven second factor too — the principal may hold a TOTP factor this plugin cannot
see, and a stolen password must not enrol an authenticator that then satisfies requireMfa(). A
user with no factor yet therefore cannot add a first passkey until the application says so:
PasskeyOptions.mayRegister receives the principal, the session’s recorded methods and the
principal’s credentialCount, is consulted after the built-in rules, and decides the first passkey
when it is set — admit a password-only first enrolment only for a principal that holds no other
factor. false or a throw answers 403 registration-refused. One principal holds at most 16
credentials (409 credential-limit, enforced atomically by the store’s save), and only the six
defined WebAuthn transports values are stored, each once. An EC2 key is checked to be a point on
P-256 at registration, because the runtimes disagree on whether Web Crypto does.
Attestation statements are NOT verified: attestation: 'none' is requested, any fmt the client
sends is accepted with its statement unread, and the stored credential records
attestation: 'unverified'. Verifying attestations against trust roots is out of scope.
Refresh Tokens
RefreshTokenService is an app-instantiated service (like PasswordHasher) — it is not an
AuthPlugin option and registers nothing. It mints typed access + refresh pairs with distinct
random jtis, rotates on every refresh, and revokes on logout. A refresh token is a
signed JWT with type: 'refresh', which bearer authentication refuses. A replayed refresh token
revokes its complete descendant family. refresh()/revoke() never throw on a bad token — invalid,
expired, or tampered input yields null/false.
To make logout invalidate the paired access credential before its normal expiry, construct one
IAccessTokenRevocationStore and pass that same instance to both AuthPlugin and
RefreshTokenService. MemoryAccessTokenRevocationStore is single-process; a multi-instance
application supplies a shared implementation. Access revocation requires accessToken.expiresIn, so
revocation entries are bounded. RefreshTokenStore implementations must persist family lineage and
make rotate(jti, successor) and revokeFamily(jti) linearizable per family; the shipped
MemoryRefreshTokenStore does both with lazy expiry on get() and amortised expiry sweeps on
save() and rotate(). Sweeps remove only expired records; unexpired revoked records remain
available for rotation reuse detection. No background timer is required.
import {
AuthPlugin,
MemoryAccessTokenRevocationStore,
MemoryRefreshTokenStore,
RefreshTokenService,
} from '@setu-ts/auth-plugin';
import type { IJwtService, IRuntimeServices } from '@setu-ts/common';
import { createApplication } from '@setu-ts/kernel';
import { createRuntimeServices, RuntimePlugin } from '@setu-ts/runtime';
const accessTokenRevocations = new MemoryAccessTokenRevocationStore(createRuntimeServices());
const app = createApplication({
plugins: [
RuntimePlugin(),
AuthPlugin({
jwt: { secret: config.get('JWT_SECRET'), accessTokenRevocationStore: accessTokenRevocations },
}),
],
});
await app.start();
const jwt = app.services.get<IJwtService>('jwt');
const runtime = app.services.get<IRuntimeServices>('runtime');
const refresh = new RefreshTokenService({
jwt,
store: new MemoryRefreshTokenStore(runtime),
runtime,
accessToken: { expiresIn: '15m' },
refreshTokenExpiresIn: '30d',
accessTokenRevocationStore: accessTokenRevocations,
});
const pair = await refresh.issue(principal); // { accessToken, refreshToken }
const next = await refresh.refresh(pair.refreshToken); // new pair; old token now rejected
await refresh.revoke(next!.refreshToken); // logout
| Option | Type | Default | Description |
|---|---|---|---|
jwt | IJwtService | - | Signs/verifies both tokens. |
store | RefreshTokenStore | - | Rotation/revocation backend. |
runtime | IRuntimeServices | - | randomBytes (jti) + now() (expiry). |
accessToken.expiresIn | string | jwt default | Access-token lifetime. |
accessToken.audience | string | - | aud on both tokens; enforced on verify. |
accessToken.issuer | string | - | iss on both tokens; enforced on verify. |
refreshTokenExpiresIn | string | '7d' | Refresh-token lifetime (JWT exp AND record). |
accessTokenRevocationStore | IAccessTokenRevocationStore | - | Shared access-token invalidation store; requires accessToken.expiresIn. |
Rate Limiting
rateLimitMiddleware(options) is a standalone fixed-window limiter, independent of AuthPlugin and
registered under no capability token. Over-limit requests are short-circuited with 429
(Retry-After always set; RateLimit-Limit/RateLimit-Remaining/RateLimit-Reset set unless
standardHeaders: false — Reset and Retry-After are both delta-seconds). The default store is
in-memory (single-process); use RedisRateLimitStore for multi-instance deployments (pass an
ioredis-compatible client, or ioredis is lazily imported on first use).
The 429 body uses @setu-ts/common’s error-responder seam, so it follows the application’s
configured error format. The operational paths /live, /ready, /health, /metrics,
/openapi.json, and /docs are exempt by default through DEFAULT_RATE_LIMIT_EXCLUDED_PATHS; an
exclude list replaces the defaults, so spread the constant to extend them. RedisRateLimitStore
prefixes its keys with 'setu:ratelimit:' by default; set keyPrefix per application when several
share Redis, or '' to keep pre-M90a keys.
RedisRateLimitStore is constructed by the application, so no plugin wires its connection errors.
Pass connectionErrorReporter (built with createConnectionErrorReporter from @setu-ts/common
over your logger) and the 'error' events of the client it builds are logged — first error at
warn, repeats at debug, recovery at info — rather than printed by ioredis to console.error
on every reconnect attempt. An injected client never gets a listener.
To back the store with node-redis, pass
new RedisRateLimitStore({ client: fromNodeRedis(createClient({ url })) }) (fromNodeRedis from
@setu-ts/common, createClient from redis). See
Using node-redis.
The default key resolves in this order: the authenticated principal, the client IP published by
ipSecurityMiddleware (see http-security-plugin), IRequest.ip, and only then one global
'anonymous' bucket — shared by every caller for whom none of the three resolved.
| Expression | Value |
|---|---|
(await import('@setu-ts/auth-plugin')).defaultRateLimitKey({ request: { user: { id: 'u1' } }, state: new Map() }) | "user:u1" |
(await import('@setu-ts/auth-plugin')).defaultRateLimitKey({ request: { ip: '203.0.113.7' }, state: new Map() }) | "ip:203.0.113.7" |
(await import('@setu-ts/auth-plugin')).defaultRateLimitKey({ request: {}, state: new Map() }) | "anonymous" |
Node, Deno and Bun populate IRequest.ip with the socket peer, so an unauthenticated caller uses
ip:<peer> even without IP security middleware. Workers and plain fetch calls have no peer and
still use the shared bucket unless a trusted header or custom key resolves a client.
To make it per client behind a proxy, register ipSecurityMiddleware with trustProxy and the
constraint that matches your deployment — or pass your own keyGenerator. trustProxy on its own
takes the forwarded header’s leftmost entry, which is safe only behind a proxy that OVERWRITES
that header. The standard nginx idiom appends, and there the leftmost entry is supplied by the
caller: a client rotating forged X-Forwarded-For values would get a fresh bucket per request,
which is a weaker position than the shared 'anonymous' one it replaced. Supply trustedProxies
(your proxies’ addresses or CIDR blocks) or proxyHops so the header is walked right to left and
the first entry your infrastructure did not add is the client.
import {
DEFAULT_RATE_LIMIT_EXCLUDED_PATHS,
rateLimitMiddleware,
RedisRateLimitStore,
} from '@setu-ts/auth-plugin';
// Keyed by the authenticated user. Absent a principal AND a client IP, every
// caller shares ONE 'anonymous' bucket — see the key-resolution order above.
app.middleware.add(
rateLimitMiddleware({
windowMs: 60_000,
max: 100,
exclude: [...DEFAULT_RATE_LIMIT_EXCLUDED_PATHS, /^\/internal\//],
}),
);
// Redis-backed, keyed by authenticated user
rateLimitMiddleware({
windowMs: 60_000,
max: 5,
keyGenerator: (ctx) => ctx.request.user?.id ?? ctx.request.ip ?? 'anonymous',
store: new RedisRateLimitStore({
url: 'redis://localhost:6379',
runtime,
keyPrefix: 'orders-api:rl:',
}),
});
| Option | Type | Default | Description |
|---|---|---|---|
windowMs | number | - | Window length in ms. |
max | number | - | Max requests per window per key. |
store | RateLimitStore | MemoryRateLimitStore | Counter backend. |
keyGenerator | (ctx) => string | principal → client IP → IRequest.ip → 'anonymous' | Caller identity for the counter. |
message | string | 'Rate limit exceeded' | 429 body message. |
standardHeaders | boolean | true | Emit RateLimit-* headers. |
exclude | readonly PathPattern[] | six operational paths | Paths skipped entirely; strings match exactly and regexps test the path. |
MemoryRateLimitStore reclaims expired windows on increment() with a doubling-threshold sweep
starting at 1,024 entries. Each key retains its own window duration; live windows are never evicted
for a hard cap, so active rate limits survive cleanup.
Diagnostics
AuthPlugin({ authorizationDiagnostics }) opts into minimized authorization-decision explanations
for the local diagnostics connector’s GET /v1/authorization (@setu-ts/diagnostics-plugin). It is
explicit: the option is absent by default, and when supplied enabled must be true.
| Field | Type | Meaning |
|---|---|---|
enabled | boolean | Explicit opt-in; must be true (false throws). |
roles | Record<string, string> | Exact role name → approved display alias. At most 128 entries. |
permissions | Record<string, string> | Exact permission name → approved display alias. At most 128. |
policyRevision | string | Approved alias for the policy revision; only when configured. |
Only the exact role and permission names in the allowlists are observed, each under its approved
alias; a decision whose requested rules are not all approved is dropped before buffering and counted
as droppedUnapproved. A recorded decision names its rules only by their approved aliases; raw role
and permission names, request bodies, headers and claim values are never captured.
setu devtool enable writes this option for you; this section is for the reader who configures it
by hand. Enable only on an approved development dataset.
import { AuthPlugin } from '@setu-ts/auth-plugin';
AuthPlugin({
authorizationDiagnostics: {
enabled: true,
roles: { admin: 'Administrator' },
permissions: { 'users:write': 'Modify users' },
policyRevision: 'rev-2026-01',
},
});
Guards and OpenAPI
Every guard this package returns is branded with RouteSecurityMetadata from @setu-ts/common, so
@setu-ts/openapi-plugin
can document which operations require authentication without either package importing the other. Set
OpenApiPlugin({ deriveSecurity: { scheme: 'bearerAuth' } }) and a route carrying requireAuth()
is documented as protected; one carrying publicRoute() is documented as public.
The brand is symbol-keyed and non-enumerable — guard behaviour is unchanged — and it carries
authentication presence only. An OpenAPI security requirement names a scheme, not a role, so
requireRole('admin') documents that authentication is required and nothing about the role.
License
MIT
Exports
| Export | Kind |
|---|---|
authMiddleware | function |
AuthPlugin | function |
defaultRateLimitKey | function |
definePolicy | function |
publicRoute | function |
rateLimitMiddleware | function |
requireAllPermissions | function |
requireAnyRole | function |
requireAuth | function |
requireMfa | function |
requirePermission | function |
requirePolicy | function |
requireRole | function |
requireScopedPermission | function |
requireScopedRole | function |
safeReturnTo | function |
AuthorizationDeniedError | class |
AuthPluginConfigurationError | class |
GrantResolutionError | class |
MalformedPasswordHashError | class |
MemoryAccessTokenRevocationStore | class |
MemoryPasskeyStore | class |
MemoryRateLimitStore | class |
MemoryRefreshTokenStore | class |
MemorySamlRequestStore | class |
MemoryTotpStore | class |
PasswordHasher | class |
RateLimitStoreUnavailableError | class |
RedisRateLimitStore | class |
RefreshTokenService | class |
SamlRuntimeLoadError | class |
TotpService | class |
UnknownPolicyError | class |
DEFAULT_MAX_PENDING_SAML_REQUESTS | const |
DEFAULT_RATE_LIMIT_EXCLUDED_PATHS | const |
DEFAULT_RATE_LIMIT_KEY_PREFIX | const |
ApiKeyOptions | interface |
AuthMiddlewareOption | interface |
AuthorizationDiagnosticsOptions | interface |
AuthPluginOptions | interface |
IAccessTokenRevocationStore | interface |
IAuthHttp | interface |
IAuthorizationDiagnosticsSource | interface |
IAuthorizationPolicyService | interface |
IAuthorizationService | interface |
IAuthService | interface |
IAuthStrategy | interface |
IJwtService | interface |
IPasskeyStore | interface |
IPrincipal | interface |
ISamlRequestStore | interface |
ITotpStore | interface |
JwtOptions | interface |
JwtSignOptions | interface |
LocalOptions | interface |
MemorySamlRequestStoreOptions | interface |
MfaOptions | interface |
OAuth2Provider | interface |
OidcProvider | interface |
PasskeyOptions | interface |
PasskeyRegistrationContext | interface |
PasskeySaveOptions | interface |
PasswordHasherOptions | interface |
ProviderTokens | interface |
RateLimitOptions | interface |
RateLimitResult | interface |
RateLimitStore | interface |
RbacConfig | interface |
RefreshTokenOptions | interface |
RefreshTokenRecord | interface |
RefreshTokenStore | interface |
RequireAuthOptions | interface |
ReserveAttemptResult | interface |
RoleDefinition | interface |
SamlModule | interface |
SamlPendingRequest | interface |
SamlProfile | interface |
SamlProvider | interface |
ScopedGuardOptions | interface |
ScopedRbacOptions | interface |
ScopedRoleLimit | interface |
SessionAuthOptions | interface |
SignInConfig | interface |
SignInProviderBase | interface |
StaticGrant | interface |
StoredPasskey | interface |
TokenPair | interface |
TotpEnrolment | interface |
TotpServiceOptions | interface |
TrustedIssuer | interface |
ClaimsGrantMapper | type |
ConfirmEnrolmentResult | type |
DisableResult | type |
GrantResolutionReason | type |
GrantSourceConfig | type |
IRefreshTokenRotation | type |
IssuerAlgorithm | type |
IssuerKeySource | type |
PasskeySaveResult | type |
PolicyDenial | type |
RecoveryCodesResult | type |
RecoveryVerifyResult | type |
RefreshPrincipal | type |
ScopedRbacTiming | type |
SessionStamp | type |
SignInProvider | type |
TokenEndpointAuth | type |
TotpCompleteSignInResult | type |
TotpProofResult | type |
TotpVerifyResult | type |
Generated from the package barrel by pnpm run docs:exports; pnpm run check:docs fails when it
drifts.
Full API
Every export and option is documented in PUBLIC_API.md.
Readiness and dependency outages (M113b)
RedisRateLimitStore.increment/reset wrap recognised connection faults in
RateLimitStoreUnavailableError, preserving cause. With errorHandler, clients receive 503 and
Rate limiting is temporarily unavailable. Other errors propagate unchanged. The constructor’s
commandTimeoutMs defaults to 15000, accepts 0–2147483647, and throws RangeError for invalid
values including NaN. Zero omits ioredis’s bound; an injected client keeps its own settings.
Configure a larger bound when necessary.
API reference
AuthPlugin() (@setu-ts/auth-plugin)
RedisRateLimitStore.increment/reset wrap recognised connection faults in
RateLimitStoreUnavailableError, preserving cause. With errorHandler, clients receive 503 and
Rate limiting is temporarily unavailable. Other errors propagate unchanged. The constructor’s
commandTimeoutMs defaults to 15000, accepts 0–2147483647, and throws RangeError for invalid
values including NaN. Zero omits ioredis’s bound; an injected client keeps its own settings.
Configure a larger bound when necessary.
Provides JWT and API-key authentication, local credential verification, RBAC authorization with role
hierarchy, and short-circuiting route guards. All cryptography (HS256/RS256 JWT, PBKDF2-SHA256
password hashing) runs through Web Crypto via IRuntimeServices, so no npm package is involved in
issuing or verifying a token, or in hashing a password. The package declares one optional driver —
RedisRateLimitStore lazy-loads ioredis — which nothing imports unless that store is constructed.
Registers authentication under the existing capability token, JWT only when configured, and authorization when RBAC is configured:
IJwtServiceunderCAPABILITIES.JWT('jwt') — sign/verify/decode JWTs, only whenjwtis supplied.IAuthServiceunderCAPABILITIES.AUTH('authentication') — passive strategy chain + login.IAuthorizationServiceunderCAPABILITIES.AUTHORIZATION('authorization') — RBAC checks, only whenrbacis supplied.IAuthorizationPolicyServiceunderCAPABILITIES.AUTHORIZATION_POLICIES('authorization-policies') — asynchronous, target-aware policy checks (M110a), ALWAYS registered, holding thepoliciesoption’s definitions and any class-form policies DecoratorPlugin registers.
JWT time boundaries (M113m). The first-party JwtService.verify, exposed as IJwtService, uses
fractional seconds (runtime.now() / 1000) without clock tolerance. It rejects with Token expired
once now >= exp, and with Token not yet valid while now < nbf: the not-before boundary is
inclusive, and the expiry boundary is exclusive. Previously, a token could remain accepted for up to
one second after exp; refresh before exp. The outside-issuer verifier (issuers) uses
fractional seconds with clockToleranceSec: expiry is refused once now >= exp + clockToleranceSec
(previously >), and not-before is refused while now + clockToleranceSec < nbf (unchanged).
Authorization policies (M110a). definePolicy({ name, abilities, before? }) validates a policy
(lowercase kebab-case name, at least one ability, no ability named before) and returns a frozen
copy typed by its ability names; register it through AuthPlugin({ policies }), which refuses a
malformed policy or a duplicate name when AuthPlugin(...) is called. An ability is a check
(principal, target) => boolean | Promise<boolean>, or { anonymous: true, check } to be called
with null for an anonymous request. The rules are fixed: an unknown policy or ability REJECTS with
UnknownPolicyError; an anonymous principal is denied (401) before a non-anonymous check runs;
for a signed-in principal before runs first — true allows, undefined falls through, anything
else (null included) denies; only a literal true allows; and a throw or rejection from before
or the check denies and is logged once at error with the policy, ability, hook and serialized
error — never the target or the principal. requirePolicy(policy, ability, target?) is the route
guard: 501 with no policy service, 401 when an anonymous request is denied, 403 when a
signed-in principal is denied, otherwise next(); target is a value or an extractor
(ctx) => T | undefined | Promise<T | undefined> (a function is ALWAYS treated as an extractor),
and an extractor’s throw propagates unchanged. An anonymous request to an ability that needs a
principal is refused 401 BEFORE the extractor runs, so it costs no lookup and cannot learn from
the extractor whether a record exists. Whether the ability needs a principal is read from the
REGISTERED policy on each request, never from the guard’s own policy object, so a guard the startup
scan never saw (a route added after start(), a global middleware) behaves the same; a guard naming
an unregistered policy or ability rejects without running the extractor. An undefined principal
passed to can/authorize is anonymous, exactly like null. A policy or ability name longer than
128 characters is truncated in an UnknownPolicyError’s message and fields, with the removed length
noted. It is branded for OpenAPI deriveSecurity as authenticated unless the ability is anonymous;
the brand is fixed from the guard’s own policy object when the guard is built, which the startup
scan proves agrees with the registered policy only for routes present at start(). The service’s
can resolves a boolean and authorize rejects a denial with AuthorizationDeniedError, which
carries a 401/403 status hint whose title and detail are the guards’ own, so under
errorHandler a thrown denial answers the guard’s exact body (without errorHandler the kernel
answers 500). AuthPlugin’s onBootstrap hook scans every registered route and fails start()
when a requirePolicy guard names a policy or ability that is not registered, or one whose
registered ability disagrees with the guard’s policy object on anonymous (a different policy
registered under the same name); it then SEALS the registry, so define after start() throws. Not
scanned: a route added after start() and a guard added as global middleware — an unknown name
there rejects per request (fail closed). A policy is identified by its NAME. See
Authorization.
Scoped RBAC (M110b). scopedRbac (requires rbac) grants a catalogue role IN a scope — a
ScopeRef { type, id } naming a tenant, organisation, team or region — rather than everywhere.
AuthPlugin defines ONE built-in policy, SCOPED_RBAC_POLICY ('scoped-rbac', a reserved name an
application policy may not take), on the M110a evaluator: every catalogue permission is an ability
perm:<permission> and every catalogue role an ability role:<role>, and the target is a
ScopedRbacTarget { scope, context? } — so no new capability token is added and the M110a rules
(only true allows, 401 before the check for an anonymous request, a throw denies) apply
unchanged. The wildcard * is never an ability. requireScopedRole(role | roles, { scope? }) is
any-of and requireScopedPermission(permission | permissions, { scope? }) is all-of; scope is a
ScopeSource — scopeFromTenant() (the default: the resolved ctx.request.tenant),
scopeFromParam(param, type), a fixed ScopeRef, null (global grants only) or a function. Both
guards answer 501 with no policy service, 401 anonymous BEFORE the scope source runs, 403 on a
deny, and are refused at start() by the M110a scan when they name a permission or role outside the
catalogue — so a typo is a startup error, never a silent 403. Grants come from sources, UNIONED:
{ kind: 'static', grants }, { kind: 'claims', map }, and { kind: 'custom', source } (any
IGrantSource, or a RegistryFactory resolved at onInit, which is how
createDatabaseGrantSource plugs in); one source that rejects, throws or exceeds sourceTimeoutMs
DENIES the whole check (fail closed), logged with the source name, the scope TYPE and the error
class only — never an id, a principal or a message. A grant applies in its own scope and, through
inheritsFrom(scope, signal), in every scope that inherits from it (child requests inherit parent
grants, never the reverse); the walk is depth-first with cycle refusal and the maxScopeDepth /
maxScopeNodes bounds, any of which denies. A null-scope grant is global unless
grantableIn[role] restricts the role to scopeTypes (and global: false, the default). Only
grants decide (M113e, V9-28): the roles and permissions a principal carries itself count in no
scope unless principalAuthorityIsGlobal: true, which turns each principal role into a global grant
(still subject to grantableIn) and lets each principal permission, '*' included, satisfy every
scoped permission check — the behaviour before 0.10.0. A non-boolean value is refused at
AuthPlugin(...). The unscoped guards are unaffected. Tenant consistency: a check whose OWN scope
has tenantScopeType (default 'tenant') and names a tenant other than the resolved request tenant
denies — a route parameter cannot reach outside the caller’s tenant. That comparison needs the
request: the guards and decorators pass it, and a direct policies.can(...) call made while serving
a request must pass it as the target’s context — without it the scope is checked against the
grants alone. Per-scope custom roles (customRoles, an IScopedRoleSource) bundle CATALOGUE
permissions only, resolve against the roles defined in the grant’s OWN scope, cannot shadow a
catalogue role, and are checked through permissions — a guard naming a custom role is refused at
startup. timing decides revocation latency for static and custom sources: 'request'
(default; memoised per request), { kind: 'cache', ttlMs, maxEntries } (an LRU on the monotonic
clock with in-flight coalescing; up to ttlMs) and 'sign-in' (requires signIn; resolved once by
IAuthSessionService.signIn, stored under a PRIVATE session key — never in claims, so a token
carrying a same-named claim grants nothing — carried through a pending second factor, and in force
until sign-out or session expiry; a source failure rejects sign-in with GrantResolutionError, a
503 status hint). A custom source is handed only the principal’s id and iss claim — the fields
its shared answer is keyed by — never other claims, roles or permissions. A claims source is
mapped on every check from the credential being checked and is never memoised, cached or coalesced,
because two tokens for one principal can carry different claims (under 'sign-in' it is mapped
once, from the credential that signed in). Every bound refuses an out-of-range value, NaN
included, when AuthPlugin(...) is called. Scoped decisions are NOT observed by M98h
authorizationDiagnostics — its collector watches only the global RBAC evaluator — so no scope
identifier reaches the diagnostics connector. See
Authorization.
jwt and rbac are optional. At least one passive strategy must come from jwt, issuers,
apiKey, session, or strategies; local alone cannot recognize a later request. A JWT-only
registration provides jwt and authentication; it deliberately does not register an authorization
service.
What the guards then do (M89b, X18-2). requireAuth(), publicRoute(), and M100d’s
requireMfa() resolve nothing and are unaffected: requireMfa() asks only whether the
authenticated principal carries otp or pop in claims.amr, which needs no RBAC configuration.
The four authorization guards — requireRole, requirePermission, requireAnyRole and
requireAllPermissions — answer 501 Not Implemented with Authorization is not configured,
short-circuiting before the handler. The status is 501 rather than 403 because nothing is wrong
with the caller: the deployment cannot evaluate the policy at all, and the condition is permanent
for that deployment. A principal that genuinely fails a policy check still gets 403; that path is
unchanged. Before M89b these four resolved the capability unconditionally, so the registry’s throw
escaped into the pipeline and the caller received a masked 500 Internal Server Error — a real
fault with /health, /ready and /live all reporting up. They still fail closed either way;
what changed is that the refusal is legible.
Session authentication and caller-supplied strategies (M73):
sessiontakes aSessionAuthOptionswhose single required member,toPrincipal(view), maps the openedSessionViewto the principal it carries — returningnullcontinues the chain. When present, the plugin appends an internalSessionStrategyafter the API-key strategy and requires thesessioncapability (SessionPlugin) to be registered, orregister()throws naming both plugins.strategiesaccepts caller-suppliedIAuthStrategys, appended after every built-in in declaration order; anamecolliding with any other strategy in the assembled chain throws atregister(). The assembled order is fixed — jwt → api-key → session → caller-supplied — and the first non-null principal wins, so a request carrying both a bearer header and a session cookie authenticates by the JWT.Tokens from an outside issuer (M100b):
jwtverifies tokens this application issued;issuers: readonly TrustedIssuer[]verifies access tokens an outside identity provider issued, against its published key set. Each entry is{ name, issuer, audience, keys: { jwksUri } | { discovery: true }, algorithms?, clockToleranceSec?, keySet?, toPrincipal }—audienceandtoPrincipalrequired. The internalissuersstrategy runs immediately after the JWT strategy, reading the same header and scheme; the chain is jwt → issuers → api-key → session → caller-supplied. A token’sissis read unverified only to select the entry whoseissuerequals it exactly.noneandHS*are refused before key lookup; keys are filtered bykty/crv/use/alg/key_ops/kid; RS256, PS256, ES256, ES384 and EdDSA (Ed25519) verify throughruntime.subtlewith zero npm dependencies.issexact,audcontainsaudience,exprequired,exp/nbf/futureiatwithinclockToleranceSec(default 30, max 300). Expiry is refused atnow >= exp + clockToleranceSec; not-before is refused whilenow + clockToleranceSec < nbf. Key sets are cached on the monotonic clock (keySet.ttlMs10 min), an unknownkidrefetches at most once perkeySet.minRefreshIntervalMs(60 s) with concurrent refetches coalesced, the last good set survives fetch failures forkeySet.maxStaleMs(24 h) and is then dropped, and each fetch is bounded bykeySet.fetchTimeoutMs(5 s), 64 KiB and 64 keys. Every refusal — duplicate name or issuer, empty audience, unsupported algorithm, out-of-range tolerance or timing, akeySet.ttlMsabovekeySet.maxStaleMs,httpwithoutissuersorsignIn, non-httpsnon-loopback URL — throwsAuthPluginConfigurationErrorfromAuthPlugin(...).http?: IAuthHttpreplaces the defaultfetchseam. Theauthhealth indicator staysupwithdata.current: booleananddata.issuerscarrying per-issuerstale/expired/unfetchedstates. It performs no I/O.typ: at+jwt(RFC 9068) is not enforced (only a Keycloaktyp: "ID"claim is refused), so the API needs an audience distinct from any sign-in client id; multi-tenant Entra ID is not supported.IJwtServiceremains self-issued only.subis unique only within its issuer, so whenever an issuer is not the only identity source — another issuer, a self-issuedjwt(whose strategy also mapssubto the id), an API key, a session or a custom strategy —toPrincipalshould namespace the id (`${claims.iss}|${claims.sub}`) — and every other source too (or keep their ids free of the separator), since namespacing one side does not stop a local id of the same form colliding.keySet.fetchTimeoutMsmay not exceed 2147483647, since a larger timer delay overflows.Sign-in with an outside provider (M100c):
signIn: SignInConfig—{ basePath?: string (default '/auth'), providers: readonly SignInProvider[], refreshPrincipal? }— makes the application an OAuth 2.0 / OpenID Connect relying party over the authorization-code flow. It requiresSessionPlugin(register()throws naming both plugins otherwise), addsCAPABILITIES.AUTH_SESSIONtoprovidesand registers the plugin’sIAuthSessionServicethere, and appends an internalauth-sessionstrategy after thesessionstrategy (chain jwt → issuers → api-key → session → auth-session → caller-supplied) which returns the stored principal withclaims.amrset to the recorded methods, overwriting anyamrthe principal carried.SignInProvideris a union discriminated onkind:OidcProvider(issuer; endpoints and keys from the same issuer-checked discovery document M100b uses;scopesmust containopenid; optionalrpInitiatedLogout: { postLogoutRedirectUri, idTokenHint?: true }) andOAuth2Provider(authorizationEndpoint,tokenEndpoint,userinfoEndpoint). Both sharename(kebab-case),clientId,clientSecret?,tokenEndpointAuth?('client_secret_basic'— the default with a secret —'client_secret_post'or'none'),scopes?,redirectUri(its path must end with<basePath>/<name>/callback; a leading mount prefix is allowed),failureRedirect?(same-origin path),toPrincipal(claims)andonTokens?(tokens: ProviderTokens). Routes:GET <basePath>/<name>/login,GET <basePath>/<name>/callback, and onePOST <basePath>/logout. Login stores{ state, provider, verifier, nonce?, returnTo, createdAt }in the user’s session (at most 3, 10-minute lifetime), sends PKCE S256 for every provider and neverresponse_mode, and answers503provider-unavailablewhen discovery cannot be read.returnTois kept only as a same-origin path of at most 256 bytes after percent-encoding non-ASCII (else/) and is never read from the callback URL. The callback refuses with401(or afailureRedirect?error=<code>redirect) and one of the fixed codesprovider-denied,state-invalid(unknown, replayed, expired, another provider’s, or an RFC 9207issnaming another issuer),exchange-failedandprofile-unavailable, throughrespondWithError;toPrincipalreturningnullor throwing answers403principal-refused. The attempt is consumed before the exchange. The ID token is verified by the M100b verifier withaudience = clientId, plusiss === issuer,nonceequality and, for several audiences,azp === clientId. Discovered endpoints must behttps(or loopbackhttp) and printable ASCII — an endpoint with a space, control character or non-ASCII code point answers the login503and drops RP-initiated logout to a local one — and theoauth2userinfo read sends the access token asAuthorization: Bearer. On success the callback callsIAuthSessionService.signIn(ctx, principal, { methods: ['fed'] })(session id regenerated), hands the tokens toonTokens(a throwingonTokensdoes not undo the sign-in), and redirects to the storedreturnTo. Logout callssignOutand redirects to/, or — for the one provider allowed to setrpInitiatedLogout, and only when the session signed in through it (IAuthSessionService.signInclears that marker) — to itsend_session_endpointwithclient_id,post_logout_redirect_uriand, only whenidTokenHint: truestored one,id_token_hint.refreshPrincipal(stored)re-reads the principal per request (nullor a throw → anonymous). WhatsignOutrevokes depends on the session strategy: the store strategy deletes the entry, the default cookie strategy leaves a copied cookie valid until itsmaxAge. AsameSite: 'strict'session cookie is not sent on the provider’s redirect, so every callback fails closed asstate-invalid. Every configuration refusal throwsAuthPluginConfigurationErrorfromAuthPlugin(...).Phasing (M16b, shipped): refresh tokens and rate limiting shipped in M16b as standalone additions —
RefreshTokenService(app-instantiated; NOT anIAuthStrategy, since a refresh token arrives in the request body, not as a passive header credential) andrateLimitMiddleware(a decoupled middleware factory with no capability token). Neither is anAuthPluginoption.IJwtServicestill exposes onlysign/verify/decode— a refresh token is a signed JWT carryingtype: 'refresh'and ajti.
Session and account lifecycle (M124, 0.11.0):
signIn.providersmay be empty with neithermfanorpasskeys— a password-only sign-in that registersCAPABILITIES.AUTH_SESSION, theauth-sessionstrategy and the logout route only; a non-arrayprovidersstill throwsAuthPluginConfigurationError.signIn.sessionStamp?: SessionStamp, whereSessionStamp = (principal: IPrincipal) => string | null | Promise<string | null>, is read once byIAuthSessionService.signInbefore any write (a throw, or a value other than a string ornull, rejectssignInand records nothing); its base64url SHA-256 digest (ornull) is stored asstampin the signed-in record, or in the pending second-factor record and copied on promotion. On every request theauth-sessionstrategy resolves the principal (snapshot orrefreshPrincipal), callssessionStampwith it, and compares digests in constant time; a mismatch, a throw or a malformed value makes the request anonymous. A record withoutstampreads asnull, which matches only a user whose stamp isnull. The plugin’s passkey registration routes admit through the same re-checks (refreshPrincipal, then the stamp), so a session either has ended cannot enrol a credential;IAuthSessionService.current(ctx)remains the synchronous, un-re-checked snapshot. A non-functionsessionStampthrowsAuthPluginConfigurationErrorfromAuthPlugin(...). No API bumps the stamp: the application changes it on its own user record.requireAuth(options?: RequireAuthOptions),RequireAuthOptions = { readonly signInPage?: string }: without options, unchanged. WithsignInPage(a same-origin absolute path thatsafeReturnTokeeps unchanged, elseAuthPluginConfigurationErrorwhen the guard is built), an unauthenticatedGET/HEADthat is notX-Requested-With: XMLHttpRequestand carriesSec-Fetch-Mode: navigate(or, without that header, anAcceptnamingtext/htmlorapplication/xhtml+xml) is answered302tosignInPagewithreturnTo=safeReturnTo(path + query)added to its query; every other unauthenticated request, and a request forsignInPage’s own path, gets the existing 401. Both unauthenticated answers appendAccept,Sec-Fetch-ModeandX-Requested-WithtoVary.safeReturnTo(value: string | undefined, fallback = '/'): stringis exported unchanged: it returnsvaluewhen it is a single-leading-slash path with no\, no control character and at most 256 bytes after non-ASCII characters are percent-encoded (the encoded form), elsefallback.
Exports
| Export | File | Description |
|---|---|---|
AuthPlugin | src/plugin/auth-plugin.ts | Plugin factory |
AuthPluginOptions | src/interfaces/index.ts | Plugin factory options (jwt / issuers / signIn / http / apiKey / local / rbac / policies / session / strategies / authorizationDiagnostics) |
AuthMiddlewareOption | src/interfaces/index.ts | Global authentication middleware priority and path exclusions |
AuthPluginConfigurationError | src/errors.ts | Invalid passive-strategy or middleware configuration |
AuthorizationDiagnosticsOptions | src/interfaces/index.ts | M98h opt-in for minimized authorization decision explanations through the diagnostics connector |
IAuthorizationDiagnosticsSource | re-export | From @setu-ts/common — the source AuthPlugin registers under CAPABILITIES.AUTHORIZATION_DIAGNOSTICS |
JwtOptions | src/interfaces/index.ts | JWT config (key material, algorithm, expected aud/iss, header/scheme) |
ApiKeyOptions | src/interfaces/index.ts | API-key strategy config (header + validate callback) |
LocalOptions | src/interfaces/index.ts | Local credential config (verify callback) |
SessionAuthOptions | src/interfaces/index.ts | Session strategy config (required toPrincipal callback) |
TrustedIssuer | src/interfaces/index.ts | M100b outside identity provider: issuer, audience, key source, algorithms, clock tolerance, key-set timings, toPrincipal |
IssuerKeySource | src/interfaces/index.ts | { jwksUri } or { discovery: true } |
IssuerAlgorithm | src/interfaces/index.ts | 'RS256' | 'PS256' | 'ES256' | 'ES384' | 'EdDSA' issuer allowlist entry |
IAuthHttp | src/interfaces/index.ts | Outbound seam: get for issuer key sets and discovery documents, post (form-encoded) for the sign-in token exchange |
SignInConfig | src/interfaces/index.ts | M100c signIn option: basePath, providers, refreshPrincipal, sessionStamp (M124); providers: [] is a password-only sign-in |
SignInProvider | src/interfaces/index.ts | OidcProvider | OAuth2Provider | SamlProvider, discriminated on kind (M100f added the saml arm) |
SignInProviderBase | src/interfaces/index.ts | Fields both provider arms share (name, clientId, credentials, redirectUri, toPrincipal, …) |
OidcProvider | src/interfaces/index.ts | OpenID Connect provider: issuer (discovery), optional rpInitiatedLogout |
OAuth2Provider | src/interfaces/index.ts | Plain OAuth 2.0 provider: authorization, token and userinfo endpoints |
TokenEndpointAuth | src/interfaces/index.ts | 'client_secret_basic' | 'client_secret_post' | 'none' |
ProviderTokens | src/interfaces/index.ts | The provider’s token response, handed to onTokens and stored nowhere else |
RefreshPrincipal | src/interfaces/index.ts | signIn.refreshPrincipal: per-request re-read of the stored principal; null → anonymous |
SessionStamp | src/interfaces/index.ts | M124 signIn.sessionStamp: reads the user’s stamp; its digest is recorded at sign-in and compared per request |
PasswordHasher | src/services/password-hasher.ts | PBKDF2-SHA256 hash/verify utility; 600,000 iterations by default, needsRehash for upgrades |
MalformedPasswordHashError | src/services/password-hasher.ts | Thrown by PasswordHasher.verify when stored is not a well-formed hash |
PasswordHasherOptions | src/services/password-hasher.ts | M113e: { iterations? } — the PBKDF2 count for new hashes, 100,000–10,000,000, default 600,000 |
authMiddleware | src/middleware/auth-middleware.ts | Global middleware: authenticates and populates ctx.request.user |
requireAuth | src/guards/index.ts | Guard: require an authenticated principal (401); M124 { signInPage } redirects a browser navigation (302 with returnTo) |
RequireAuthOptions | src/interfaces/index.ts | M124 requireAuth options: signInPage? |
safeReturnTo | src/sign-in/return-to.ts | M124 export: a same-origin returnTo path, else the fallback |
requireRole | src/guards/index.ts | Guard: require a role (401/403) |
requirePermission | src/guards/index.ts | Guard: require a permission (401/403) |
requireAnyRole | src/guards/index.ts | Guard: require any of the given roles |
requireAllPermissions | src/guards/index.ts | Guard: require all of the given permissions |
publicRoute | src/guards/index.ts | Guard: explicitly allow unauthenticated access |
definePolicy | src/policies/define-policy.ts | M110a: validates a policy and returns a frozen copy typed by its ability names and target; throws AuthPluginConfigurationError when malformed |
requirePolicy | src/policies/policy-guard.ts | M110a route guard over IAuthorizationPolicyService: 501 / 401 / 403 / next(); target value or per-request extractor; refused at startup when its policy ability is not registered |
AuthorizationDeniedError | src/policies/errors.ts | M110a: authorize’s denial rejection — failure / policy / ability, branded with the guards’ 401/403 status hint |
UnknownPolicyError | src/policies/errors.ts | M110a: rejection for an unregistered policy or ability (policy, ability?); unbranded, so a masked 500 |
PolicyDenial | src/policies/errors.ts | M110a: 'authentication-required' | 'insufficient-privileges' |
requireScopedRole | src/scoped/scoped-guards.ts | M110b route guard: any-of catalogue roles in the request’s scope (scopeFromTenant() by default); 501 / 401 / 403 / next(); refused at startup for a name outside the catalogue |
requireScopedPermission | src/scoped/scoped-guards.ts | M110b route guard: all-of catalogue permissions in the request’s scope; otherwise as requireScopedRole |
ScopedGuardOptions | src/scoped/scoped-guards.ts | M110b: { scope?: ScopeSource } for the two scoped guards |
GrantResolutionReason | src/scoped/errors.ts | M110b: 'source-failed' | 'source-timeout' | 'grant-limit' — GrantResolutionError.reason |
GrantResolutionError | src/scoped/errors.ts | M110b: sign-in-timing grant resolution failed (reason, source?); 503 status hint, no caller input in the message |
ScopedRbacOptions | src/interfaces/index.ts | M110b: the scopedRbac option — sources, inheritsFrom, tenantScopeType, grantableIn, customRoles, timing and the bounds; principalAuthorityIsGlobal (M113e, default false) makes the principal’s own roles and permissions count in every scope |
GrantSourceConfig | src/interfaces/index.ts | M110b: static / claims / custom grant source arms |
StaticGrant | src/interfaces/index.ts | M110b: { subject, role, scope } written into configuration |
ClaimsGrantMapper | src/interfaces/index.ts | M110b: (claims, principal) => ScopedGrant[] for the claims arm |
ScopedRoleLimit | src/interfaces/index.ts | M110b: grantableIn entry — scopeTypes, global? |
ScopedRbacTiming | src/interfaces/index.ts | M110b: 'request' / 'sign-in' / { kind: 'cache', ttlMs, maxEntries } |
requireMfa | src/guards/index.ts | M100d step-up guard: 401 anonymous, 403 second-factor-required when claims.amr lacks otp or pop; a non-array amr counts as absent |
TotpService | src/mfa/totp-service.ts | M100d app-instantiated TOTP service: beginEnrolment / confirmEnrolment / verify / verifyRecoveryCode / generateRecoveryCodes / disable / completeSignIn / completeSignInWithRecoveryCode |
TotpServiceOptions | src/mfa/totp-service.ts | TotpService constructor options (store / runtime / issuer) — deliberately NO TTL option |
TotpVerifyResult | src/mfa/totp-service.ts | 'ok' | 'invalid' | 'locked' | 'not-enrolled' |
RecoveryVerifyResult | src/mfa/totp-service.ts | 'ok' | 'invalid' | 'locked' for a recovery-code check |
TotpProofResult | src/mfa/totp-service.ts | 'proof-required' | 'invalid' | 'locked' | 'not-enrolled' — why a factor-changing call was refused |
ConfirmEnrolmentResult | src/mfa/totp-service.ts | confirmEnrolment(principalId, code, proof?): { status: 'ok', recoveryCodes } (the first set is minted at confirmation) or { status: TotpProofResult }; replacing a confirmed factor needs proof of the current one |
RecoveryCodesResult | src/mfa/totp-service.ts | generateRecoveryCodes(principalId, proof): { status: 'ok', recoveryCodes } replacing the set, or { status: TotpProofResult } — always needs proof of the current factor |
DisableResult | src/mfa/totp-service.ts | disable(principalId, proof?): 'ok' or a TotpProofResult; a confirmed factor needs proof of itself |
TotpCompleteSignInResult | src/mfa/totp-service.ts | 'signed-in' | 'invalid' | 'locked' | 'not-enrolled' | 'no-pending' |
ITotpStore | src/stores/totp-store.ts | M100d pluggable factor store: getEnrolment / stageSecret / confirmSecret / deleteEnrolment / claimStep / reserveAttempt / clearAttempts / saveRecoveryCodes / consumeRecoveryCode; stageSecret, confirmSecret (compare-and-set on the awaiting secret), claimStep, reserveAttempt and consumeRecoveryCode must each be atomic |
TotpEnrolment | src/stores/totp-store.ts | Stored factor record: secret, label, confirmed, monotonic lastClaimedStep, and the optional secret/label awaiting confirmation |
ReserveAttemptResult | src/stores/totp-store.ts | { allowed, count } from reserveAttempt — reserved BEFORE the credential is checked; a refused attempt is not recorded |
MemoryTotpStore | src/stores/totp-store.ts | Single-process ITotpStore; takes no constructor arguments (callers pass now to reserveAttempt) |
MfaOptions | src/interfaces/index.ts | M100d signIn.mfa: required(principal, methods) and the sole owner of the pending-record TTL, pendingTtlMs (default 300 000, positive integer, refused otherwise at construction), and challengePath, the same-origin code form a provider callback redirects a pending sign-in to (else returnTo) |
PasskeyOptions | src/interfaces/index.ts | M100e signIn.passkeys: rpId (host or registrable suffix of every origin), rpName, exact-match origins (https, or http on loopback), a required store, a required resolvePrincipal (null refuses the sign-in), userVerification (default required, enforced on every ceremony), and an optional mayRegister registration policy (with signIn.mfa configured and mayRegister absent, every registration — the first included — needs a recorded otp/pop; set, it decides the first passkey) |
PasskeyRegistrationContext | src/interfaces/index.ts | M100e: what PasskeyOptions.mayRegister is told — the signed-in principal, the session’s recorded methods, and the principal’s existing credentialCount |
IPasskeyStore | src/stores/passkey-store.ts | M100e pluggable credential store: listByPrincipal / findById / save / updateCounter / delete / claimChallenge. save(credential, { maxPerPrincipal }) MUST be an atomic compare-and-set (stores a NEW credential only when its id is absent AND the principal holds fewer than maxPerPrincipal, checked in the same step as the insert, answering 'saved' / 'duplicate' / 'limit' — a concurrent ceremony carrying the same id must never silently replace the first principal’s record, and a burst must never overshoot the cap), updateCounter MUST be an atomic compare-and-advance (stores the observed counter only when greater, or both zero, and reports whether it did), and claimChallenge must be atomic — the three close the duplicate-registration, clone and replay races |
StoredPasskey | src/stores/passkey-store.ts | Stored credential record: id, principal id, base64url user handle, JWK public key, COSE algorithm, counter, backedUp (display only), transports, attestation: 'unverified', created time |
MemoryPasskeyStore | src/stores/passkey-store.ts | Single-process IPasskeyStore; takes no constructor arguments; purges expired challenge claims in an amortized sweep so the claim map stays bounded |
PasskeySaveOptions | src/stores/passkey-store.ts | M100e: what IPasskeyStore.save enforces atomically with the insert — maxPerPrincipal |
PasskeySaveResult | src/stores/passkey-store.ts | M100e: IPasskeyStore.save’s outcome — 'saved', 'duplicate' or 'limit' |
SamlProvider | src/interfaces/index.ts | M100f saml sign-in arm: this application as a SAML 2.0 service provider. entityId (AuthnRequest issuer, required audience, metadata), idp (entityId the assertion Issuer must equal, ssoUrl for the HTTP-Redirect AuthnRequest, certs — one or more PEM signing certificates for rotation overlap), acsUrl (path must end <basePath>/<name>/acs; every SubjectConfirmationData Recipient must equal it), toPrincipal(profile) (null or throw refuses with 403 principal-refused), optional failureRedirect, store (default MemorySamlRequestStore, single replica only) and module (injected library). Routes: GET …/login, POST …/acs, GET …/metadata (application/samlmetadata+xml). Assertions must be signed; IdP-initiated responses, encrypted assertions and single logout are refused or not offered. ACS refusals answer 401 (or failureRedirect?error=) with assertion-invalid or state-invalid only. Pending requests are bound to the browser by __Host-setu-saml (SameSite=None; Secure; HttpOnly; Path=/; Max-Age=600) |
SamlProfile | src/interfaces/index.ts | M100f: the frozen verified identity SamlProvider.toPrincipal receives — issuer, nameID, optional nameIDFormat / sessionIndex, and attributes (string, or array for a multi-valued attribute) |
SamlModule | src/interfaces/index.ts | M100f: structural shape of an injected SAML library ({ SAML }); replaces the lazy @node-saml/node-saml import |
ISamlRequestStore | src/stores/saml-request-store.ts | M100f store port: saveRequest(request, now), peekRequest(id, now) (non-consuming read node-saml’s getAsync uses), consumeRequest(id, now) (MUST atomically delete and return — of two concurrent calls exactly one gets the record), claimAssertionId(id, retainUntil, now) (MUST be atomic; false for a reused id). The same store is handed to node-saml as its cacheProvider, so replicas sharing one agree about pending requests |
SamlPendingRequest | src/stores/saml-request-store.ts | M100f pending login: requestId, provider, returnTo, binding (the cookie value), issuedAt, expiresAt (ms, 10 minutes after login) |
MemorySamlRequestStore | src/stores/saml-request-store.ts | M100f single-process ISamlRequestStore; sweeps expired requests on write and assertion claims when their map has doubled. new MemorySamlRequestStore({ maxPendingRequests }) caps pending requests (default DEFAULT_MAX_PENDING_SAML_REQUESTS = 10,000; oldest evicted first; a non-positive-integer cap throws RangeError). A second replica holds its own map |
MemorySamlRequestStoreOptions | src/stores/saml-request-store.ts | M100f options for MemorySamlRequestStore: maxPendingRequests? (positive integer; default DEFAULT_MAX_PENDING_SAML_REQUESTS) bounds what an unauthenticated login flood can hold |
DEFAULT_MAX_PENDING_SAML_REQUESTS | src/stores/saml-request-store.ts | M100f default pending-request cap of MemorySamlRequestStore: 10_000 |
SamlRuntimeLoadError | src/errors.ts | M100f: register() rejects with it when a saml provider is configured and @node-saml/node-saml cannot be loaded (or the injected module has no SAML class); names the specifier and the Workers nodejs_compat flag; the underlying failure is cause |
RefreshTokenService | src/services/refresh-token-service.ts | Refresh tokens: issue / refresh (rotation) / revoke |
RefreshTokenOptions | src/services/refresh-token-service.ts | RefreshTokenService constructor options |
TokenPair | src/services/refresh-token-service.ts | { accessToken, refreshToken } returned by issue/refresh |
RefreshTokenStore | src/stores/refresh-token-store.ts | Pluggable async store interface for refresh-token records |
RefreshTokenRecord | src/stores/refresh-token-store.ts | Record shape store implementations produce/consume |
IRefreshTokenRotation | src/stores/refresh-token-store.ts | Result of atomically rotating a refresh record |
MemoryRefreshTokenStore | src/stores/refresh-token-store.ts | Default in-memory store with lazy expiry on get and amortised expiry sweeps on save and rotate; unexpired revoked records are retained for reuse detection |
IAccessTokenRevocationStore | src/stores/access-token-revocation-store.ts | Pluggable bounded access-token revocation interface |
MemoryAccessTokenRevocationStore | src/stores/access-token-revocation-store.ts | Single-process access-token revocation store with lazy expiry |
rateLimitMiddleware | src/middleware/rate-limit-middleware.ts | Fixed-window rate limiter middleware factory (429 short-circuit) |
RateLimitOptions | src/middleware/rate-limit-middleware.ts | rateLimitMiddleware(options) parameter, including exclude |
DEFAULT_RATE_LIMIT_EXCLUDED_PATHS | src/middleware/rate-limit-middleware.ts | The six operational paths exclude exempts by default; spread it to extend rather than replace |
RateLimitStore | src/stores/rate-limit-store.ts | Pluggable store interface (increment/reset) |
RateLimitResult | src/stores/rate-limit-store.ts | { count, resetTime } returned by increment |
MemoryRateLimitStore | src/stores/rate-limit-store.ts | Default in-memory fixed-window store |
RedisRateLimitStore | src/stores/redis-rate-limit-store.ts | Redis-backed store (inject-or-lazy ioredis), namespacing keys under keyPrefix |
DEFAULT_RATE_LIMIT_KEY_PREFIX | src/stores/redis-rate-limit-store.ts | 'setu:ratelimit:' — the namespace RedisRateLimitStore applies when no keyPrefix is given |
IAuthService | re-export | From @setu-ts/common |
IJwtService | re-export | From @setu-ts/common |
IAuthorizationPolicyService | re-export | From @setu-ts/common (M110a) |
IAuthorizationService | re-export | From @setu-ts/common |
IAuthStrategy | re-export | From @setu-ts/common |
IPrincipal | re-export | From @setu-ts/common |
JwtSignOptions | re-export | From @setu-ts/common |
RbacConfig | re-export | From @setu-ts/common |
RoleDefinition | re-export | From @setu-ts/common |
Registration
import { AuthPlugin } from '@setu-ts/auth-plugin';
app.register(
AuthPlugin({
jwt: {
secret: config.get('JWT_SECRET'), // HS256; use privateKey/publicKey PEMs for RS256
audience: 'my-app-users', // expected `aud`, enforced on verify
issuer: 'my-app', // expected `iss`, enforced on verify
},
apiKey: {
header: 'X-API-Key',
validate: (key) => apiKeyService.validate(key), // (key) => Promise<IPrincipal | null>
},
local: {
// (identifier, secret) => Promise<IPrincipal | null>
verify: (identifier, secret) => userService.checkPassword(identifier, secret),
},
rbac: {
roles: {
admin: { permissions: ['*'], inherits: ['manager'] },
manager: { permissions: ['users:read', 'users:write'], inherits: ['user'] },
user: { permissions: ['profile:read', 'profile:write'] },
},
},
}),
);
// AuthPlugin registers passive authentication globally at priority 300.
To attach authentication only to selected routes, set middleware: false and use the exported
authMiddleware() there. Existing applications should remove a hand-added global copy; leaving it
temporarily is correct but runs the strategy chain twice. middleware: { priority, exclude } moves
the global stage or skips exact/regular-expression paths. No paths are excluded by default.
Login (Issue Token)
IAuthService.verifyCredentials({ identifier, secret }) resolves to an IPrincipal | null; mint a
JWT with the separate IJwtService resolved from 'jwt' (or issue an access + refresh pair with
RefreshTokenService — see Refresh Tokens below).
import { CAPABILITIES } from '@setu-ts/common';
import type { IAuthService, IJwtService } from '@setu-ts/common';
app.router.post('/auth/login', async (ctx) => {
const auth = ctx.services.get<IAuthService>(CAPABILITIES.AUTH);
const jwt = ctx.services.get<IJwtService>('jwt');
const { username, password } = await ctx.request.json();
const principal = await auth.verifyCredentials({ identifier: username, secret: password });
if (!principal) {
return ctx.response.status(401).json({ error: 'Invalid credentials' });
}
const accessToken = await jwt.sign(
{ sub: principal.id, roles: principal.roles },
{ expiresIn: '1h', audience: 'my-app-users', issuer: 'my-app' },
);
return ctx.response.json({ accessToken });
});
Refresh Tokens (M16b)
RefreshTokenService is an app-instantiated class (like PasswordHasher) — it is NOT an
AuthPlugin option and registers no service. It issues distinct typed access + refresh JWTs, each
with a random jti; bearer authentication rejects type: 'refresh'. A pluggable server-side
RefreshTokenStore tracks refresh-token families: rotation revokes the presented refresh token and
mints a descendant, while replay or logout revokes the complete family. refresh()/revoke() never
throw on a bad token: invalid, expired, or tampered input yields null/false.
To invalidate an access credential at logout before its regular expiry, construct one
IAccessTokenRevocationStore and pass that exact instance to AuthPlugin({ jwt: { ... } }) and the
RefreshTokenService. Its revoke(jti, expiresAt) entries are bounded, so setting
accessTokenRevocationStore on the refresh service requires accessToken.expiresIn. The shipped
MemoryAccessTokenRevocationStore is single-process; multi-instance deployments must supply a
shared implementation. Custom RefreshTokenStore implementations must preserve familyId and make
rotate(jti, successor) and revokeFamily(jti) linearizable per family: family revocation must
durably block later rotations and include any earlier successor.
MemoryRefreshTokenStore expires records lazily on get() and through amortised sweeps on
save(), starting at 1,024 entries and doubling the surviving size for the next sweep (minimum
1,024). Only expired records are removed; unexpired revoked records remain for rotation reuse
detection. MemoryRateLimitStore follows the same sweep rule on increment(), retaining each
entry’s window duration and dropping only expired windows; live limits are never evicted for a cap.
import {
AuthPlugin,
MemoryAccessTokenRevocationStore,
MemoryRefreshTokenStore,
RefreshTokenService,
} from '@setu-ts/auth-plugin';
import type { IJwtService, IRuntimeServices } from '@setu-ts/common';
import { createApplication } from '@setu-ts/kernel';
import { createRuntimeServices, RuntimePlugin } from '@setu-ts/runtime';
const accessTokenRevocations = new MemoryAccessTokenRevocationStore(createRuntimeServices());
const app = createApplication({
plugins: [
RuntimePlugin(),
AuthPlugin({
jwt: { secret: config.get('JWT_SECRET'), accessTokenRevocationStore: accessTokenRevocations },
}),
],
});
await app.start();
const jwt = app.services.get<IJwtService>('jwt');
const runtime = app.services.get<IRuntimeServices>('runtime');
const refresh = new RefreshTokenService({
jwt,
store: new MemoryRefreshTokenStore(runtime),
runtime,
accessToken: { expiresIn: '15m', audience: 'my-app-users', issuer: 'my-app' },
refreshTokenExpiresIn: '30d',
accessTokenRevocationStore: accessTokenRevocations,
});
// Login: issue the pair after verifying credentials
app.router.post('/auth/login', async (ctx) => {
const principal = await auth.verifyCredentials({ identifier, secret });
if (!principal) return ctx.response.status(401).json({ error: 'Invalid credentials' });
return ctx.response.json(await refresh.issue(principal)); // { accessToken, refreshToken }
});
// Refresh: rotate the pair (the presented refresh token is revoked)
app.router.post('/auth/refresh', async (ctx) => {
const { refreshToken } = await ctx.request.json<{ refreshToken: string }>();
const pair = await refresh.refresh(refreshToken);
if (!pair) return ctx.response.status(401).json({ error: 'Invalid refresh token' });
return ctx.response.json(pair);
});
// Logout: revoke the refresh family and its paired access credentials
app.router.post('/auth/logout', async (ctx) => {
const { refreshToken } = await ctx.request.json<{ refreshToken: string }>();
await refresh.revoke(refreshToken);
return ctx.response.json({ ok: true });
});
Rate Limiting (M16b)
rateLimitMiddleware(options) is a standalone fixed-window limiter — added via
app.middleware.add(...) like authMiddleware, independent of AuthPlugin (it needs no plugin
registered, though its default key reads ctx.request.user when something upstream has populated
one — see the key-resolution order below) and registered under no capability token. Requests are
counted per key in a windowMs window; when the count exceeds max the middleware short-circuits
with 429 (downstream stages, including the handler, do not run) and a JSON body
{ error: 'Too Many Requests', message }. Headers: always Retry-After on 429; with
standardHeaders (default true) also RateLimit-Limit, RateLimit-Remaining, and
RateLimit-Reset — RateLimit-Reset and Retry-After are both delta-seconds until the window
resets (IETF draft semantics), never epoch timestamps. The default store is an in-memory
fixed-window counter (single-process); pass store: new RedisRateLimitStore({ url, runtime }) for
multi-instance deployments (ioredis is inject-or-lazy: pass client to inject, otherwise ioredis
is lazily imported on first use). This limiter’s Retry-After is the honest case for the header — a
fixed window genuinely knows when it resets — which is why the framework’s other backpressure
statuses (the resilience shedding errors, X32-7; the database classifications, M90f) deliberately
carry none: no component there holds an honest horizon, and the asymmetry is recorded beside each.
The 429 body is written through respondWithError, so it uses the application’s configured error
format — including Problem Details under errorHandler({ format: 'rfc9457' }) — just like a guard
failure. Operational probes are exempt by default: exclude is
DEFAULT_RATE_LIMIT_EXCLUDED_PATHS (/live, /ready, /health, /metrics, /openapi.json, and
/docs). A caller list replaces those defaults, so spread the constant to extend it; [] exempts
nothing. Exempt paths do not increment a counter or receive RateLimit-* headers.
RedisRateLimitStore namespaces keys under keyPrefix (default 'setu:ratelimit:') so two
applications sharing one Redis deployment do not count against each other. Pass '' to use the
pre-namespacing keys.
The application constructs RedisRateLimitStore, so no plugin supplies its connection-error
reporter. Pass connectionErrorReporter — built with createConnectionErrorReporter from
@setu-ts/common over your logger — and the 'error' events of the client the store builds are
logged (first error at warn, repeats at debug, recovery at info). Absent, the built client’s
connection errors are dropped, so nothing reaches the console or a log; an injected client never
gets a listener.
The default key is defaultRateLimitKey, which resolves in this order: the authenticated principal
(user:<id>), the client IP published by ipSecurityMiddleware (ip:<address>), IRequest.ip
(ip:<address>, the socket peer on Node, Deno and Bun), and only then one global 'anonymous' key.
The limiter alone counts each socket peer separately; behind a proxy that is one bucket per proxy
until IP security resolves the client. Workers and plain fetch have no peer.
To make the key per caller: run the limiter after authentication, pass your own keyGenerator, or
register ipSecurityMiddleware with trustProxy plus trustedProxies or proxyHops. That
last pairing is not optional. trustProxy alone takes the forwarded header’s leftmost entry,
which is caller-supplied under a proxy that appends rather than overwrites (the standard nginx
idiom), so a client rotating forged values would earn a fresh bucket per request — a weaker position
than the shared 'anonymous' key. The two constraint options walk the header right to left and
select the first entry the deployment’s own proxies did not add.
import {
DEFAULT_RATE_LIMIT_EXCLUDED_PATHS,
rateLimitMiddleware,
RedisRateLimitStore,
} from '@setu-ts/auth-plugin';
// Global: 100 requests per minute; retain the default probe exclusions.
// Keyed by authenticated user, else the socket peer (ip:<peer>) on Node/Deno/Bun,
// else ONE shared 'anonymous' bucket (see above).
app.middleware.add(
rateLimitMiddleware({
windowMs: 60_000,
max: 100,
exclude: [...DEFAULT_RATE_LIMIT_EXCLUDED_PATHS, /^\/internal\//],
}),
);
// Per-route, keyed by authenticated user, Redis-backed
app.router.post('/expensive', {
middleware: [
rateLimitMiddleware({
windowMs: 60_000,
max: 5,
keyGenerator: (ctx) => ctx.request.user?.id ?? ctx.request.ip ?? 'anonymous',
store: new RedisRateLimitStore({
url: 'redis://localhost:6379',
runtime,
keyPrefix: 'orders-api:rl:',
}),
message: 'Too many expensive calls — try again shortly',
}),
],
handler: async (ctx) => {
/* ... */
},
});
Protecting Routes
Guards are free MiddlewareFunction factories. The authorization guards resolve
IAuthorizationService from 'authorization', return 401 when no principal is attached and
403 when the check fails, and short-circuit (they do not call next()). authMiddleware
always calls next(), so an unauthenticated request still reaches the guard. (publicRoute is used
instead of public because public is a reserved word.) Role hierarchy is resolved transitively,
and the wildcard permission '*' — held directly or granted by any (direct or inherited) role —
satisfies every permission check.
import {
publicRoute,
requireAllPermissions,
requireAnyRole,
requireAuth,
requirePermission,
requireRole,
} from '@setu-ts/auth-plugin';
// Require authentication
app.router.get('/profile', {
middleware: [requireAuth()],
handler: async (ctx) => ctx.response.json(ctx.request.user),
});
// Require a role (admin satisfies 'user' via the configured `inherits` hierarchy)
app.router.delete('/users/:id', {
middleware: [requireAuth(), requireRole('admin')],
handler: async (ctx) => {
/* ... */
},
});
// Require a permission
app.router.post('/users', {
middleware: [requireAuth(), requirePermission('users:write')],
handler: async (ctx) => {
/* ... */
},
});
// Require any of several roles / all of several permissions
app.router.get('/reports', {
middleware: [requireAuth(), requireAnyRole(['admin', 'manager'])],
handler: async (ctx) => {
/* ... */
},
});
app.router.post('/bulk', {
middleware: [requireAuth(), requireAllPermissions(['users:read', 'users:write'])],
handler: async (ctx) => {
/* ... */
},
});
// Explicitly public route
app.router.get('/health', {
middleware: [publicRoute()],
handler: async (ctx) => ctx.response.json({ status: 'ok' }),
});
Accessing the Current User
authMiddleware writes the authenticated principal to ctx.request.user (one of the two
middleware-written fields on IRequest — the other is tenant, written by the multi-tenancy plugin
— so the shipped CurrentUser() parameter source resolves it).
app.router.get('/me', {
middleware: [requireAuth()],
handler: async (ctx) => {
const user = ctx.request.user!;
return ctx.response.json({ id: user.id, roles: user.roles, permissions: user.permissions });
},
});
Password Hashing
PasswordHasher is an exported utility for provisioning passwords and verifying them inside a
local.verify callback. It draws a random salt and derives a 32-byte key with PBKDF2-SHA256 via
runtime.subtle / runtime.randomBytes.
import { PasswordHasher } from '@setu-ts/auth-plugin';
const hasher = new PasswordHasher(runtime); // IRuntimeServices resolved from the 'runtime' token
const stored = await hasher.hash('correct horse battery staple');
const ok = await hasher.verify(stored, 'correct horse battery staple'); // true
if (ok && hasher.needsRehash(stored)) {
// persist await hasher.hash('correct horse battery staple') in place of `stored`
}
new PasswordHasher(runtime, options?: PasswordHasherOptions) (M113e, V9-50): options.iterations
sets the PBKDF2 count for NEW hashes — an integer from 100,000 to 10,000,000, default 600,000;
anything else (including NaN and fractions) throws TypeError at construction naming the bounds,
not the value. verify ignores the option and reads the count stored in each hash, so every
existing hash keeps verifying. needsRehash(stored): boolean is true when the stored count is
below the configured one and false at or above it (lowering the configuration never downgrades a
hash); it throws MalformedPasswordHashError on exactly the inputs verify refuses. A deployed
Cloudflare Worker is reported to refuse PBKDF2 counts above 100,000 — unverified against a deployed
Worker — so a Workers application passes { iterations: 100_000 }.
verify(stored, secret) throws the exported MalformedPasswordHashError when stored is not a
well-formed pbkdf2$… string, instead of returning false. The two parameters are both plain
strings, so a reversed call — password in the stored position — used to fail closed and
silently: every correct password answered 401 Invalid credentials with nothing logged. The
malformed branch detects exactly that mistake and names both positions; a genuinely wrong password
still returns false.