Events
WIPAuthInMemoryEvents, AuthRedisEvents, the typed event taxonomy, and AuthWebhookDeliverer for signed outbound delivery.
The events bus
duck-auth emits every lifecycle event (identity created, session rotated,
MFA enrolled, anomaly signalled, lockout fired) through a single
AuthEvents.IBus. The default is AuthInMemoryEvents; production deployments
swap to AuthRedisEvents for cross-process fan-out.
import { AuthInMemoryEvents } from '@gentleduck/auth/core'
export const auth = new AuthEngine({
events: new AuthInMemoryEvents(),
})
const unsubscribe = auth.events.on('session.rotated', (event) => {
audit.log('session_rotated', event)
})
// later
unsubscribe()
The handler is awaited if it returns a promise - the runtime fans out
synchronously to give observers a chance to flush before the request
returns. Throwing handlers do not break the originating call; the error
is routed to the onError sink (if configured) and the bus moves on.
AuthRedisEvents (production)
import { AuthRedisEvents } from '@gentleduck/auth/adapters/redis'
import Redis from 'ioredis'
const redis = new Redis(process.env.REDIS_URL!)
export const auth = new AuthEngine({
events: new AuthRedisEvents(redis, { channel: 'auth-events' }),
})
AuthRedisEvents publishes on a Pub/Sub channel and replays through the
same on() API. Every replica receives every event - useful for cache
invalidation and presence tracking.
The event taxonomy
Identity events
| Event | Payload |
|---|---|
identity.created | { identityId, email, tenantId? } |
identity.updated | { identityId, changes } |
identity.deleted | { identityId, soft: boolean } |
identity.restored | { identityId } |
identity.erased | { identityId, reason } |
identity.linked | { identityId, providerId, sub } |
identity.unlinked | { identityId, providerId } |
identity.merged | { primaryId, absorbId, conflictPolicy } |
Session events
| Event | Payload |
|---|---|
session.created | { sessionId, identityId, aal, amr } |
session.rotated | { sessionId, previousSessionId, reason } |
session.revoked | { sessionId, reason } |
session.impersonate-start | { sessionId, actorId, subjectId } |
session.impersonate-release | { sessionId, actorId, subjectId, durationMs } |
Credential events
| Event | Payload |
|---|---|
password.changed | { identityId, byActorId? } |
password.reset | { identityId, tokenId } |
mfa.enrolled | { identityId, factor: 'totp' | 'backup-codes' | 'webauthn' } |
mfa.verified | { identityId, factor } |
mfa.removed | { identityId, factor } |
Sign-up state machine
| Event | Payload |
|---|---|
signup.started | { stateId, email } |
signup.advanced | { stateId, fromStep, toStep } |
signup.completed | { stateId, identityId } |
Security / operational
| Event | Payload |
|---|---|
anomaly | { identityId, signals: AnomalySignal[], decision } |
lockout | { identityId? | ip?, reason, until } |
The lockout event is the one strict() will complain about if no
listener is attached in production - at minimum, you should be paging on
or auditing every lockout.
AuthWebhookDeliverer
For outbound integrations (audit log SaaS, CRM, downstream services), ship every event over signed HTTPS:
import { AuthWebhookDeliverer } from '@gentleduck/auth/core'
const deliverer = new AuthWebhookDeliverer({
url: 'https://example.com/auth-events',
secret: process.env.WEBHOOK_SECRET!,
})
auth.events.on('session.rotated', (event) => {
deliverer.deliver({ type: 'session.rotated', ...event })
})
The body is HMAC-SHA256 signed; the signature rides on an
X-Duck-Auth-Signature header. On the receiving side:
import { authVerifyWebhookSignature } from '@gentleduck/auth/core'
const raw = await req.text()
const sig = req.headers.get('x-duck-auth-signature')!
const ok = authVerifyWebhookSignature(raw, sig, process.env.WEBHOOK_SECRET!)
if (!ok) return new Response('bad signature', { status: 401 })
Comparisons are constant-time. The deliverer ships with a dead-letter
sink - failed POSTs are buffered and retried with exponential backoff
before being dropped to an events.deadletter event for inspection.