npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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/.

Auth lifecycle


Table of contents

Installation

Install the package with your workspace package manager:

pnpm add @basetime/a2w-auth

Requirements

Before wiring the adapters, ensure the deployment meets these runtime constraints.

Node.js

  • Node.js >=22.0.0 — enforced by the package engines field in package.json.
  • The published package is ESM ("type": "module"); import from the dist/ entry points declared in exports.

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 Secure cookies over plain HTTP, so login can succeed while the session never sticks.
  • Local development: localhost is treated as a secure context even on HTTP. The Express and Fastify examples proxy the SPA to the API so sameSite=strict cookies work during development.
  • Behind a load balancer: configure Express trust proxy (or the Fastify equivalent) so req.ip reflects 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. bcrypt is 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-gyp to compile the addon during pnpm install.
  • Existing hashes. Passwords hashed with earlier bcrypt installs (including bcryptjs-compatible hashes) verify unchanged — see IPasswordEncoder.
  • Alternative. Deployments that cannot use native addons may inject ScryptPasswordEncoder (pure node:crypto) via the factory passwordEncoder option instead.

Cryptographic secrets

Every deployment needs signing and encryption material at runtime:

  • RSA key pair — PEM rsaPriv and rsaPub for RS256 JWT signing (TokenStorageConfig).
  • encryptionKey — exactly 32 UTF-8 bytes for AES-256-GCM refresh-token encryption at rest (Encryption config).

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-auth

Then 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} and GET /.well-known/jwks.json (the session key user becomes the /auth/user/* namespace).
  • Login body: clients must send { email, password, tenantId } — see Multi-tenancy.
  • Next steps: see Express Middleware for Redis throttling, onReady event hooks, route overrides, and error handling, or run the full demo at examples/express/.

Concepts

At a high level, @basetime/a2w-auth separates authentication into a few small pieces:

  • A session describes one login flow, such as user or admin.
  • 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 HttpSession for HTTP adapters or ISession at 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 sessions map, such as user or admin. HTTP adapters use it as the route namespace (/auth/user/*), the request decoration (req.user), and the default token kind when 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 admin refresh token cannot be used in the user session.
  • 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 IProvider implementation 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 ITokenStorage implementation that signs/verifies JWTs and manages refresh-token lifecycle operations. The default TokenStorage signs 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 iss claim. 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 HttpCore instances.

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 (tid claim) and on the persisted refresh-token row.
  • The User record is resolved from the database using the tenantId and email.
  • 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 Lua INCR + EXPIRE-on-first so the lockout window measures from the first failed attempt and cannot be extended indefinitely by drip-feeding bad attempts.
  • FirebaseThrottler — persists each counter as a throttleCounters/{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 match RedisThrottler: expireAt is only set on document creation/reset, so trickle-DoS cannot extend an existing lockout.

Both implementations:

  • Throttle login by tenant:<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 refreshToken by the presented refresh-token value.
  • Expose maxAttempts, windowSeconds, and keyPrefix constructor options with the same defaults (6, 60, passwordGrant:failure:). FirebaseThrottler adds an optional collectionName (default throttleCounters).

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 an ioredis connection 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 Lua INCR + EXPIRE-on-first. Constructor takes an IORedis client (use redisProvider.getConnection()), the ILogger, and { maxAttempts?, windowSeconds?, keyPrefix? }.
  • FirebaseThrottler — Firestore-backed with TTL on expireAt. Constructor takes a FirebaseProvider, the ILogger, 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 workflow

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

CoreAuth workflow

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

CoreEvents workflow

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-ttl

This 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

Token system workflow

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). The iss claim identifies the token issuer and, when issuer verification is configured, selects either the local RSA public key or a registered remote JWKS endpoint. The tid claim pins the token to a single tenant; HttpCore cross-checks it against the stored user's tenantId on 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 RS256 signing key, carries the user id, the tid claim (so rotation can scope the database lookup by tenant without an extra read), and the aud claim that matches its kind. Used solely to mint a new ID token (and a fresh refresh token) once the ID token expires. Persisted server-side as hash + ciphertext along with the tenantId column, never as plaintext. Existing refresh tokens may not carry iss; 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

  1. Login (HttpCore.login): credentials are throttled along both a tenant:<tid>:email: and (when known) a tenant:<tid>:ip: axis so a noisy tenant cannot starve another tenant's counters. On success CoreAuth.grant calls tokenStorage.createToken (ID token, embeds tid) and tokenStorage.createRefreshToken (refresh token, embeds tid and persists tenantId on the database row). The pair is set as cookies; the Authed payload is also returned in the response body.
  2. Per-request identity (HttpCore.exchangeToken): reads the ID-token cookie, calls tokenStorage.decodeToken, verifies the token's signature and trusted issuer, and hydrates the user from IProvider. If the ID-token cookie is missing or expired the handler transparently falls back to refreshToken so the client never sees the refresh path explicitly. That fallback inherits refresh throttling — a locked-out refresh token rejects with HttpError('Locked', 423) even though the client called the token endpoint, not /refresh.
  3. Refresh (HttpCore.refreshTokenCoreAuth.refreshTokenGrant): the refresh-token cookie is exchanged for a new ID + refresh pair via tokenStorage.rotateRefreshToken. Refresh-token verification uses the same issuer-aware verifier, with a local-key fallback only for refresh tokens that predate the iss claim. The new pair is written back into the cookies and returned in the response body.
  4. Logout (HttpCore.logout): the refresh-token cookie is read, the corresponding record is deleted via tokenStorage.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:

  1. Verify the JWT signature and issuer policy on the presented token. An invalid signature, expired JWT, or unknown issuer short-circuits to null without touching the database. Refresh tokens missing iss continue to verify against the local public key for backward compatibility.
  2. Look up the persisted record by SHA-256 hash of the presented token. No record → null.
  3. Reuse detection. If the looked-up record already has usedAt set, this token has been rotated before; an attacker (or a confused client) is replaying it. The entire familyId is deleted with IProvider.deleteRefreshTokenFamily, invalidating every descendant token, and the caller must re-authenticate.
  4. Expiry check against the persisted dateExpires. Past expiry → the record is removed and the caller gets null.
  5. Mint a new refresh token inside the same familyId, with the presented token's hash recorded as parentHash fo