Agora

Backend SDK

One typed interface, two drivers — remote (HTTP Admin API) and embedded (in-process).

@adonis-agora/authkit-sdk is a backend SDK for managing AuthKit from server-side code: users, sessions, OIDC clients, organizations, runtime settings, the audit log, token verification, and signing-key rotation. It exposes a single typed interface backed by two interchangeable drivers:

  • remote — HTTP against the Admin REST API (/api/authkit/v1), authenticated with a Bearer API key. Use it from any backend service, even one that has nothing else to do with AuthKit.
  • embedded — in-process calls that resolve the server services straight from the AdonisJS container. Use it when the IdP runs in the same app: zero HTTP, zero API key, same return shapes.

Both drivers implement the exact same interface and return the same shapes, so you can start embedded and split out to remote later without touching call sites.

Install

pnpm add @adonis-agora/authkit-sdk

The server package (@adonis-agora/authkit-server) is an optional peer dependency — only the embedded driver needs it (it is imported lazily). Remote-only consumers can skip it entirely and carry zero AdonisJS server dependency.

Creating a client

createAuthkit always returns a Promise<Authkit> (the embedded driver lazy-loads the server kit).

Remote

import { createAuthkit } from '@adonis-agora/authkit-sdk'

const authkit = await createAuthkit({
  mode: 'remote',
  baseUrl: 'https://idp.example.com', // host root; /api/authkit/v1 is appended
  apiKey: process.env.AUTHKIT_ADMIN_API_KEY!,
  // apiPrefix: '/authkit/api',  // optional — must match adminApi.prefix on the server (default '/api/authkit/v1')
  // fetchImpl: customFetch,     // optional — defaults to global fetch
})
OptionTypeDefaultNotes
baseUrlstringHost root of the IdP (e.g. https://auth.acme.com).
apiKeystringBearer API key; must match one of the adminApi.apiKeys on the server.
apiPrefixstring'/api/authkit/v1'Prefix appended to baseUrl for every request. Change this when the server's registerAuthHost uses a custom adminApi: { prefix }.
fetchImpltypeof fetchglobal fetchOverride the fetch implementation (useful in edge runtimes or tests).

Embedded

import { createAuthkit } from '@adonis-agora/authkit-sdk'
import type { ApplicationService } from '@adonisjs/core/types'

export async function makeAuthkit(app: ApplicationService) {
  return createAuthkit({ mode: 'embedded', app })
}

The embedded driver derives the password-reset link origin from your authkit.issuer config when sending invite/reset e-mails.

The drivers directly — createRemoteAuthkit / createEmbeddedAuthkit

createAuthkit is a dispatcher, nothing more: it reads mode and calls one of the two driver factories, both of which are exported from the package root and can be called directly.

import { createRemoteAuthkit, createEmbeddedAuthkit } from '@adonis-agora/authkit-sdk'
import type { RemoteOptions, EmbeddedOptions } from '@adonis-agora/authkit-sdk'

// Synchronous — a typed fetch wrapper, nothing to load.
const remote = createRemoteAuthkit({
  baseUrl: 'https://idp.example.com',
  apiKey: process.env.AUTHKIT_ADMIN_API_KEY!,
})

// Async — lazily imports @adonis-agora/authkit-server on first call.
const embedded = await createEmbeddedAuthkit({ app })

The two differ in exactly one way that matters at the call site: createRemoteAuthkit returns an Authkit, while createEmbeddedAuthkit returns a Promise<Authkit>. The embedded driver has to reach into @adonis-agora/authkit-server, and it imports it lazily so that remote-only consumers never need the server package installed at all. createAuthkit papers over the difference by always returning a promise, which is why await works regardless of the mode you pass it.

RemoteOptions is the option table above minus mode; EmbeddedOptions is just { app: ApplicationService }.

Which should you reach for?

  • createAuthkit({ mode }) when the driver is a deployment decision — the same service runs alongside the IdP in one environment and across the network in another, and the mode comes from config. One call site, one await, both topologies.

  • createRemoteAuthkit / createEmbeddedAuthkit when the driver is fixed and known at the call site. You get a narrower type, you drop the mode discriminant, and in the remote case you skip the pointless promise:

    services/authkit.ts
    import { createRemoteAuthkit } from '@adonis-agora/authkit-sdk'
    
    // No top-level await needed — this is a plain value.
    export const authkit = createRemoteAuthkit({
      baseUrl: env.get('AUTHKIT_URL'),
      apiKey: env.get('AUTHKIT_ADMIN_API_KEY'),
    })

Either way you end up with the same Authkit interface, so nothing downstream cares which door you came through.

Methods

ResourceMethodReturns
userslist({ search?, page?, size? }){ meta: { page, size, total }, data }
usersget(id)the user
userscreate({ email, name?, password?, invite? })the user + invited
usersupdate(id, { globalRoles?, name?, avatarUrl? })the updated user
usersdisable(id) / enable(id){ id, disabled }
usersresetPassword(id){ id, sent }
usersdelete(id)cascade counts + { id, deleted }
sessionslist(userId){ canList, sessions, grants }
sessionsrevokeAll(userId){ sessions, grants, accessTokens, refreshTokens }
clientslist(){ data, canList }
clientsget(id)the client
clientscreate(input){ clientId, clientSecret } — secret shown once
clientsupdate(id, input)the updated client
clientsregenerateSecret(id){ clientId, clientSecret } — new secret once
clientsdelete(id){ clientId, deleted }
auditlist({ type?, subject?, page?, size? }){ meta: { page, size, total }, data }
stats()MAU, daily sign-ins/sign-ups, totals (see below)
tokensverify(token)introspection result (PAT or opaque access token)
organizationslist(){ data: AuthkitOrganization[] }
organizationscreate({ name, slug, ownerAccountId, logoUrl? })the org
organizationsget(id)org + members[] + pendingInvitations[]
organizationsupdate(id, { name?, logoUrl? })the updated org
organizationsdelete(id){ id, deleted }
organizations.memberslist(orgId)AuthkitOrgMember[]
organizations.membersadd(orgId, { accountId, role }){ orgId, accountId, role, added }
organizations.membersremove(orgId, accountId){ orgId, accountId, removed }
organizations.membersupdateRole(orgId, accountId, role){ orgId, accountId, role, updated }
organizations.invitationscreate(orgId, { email, role })the invitation
organizations.invitationsrevoke(orgId, invitationId){ orgId, invitationId, revoked }
settingslist(){ data: AuthkitSetting[] }
settingsget(key)AuthkitSetting404 / Error when absent
settingsset(key, value)AuthkitSetting — upserts the row
settingsdelete(key){ key, deleted: true }
keysstatus(){ ageDays, policy, nextRotationInDays }
keysrotate(input?){ rotated: true, newKid, retiredKids, keptKids }

The clientSecret from create/regenerateSecret is the only time the secret is returned in clear text — store it immediately.

Paginated listings

users.list() and audit.list() answer the { meta, data } envelope Lucid's own .paginate() uses — the shape every @adonis-agora/* package returns for a paginated listing. Request side is { page, size } (1-based page, size capped at 200), matching @adonis-agora/filter.

const { meta, data } = await authkit.users.list({ page: 2, size: 50 })
// meta.page  — the page returned (1-based)
// meta.size  — the page size actually applied (already capped)
// meta.total — total matching accounts across every page
// data       — AuthkitUser[]

const pageCount = Math.ceil(meta.total / meta.size)

keys

Managed signing-key operations, so rotation can live in a deploy script or a scheduled job instead of a browser session.

const status = await authkit.keys.status()
// status.ageDays             — age of the current signing key, in days
// status.policy              — { enabled, maxAgeDays, keep }, the effective rotation policy
// status.nextRotationInDays  — days until the policy is due; null while rotation is off

if (status.policy.enabled && status.nextRotationInDays === 0) {
  const result = await authkit.keys.rotate({ keep: 2 })
  console.log(result.newKid, result.keptKids)
}

rotate(input?) accepts { retire?, keep? }. keep is how many keys stay published in the JWKS (default 2); retire: true overrides it and keeps only the new key, dropping every predecessor into retiredKids. Rotation takes effect live — the next token is signed with newKid.

Both methods are unavailable when the IdP's JWKS is not a managed keystore: the remote driver throws AuthkitApiError with status 501, the embedded driver throws a plain Error. See Admin REST API — signing keys for the wire contract.

settings

Read and write entries in the auth_settings table. All methods return 404 (remote) or throw Error (embedded) when the table is absent. get also throws when the key does not exist.

// Toggle bot protection off at runtime
await authkit.settings.set('bot_protection', { enabled: false })

// Override which flows are protected (keep verify from config)
await authkit.settings.set('bot_protection', {
  enabled: true,
  on: ['login', 'signup', 'reset'],
})

// Inspect the current stored value
const setting = await authkit.settings.get('bot_protection')
console.log(setting.value, setting.updatedAt)

// Remove the row — reverts to static config
await authkit.settings.delete('bot_protection')

// List all settings
const { data } = await authkit.settings.list()

AuthkitSetting shape:

interface AuthkitSetting {
  key: string
  value: unknown        // parsed JSON
  updatedAt: string | null
  updatedBy: string | null
}

stats()

Returns a summary of the IdP's activity:

const stats = await authkit.stats()
// stats.mau             — Monthly Active Users (last 30 days)
// stats.totalUsers
// stats.activeSessions  — null if the adapter can't enumerate
// stats.signInsPerDay   — [{ date: 'YYYY-MM-DD', count }]
// stats.signUpsPerDay
// stats.auditSupported  — false if the sink doesn't support list

users.delete(id)

Permanently deletes a user and runs the LGPD/GDPR cascade:

const result = await authkit.users.delete('acc-42')
// result.deleted, result.sessions, result.grants, result.pats,
// result.passkeys, result.providerIdentities, result.auditAnonymized, result.avatarDeleted

Example

const created = await authkit.users.create({ email: 'ana@example.com', invite: true })
// created.invited === true → reset/invite e-mail dispatched

await authkit.users.update(created.id, { globalRoles: ['ADMIN'] })

// Organizations
const org = await authkit.organizations.create({
  name: 'Acme',
  slug: 'acme',
  ownerAccountId: created.id,
})
await authkit.organizations.members.add(org.id, { accountId: 'acc-7', role: 'member' })

const intro = await authkit.tokens.verify(bearerToken)
if (intro.active) {
  console.log(intro.sub, intro.scopes)
}

Error handling

The remote driver throws AuthkitApiError for any non-2xx response, parsed from the API's { error: { code, message } } envelope. Network/parse failures throw the same error with code network_error and status: 0.

import { AuthkitApiError } from '@adonis-agora/authkit-sdk'

try {
  await authkit.users.create({ email: 'dup@example.com' })
} catch (err) {
  if (err instanceof AuthkitApiError) {
    // err.status (e.g. 409), err.code (e.g. 'email_taken'), err.message
  }
}

The embedded driver throws plain Error for the equivalent failure conditions (not found, e-mail taken, unsupported capability), since there is no HTTP status to map.

Which driver should I use?

  • Same app as the IdP? Use embedded — no HTTP hop, no API key to manage, and it works even when the Admin REST API is not enabled.
  • Separate service / different deploy? Use remote and enable the Admin REST API with an API key.

Both speak the identical interface — see the Admin REST API reference for the underlying wire contract.

On this page