Skip to main content

Core

WIP

The AuthEngine, what createAuth takes, the facets it exposes, and the request-scoped calls every server adapter makes

Everything in duck-auth hangs off one AuthEngine. You build it once at boot and share it; it holds no per-request state.

Building the engine

createAuth() is the entry point. It validates the config, builds the engine, and runs strict() for the environment.

import { MemoryAdapter } from '@gentleduck/auth/adapters/memory'
import { createAuth } from '@gentleduck/auth/core'
import { cookieTransport } from '@gentleduck/auth/core/transport'
import { passwords } from '@gentleduck/auth/providers/passwords'

export const auth = createAuth({
  baseUrl: 'https://app.example.com',
  stores: new MemoryAdapter(),
  transport: cookieTransport(),
  providers: [passwords()],
})
KeyDefaultWhat it does
baseUrlrequiredThe public origin every link, redirect and callback is built against. Must be absolute http(s); a trailing slash is dropped.
storesrequiredidentities, sessions and credentials, plus an optional orgs. An adapter provides all of them; see Adapters.
transporta secure duck-sid cookieHow the session travels. See Transports.
providersnoneProviders, or thunks (engine, deliver) => provider. A falsy entry is skipped, so cond && provider() works.
limiterMemoryLimiterWhat every flow spends attempts against. See Adapters.
delivernoneYour mailer. See Delivery.
eventsan in-process busWhere lifecycle events go. See Events.
session7 days idle, 30 days absolutettlMs, absoluteTtlMs, freshnessMs, maxSessionsPerIdentity. See Sessions.
identities16 KiB profile capsoftDeleteGracePeriodMs, profileMaxBytes.
hijack, anomalydetection offSee Sessions and Anomaly detection.
captcharefuses every tokenSee Captcha.
resolveActornoneWho created_by and updated_by name when no withActor scope is open.
strictNODE_ENV, else 'development'Which environment's checks run at the end of construction; false skips them.

An unknown key throws AUTH_MISCONFIGURED rather than being ignored, so sessions for session fails loudly.

new AuthEngine(cfg) builds the same engine without the key check, the transport default and the strict() call.

Facets

FacetWhat it holds
auth.flowsThe multi-step operations: sign-in, sign-out, step-up, password reset, email verification, sign-up, account deletion, impersonation, linking. See Flows.
auth.sessionsCreate, rotate, revoke and list sessions. See Sessions.
auth.identitiesAccounts: create, update, link, soft-delete, erase, export. See Identities.
auth.providersThe registry: list(), has(id), get(id).
auth.passwordsStrength checks and password writes. Needs passwords().
auth.mfaTOTP, backup codes, WebAuthn MFA, remembered devices. Needs mfaProvider().
auth.passkeysPasskey registration. Needs passkey().
auth.apiKeysCreate, rotate and revoke API keys. Needs apiKeyProvider().
auth.orgsOrganizations and memberships. Needs an orgs store.
auth.hijack, auth.anomalySession drift and risk scoring.
auth.transport, auth.events, auth.limiter, auth.captchaWhat the config supplied, or the defaults.
auth.pluginsPlugins installed with auth.use().

A provider-backed getter throws AUTH_PROVIDER_NOT_REGISTERED, naming the factory to add, when its provider is missing.

Resolving the caller

resolveSession reads the transport, looks the session up and loads its identity. It answers an Answer:

const resolved = await auth.resolveSession({ headers }).orNull()
if (!resolved?.identity) throw new AuthError('AUTH_UNAUTHENTICATED')
resolved.session.aal // 1, or 2 after a second factor
  • orNull() answers null when the request carries no session or one that was revoked or expired.
  • It still throws AUTH_SESSION_IDENTITY_ERASED when the session outlived its identity.
  • identity is null for a guest session.
  • Pass { expectedTenantId } to refuse a session from another tenant, and { requestSnapshot } to run the anomaly detectors.

Reading a session slides its idle deadline once less than half of it is left, never past the absolute one.

Transactions

auth.withTransaction(tx) returns a view whose stores run on your driver's transaction handle and whose events wait in pending until you publish them:

await db.transaction(async (tx) => {
  const bound = auth.withTransaction(tx)
  const ada = await bound.identities.create({ profile: { email: 'ada@example.com', username: 'ada' } })
  await bound.passwords.set(ada.id, 'correct-horse-battery', bound.stores.credentials)
  return bound.pending
}).then((pending) => pending.flush())

It throws AUTH_MISCONFIGURED when a store cannot join a transaction (the memory adapter, or a hand-mixed store bag).

Plugins

A plugin bundles providers, event listeners, an install hook and a facet under one id:

await auth.use({
  id: 'audit',
  events: { 'signin.success': (p) => console.log('signed in', p.identity.id) },
})

Installing is all or nothing: a failure part-way unwinds what landed.

Strict mode

auth.strict({ env }) throws AUTH_MISCONFIGURED, listing every problem at once, when a production engine is holding something that only works in development: a memory store or limiter, an in-process event bus holding a fleet handler, an insecure cookie, a weak signing key, no lockout listener. createAuth calls it as it returns, before any listener can exist, so a production engine passes strict: false and calls auth.strict({ env: 'production' }) once its lockout listener is subscribed. See Production hardening.