Core
WIPThe 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()],
})
| Key | Default | What it does |
|---|---|---|
baseUrl | required | The public origin every link, redirect and callback is built against. Must be absolute http(s); a trailing slash is dropped. |
stores | required | identities, sessions and credentials, plus an optional orgs. An adapter provides all of them; see Adapters. |
transport | a secure duck-sid cookie | How the session travels. See Transports. |
providers | none | Providers, or thunks (engine, deliver) => provider. A falsy entry is skipped, so cond && provider() works. |
limiter | MemoryLimiter | What every flow spends attempts against. See Adapters. |
deliver | none | Your mailer. See Delivery. |
events | an in-process bus | Where lifecycle events go. See Events. |
session | 7 days idle, 30 days absolute | ttlMs, absoluteTtlMs, freshnessMs, maxSessionsPerIdentity. See Sessions. |
identities | 16 KiB profile cap | softDeleteGracePeriodMs, profileMaxBytes. |
hijack, anomaly | detection off | See Sessions and Anomaly detection. |
captcha | refuses every token | See Captcha. |
resolveActor | none | Who created_by and updated_by name when no withActor scope is open. |
strict | NODE_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
| Facet | What it holds |
|---|---|
auth.flows | The multi-step operations: sign-in, sign-out, step-up, password reset, email verification, sign-up, account deletion, impersonation, linking. See Flows. |
auth.sessions | Create, rotate, revoke and list sessions. See Sessions. |
auth.identities | Accounts: create, update, link, soft-delete, erase, export. See Identities. |
auth.providers | The registry: list(), has(id), get(id). |
auth.passwords | Strength checks and password writes. Needs passwords(). |
auth.mfa | TOTP, backup codes, WebAuthn MFA, remembered devices. Needs mfaProvider(). |
auth.passkeys | Passkey registration. Needs passkey(). |
auth.apiKeys | Create, rotate and revoke API keys. Needs apiKeyProvider(). |
auth.orgs | Organizations and memberships. Needs an orgs store. |
auth.hijack, auth.anomaly | Session drift and risk scoring. |
auth.transport, auth.events, auth.limiter, auth.captcha | What the config supplied, or the defaults. |
auth.plugins | Plugins 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()answersnullwhen the request carries no session or one that was revoked or expired.- It still throws
AUTH_SESSION_IDENTITY_ERASEDwhen the session outlived its identity. identityisnullfor 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.