@basetime/a2w-auth
v0.0.4
Published
Authentication library for Addtowallet.
Readme
@basetime/a2w-auth
@basetime/a2w-auth is the shared auth library for Addtowallet services. Use it behind an HTTP adapter or directly from Node code to sign users in, issue and refresh tokens, manage cookies, log users out, and protect routes. Each auth session plugs in the pieces it needs: an IProvider for users, an ITokenStorage for refresh tokens, plus optional IThrottler, ILogger, and IPasswordEncoder implementations.
Documentation is available at https://basetime.github.io/addtowallet/auth/.
Table of contents
- Installation
- Requirements
- Quick start (Express)
- Concepts
- Terminology
- Multi-tenancy
- Throttling
- Sessions
- Configuration
- HttpCore
- High-level handlers
- grant(options)
- refreshFromCookie(options)
- exchangeIdTokenCookie(options)
- endSession()
- readSessionTokens()
- writeSessionCookies(tokens)
- clearSessionCookies()
- assertNotThrottled(throttleKey)
- recordFailedAttempt(throttleKey)
- decodeIdToken(idToken)
- resolveAuthedFromIdToken(idToken, refreshToken)
- revokeRefreshToken(refreshToken)
- Adding additional sessions
- CoreAuth
- Events
- Components
- Token system
- Security
- Adapters
- Examples
Installation
Install the package with your workspace package manager:
pnpm add @basetime/a2w-authRequirements
Before wiring the adapters, ensure the deployment meets these runtime constraints.
Node.js
- Node.js
>=22.0.0— enforced by the packageenginesfield inpackage.json. - The published package is ESM (
"type": "module"); import from thedist/entry points declared inexports.
HTTPS and cookies
Session cookies default to secure, httpOnly, sameSite=strict, and path=/ (see DEFAULT_SESSION_COOKIE_OPTIONS and Cookie hardening).
- Production must be HTTPS. Browsers refuse to persist or send
Securecookies over plain HTTP, so login can succeed while the session never sticks. - Local development:
localhostis treated as a secure context even on HTTP. The Express and Fastify examples proxy the SPA to the API sosameSite=strictcookies work during development. - Behind a load balancer: configure Express
trust proxy(or the Fastify equivalent) soreq.ipreflects the real client address when login throttling uses the IP axis. See the Deployment checklist.
Native bcrypt
The default BcryptPasswordEncoder (wired by FirebaseAuthFactory and RedisAuthFactory) uses native bcrypt at cost factor 12.
- Native addon.
bcryptis not pure JavaScript; installs need a compatible Node runtime. Prebuilt binaries are used automatically when one exists for your Node ABI and OS. - Build toolchain. When no prebuilt binary is available, you need a C++ compiler, Python, and
node-gypto compile the addon duringpnpm install. - Existing hashes. Passwords hashed with earlier
bcryptinstalls (includingbcryptjs-compatible hashes) verify unchanged — seeIPasswordEncoder. - Alternative. Deployments that cannot use native addons may inject
ScryptPasswordEncoder(purenode:crypto) via the factorypasswordEncoderoption instead.
Cryptographic secrets
Every deployment needs signing and encryption material at runtime:
- RSA key pair — PEM
rsaPrivandrsaPubfor RS256 JWT signing (TokenStorageConfig). encryptionKey— exactly 32 UTF-8 bytes for AES-256-GCM refresh-token encryption at rest (Encryptionconfig).
Generate keys with the commands in Quick start (Express) and the Deployment checklist.
Quick start (Express)
Wire the Express adapter in a few lines. Install the peer dependencies first (cookie-parser is required because authConfig reads req.cookies):
Install the peer dependencies first (cookie-parser is required because authConfig reads req.cookies):
pnpm add express cookie-parser @basetime/a2w-authThen setup your code.
import {
CoreEvents,
Encryption,
FirebaseProvider,
HttpSession,
TokenStorage,
} from '@basetime/a2w-auth';
import { authConfig, authProtected, authRoutes } from '@basetime/a2w-auth/adapters/express';
import cookieParser from 'cookie-parser';
import express from 'express';
const app = express();
app.use(express.json());
app.use(cookieParser());
const firebaseProvider = new FirebaseProvider({
apiKey: env.firebase.apiKey,
authDomain: env.firebase.authDomain,
projectId: env.firebase.projectId,
appId: env.firebase.appId,
});
const encryption = new Encryption({ encryptionKey: env.encryptionKey });
const tokenStorage = new TokenStorage({
provider: firebaseProvider,
encryption,
rsaPriv: env.rsaPriv,
rsaPub: env.rsaPub,
logger: console,
});
app.use(
authConfig({
sessions: {
user: new HttpSession({
provider: firebaseProvider,
tokenStorage,
logger: console,
defaultTenantId: 'my-tenant',
idTokenKey: 'idToken',
refreshTokenKey: 'refreshToken',
idTokenMaxAge: 60 * 60 * 1000,
refreshTokenMaxAge: 30 * 86400 * 1000,
onReady: (core) => {
core.on(CoreEvents.Authenticated, () => {
console.log('Authenticated');
});
core.on(CoreEvents.Logout, () => {
console.log('Logout');
});
},
}),
},
// Optional: trust id tokens minted by external issuers (e.g. SSO gateway or
// a partner auth service). Tokens with unknown issuers are rejected.
issuers: {
'https://issuer.example.com': {
url: 'https://issuer.example.com/.well-known/jwks.json',
ttl: 3600, // seconds to cache remote JWKS keys
},
},
}),
);
app.use(authRoutes());
app.get('/dashboard', authProtected(), (req, res) => {
if (!req.user) {
return res.status(401).json({ error: 'Unauthorized' });
}
return res.json({
id: req.user.id,
email: req.user.email,
tenantId: req.user.tenantId,
});
});
app.get('/api/me', (req, res) => {
// authConfig() hydrates req.user from the user-session id-token cookie.
if (!req.user) {
return res.status(401).json({ error: 'Unauthorized' });
}
return res.json({
displayName: req.user.displayName,
photoURL: req.user.photoURL,
});
});- Routes mounted:
POST /auth/user/{login,logout,token,refresh}andGET /.well-known/jwks.json(the session keyuserbecomes the/auth/user/*namespace). - Login body: clients must send
{ email, password, tenantId }— see Multi-tenancy. - Next steps: see Express Middleware for Redis throttling,
onReadyevent hooks, route overrides, and error handling, or run the full demo atexamples/express/.
Concepts
At a high level, @basetime/a2w-auth separates authentication into a few small pieces:
- A session describes one login flow, such as
useroradmin. - A provider knows how to find users, check passwords, and store refresh-token records.
- Token storage signs id tokens, rotates refresh tokens, and verifies tokens later.
- An adapter connects the core auth logic to a framework like Express or Fastify.
Most applications configure one session, mount an adapter, and let the package handle the usual login, refresh, logout, and route-protection work. Larger applications can add more sessions when different audiences need different providers, cookie names, token lifetimes, or route namespaces.
Terminology
- Session — one configured authentication flow, represented by
HttpSessionfor HTTP adapters orISessionat the core layer. A session owns its provider, token storage, cookie names, token lifetimes, and optional throttler/logger/password encoder. - Session key — the key in the adapter's
sessionsmap, such asuseroradmin. HTTP adapters use it as the route namespace (/auth/user/*), the request decoration (req.user), and the default tokenkindwhen the session does not set one explicitly. - Kind — the token partition for a session. It is embedded in minted tokens and checked during refresh/revocation so, for example, an
adminrefresh token cannot be used in theusersession. - Tenant — the customer, workspace, or other isolation boundary identified by
tenantId. User lookup, password checks, throttle keys, id-token claims, and refresh-token rows are scoped to the tenant. - Provider — an
IProviderimplementation that loads users, verifies stored password hashes, and persists refresh-token rows. The package ships Firebase and Redis providers, and sessions can use custom implementations. - Token storage — an
ITokenStorageimplementation that signs/verifies JWTs and manages refresh-token lifecycle operations. The defaultTokenStoragesigns id and refresh tokens with RSA keys, stores only encrypted/hashed refresh-token material, and rotates refresh tokens on use. - Id token — the short-lived JWT used to authenticate requests. It carries the subject (
sub), tenant (tid), session partition (aud/typ), issuer (iss), expiration, and optional profile claims. - Refresh token — the longer-lived JWT used only to mint a fresh id token and replacement refresh token. Refresh tokens are persisted by hash/encrypted value, belong to a rotation family, and are revoked on logout or reuse detection.
- Issuer — the JWT
issclaim. Locally minted tokens use the configured token-storage issuer; adapters can also register trusted remote issuers with JWKS URLs and cache TTLs so inbound id tokens can be verified against those keys. - Adapter — the framework integration layer, such as Express, Fastify, or Node. Adapters turn session configuration into routes, cookies, request decorations, and per-request
HttpCoreinstances.
Multi-tenancy
The library is tenant-aware end to end.
- Every user belongs to exactly one tenant, identified by
tenantId. - Email is unique per
(tenantId, email), never globally, so two tenants can each have a user with the same email. - Tenants are recorded in the id-token JWT (
tidclaim) and on the persisted refresh-token row. - The
Userrecord is resolved from the database using thetenantIdandemail. - A default tenantId can be configured when multi-tenancy is not needed.
Throttling
Authentication operations are protected by IThrottler. Which ensures attackers get locked out of the system after a certain number of failed attempts.
Two implementations ship with the package; pick whichever matches the infrastructure already attached to your auth stack. Construct one (or one per session) and pass it on session.throttler. Omit session.throttler to disable throttling for that session.
RedisThrottler— uses an atomic LuaINCR + EXPIRE-on-firstso the lockout window measures from the first failed attempt and cannot be extended indefinitely by drip-feeding bad attempts.FirebaseThrottler— persists each counter as athrottleCounters/{sha256(prefix + key)}document and updates it inside a Firestore transaction so concurrent failures cannot lose increments. Hashing the document id keeps sensitive throttle keys (refresh tokens, emails) out of Firestore at rest. The fixed-window semantics matchRedisThrottler:expireAtis only set on document creation/reset, so trickle-DoS cannot extend an existing lockout.
Both implementations:
- Throttle
loginbytenant:<tid>:email:<lowercased>and (when present)tenant:<tid>:ip:<addr>, so throttle counters stay isolated across tenants and an attacker bouncing the same email between tenants doesn't bypass per-account lockouts. - Throttle
refreshTokenby the presented refresh-token value. - Expose
maxAttempts,windowSeconds, andkeyPrefixconstructor options with the same defaults (6,60,passwordGrant:failure:).FirebaseThrottleradds an optionalcollectionName(defaultthrottleCounters).
Sessions
Every auth flow this package serves is described by an ISession (provider, token storage, token partition, logical token keys, and TTLs). HTTP adapters extend that with HttpSession, which adds optional cookie and route configuration and an onReady boot hook. Each session carries the real IProvider and ITokenStorage instances it uses, plus optional IThrottler, ILogger, and IPasswordEncoder instances; cookies follow the token max-ages, which double as the underlying JWT expirations:
import { CoreEvents, HttpSession } from '@basetime/a2w-auth';
const userSession = new HttpSession({
provider: firebaseProvider,
tokenStorage,
logger: console,
idTokenKey: 'idToken',
refreshTokenKey: 'refreshToken',
idTokenMaxAge: 60 * 60 * 1000, // 1 hour
refreshTokenMaxAge: 30 * 86400 * 1000, // 30 days
onReady: (core) => {
core.on(CoreEvents.Authenticated, () => {
console.log('Authenticated');
});
},
});
const sessions = {
user: new HttpSession({
provider: firebaseProvider,
tokenStorage,
logger: console,
idTokenKey: 'idToken',
refreshTokenKey: 'refreshToken',
idTokenMaxAge: 60 * 60 * 1000,
refreshTokenMaxAge: 30 * 86400 * 1000,
onReady: (core) => {
core.on(CoreEvents.Authenticated, () => {
console.log('Authenticated');
});
core.on(CoreEvents.Logout, () => {
console.log('Logout');
});
},
}),
admin: new HttpSession({
provider: firebaseProvider,
tokenStorage,
logger: console,
idTokenKey: 'adminIdToken',
refreshTokenKey: 'adminRefreshToken',
idTokenMaxAge: 15 * 60 * 1000,
refreshTokenMaxAge: 86400 * 1000,
onReady: (core) => {
core.on(CoreEvents.Authenticated, () => {
console.log('Authenticated');
});
},
}),
api: new HttpSession({
provider: firebaseProvider,
tokenStorage,
logger: console,
kind: 'organization',
idTokenKey: 'apiIdToken',
refreshTokenKey: 'apiRefreshToken',
idTokenMaxAge: 60 * 60 * 1000,
refreshTokenMaxAge: 30 * 86400 * 1000,
routes: {
login: { path: '/auth/api/login', method: 'post' },
logout: { path: '/auth/api/logout', method: 'post' },
},
}),
};The Express and Fastify adapters accept a sessions: Record<string, HttpSession> map and build one HttpCore per entry per request. The session map's keys become the route namespace, the request decoration, and (when kind is omitted) the token partition kind. For a single user session, the adapters mount:
| Method + path | HttpCore method | Decoration |
| ----------------------------- | ------------------------ | ---------- |
| POST /auth/user/login | HttpCore.login | req.user |
| POST /auth/user/logout | HttpCore.logout | req.user |
| POST /auth/user/token | HttpCore.exchangeToken | req.user |
| POST /auth/user/refresh | HttpCore.refreshToken | req.user |
| GET /.well-known/jwks.json | HttpCore.jwks (shared) | - |
Add another session entry (e.g. admin: new HttpSession({ kind: 'admin', ... })) to get a parallel /auth/admin/* route set and a req.admin decoration. JWKS stays singular when every session points at the same ITokenStorage instance (and therefore the same signing key pair); sessions backed by different ITokenStorage instances publish their own JWKS via that storage.
Configuration
authConfig and authPlugin accept { sessions } plus optional issuers and jwks. Each HttpSession carries real IProvider, ITokenStorage, optional ILogger, IThrottler, and IPasswordEncoder instances. Adapters build one CoreAuth and one boot-time HttpCore per session key at startup.
import {
BcryptPasswordEncoder,
Encryption,
FirebaseProvider,
HttpSession,
RedisProvider,
RedisThrottler,
TokenStorage,
} from '@basetime/a2w-auth';
const firebase = new FirebaseProvider({ apiKey, authDomain, projectId, appId });
const redis = new RedisProvider({ host, port, keyPrefix: 'auth:' });
const logger = console;
const tokenStorage = new TokenStorage({
provider: firebase,
encryption: new Encryption({ encryptionKey: env.encryptionKey }),
rsaPriv: env.rsaPriv,
rsaPub: env.rsaPub,
issuer: 'https://auth.example.com',
logger,
});
const throttler = new RedisThrottler(redis.getConnection(), logger, {
maxAttempts: 6,
windowSeconds: 60,
});
const config = {
issuers: {
'https://partner.example.com': {
url: 'https://partner.example.com/.well-known/jwks.json',
ttl: 3600,
},
},
sessions: {
user: new HttpSession({
provider: firebase,
tokenStorage,
logger,
throttler,
passwordEncoder: new BcryptPasswordEncoder(),
idTokenKey: 'idToken',
refreshTokenKey: 'refreshToken',
idTokenMaxAge: 60 * 60 * 1000,
refreshTokenMaxAge: 30 * 86400 * 1000,
defaultTenantId: 'my-tenant',
onReady: (core) => {
core.on(CoreEvents.Authenticated, () => {});
},
}),
},
jwks: false,
};Top-level fields
| Field | Required | Purpose |
| ---------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| sessions | yes | Record<string, HttpSession> — at least one entry. Keys become route namespaces (/auth/<key>/…), request decorations (req.user, req.admin, …), and (when kind is omitted on the session) the token partition. Each HttpSession carries its own provider, token storage, optional throttler/logger/password-encoder. See Sessions. |
| issuers | no | Record<string, { url: string; ttl: number }> mapping trusted JWT iss values to remote JWKS settings. url is the JWKS document URL; ttl is the cache lifetime in seconds before the document is fetched again. Tokens whose iss is registered are verified with that remote JWKS; tokens with unknown issuers are rejected. See Issuer and JWKS verification. |
| jwks | no | Fastify only (or authRoutes via req.authConfig). Override or disable the shared GET /.well-known/jwks.json route. Per-session route overrides live on HttpSession.routes. |
There are no top-level providers, tokenStorage, throttler, loggers, or passwordEncoder dictionaries any more. Every dependency lives on the session that uses it, so two sessions can pick completely independent providers, signing keys, throttlers, or loggers in the same deployment — and sessions that share infrastructure simply share the same instance.
HttpSession options
Each entry in sessions is an HttpSession instance (constructed with new HttpSession({ ... })). Sessions implement ISession and add HTTP-layer fields (cookies, routes, hydrators, onReady).
| Field | Required | Notes |
| -------------------- | --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| provider | yes | IProvider instance used for user lookup and password verification (e.g. new FirebaseProvider(...), new RedisProvider(...), or a custom implementation). |
| tokenStorage | yes | ITokenStorage instance used to sign / verify JWTs and persist refresh-token rows (typically new TokenStorage({ provider, encryption, rsaPriv, rsaPub, ..., logger })). |
| throttler | no | IThrottler instance (e.g. new RedisThrottler(redis.getConnection(), logger, { maxAttempts, windowSeconds })). When omitted, throttle checks are disabled. |
| logger | no | ILogger instance for this session's HttpCore / CoreAuth logging. Defaults to noopLogger. |
| passwordEncoder | no | IPasswordEncoder instance. Defaults to BcryptPasswordEncoder. |
| kind | no (Express/Fastify) / yes (Node) | Token partition string (e.g. 'user', 'admin'). In Express and Fastify adapters, defaults to the sessions map key when omitted. Enforced on mint, lookup, and verification. |
| idTokenKey | yes | Cookie name for the short-lived id token. |
| refreshTokenKey | yes | Cookie name for the refresh token. |
| idTokenMaxAge | yes | Cookie + JWT lifetime in ms (minimum 1000). |
| refreshTokenMaxAge | yes | Cookie + JWT lifetime in ms (minimum 1000). |
| defaultTenantId | no | Fallback tenant id used by HttpCore.login when neither req.body.tenantId nor a session-level default is set. See Multi-tenancy. |
| cookieOptions | no | Override DEFAULT_SESSION_COOKIE_OPTIONS (secure, httpOnly, sameSite, path, …). |
| routes | no | Per-session overrides for login / logout / token / refresh paths. Set a key to false to skip mounting that route. |
| hydrateUser | no | Optional callback that enriches the sanitized user before it is attached to the request decoration. |
| hydrateAuthed | no | Optional callback that reshapes the core Authed payload before grants return it. |
| onReady | no | Per-session boot hook receiving that session's boot-time HttpCore for listener registration (core.on(...)). Runs once at adapter setup, never per request. |
Providers
IProvider drives user lookup, password verification (delegated to the provider's stored hash), and refresh-token persistence. The package ships two implementations:
FirebaseProvider— Firestore-backed. Constructor accepts the Firebase web-SDK config (apiKey,authDomain,projectId,appId).RedisProvider— Redis-backed. Constructor accepts anioredisconnection or{ host, port, keyPrefix?, ... }options.
Two sessions may share one provider instance, or each can hold its own. The same instance is typically passed to the session's provider field and to its tokenStorage (so refresh-token rows land on the same backend that holds the user records):
import { FirebaseProvider, HttpSession, RedisProvider, TokenStorage } from '@basetime/a2w-auth';
const firebase = new FirebaseProvider({ apiKey, authDomain, projectId, appId });
const userTokenStorage = new TokenStorage({
provider: firebase,
encryption,
rsaPriv,
rsaPub,
logger,
});
const sessions = {
user: new HttpSession({
provider: firebase,
tokenStorage: userTokenStorage,
/* idTokenKey, refreshTokenKey, max ages, ... */
}),
};Mixed providers (e.g. Firestore users + Redis refresh tokens) work by constructing TokenStorage from a different IProvider than the one passed to session.provider:
const firestoreUsers = new FirebaseProvider({ apiKey, authDomain, projectId, appId });
const redis = new RedisProvider({ host, port, keyPrefix: 'auth:' });
const refreshStorage = new TokenStorage({
provider: redis,
encryption,
rsaPriv,
rsaPub,
logger,
});
const sessions = {
user: new HttpSession({
provider: firestoreUsers,
tokenStorage: refreshStorage,
/* ... */
}),
};Throttler
IThrottler instances guard login and refreshToken along multiple axes (per-tenant email, per-tenant IP, refresh-token value). The package ships:
RedisThrottler— atomic LuaINCR + EXPIRE-on-first. Constructor takes anIORedisclient (useredisProvider.getConnection()), theILogger, and{ maxAttempts?, windowSeconds?, keyPrefix? }.FirebaseThrottler— Firestore-backed with TTL onexpireAt. Constructor takes aFirebaseProvider, theILogger, and{ maxAttempts?, windowSeconds?, keyPrefix?, collectionName? }. See Throttling.
Omit session.throttler to disable throttling for a session (noopThrottler is used internally). Sessions can share a throttler instance or each carry its own.
import { HttpSession, RedisProvider, RedisThrottler } from '@basetime/a2w-auth';
const redis = new RedisProvider({ host, port, keyPrefix: 'auth:' });
const throttler = new RedisThrottler(redis.getConnection(), logger, {
maxAttempts: 6,
windowSeconds: 60,
});
const sessions = {
user: new HttpSession({
provider,
tokenStorage,
throttler,
/* ... */
}),
};Firebase vs Redis
The Firebase and Redis implementations cover different concerns — do not treat one as a drop-in replacement for the other:
| Concern | Firebase | Redis |
| ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| User + refresh-token persistence | FirebaseProvider passed as session.provider and to TokenStorage | RedisProvider passed as session.provider and to TokenStorage |
| Login / refresh throttling | FirebaseThrottler; requires Firestore TTL on expireAt (see Throttling) | RedisThrottler |
| When throttling is omitted | Throttle checks disabled (noopThrottler) | — |
Each session selects its provider, token storage, and (optional) throttler independently. A common production setup uses one FirebaseProvider for users + refresh tokens and a RedisThrottler purely for throttling; all-Firebase and all-Redis deployments are equally valid.
For fully custom composition (e.g. injecting your own IProvider or ITokenStorage implementation), pass the instances directly on the session — the Node adapter is convenient when you don't need HTTP cookies and routes.
For key generation and deployment hardening, see Requirements and the Deployment checklist.
HttpCore
HttpCore is the framework-agnostic HTTP entry point for one auth session. It is constructed with a single HttpSession (captured in the constructor; every method operates against that session, so callers no longer pass it on each call), an IAuth implementation, and the shared cookie / throttle / token / database / logger dependencies.
HttpCore exposes a typed on / off / dispatch / dispatchEvent surface keyed on CoreEventPayloads that delegates to the shared IAuth event bus -- so listeners registered through core.on(...) or auth.on(...) fire for every session and request sharing the same IAuth. HttpCore dispatches CoreEvents.PreAuthenticate before login and id-token exchange, CoreEvents.PreRefresh before refresh-token rotation (both cancellable by event.stopImmediatePropagation()), CoreEvents.Authenticated after login and id-token exchange, CoreEvents.Refreshed after refresh-token rotation, CoreEvents.AuthenticateFailed, CoreEvents.AuthenticateThrottled, CoreEvents.RefreshFailed, and CoreEvents.Logout from the relevant flows; the lower-level primitives still expose GrantHooks for callers that want to fire their own custom events from a bespoke flow.
The Express and Fastify adapters build one HttpCore per entry in their sessions config map. Direct use is rare in application code -- you typically interact with HttpCore via the high-level handlers wired up by the adapters. The reference below shows both the high-level handlers and the lower-level primitives consumers can compose into custom flows.
High-level handlers
HttpCore.login(req), logout(req), exchangeToken(req), refreshToken(req), and jwks(req) are the five handlers wired to the /auth/<sessionKey>/{login,logout,token,refresh} and /.well-known/jwks.json routes by the framework adapters. Each accepts a structurally minimal HttpRequest and emits the standard CoreEvents on success / failure / throttle.
On authentication failure the handlers return null (or 'ok' for logout) rather than throwing. login and refreshToken reject with HttpError('Locked', 423) when throttled. exchangeToken does not throttle the id-token decode path, but when it falls back to refreshToken — for example because the id-token cookie is absent — the same 423 rejection can propagate. Framework adapters forward these rejections to your error middleware.
grant(options)
Runs a throttle-protected grant. It checks one or more throttle keys, calls your grant function, records failed attempts, writes the session cookies (unless skipCookies: true), and fires caller-owned hooks for onSuccess, onFailure, and onThrottled. The session is whichever one this HttpCore was constructed with.
The options argument is GrantOptions (throttle keys, cookies, hooks). Do not confuse it with TokenMintOptions, which configures JWT minting on IAuth.grant / passwordGrant / refreshTokenGrant.
const authed = await adminCore.grant({
throttleKey: [`admin:${req.user!.id}`, req.ip ? `ip:${req.ip}` : ''],
grant: () => mintAdminTokens(req.user!, req.body.token),
hooks: {
onSuccess: (authed) => adminCore.dispatch(AdminEvents.Login, { authed }),
onFailure: () => adminCore.dispatch(AdminEvents.LoginFailed, { id: req.user!.id }),
onThrottled: () => adminCore.dispatch(AdminEvents.LoginThrottled, { id: req.user!.id }),
},
});Pass skipCookies: true for API-key style flows that return tokens in the response body instead of writing cookies:
const apiKeyGrant = await apiCore.grant({
skipCookies: true,
throttleKey: `apiKey:${apiKeyHash}`,
grant: () => mintApiKeyTokens(apiKey),
});refreshFromCookie(options)
Reads the refresh-token cookie for this HttpCore's session and delegates to grant, using the presented refresh token as the throttle key.
const refreshed = await adminCore.refreshFromCookie({
refresh: (refreshToken) =>
adminAuth.refreshTokenGrant(refreshToken, {
kind: 'admin',
idTokenExp: 3600,
refreshTokenExp: 86400,
}),
hooks: {
onSuccess: (authed) => adminCore.dispatch(AdminEvents.Refresh, { authed }),
onFailure: () => adminCore.dispatch(AdminEvents.RefreshFailed, {}),
},
});exchangeIdTokenCookie(options)
Reads the id-token cookie for this HttpCore's session, decodes it, hydrates the user from the database, and optionally falls back to a refresh flow when the id-token cookie is absent.
const authed = await adminCore.exchangeIdTokenCookie({
fallback: () => adminCore.refreshFromCookie({ refresh }),
hooks: {
onSuccess: (authed) => adminCore.dispatch(AdminEvents.Authenticate, { authed }),
},
});endSession()
Revokes the refresh token when present, clears the session cookies, and returns whether a refresh token was revoked.
const { revokedRefreshToken } = await adminCore.endSession();
await adminCore.dispatch(CoreEvents.Logout, { revokedRefreshToken });readSessionTokens()
Reads the current id-token and refresh-token cookie values for this HttpCore's session without mutating them.
const { idToken, refreshToken } = adminCore.readSessionTokens();
logger.debug('Admin session cookies present', {
hasIdToken: Boolean(idToken),
hasRefreshToken: Boolean(refreshToken),
});writeSessionCookies(tokens)
Writes the id-token and refresh-token cookies using the cookie keys and lifetimes from this.session.
adminCore.writeSessionCookies({
idToken: tokens.idToken,
refreshToken: tokens.refreshToken,
});clearSessionCookies()
Clears both cookies for this HttpCore's session without revoking any persisted refresh token. Prefer endSession() for normal logout flows.
adminCore.clearSessionCookies();assertNotThrottled(throttleKey)
Checks one or more throttle keys and throws HttpError('Locked', 423) if any axis is locked out.
await core.assertNotThrottled([`password:${email.toLowerCase()}`, req.ip ? `ip:${req.ip}` : '']);recordFailedAttempt(throttleKey)
Increments one or more throttle keys after a failed custom check.
const throttleKeys = [
`tenant:${tenantId}:email:${email.toLowerCase()}`,
req.ip ? `tenant:${tenantId}:ip:${req.ip}` : '',
];
await core.assertNotThrottled(throttleKeys);
const authed = await auth.getAuthenticatedUser(email, password, tenantId);
if (!authed) {
await core.recordFailedAttempt(throttleKeys);
return null;
}decodeIdToken(idToken)
Decodes and verifies an id token through the configured ITokenStorage. It returns null for malformed, expired, or incorrectly signed tokens.
Asynchronous because the underlying jose verification is Promise-based; readSessionIdToken() is async for the same reason.
const decoded = await core.decodeIdToken(idToken);
if (!decoded || decoded.aud !== 'admin') {
throw new HttpError('Unauthorized', 401);
}resolveAuthedFromIdToken(idToken, refreshToken)
Decodes an id token, loads the matching user from the database, and returns an Authed payload using the refresh token value you pass through.
const authed = await core.resolveAuthedFromIdToken(idToken, refreshToken ?? '');
if (!authed) {
return null;
}revokeRefreshToken(refreshToken)
Revokes one refresh token in the database partition matching this HttpCore's session kind.
await adminCore.revokeRefreshToken(refreshToken);HttpCore does not know about Express, Fastify, or any other HTTP framework; framework-specific adapters provide the ICookies implementation and pass plain request data into the handler methods.
Adding additional sessions
A new auth flow (admin, organization, API key, ...) is a new entry in the adapter's sessions map. Each HttpSession carries its own provider, token storage, throttler, logger, cookie keys, kind partition, and TTLs; share an instance across sessions when you want them to use the same underlying infrastructure, or pass distinct instances when you don't.
import { HttpSession } from '@basetime/a2w-auth';
import { authConfig, authProtected, authRoutes } from '@basetime/a2w-auth/adapters/express';
const config = {
sessions: {
user: new HttpSession({
provider,
tokenStorage,
throttler,
logger,
idTokenKey: 'idToken',
refreshTokenKey: 'refreshToken',
idTokenMaxAge: 60 * 60 * 1000,
refreshTokenMaxAge: 30 * 86400 * 1000,
}),
admin: new HttpSession({
provider,
tokenStorage,
throttler,
logger,
kind: 'admin',
idTokenKey: 'adminIdToken',
refreshTokenKey: 'adminRefreshToken',
idTokenMaxAge: 15 * 60 * 1000, // shorter id-token lifetime for admins
refreshTokenMaxAge: 86400 * 1000, // shorter refresh-token lifetime too
}),
},
};
app.use(authConfig(config));
app.use(authRoutes());
// Routes:
// POST /auth/user/login POST /auth/admin/login
// POST /auth/user/logout POST /auth/admin/logout
// POST /auth/user/token POST /auth/admin/token
// POST /auth/user/refresh POST /auth/admin/refresh
// GET /.well-known/jwks.json (shared)
app.use('/admin', authProtected({ session: 'admin', to: '/admin/login' }));The session key (admin here) is the request decoration name (req.admin) and the route namespace. For custom grant flows that don't use the standard email/password login schema -- for example an API-key flow that returns tokens in the response body -- compose the lower-level primitives (core.grant({ skipCookies: true, ... }), core.refreshFromCookie(...)) instead of relying on HttpCore.login.
CoreAuth
The src/core layer is the credential-and-token service underneath the HTTP layer. It knows how to authenticate a user, issue an Authed payload, and refresh that payload from a refresh token, but it does not know about cookies, HTTP requests, or Express routers. Event semantics are delegated to an injected IDispatcher instance (defaults to CoreDispatcher), so CoreAuth only forwards on / off / dispatch calls -- it never owns listener registration or propagation rules. HttpCore sits one layer above and turns those core auth operations into HTTP-friendly flows.
IAuth is the minimal contract the HTTP layer expects from an auth service:
getAuthenticatedUser(email, password, tenantId)
Validates (email, tenantId) + password and returns the full user record, or null for missing users in that tenant or bad passwords.
const user = await auth.getAuthenticatedUser(email, password, tenantId);
if (!user) {
return null;
}grant(user, options)
Issues an Authed payload for an already-authenticated user. The user's tenantId is embedded in the minted id token as the tid claim. options is TokenMintOptions: the per-session token-kind partition and JWT expirations the tokens are signed with.
const authed = await auth.grant(user, {
kind: 'user',
idTokenExp: 3600,
refreshTokenExp: 86400,
});passwordGrant(email, password, tenantId, options)
Combines credential validation and token issuance for login endpoints. Looks up the user by (email, tenantId) so two tenants can share an email without leaking. options is the same TokenMintOptions shape as grant.
const authed = await auth.passwordGrant(email, password, tenantId, {
kind: 'user',
idTokenExp: 3600,
refreshTokenExp: 86400,
});refreshTokenGrant(refreshToken, options)
Rotates a refresh token and returns a fresh Authed payload. options is TokenMintOptions: the per-session token-kind partition and expirations the freshly minted replacement tokens are signed with.
const authed = await auth.refreshTokenGrant(refreshToken, {
kind: 'user',
idTokenExp: 3600,
refreshTokenExp: 86400,
});HttpCore derives TokenMintOptions from its session on every call — the seconds-precision token expirations come from session.idTokenMaxAge / 1000 and session.refreshTokenMaxAge / 1000, so the cookie and JWT lifetimes always agree. Custom grant functions you pass into HttpCore.grant(...) accept GrantOptions instead; the grant callback inside can call IAuth with TokenMintOptions, or any function that returns Authed | null.
Events
HttpCore (and the underlying IAuth) emit events when authentication requests succeed, fail, or are throttled. Both expose the same on / off / dispatch surface, which delegates to a shared IDispatcher instance keyed on CoreEventPayloads. Listener registration is per-IAuth -- not per-session or per-request.
Listeners receive an IEvent envelope:
interface IEvent<TEventPayloads, E extends keyof TEventPayloads & string> {
name: E;
payload: TEventPayloads[E];
stopped: boolean;
stopImmediatePropagation: () => void;
}Destructure payload to read the typed event fields. Call event.stopImmediatePropagation() to halt the remaining listeners for that dispatch (the dispatcher snapshots its listener set at dispatch time, runs them serially, awaits each one, and breaks the chain when stopped flips to true). Errors thrown from one listener are isolated and do not stop subsequent listeners from running.
auth.on(CoreEvents.Authenticated, ({ payload: { authed } }) => {
// ...
});
auth.on(CoreEvents.AuthenticateFailed, ({ payload: { email, ip, reason } }) => {
// ...
});on / off / dispatch also accept an EventInput object literal -- { name, payload? } -- instead of the bare event name. The dispatcher validates the literal at runtime with Zod (extra keys are rejected) so callers cannot smuggle their own stopped flag or override stopImmediatePropagation:
auth.dispatch({ name: CoreEvents.Logout, payload: { revokedRefreshToken: true } });dispatch(...) resolves to true / false for "listeners existed". When callers need to branch on whether a listener cancelled propagation, use auth.dispatchEvent(...) / core.dispatchEvent(...) instead; it returns the same IEvent every listener saw (with stopped reflecting any stopImmediatePropagation() call), or null when no listeners were registered. This is the surface HttpCore uses internally to fire CoreEvents.PreAuthenticate and abort login and id-token exchange, and CoreEvents.PreRefresh to abort refresh-token rotation, when a listener cancels the event.
The events are:
PreAuthenticate
Emitted by HttpCore.login and HttpCore.exchangeToken (id-token cookie path) immediately before the underlying grant or cookie exchange runs. Listeners that call event.stopImmediatePropagation() cancel the flow: the handler returns null without invoking the grant, checking or incrementing throttle counters (where applicable), writing session cookies, or dispatching downstream success events. HttpCore uses dispatchEvent (not dispatch) for this event so it can branch on event.stopped. event.payload is:
{
event: CoreEvents; // The CoreEvents value the flow is about to dispatch on success. Currently always CoreEvents.Authenticated; treat as a branching key in case future successor events are added.
email: string; // The email the upcoming flow is scoped to (from the login body or decoded session cookies).
tenantId: string; // The tenant the upcoming flow is scoped to (from the login body or decoded session cookies).
ip?: string; // Client IP from the request, when req.ip was available.
}auth.on(
CoreEvents.PreAuthenticate,
({ payload: { event, email, tenantId, ip }, stopImmediatePropagation }) => {
if (event !== CoreEvents.Authenticated) {
return;
}
if (ip && isBlocklisted(ip, tenantId)) {
stopImmediatePropagation();
}
},
);PreRefresh
Emitted by HttpCore.refreshToken (and by HttpCore.exchangeToken when it falls back to refresh) immediately before the refresh-token rotation runs. Listeners that call event.stopImmediatePropagation() cancel the flow: the handler returns null without invoking the refresh grant, writing session cookies, or dispatching Refreshed or RefreshFailed. HttpCore uses dispatchEvent (not dispatch) for this event so it can branch on event.stopped. event.payload is:
{
uid?: string; // User id embedded in the refresh token, when known.
tenantId?: string; // Tenant the refresh attempt is scoped to, when known.
ip?: string; // Client IP from the request, when req.ip was available.
}auth.on(CoreEvents.PreRefresh, ({ payload: { uid, tenantId, ip }, stopImmediatePropagation }) => {
if (ip && isBlocklisted(ip, tenantId)) {
stopImmediatePropagation();
}
});Authenticated
Emitted when a session is authenticated successfully via password grant or id-token cookie exchange. event.payload is:
{
authed: Authed; // { user, idToken, refreshToken } returned by the grant. Listeners may enrich authed.user before the handler returns.
user: string; // The authenticated user's id (authed.user?.id).
email: string; // The email used to authenticate.
tenantId: string; // The tenant the authenticated user belongs to.
}Refreshed
Emitted when a refresh token is successfully exchanged for a new id token and refresh token pair. event.payload is:
{
authed: Authed; // { user, idToken, refreshToken } returned by the grant. Listeners may enrich authed.user before the handler returns.
user: string; // The refreshed user's id (authed.user?.id).
email: string; // The email of the refreshed user.
tenantId: string; // The tenant the refreshed user belongs to.
}AuthenticateFailed
Emitted when a password grant fails because the credentials are invalid. event.payload is:
{
email: string; // The email that failed to authenticate.
ip?: string; // Client IP from the request, when req.ip was available.
tenantId: string; // The tenant the attempted login was scoped to.
reason?: 'invalid-credentials'; // Present when the failure was a bad email/password pair.
}AuthenticateThrottled
Emitted when a login attempt is rejected because the email or IP throttle axis is locked out. event.payload is:
{
email: string; // The email that was throttled.
ip?: string; // Client IP from the request, when req.ip was available.
tenantId: string; // The tenant the throttled login was scoped to.
}Logout
Emitted after a logout handler finishes clearing the session cookies. event.payload is:
{
revokedRefreshToken: boolean; // true when a refresh-token cookie was present and revoked server-side.
}Components
IAuth
CoreAuth is the bundled IAuth implementation. It composes database, token storage, password encoding, and encryption. Each IAuth instance is constructed with an ISession and exposes it via IAuth.getSession so event listeners registered in HttpSession.onReady can read session-scoped settings:
onReady: (core) => {
core.on(CoreEvents.Authenticated, () => {
const { kind, idTokenMaxAge } = core.session;
// kind, idTokenMaxAge, refreshTokenMaxAge, idTokenKey, refreshTokenKey
});
},IProvider
FirebaseProvider is the bundled Firestore-backed implementation. It owns the Firebase Web SDK connection and exposes getConnection(): Firestore.
RedisProvider is the bundled Redis-backed implementation. It constructs (or accepts) an ioredis client and exposes getConnection(): IORedis.
ITokenStorage
TokenStorage is the bundled ITokenStorage implementation. It signs and verifies JWTs, then persists refresh-token records through the injected IProvider.
IThrottler
FirebaseThrottler is the bundled IThrottler implementation. It composes the Firebase Web SDK to throttle requests. It uses Firestore TTL to automatically clean up expired throttle counters.
If you plan to use FirebaseThrottler, enable Firestore TTL on the expireAt field for the throttler collection group:
gcloud firestore fields ttls update expireAt \
--collection-group=throttleCounters \
--enable-ttlThis creates a Firestore collection group TTL on expireAt so the database can automatically clean up old throttling documents after their fixed window expires.
RedisThrottler is the bundled IThrottler implementation. It composes the Redis client to throttle requests.
IPasswordEncoder
BcryptPasswordEncoder is the bundled IPasswordEncoder implementation. It uses native bcrypt to hash and verify passwords.
bcrypt is a native addon: installs require a compatible Node runtime and, when a prebuilt binary is unavailable for your platform, a build toolchain (C++ compiler, Python, node-gyp). Prebuilt binaries are used automatically when npm can fetch a match for your Node ABI and OS.
Existing bcrypt hashes remain compatible — no migration is needed for stored passwords when adopting or upgrading this package.
ScryptPasswordEncoder is the bundled IPasswordEncoder implementation. It uses native scrypt to hash and verify passwords.
IEncryption
Encryption is the bundled IEncryption implementation. It writes new ciphertexts with aes-256-gcm (12-byte IV + 16-byte GCM authentication tag, version: 2) and transparently decrypts legacy aes-256-ctr payloads (version: 1) for backward compatibility.
Token system
Every authenticated session is backed by two JWTs minted by ITokenStorage (the default implementation is TokenStorage):
- ID token – short-lived (default 1 hour), signed with the configured RSA private key (
RS256), carries the user identity claims (iss,sub,name,email,picture,aud,tid,scope). Theissclaim identifies the token issuer and, when issuer verification is configured, selects either the local RSA public key or a registered remote JWKS endpoint. Thetidclaim pins the token to a single tenant;HttpCorecross-checks it against the stored user'stenantIdon every protected request. Verified on every protected request and treated as the source of truth for "who is this caller right now". Never persisted server-side. - Refresh token – long-lived (default 30 days), same
RS256signing key, carries the user id, thetidclaim (so rotation can scope the database lookup by tenant without an extra read), and theaudclaim that matches itskind. Used solely to mint a new ID token (and a fresh refresh token) once the ID token expires. Persisted server-side ashash + ciphertextalong with thetenantIdcolumn, never as plaintext. Existing refresh tokens may not carryiss; missing-issuer refresh tokens still verify with the local public key for backward compatibility.
Both tokens are partitioned by kind (User, Admin, …) so an end-user ID token can never satisfy an admin-only check and vice versa. Each HttpSession names the cookies and kind for one flow.
Storage (RSA keys)
TokenStorage is the bundled ITokenStorage implementation. Construct one (or more) and pass it to each session via tokenStorage:
import { Encryption, TokenStorage } from '@basetime/a2w-auth';
const tokenStorage = new TokenStorage({
provider, // IProvider used for refresh-token row persistence
encryption: new Encryption({ encryptionKey: env.encryptionKey }),
rsaPriv: env.rsaPriv,
rsaPub: env.rsaPub,
// algorithm: 'RS256', // optional, default
// issuer: 'https://auth.example.com', // optional local `iss` claim
// kid: '2026-05-key-1', // optional JWKS key id (defaults to an RFC 7638 thumbprint)
logger,
});Per-session token lifetimes are not configured on TokenStorage — they come from each session's idTokenMaxAge / refreshTokenMaxAge (milliseconds), which HttpCore converts to seconds-precision JWT exp values so the cookie and JWT lifetimes always agree.
Two sessions that share signing keys and refresh-token backend should share the same TokenStorage instance (then the JWKS endpoint serves a single document for both). Sessions that need different keys construct their own TokenStorage.
Issuer and JWKS verification
Adapters can register trusted external JWT issuers with issuers, a dictionary from iss claim to remote JWKS settings (url, ttl):
app.use(
authConfig({
issuers: {
'https://partner.example.com': {
url: 'https://partner.example.com/.well-known/jwks.json',
ttl: 3600,
},
},
sessions: {
user: new HttpSession({
provider,
tokenStorage,
idTokenKey: 'idToken',
refreshTokenKey: 'refreshToken',
idTokenMaxAge: 60 * 60 * 1000,
refreshTokenMaxAge: 30 * 86400 * 1000,
}),
},
}),
);All production JWT decoding flows through TokenStorage.decodeToken. When an issuer policy is installed, TokenStorage delegates to JwtVerifier, which applies this trust policy:
| Token kind | iss claim | Verification path |
| ---------- | ----------------- | ----------------------------------------------- |
| ID token | Registered issuer | Fetch and cache the issuer's JWKS with jose |
| ID token | Local issuer | Verify with the configured local RSA public key |
| ID token | Unknown issuer | Reject |
| ID token | Missing issuer | Reject |
| Refresh | Registered issuer | Fetch and cache the issuer's JWKS with jose |
| Refresh | Local issuer | Verify with the configured local RSA public key |
| Refresh | Unknown issuer | Reject |
| Refresh | Missing issuer | Verify with the local key for legacy tokens |
Set issuer on TokenStorageOptions when this deployment mints local ID tokens that should continue to verify without a network fetch:
const tokenStorage = new TokenStorage({
provider,
encryption,
rsaPriv,
rsaPub,
issuer: 'https://auth.example.com',
});Do not list the local issuer in the adapter-level issuers map. Adapter boot validation rejects that configuration because local tokens already verify against the configured RSA public key.
Cookie surface
HttpCore.writeSessionCookies writes the pair as two HTTP cookies, both flagged secure, httpOnly, sameSite=strict, path=/:
| Cookie key | Contents | Default lifetime |
| ------------------------- | ------------ | ---------------- |
| session.idTokenKey | ID token JWT | 1 hour |
| session.refreshTokenKey | Refresh JWT | 30 days |
Because the cookies are httpOnly the browser cannot read them; clients call exchangeToken on each page load, which decodes the ID-token cookie and returns the hydrated Authed payload (user record + tokens) so the SPA can populate its in-memory user state.
Logout flow
- Login (
HttpCore.login): credentials are throttled along both atenant:<tid>:email:and (when known) atenant:<tid>:ip:axis so a noisy tenant cannot starve another tenant's counters. On successCoreAuth.grantcallstokenStorage.createToken(ID token, embedstid) andtokenStorage.createRefreshToken(refresh token, embedstidand persiststenantIdon the database row). The pair is set as cookies; theAuthedpayload is also returned in the response body. - Per-request identity (
HttpCore.exchangeToken): reads the ID-token cookie, callstokenStorage.decodeToken, verifies the token's signature and trusted issuer, and hydrates the user fromIProvider. If the ID-token cookie is missing or expired the handler transparently falls back torefreshTokenso the client never sees the refresh path explicitly. That fallback inherits refresh throttling — a locked-out refresh token rejects withHttpError('Locked', 423)even though the client called the token endpoint, not/refresh. - Refresh (
HttpCore.refreshToken→CoreAuth.refreshTokenGrant): the refresh-token cookie is exchanged for a new ID + refresh pair viatokenStorage.rotateRefreshToken. Refresh-token verification uses the same issuer-aware verifier, with a local-key fallback only for refresh tokens that predate theissclaim. The new pair is written back into the cookies and returned in the response body. - Logout (
HttpCore.logout): the refresh-token cookie is read, the corresponding record is deleted viatokenStorage.revokeRefreshToken, and both cookies are cleared.
Refresh-token rotation and reuse detection
Refresh tokens rotate on every successful exchange. The full mechanism lives in TokenStorage.rotateRefreshToken:
- Verify the JWT signature and issuer policy on the presented token. An invalid signature, expired JWT, or unknown issuer short-circuits to
nullwithout touching the database. Refresh tokens missingisscontinue to verify against the local public key for backward compatibility. - Look up the persisted record by SHA-256 hash of the presented token. No record →
null. - Reuse detection. If the looked-up record already has
usedAtset, this token has been rotated before; an attacker (or a confused client) is replaying it. The entirefamilyIdis deleted withIProvider.deleteRefreshTokenFamily, invalidating every descendant token, and the caller must re-authenticate. - Expiry check against the persisted
dateExpires. Past expiry → the record is removed and the caller getsnull. - Mint a new refresh token inside the same
familyId, with the presented token's hash recorded asparentHashfo
