khalidadil
  • Joined on 2025-08-25

@khalidadil/auth-manager (0.3.0-rc.1)

Published 2026-09-17 11:00:21 +00:00 by khalidadil

Installation

@khalidadil:registry=
npm install @khalidadil/auth-manager@0.3.0-rc.1
"@khalidadil/auth-manager": "0.3.0-rc.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.3.0. Adds a framework-agnostic core, a Fastify adapter with operator (admin) email OTP, schema namespacing through a search_path-pinned pool, a Redis sliding-window rate-limit store and hand-written TypeScript declarations. The Express adapter is unchanged for 0.2.0 hosts.

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, CUSTOMER_COOKIE, ADMIN_COOKIE
@khalidadil/auth-manager/fastify createFastifyAuth, runMigrations, authPoolConfig
@khalidadil/auth-manager/styles.css the bundled stylesheet

/core and /fastify never import express, fastify or React at runtime. Peers (express, fastify, pg, react, react-dom, react-router-dom) are all optional: a Fastify host installs only pg and fastify.

Install from the Gitea registry: npm config set @khalidadil:registry https://git.kadil.dev/api/packages/khalidadil/npm/ then npm install @khalidadil/auth-manager@0.3.0 pg.

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'],
  logger: console,
});
// core.flows.{requestCustomerCode, verifyCustomerCode, requestAdminCode, verifyAdminCode, logout}
// core.{sessions, principals, codes, cookies, email, rateLimit, checkOrigin, ready, cookieNames}

Throws, in order: opts.db (pg Pool) is required, opts.appName is required, opts.cookies.prefix is required, then schema-name, adminCodeDigits ∈ {6,7,8}, origin and rate-limit 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-code answers 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, with recheckAllowlist, 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 most bodyLimit bytes (else 413). Malformed JSON is 400 invalid.
  • Origin rule on unsafe methods: GET/HEAD/OPTIONS pass; an Origin header must be an exact allowlisted origin (Origin: null is never allowed); otherwise Sec-Fetch-Site must be same-origin or none; a request carrying neither header is allowed.
  • Log events (never the raw email): admin_code.not_allowlisted {emailSha, domain}, admin_code.send_failed, admin_login.succeeded {adminId}, admin_login.failed {reason, emailSha}, admin_session.revoked_not_allowlisted {adminId, count}, rate_limit.store_error {bucket}.

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}$/, not pg_*, not information_schema.
  • authPoolConfig throws when the URL already carries options= (pg merges URL params over config).
  • runMigrations(pool, { schema }) runs on one locked client (pg_advisory_lock), creates the schema, asserts current_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 Fastify onReady hook) throws AUTH_SCHEMA_MISMATCH on a pool whose search_path is not exactly the schema, AUTH_SCHEMA_IS_ROLE when current_user === schema, and AUTH_NOT_MIGRATED when sessions/email_codes are missing. A host schema_migrations table without an id column raises AUTH_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:' }): client is any ioredis-style eval(script, numKeys, ...args). Key ${prefix}${bucket}:${sha256(identifier).slice(0, 32)} (no raw email or IP), SLIDING_WINDOW_LUA uses Redis TIME as the clock, sets PEXPIRE to the window and does not record denied hits. Tested against redis: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_error logged), verify-code answers 503 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 (AuthCoreOptions, AuthCore, HttpResult, stores) and fastify.d.ts (FastifyAuthOptions, FastifyAuth, the fastify module augmentation interface FastifyRequest { authSession: AuthState | null } — hosts must not redeclare it). 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>

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, 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 migrations, Express OTP Postgres test DB
npm run test:fastify test/fastify/** via fastify.inject Postgres test DB
npm run test:regression builds dist, then the Express contract + a differential transcript against the 0.2.0 baseline (npm run baseline fetches it) Postgres test DB, registry
npm run test:types tsc --noEmit on test/types/usage.ts (all five subpaths) —
npm run test:pack npm pack → temp pnpm project without react/express → imports, migrations, tsc Postgres test DB, npmjs
npm run verify build → every tier with pass floors (unit 40 · redis 6 · db 25 · fastify 20 · regression 30 · types) → 500-line file gate; prints VERIFY OK (also prepublishOnly) all of the above

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. Node ≥ 22 for the tests; the runtime engines stays >=20.

Build

npm run build bundles the client (dist/auth-manager.{js,css}) and three single-file server bundles (dist/server/{index,core,fastify}.js) into dist.next, checks them (core/fastify import without express) and swaps the directory in atomically.

Dependencies

Dependencies

ID Version
@oslojs/crypto ^1.0.1
@oslojs/encoding ^1.1.0
arctic ^2.3.0
cookie ^0.7.2
nodemailer ^6.9.16

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
Details
npm
2026-09-17 11:00:21 +00:00
3
next
989 KiB
Assets (1)
Versions (5) View all
0.4.1 2026-10-19
0.4.0 2026-10-09
0.3.0 2026-09-17
0.3.0-rc.1 2026-09-17
0.2.0 2026-09-02