Agora integration
How AuthKit wires into the Agora ecosystem — diagnostics events, request context, Authz, resilience, and durable GDPR workflows.
AuthKit is a standalone OIDC stack, but inside an Agora app it slots into the shared seams the other libraries already use. Every integration here is opt-in and best-effort: none of them are required for AuthKit to run, and each degrades to a no-op when the sibling library is absent.
Diagnostics events (agora:authkit:*)
AuthKit republishes every audit event type onto the Agora diagnostics bus
as agora:authkit:<AuditEventType> — for example:
agora:authkit:login.success
agora:authkit:login.failure
agora:authkit:mfa.enabled
agora:authkit:account.locked
agora:authkit:pat.issued
agora:authkit:impersonation
agora:authkit:organization.invitation_acceptedThe audit sink does the republish automatically. It is fire-and-forget: it never touches
the request path, and emit() is guarded (hasSubscribers) so it is a true no-op when
nothing is subscribed.
The payload is redacted on this bridge. Only { type, accountId, actorId, clientId }
cross it — email, ip and the free-form metadata (which can carry PII, e.g. the
invited address or an email change) are stripped, so a downstream store such as Telescope
never persists raw PII and account deletion needs no cross-library purge. The onEvent
hook and the audit webhook still receive the complete event; only the diagnostics
republish is redacted.
Subscribing
Use onDiagnostic from @adonis-agora/diagnostics, ideally in a preload file so the
subscription is up before the app serves traffic:
import { onDiagnostic } from '@adonis-agora/diagnostics'
// One specific channel:
onDiagnostic('agora:authkit:account.locked', (event) => {
// ship to your SIEM, page on-call, etc.
})
// Every agora:authkit:* channel — including ones registered later:
onDiagnostic('agora:authkit:*', (event) => {
metrics.increment(`auth.${event.channel}`)
})A subscriber runs inline on the producer's emit call. The republish itself is off
the hot path, but heavy work in your handler (network, disk) is not — batch, queue, or
hand off to a worker. See Diagnostics → consumers.
Telescope — automatic, plus a Security dashboard
Because the events ride the diagnostics bus, @adonis-agora/telescope
records each one as a diagnostic entry tagged lib:authkit with zero AuthKit-specific
config. For a purpose-built auth view, register the dedicated extension:
import { defineConfig } from '@adonis-agora/telescope'
import { defineAuthkitTelescopeExtension } from '@adonis-agora/authkit-server/telescope'
export default defineConfig({
extensions: [defineAuthkitTelescopeExtension()],
})This contributes a navigable Auth entry type and a Security dashboard (login success rate, MFA/passkey enrollments, lockouts, PAT/impersonation activity). The full reference is on the Observability page.
Context population (@agora/context:set)
@adonis-agora/context carries an ambient per-request store with a
userRef and tenantId, so every other library (logging, durable jobs, authz) sees who
the current request is acting for. Authentication runs after the context is established, so
the principal is written into the store after the fact.
@adonis-agora/context publishes a symmetric write slot for exactly this — a
globalThis slot keyed by Symbol.for('@agora/context:set') (exported as CONTEXT_SET).
AuthKit's client middleware uses that slot once it resolves an identity, so you don't have
to wire anything yourself. It writes three things:
userRef—{ type: 'user', id: identity.userId }globalRoles— the identity's roles claimtenantId— derived from the first non-empty organization claim on the raw token:active_organization_id,org_id,organization_id,tenant_id,tid
// What the bridge effectively writes, through the published write slot — a no-op
// if @adonis-agora/context is not installed or no context is active.
Context.set({
userRef: { type: 'user', id: identity.userId },
globalRoles: identity.globalRoles,
tenantId: identity.raw.active_organization_id, // first claim present, if any
})The write is best-effort: if no context is active (a route the context middleware
didn't cover), the value is silently dropped — it never throws. Make sure the context
server middleware runs before authkit_middleware. See
Context → customization for the write-slot contract.
So mapping AuthKit organizations onto context tenants needs no middleware of your own: as
long as the active organization travels in one of those claims, tenantId is already set for
logging, durable jobs and Authz. Write your own value only when the tenant is not the
organization on the token.
Authz — the identity / userRef seam
@adonis-agora/authz decides what a user can do; AuthKit decides who they
are. They meet at the user reference. AuthKit's resolveUser(identity) (see
Client) produces your domain user, and Authz's resolveUserRef(user)
turns that user into the { type, id } subject Bouncer checks against:
export default defineConfig({
// The same user AuthKit resolved becomes the subject Authz evaluates.
resolveUserRef: (user) => ({ type: 'account', id: user.id }),
})The chain is: OIDC claims → Identity → resolveUser → your user → resolveUserRef →
Bouncer policy. AuthKit contributes the IdP's globalRoles claim; Authz owns roles and
permissions in the database and layers them on top. Because both sides
agree on the userRef, a durable job dispatched under a request's context (carrying that
same ref) can re-evaluate the same policies on a worker.
Resilience on outbound calls
The relying party makes outbound HTTP calls to the IdP — discovery, JWKS, code exchange,
refresh, token exchange. Pass a composed policy from @adonis-agora/resilience
as resilience in the client config to wrap every outbound call:
import { defineConfig, resolvers } from '@adonis-agora/authkit-client'
import { wrap, timeout, retry, circuitBreaker } from '@adonis-agora/resilience'
export default defineConfig({
// ...
resolver: resolvers.jwt({ tokenSource: 'session' }),
resilience: wrap(timeout(2000), retry({ attempts: 3 }), circuitBreaker({ threshold: 5 })),
})@adonis-agora/resilience is an optional peer dependency: without it configured, the
client's outbound calls are a plain fetch (no behavior change), and the package never needs
to be installed. See Client → Resilience.
Durable GDPR workflows
AuthKit's erasure cascade is
transactional within the IdP's database. When a deletion must fan out beyond it — purge a
data warehouse, revoke third-party SaaS access, delete object-storage blobs, notify
downstream services — model the fan-out as a @adonis-agora/durable
workflow so each external step is checkpointed and retried:
import app from '@adonisjs/core/services/app'
import { WorkflowEngine } from '@adonis-agora/durable'
import { createAuthkit } from '@adonis-agora/authkit-sdk'
const engine = await app.container.make(WorkflowEngine)
engine.register('gdpr-erasure', '1', async (ctx, input) => {
const { accountId } = input as { accountId: string }
// 1. Run AuthKit's own cascade (idempotent; safe to replay).
await ctx.localStep('authkit-delete', async () => {
const authkit = await createAuthkit({ mode: 'embedded', app })
return authkit.users.delete(accountId)
})
// 2. Fan out to external systems — each step is checkpointed independently.
await ctx.localStep('purge-warehouse', async () => purgeWarehouse(accountId))
await ctx.localStep('revoke-saas', async () => revokeSaasAccess(accountId))
await ctx.localStep('delete-blobs', async () => deleteUserBlobs(accountId))
return { status: 'erased', accountId }
})ctx.localStep runs the body in-process and checkpoints its result — the right primitive
when the erasure code lives in the same app. ctx.step is a different thing: it is always
dispatched, and its second argument is the step's input, not a body — use it (with a
@Step reference or a step name) when the purge runs on a separate worker. See
Workflows & steps.
A subscriber on agora:authkit:account.deleted (or user.deleted for admin deletions) is a
natural trigger to start the workflow. Because the durable run carries the dispatching
request's context (userRef, tenantId), each external step knows on whose
behalf it is acting — useful for the audit trail of the erasure itself.
Use the embedded SDK driver inside the same app, or remote when
the workflow worker is a separate service from the IdP. Either way the users.delete
return shape (cascade counts) is identical, so the workflow code does not change.
Summary
| Seam | Library | Mechanism | Required? |
|---|---|---|---|
| Auth events | Diagnostics | agora:authkit:* channels via onDiagnostic, redacted payload | No (no-op when unsubscribed) |
| Observability | Telescope | generic watcher + opt-in Security extension | No (optional peer) |
| Current principal | Context | CONTEXT_SET write slot → userRef, globalRoles, tenantId | No (silent if absent) |
| Authorization | Authz | resolveUser → resolveUserRef → Bouncer | No (separate library) |
| Outbound resilience | Resilience | resilience policy on the client | No (optional peer) |
| Erasure fan-out | Durable | workflow triggered by account.deleted | No (your orchestration) |