@khalidadil/auth-manager (0.4.1)
Installation
@khalidadil:registry=npm install @khalidadil/auth-manager@0.4.1"@khalidadil/auth-manager": "0.4.1"About this package
@khalidadil/auth-manager
Passwordless auth module: email one-time codes (preferred), legacy magic links, Google SSO for admins, sessions, an address book and a React account shell. Postgres-backed, host-injected pool.
Version 0.4.1. A security release: the email shape check runs in linear time (it was an unauthenticated ReDoS on the Express routes), nodemailer moves to 10.1.0, and the dev/test dependencies are patched; no API or behaviour changes (see CHANGELOG).
0.4.0 added bearer-token sessions, a framework-free
node:httpadapter (/node), a durable wrong-code budget and an optional resend cooldown for operator codes, and a break-glass session mint for box-local CLIs. Every feature is off by default; the budget and break-glass need the opt-inoperatormigration set. DefaultrunMigrations, Express and Fastify behave exactly as in 0.3.0 (0.3.0 added the core, the Fastify adapter, schema namespacing, the Redis rate-limit store and the TypeScript declarations).
Package subpaths
| Import | Exports |
|---|---|
@khalidadil/auth-manager |
React: AuthProvider, useAuthContext, useCustomer, useAdmin, useAddresses, useFacetConfig, useFacetValues, LoginForm, AccountMenu, AccountShell, AccountHomePage, AccountAddressesPage, AddressForm, FacetSelect, FacetPrompt, AccountFacetPage, AdminLoginPage, RequireAdmin |
@khalidadil/auth-manager/server (alias /express) |
mountAuth, runMigrations, findSession, destroySession, getForCustomer, setForCustomer, CUSTOMER_COOKIE, ADMIN_COOKIE |
@khalidadil/auth-manager/core |
createAuthCore, runMigrations, authPoolConfig, assertSchemaName, createMemoryRateLimitStore, createRedisRateLimitStore, SLIDING_WINDOW_LUA, DEFAULT_RATE_LIMITS, generateToken, hashToken, parseBearer, CUSTOMER_COOKIE, ADMIN_COOKIE |
@khalidadil/auth-manager/fastify |
createFastifyAuth, runMigrations, authPoolConfig |
@khalidadil/auth-manager/node |
createNodeAuth, parseBearer, runMigrations, authPoolConfig |
@khalidadil/auth-manager/styles.css |
the bundled stylesheet |
/core, /fastify and /node never import express, fastify or React at runtime (the build
checks /core and /node for both frameworks). Peers (express, fastify, pg, react,
react-dom, react-router-dom) are all optional: a Fastify host installs only pg and fastify,
a node:http host only pg.
Install from the Gitea registry (anonymous reads): npm config set @khalidadil:registry https://git.kadil.dev/api/packages/khalidadil/npm/
then npm install @khalidadil/auth-manager@0.4.1 pg. The package is ESM-only.
Core
createAuthCore(opts) composes the shared library (sessions, email codes, principals, email sender,
rate limiter) into a framework-neutral object. Every flow returns an HttpResult
({ status, body, headers?, cookies?, after? }) that an adapter serialises; after runs post-response.
import { createAuthCore, createMemoryRateLimitStore } from '@khalidadil/auth-manager/core';
const core = createAuthCore({
db: pool, // required: pg Pool (query + connect)
appName: 'Filebot', // required: no brand default in the core
cookies: { prefix: 'filebot', names: { admin: 'filebot_operator' }, sameSite: { admin: 'strict' } },
isProduction: process.env.NODE_ENV === 'production',
schema: 'auth_manager', // optional; set → core.ready() asserts the pinned pool
email: { smtp, fromAddress, send, logCodes: 'auto' },
adminEmails: ['ops@example.test'], // or (emailLc) => boolean | Promise<boolean>
adminCodeDigits: 8, adminSessionMaxAgeMs: null,
rateLimitStore: createMemoryRateLimitStore(), rateLimits: {},
allowedOrigins: ['https://app.example.test'],
sessionTransport: 'cookie', // or 'bearer' (cookies then optional; see Bearer sessions)
adminVerifyBudget: null, // or { maxFailures, windowMs }; needs the operator migration set
adminCodeResendCooldownMs: 0, // 0 = off
logger: console,
});
// core.flows.{requestCustomerCode, verifyCustomerCode, requestAdminCode, verifyAdminCode, logout}
// core.{sessions, principals, codes, cookies, email, rateLimit, checkOrigin, ready, cookieNames,
// sessionTransport, mintBreakGlassSession}
Throws, in order: opts.db (pg Pool) is required, opts.appName is required,
opts.sessionTransport must be "cookie" or "bearer", opts.cookies.prefix is required (cookie
mode only), then schema-name, adminCodeDigits ∈ {6,7,8}, origin, rate-limit, budget and cooldown
validation, and the __Host- rule (__Host- cookie names need isProduction and no domain).
No database query happens at construction.
Express — 0.2 → 0.3: nothing to change
mountAuth(app, opts) keeps every 0.2.0 option, default, route, log line, cookie and return value.
A 0.2.0 host upgrades by bumping the version. The default mount registers no new routes.
import { Pool } from 'pg';
import { mountAuth, runMigrations } from '@khalidadil/auth-manager/server';
const pgPool = new Pool({ connectionString: process.env.DATABASE_URL });
await runMigrations(pgPool, { logger }); // idempotent, safe on every boot
const { authMiddleware, requireCustomer, requireAdmin, requireCustomerOrAdmin } = mountAuth(app, {
db: pgPool,
apiBase: '/api',
callbackBaseUrl: process.env.PUBLIC_BASE_URL, // required while magicLinks or googleSso is on
smtp: process.env.SMTP_HOST ? { host: process.env.SMTP_HOST, port: process.env.SMTP_PORT,
user: process.env.SMTP_USER, pass: process.env.SMTP_PASS, from: process.env.SMTP_FROM } : undefined,
fromAddress: 'no-reply@example.com',
appName: 'Pawfectly Yours',
googleClientId: process.env.GOOGLE_CLIENT_ID,
googleClientSecret: process.env.GOOGLE_CLIENT_SECRET,
adminEmails: (process.env.ADMIN_EMAILS || '').split(',').map((s) => s.trim()).filter(Boolean),
isProduction: process.env.NODE_ENV === 'production',
cookieDomain: process.env.COOKIE_DOMAIN,
logger,
});
Routes under ${apiBase}/auth: POST /customer/request-code, POST /customer/verify-code,
POST /customer/request (magic link), POST /customer/logout, POST /admin/logout, GET /me,
GET /admin/me; ${apiBase}/account/addresses (CRUD) and ${apiBase}/account/facet-values; at the
host root GET /auth/verify and GET /auth/admin/google[/callback]. The customer code routes now run
on the core flows with identical responses.
New opt-in options (defaults reproduce 0.2.0): cookiePrefix ('pawfectly'), cookieNames,
logCodes ('auto'), sendEmail, rateLimitStore, rateLimits, magicLinks (true),
googleSso (true), adminOtp (false), allowedOrigins, adminCodeDigits (8).
mountAuth(app, { db, magicLinks: false, googleSso: false }) needs no callbackBaseUrl.
adminOtp: true mounts POST /admin/request-code + POST /admin/verify-code under ${apiBase}/auth
and requires a non-empty allowedOrigins.
Behaviour delta for hosts with SMTP configured: codes and magic links are no longer written to
the log when a transport exists (see Code-logging policy). Hosts without SMTP_HOST are byte-identical.
Fastify
import pg from 'pg';
import { createFastifyAuth, authPoolConfig, runMigrations } from '@khalidadil/auth-manager/fastify';
import { createRedisRateLimitStore } from '@khalidadil/auth-manager/core';
const authPool = new pg.Pool(authPoolConfig({ connectionString: process.env.DATABASE_URL, schema: 'auth_manager', max: 4 }));
await runMigrations(authPool, { schema: 'auth_manager', logger }); // after the host's own migrations
const auth = createFastifyAuth({
db: authPool, schema: 'auth_manager', appName: 'Filebot', routePrefix: '/console/api/auth',
isProduction: isProd,
cookies: { prefix: 'filebot', names: { admin: isProd ? '__Host-filebot_operator' : 'filebot_operator' } },
adminEmails: operatorEmails,
allowedOrigins: [new URL(publicBaseUrl).origin, ...devOrigins],
email: { smtp },
rateLimitStore: createRedisRateLimitStore(redis, { prefix: 'fbx:auth:rl:' }),
logger,
});
await app.register(auth.plugin); // FIRST; its onReady hook runs core.ready() at listen()
app.register(async (c) => {
c.addHook('preHandler', auth.requireAdmin);
// console routes…
}, { prefix: '/console/api' });
createFastifyAuth returns { core, plugin, resolve, requireAdmin, optionalAdmin, csrfGuard }.
schema (may be 'public') and a non-empty allowedOrigins are required. Adapter defaults:
routePrefix '/api/auth', bodyLimit 4096, recheckAllowlist true, admin cookie SameSite=Strict,
adminSessionMaxAgeMs 7 days (absolute cap over the 24 h rolling session).
The plugin decorates request.authSession (AuthState | null), adds one onReady hook and
registers the routes in an encapsulated child under routePrefix. Nothing else touches the host:
no parsers, hooks or error handlers outside the prefix; a host route issues zero pool queries.
Guards are async preHandlers: requireAdmin answers 403 {error:'origin_forbidden'} for an
unsafe request that fails the origin rule (before any DB query) and 401 {error:'admin_required'}
without an admin session; optionalAdmin only resolves; csrfGuard applies the origin rule alone.
resolve(request) is memoised per request. A host needing its own error body composes
await auth.resolve(req) and auth.core.checkOrigin({ method, origin, secFetchSite }) itself.
Cookies are read from request.cookies when @fastify/cookie has run, else parsed from the header;
they are written as set-cookie headers, so the plugin works with or without @fastify/cookie.
Operator OTP
Route (under routePrefix) |
Request | Responses |
|---|---|---|
POST /admin/request-code |
{"email"} |
always 200 {"ok":true}; an IP limit adds Retry-After |
POST /admin/verify-code |
{"email","code"} |
200 {"ok":true,"admin":{id,email,displayName}} + admin cookie (24 h, HttpOnly, Secure in prod, SameSite=Strict); 400 {"ok":false,"error":"invalid"} (email shape); 401 {"ok":false,"error":"invalid"} for every code/allowlist failure; 429 too_many_attempts; 503 rate_limit_unavailable; 500 server_error |
POST /admin/logout |
no body | 200 {"ok":true} + expired cookie |
GET /admin/me |
cookie | 200 {"admin":{…}} / 401 {"error":"admin_required"} |
- Codes are 8 digits (
adminCodeDigits), 10-minute TTL, one live code per email, 5 attempts then locked. request-codeanswers before any allowlist, database or SMTP work; a non-allowlisted email writes nothing and sends nothing, so the response never reveals allowlist membership.- Every verify failure other than 429/400/503/500 is exactly
401 invalid, returned no earlier than 250 ms after receipt (uniform timing). The allowlist is re-checked at verify and, withrecheckAllowlist, on every guarded request: a removed operator loses all sessions. - Request-code and verify-code require
Content-Type: application/json(else 415) and a body of at mostbodyLimitbytes (else 413). Malformed JSON is400 invalid. - Origin rule on unsafe methods:
GET/HEAD/OPTIONSpass; anOriginheader must be an exact allowlisted origin (Origin: nullis never allowed); otherwiseSec-Fetch-Sitemust besame-originornone; a request carrying neither header is allowed. - Log events (never the raw email, a code or a token):
admin_code.not_allowlisted {emailSha, domain},admin_code.send_failed,admin_code.budget_exhausted {emailSha},admin_code.cooldown {emailSha},admin_login.succeeded {adminId},admin_login.failed {reason, emailSha}(reasonbudget_exhaustedwith a verify budget),admin_session.revoked_not_allowlisted {adminId, count},admin_session.break_glass_minted {adminId, emailSha, label},rate_limit.store_error {bucket}. - The same routes and responses are served by the
/nodeadapter; in bearer mode verify answers{ok, admin, token, expiresAt}without a cookie and/admin/meanswers{admin, expiresAt}.
Bearer sessions
sessionTransport: 'bearer' (core and /node; default 'cookie') moves the session token from a
cookie into the response body and the Authorization header. cookies is then optional and no
cookie is ever emitted.
verifyAdminCodesucceeds with200 {ok:true, admin:{id,email,displayName}, token, expiresAt};tokenis 64 lowercase hex andexpiresAtis the effective expiry,min(row expires_at, row created_at + adminSessionMaxAgeMs)(rows roll for 24 h). Both values are read back from the new row, so they are on the database clock, the clock the cap is enforced against, and/admin/mereports exactly the same instant.verifyCustomerCodesucceeds with{ok:true, next, token, expiresAt}throughcore.flows; no adapter mounts customer routes in bearer mode.logoutanswers200 {ok:true}and deletes the session row (no clear-cookie).parseBearer(header)returns the token only forBearer <exactly 64 lowercase hex>(scheme in any case, spaces or tabs between, nothing after; the first element of an array header), elsenullwithout any database work, so other bearer formats (API keys, app tokens) never reach a lookup.core.sessions.resolveToken(token)resolves a token to{session, customer, admin}, keeps the admin absolute cap, never throws and fails closed to all-null.
A host with its own auth routes drives the core directly: createAuthCore({ …, sessionTransport: 'bearer' }), core.flows.requestAdminCode / verifyAdminCode / logout, and
core.sessions.resolveToken(parseBearer(req.headers.authorization)) plus core.checkOrigin in its
guard. It writes each HttpResult's status, body and headers (Retry-After), and it must
also do what the adapters do for it:
- Run
after. A request-code result that passed the limiters carriesafter(): run it once the response is sent (it checks the allowlist, issues the code and mails it, and never throws; a host that never runs it never sends a code), and await the pending runs at shutdown. - Re-check the allowlist on every guarded request. When
core.principals.isAdminEmail(state.admin.email)is false, treat the request as unauthenticated and callcore.sessions.destroyAllForAdmin(state.admin.id); if the allowlist throws, refuse the request but revoke nothing.resolveTokenalone does not re-check, so without this a removed operator's bearer stays valid until the cap. - Send
Cache-Control: no-storeon every auth response, above all on the verify response that carries the token.
A host's own session probe reports the expiry the server enforces as
min(session.expiresAt, session.createdAt + adminSessionMaxAgeMs) over resolveToken's session
(both from the row; session.expiresAt alone without a cap). With a cap below the 24 h rolling
expiry that is exactly the expiresAt verify returned.
Express's mountAuth stays cookie-only, and createFastifyAuth throws
sessionTransport 'bearer' is not supported; use @khalidadil/auth-manager/node.
node:http adapter (/node)
import http from 'node:http';
import pg from 'pg';
import { createNodeAuth, authPoolConfig, runMigrations } from '@khalidadil/auth-manager/node';
const authPool = new pg.Pool(authPoolConfig({ connectionString: process.env.DATABASE_URL, schema: 'console_auth' }));
await runMigrations(authPool, { schema: 'console_auth', include: ['operator'], logger });
const auth = createNodeAuth({
db: authPool, schema: 'console_auth', appName: 'Console',
sessionTransport: 'bearer',
allowedOrigins: ['https://console.example.test'],
adminEmails: operatorEmails,
adminCodeDigits: 8,
adminSessionMaxAgeMs: 12 * 60 * 60 * 1000, // hard cap
adminVerifyBudget: { maxFailures: 5, windowMs: 60 * 60 * 1000 }, // wrong codes per email per hour
rateLimits: {
admin_email_request: { max: 5, windowMs: 60 * 60 * 1000 }, // silent: still 200
admin_ip_request: { max: 10, windowMs: 60 * 60 * 1000 }, // 200 + Retry-After
},
email: { send: mailer.send, fromAddress: 'console@example.test', logCodes: 'never' },
clientIp: (req) => trustedProxyClientIp(req),
logger,
});
await auth.core.ready(); // AUTH_NOT_MIGRATED when the operator set is missing
const server = http.createServer(async (req, res) => {
if (await auth.handle(req, res)) return; // the four admin routes under /api/auth
if (req.url.startsWith('/api/console/')) {
const { admin } = await auth.resolve(req); // Authorization: Bearer <token>
if (!admin) return reply(res, 401, { ok: false, reason: 'unauthenticated' });
// …
}
});
// shutdown: server.close(); await auth.drain(5000);
That is a hardened operator profile: 8-digit codes, 5 code requests per email and 10 per IP per hour, 5 wrong codes per email per hour across every code, and a 12 h session cap.
createNodeAuth(opts) takes the core options plus schema (required, may be 'public'), a
non-empty allowedOrigins (required), routePrefix ('/api/auth'), bodyLimit (4096),
recheckAllowlist (true), clientIp(req) (default req.socket.remoteAddress; pass a
trusted-proxy resolver behind a proxy) and onError(err, req, res) (default: logged,
500 {ok:false,error:'server_error'}, the message never echoed). Its defaults match the Fastify
adapter's: admin cookie SameSite=Strict and a 7-day adminSessionMaxAgeMs cap.
It returns { core, handle, resolve, requireAdmin, sendResult, drain }:
handle(req, res)→Promise<boolean>servesPOST {prefix}/admin/request-code,/admin/verify-code,/admin/logoutandGET {prefix}/admin/mewith the Operator OTP wire contract above (origin rule first, 415, 413 withConnection: close, 400 for bad JSON,Cache-Control: no-storeon every response, headers the host set earlier kept). Anything else returnsfalseuntouched, so the host keeps routing.resolve(req)→AuthState, memoised per request; bearer mode readsAuthorization, cookie mode the session cookies; the allowlist re-check revokes a removed admin's sessions.requireAdmin(req, res)→ the admin, ornullafter answering403 {error:'origin_forbidden'}or401 {error:'admin_required'}. A host that needs its own error body composesresolveandcore.checkOrigin, as above.sendResult(req, res, result)writes a coreHttpResultfor host routes built oncore.flows.drain(timeoutMs)awaits post-response work (code delivery); call it at shutdown.
No route mints a break-glass session. In cookie mode the adapter sets and reads the admin cookie
and ignores Authorization.
Wrong-code budget and resend cooldown
adminVerifyBudget: { maxFailures, windowMs } (default null = off; maxFailures an integer
1..100, windowMs an integer 60000..86400000) allows at most maxFailures wrong admin codes per
email in any sliding window, across all of that email's codes. A new code request, code expiry
and a successful login never reset it; only elapsed time does. It is stored in
email_verify_failures (one row per email, created only by a guess against a live code), so it
survives restarts and is shared by every process on the database.
- While exhausted, verify answers the uniform
401 invalid(the reason is logged only) and request-code still answers200but issues no code (admin_code.budget_exhausted). - Malformed codes, missing and expired codes are not charged. Parallel guesses serialize on the budget row, so a burst never charges past the limit. The per-code 5-attempt cap still applies.
adminCodeResendCooldownMs(an integer 0..600000; default 0 = off) refuses to issue a new code while one was issued for that email within the window (admin_code.cooldown; still200).- Customer flows and
core.codes.verifyare unaffected.
Break-glass session mint
core.mintBreakGlassSession({ email, label? }) → { token, sessionId, expiresAt, admin } mints an
admin session without OTP, the budget or any rate limit. It exists for a box-local CLI, where
shell access to the host already implies control, and no adapter exposes it over HTTP.
import { createAuthCore } from '@khalidadil/auth-manager/core';
const core = createAuthCore({ db: authPool, schema: 'console_auth', appName: 'Console',
sessionTransport: 'bearer', adminEmails: operatorEmails, adminSessionMaxAgeMs: 12 * 3600_000,
email: { logCodes: 'never' }, logger });
const { token, expiresAt } = await core.mintBreakGlassSession({ email, label: 'console-cli' });
The email must be on the allowlist (else AUTH_NOT_ALLOWLISTED). The session row records
issued_via = 'break_glass', the label (default 'auth-manager break-glass', at most 200
characters) as its user agent and no IP; OTP sessions keep issued_via NULL. A warn
admin_session.break_glass_minted {adminId, emailSha, label} is logged; the token never is.
Without the operator migration set it throws AUTH_NOT_MIGRATED.
Opt-in migration sets
migrations/*.sql (001-003) is the default set: runMigrations(pool) and
runMigrations(pool, { schema }) apply exactly these, as in 0.2.0 and 0.3.0. The operator set,
migrations/operator/004_admin_otp_hardening.sql, is applied only on request:
await runMigrations(authPool, { schema: 'console_auth', include: ['operator'] });
It adds email_verify_failures (email PK, fail_at timestamptz[], updated_at) and the nullable
column sessions.issued_via, both additive, so 0.2.0 and 0.3.0 code keep working on a schema that
has them. The verify budget and break-glass need it.
includeis validated before anything else: an unknown set throwsrunMigrations include: unknown migration set "<x>"with no query. A non-emptyincludetakes the advisory lock even without a schema.- Ledger ids are file basenames (
004_admin_otp_hardening); a duplicate id throws before connecting. 004 is reserved for the operator set; the next default migration is 005. core.ready()checks for the set only when bothschemaandadminVerifyBudgetare set, and then throwsAUTH_NOT_MIGRATEDnaminginclude: ['operator']. Without a schema, a missing table fails closed: verify answers500and request-code issues nothing.
Schema namespacing
authPoolConfig({ connectionString, schema, max = 4, applicationName = 'auth-manager' }) returns a
pg.Pool config whose startup parameter options: '-c search_path=<schema>' pins every connection
to that schema for its whole life (RESET/DISCARD ALL return to it). Migrations and SQL are
unchanged; unqualified names simply resolve inside the schema. <schema>.schema_migrations(id) never
touches a host table such as public.schema_migrations(name).
assertSchemaName:/^[a-z_][a-z0-9_]{0,62}$/, notpg_*, notinformation_schema.authPoolConfigthrows when the URL already carriesoptions=(pg merges URL params over config).runMigrations(pool, { schema })runs on one locked client (pg_advisory_lock), creates the schema, assertscurrent_schemas(false)is exactly[schema], applies pending files and on any error destroys that client (no poisoned connection, lock released).{ lock: true }takes the lock without a schema. Without either option the 0.2.0 sequence runs verbatim.core.ready()(called by the FastifyonReadyhook) throwsAUTH_SCHEMA_MISMATCHon a pool whose search_path is not exactly the schema,AUTH_SCHEMA_IS_ROLEwhencurrent_user === schema, andAUTH_NOT_MIGRATEDwhensessions/email_codesare missing. A hostschema_migrationstable without anidcolumn raisesAUTH_SCHEMA_MIGRATIONS_COLLISION.- Connect to Postgres directly: PgBouncer transaction mode strips startup options.
Code-logging policy
hasTransport = !!(smtp.host && smtp.port) || typeof send === 'function';
logsCodes = logCodes === 'auto' && !hasTransport. There is no 'always'.
| Transport | logCodes |
Result |
|---|---|---|
| none | 'auto' (default) |
0.2.0 behaviour: code box in the log, magic-link banner on stdout, boot line No SMTP_HOST in env — magic links will only print to console. |
| none | 'never' |
boot warn no email transport and logCodes=never — logins cannot complete; nothing is sent or logged |
SMTP or send |
any | codes and links reach only the email; boot line SMTP transport configured: host:port (login codes and links are not logged) or custom email transport configured (…); a send failure logs SMTP send failed (code not logged) with the code scrubbed from the error text |
send({ from, to, subject, text, html, kind: 'login_code' | 'magic_link' }) wins over smtp when
both are given. Break-glass for a host that cannot receive mail: unset SMTP_HOST (then 'auto' logs).
Rate-limit stores
Buckets: ip_request 10/h, email_request 3/15 min, ip_verify 20/h, and admin_ip_request,
admin_email_request, admin_ip_verify with the same numbers in a separate keyspace
(DEFAULT_RATE_LIMITS; override per bucket with rateLimits: { bucket: { max, windowMs } }).
createMemoryRateLimitStore()(default): one sliding window per bucket, decided synchronously, identical to 0.2.0's limiter; never throws.createRedisRateLimitStore(client, { prefix = 'auth-manager:rl:' }):clientis any ioredis-styleeval(script, numKeys, ...args). Key${prefix}${bucket}:${sha256(identifier).slice(0, 32)}(no raw email or IP),SLIDING_WINDOW_LUAuses RedisTIMEas the clock, setsPEXPIREto the window and does not record denied hits. Tested againstredis:7.- A custom store
{ hit(bucket, identifier, rule) → { allowed: true } | { allowed: false, retryAfterMs } }may throw; the flows then fail closed: request-code answers 200 and sends nothing (rate_limit.store_errorlogged), verify-code answers503 rate_limit_unavailable.
Types
Hand-written declarations ship in types/ and are wired through exports (types condition first):
types/index.d.ts (React entry: components as ComponentType<any>, hooks return any, no
@types/react needed), server.d.ts (mountAuth(app: ExpressAppLike, opts: MountAuthOptions): AuthGuards, structural Express types), core.d.ts (AuthCoreBaseOptions, AuthCoreOptions,
AuthCore, HttpResult, stores), fastify.d.ts (FastifyAuthOptions, FastifyAuth, the
fastify module augmentation interface FastifyRequest { authSession: AuthState | null } — hosts
must not redeclare it), node.d.ts (NodeAuthOptions, NodeAuth, only node:http types) and
common.d.ts (shared types incl. SessionTransport, VerifyBudget, BreakGlassResult,
RunMigrationsOptions.include).
AuthCoreOptions is a union: cookie mode (the default) requires cookies, bearer mode makes it
optional. FastifyAuthOptions keeps cookies required and accepts only sessionTransport?: 'cookie'.
PgPoolLike is structural; a real pg.Pool satisfies it. Verified under NodeNext + strict +
skipLibCheck: false by npm run test:types and, from the packed tarball, by npm run test:pack.
Client wiring (unchanged)
import { AuthProvider, LoginForm, AccountShell, AdminLoginPage, RequireAdmin } from '@khalidadil/auth-manager';
import '@khalidadil/auth-manager/styles.css';
<AuthProvider apiBase="/api"><App /></AuthProvider>
Cookie names
buildCookieNames(prefix) yields <prefix>_customer_session (90 d rolling), <prefix>_admin_session
(24 h rolling; Fastify adds a 7 d cap), <prefix>_oauth_state and <prefix>_oauth_verifier (10 min).
Prefix pawfectly reproduces the 0.2.0 constants CUSTOMER_COOKIE / ADMIN_COOKIE; cookieNames
overrides single names (e.g. __Host-filebot_operator in production).
Testing tiers + services
| Script | What runs | Needs |
|---|---|---|
npm test |
test/unit/** (cookies, origin, schema, memory store, email sender, core options, bearer parsing, node HTTP helpers, mountAuth options) |
— |
npm run test:redis |
test/redis/** — the Lua store against a real Redis |
AUTH_TEST_REDIS_URL (e.g. docker run -d --name auth-am-redis -p 127.0.0.1:6380:6379 redis:7.4-alpine) |
npm run test:db |
test/db/** — flows, sessions, schema'd and opt-in migrations, bearer sessions, verify budget, break-glass, Express OTP |
Postgres test DB |
npm run test:fastify |
test/fastify/** via fastify.inject |
Postgres test DB |
npm run test:node |
test/node/** — the /node adapter on a real http.Server |
Postgres test DB |
npm run test:regression |
builds dist, then the Express contract, a differential transcript against the 0.2.0 baseline, Fastify parity against the 0.3.0 baseline and the operator-set compatibility of both (npm run baseline and npm run baseline -- --version 0.3.0 fetch them) |
Postgres test DB, registry |
npm run test:types |
tsc --noEmit on test/types/usage.ts (every typed subpath) |
— |
npm run test:pack |
npm pack → temp pnpm project without react/express → imports, default and operator migrations, tsc |
Postgres test DB, npmjs |
npm run verify |
build → every tier with pass floors (unit 55 · redis 6 · db 60 · fastify 37 · node 14 · regression 38 · types 1) → 500-line file gate; prints VERIFY OK (also prepublishOnly) |
all of the above |
npm run smoke:consumer -- --tarball |
the packed tarball (or --spec <pkg@ver> --anonymous, the registry package with a token-free userconfig) in a temp npm project, driving a raw node:http host through login, budget lockout and break-glass; prints CONSUMER SMOKE OK |
CONSUMER_SMOKE_DB_URL (required) |
npm run smoke:apparel |
swaps this build's dist into the sibling main checkout that apparel-designer runs, records transcripts on an upgraded and a fresh database against the release that checkout holds, restores it; prints SMOKE IDENTICAL (-- --post-release after a release) |
APPAREL_SMOKE_DB_URL, sibling checkouts |
bash scripts/ci-gate.sh |
the clean-clone gate: its own labelled Postgres and Redis containers, npm ci, both baselines from the registry, verify, test:pack, the consumer smoke; prints CI GATE OK |
Docker, corepack |
Postgres: COMMERCE_TEST_DATABASE_URL (alias AUTH_TEST_DATABASE_URL; default
postgres://postgres:commerce@localhost:5434/auth_manager_test; the URL must contain test).
Each test file works in its own am_<file>_<pid> schema and drops it afterwards. Every tier writes
TAP to test/.tap/<tier>.tap and fails on zero passes or any skipped test. The smokes write their
reports to test/.smoke/. Node ≥ 22 for the tests; the runtime engines stays >=20.
Build
npm run build bundles the client (dist/auth-manager.{js,css}) and four single-file server
bundles (dist/server/{index,core,fastify,node}.js) into dist.next, checks them (core, fastify
and node import without express; node has no fastify import) and swaps the directory in
atomically. The tarball ships dist, migrations (including migrations/operator/), types and
this README.
Dependencies
Dependencies
| ID | Version |
|---|---|
| @oslojs/crypto | ^1.0.1 |
| @oslojs/encoding | ^1.1.0 |
| arctic | ^2.3.0 |
| cookie | ^0.7.2 |
| nodemailer | ^10.1.0 |
Development Dependencies
| ID | Version |
|---|---|
| @fastify/cookie | ^11.0.0 |
| @rollup/plugin-commonjs | ^28.0.0 |
| @rollup/plugin-json | ^6.1.0 |
| @rollup/plugin-node-resolve | ^15.3.0 |
| @types/node | ^22.10.0 |
| @types/pg | ^8.11.0 |
| express | ^4.21.0 |
| fastify | ^5.2.0 |
| ioredis | ^5.4.0 |
| pg | ^8.13.0 |
| react | ^19.2.5 |
| react-dom | ^19.2.5 |
| react-router-dom | ^7.1.0 |
| rollup | ^4.30.0 |
| rollup-plugin-esbuild | ^6.1.0 |
| rollup-plugin-postcss | ^4.0.2 |
| typescript | ^5.7.0 |
Peer Dependencies
| ID | Version |
|---|---|
| express | >=4.18.0 |
| fastify | >=5.0.0 |
| pg | >=8.11.0 |
| react | >=18.0.0 |
| react-dom | >=18.0.0 |
| react-router-dom | >=6.0.0 |