Agora

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_accepted

The 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:

start/diagnostics.ts
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:

config/telescope.ts
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 claim
  • tenantId — 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:

config/authz.ts
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 → IdentityresolveUser → 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:

config/authkit_client.ts
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:

start/durable.ts
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

SeamLibraryMechanismRequired?
Auth eventsDiagnosticsagora:authkit:* channels via onDiagnostic, redacted payloadNo (no-op when unsubscribed)
ObservabilityTelescopegeneric watcher + opt-in Security extensionNo (optional peer)
Current principalContextCONTEXT_SET write slot → userRef, globalRoles, tenantIdNo (silent if absent)
AuthorizationAuthzresolveUserresolveUserRef → BouncerNo (separate library)
Outbound resilienceResilienceresilience policy on the clientNo (optional peer)
Erasure fan-outDurableworkflow triggered by account.deletedNo (your orchestration)

On this page