Skip to main content

Events

WIP

The typed event bus, every event duck-auth emits and its payload, the audit envelope, and fanning events out across a fleet with Redis

Every lifecycle change is published on auth.events: sign-ins, rotations, lockouts, MFA changes, impersonation. Subscribe for audit logs, alerts, cache busting, or webhooks.

Subscribing

const off = auth.events.on('lockout', async ({ identityId, until }) => {
  await pager.notify(`identity ${identityId} locked until ${new Date(until).toISOString()}`)
})

off() // unsubscribe

Payloads are typed per event name. Handlers run one after another; a handler that throws is logged and the rest still run, so a broken listener never fails the sign-in that emitted.

strict({ env: 'production' }) refuses an engine with no lockout listener, since a lockout nobody hears is a brute force nobody sees.

Where a handler runs

// A write that must happen once: on the server that emitted, inside its request.
auth.events.on('session.revoked', (e) => auditLog.write(e), { delivery: 'origin' })

// State every server keeps: on every server.
auth.events.on('authz.revoked', ({ identityId }) => decisionCache.drop(identityId))
deliveryRuns onRequest context
'origin'the server that emitted, oncekept
'fleet', the defaultevery serverkept on the emitter; the others get a copy of the payload and none

On the in-process bus both run on the one server there is. Webhooks are delivered as origin, once per emit.

The events

EventPayloadEmitted when
session.createdsession, identityA session was minted.
session.rotatedsession, previousSessionId?A session was replaced through rotation.
session.revokedsessionId, identityIdA session was deleted by request, cascade or rotation.
session.expiredreason, sessionId?, identityId?, count?A session aged out: 'sliding', 'absolute', 'impersonation' or 'gc'.
signin.successidentity, factorsA sign-in issued its session.
signin.failedproviderId, reason, ip?A sign-in, link or delivery failed. reason is for logs only.
signup.completedidentityAn identity was created.
lockoutidentityId, untilThe limiter refused an identity's attempt; until is epoch ms.
mfa.enrolled, mfa.removedidentityId, methodA second factor was added or removed.
identity.linkedidentityId, providerIdA provider login was attached.
identity.unlinkedidentityId, providerId, allowedLockoutA provider login was removed.
identity.erasedidentityId, reasonAn identity was erased for good.
identity.impersonatedrealIdentityId, targetIdentityId, reason, iamDecisionId?An impersonation began.
identity.impersonation.endedsessionId, realIdentityId, targetIdentityId, endedByIt ended: 'release', 'revoke' or 'expiry'.
recovery.password.requestedidentityIdA reset link was sent.
recovery.password.completedidentityIdA password was reset through its link.
recovery.mfa.escalatedidentityId, credentialIdA backup code stood in for the second factor.
suspicioussignal, score, meta, identityId?Hijack or anomaly detection flagged a request.
org.member.added, org.roles.setorgId, identityId, rolesA member joined an org, or their roles changed.
org.member.removedorgId, identityIdA member left an org.
authz.revokedidentityId, atPublished by your authorization side; duck-auth only listens.

session.expired is kept apart from session.revoked so an audit trail can tell "nobody did this" from "someone revoked this".

The audit envelope

Most payloads carry an optional audit field, filled in by the engine:

  • actorId: who performed the action, from the ambient actor (withActor(id, fn) or the server adapters' actor wrappers). It differs from the payload's identityId when an operator acts on someone else's account.
  • actingAs: set while the session is an impersonation, naming the real operator.

A payload that already carries audit is left alone. runWithAuditEnvelope(envelope, fn) from @gentleduck/auth/core/events sets one for everything emitted inside fn.

Across a fleet

The default bus is in-process: a fleet handler hears only its own instance. With more than one instance and any fleet handler, publish through Redis:

import { redisEvents } from '@gentleduck/auth/adapters/redis'

createAuth({
  // ...
  events: redisEvents({ redis, prefix: 'auth:events' }),
})
  • redis is an @upstash/redis-shaped client with publish and subscribe. For ioredis or iovalkey, valkeyEvents({ cmd, sub }) from @gentleduck/auth/adapters/valkey takes a command connection and a dedicated subscriber one.
  • Each emit goes to the channel ${prefix}:${event}. The emitter runs its origin and fleet handlers straight off the emit, and every other instance runs its fleet handlers when the message arrives, with Date fields revived. An instance subscribes to a channel only while it holds a fleet handler for that event.
  • A failed publish is logged, not thrown, so Redis being down never fails a sign-in.

strict({ env: 'production' }) refuses the in-process bus while it holds a fleet handler, naming the events, and passes it when every handler is origin. It sees only the handlers registered before it runs.