Skip to main content

Events

WIP

AuthInMemoryEvents, 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

EventPayload
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

EventPayload
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

EventPayload
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

EventPayload
signup.started{ stateId, email }
signup.advanced{ stateId, fromStep, toStep }
signup.completed{ stateId, identityId }

Security / operational

EventPayload
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.