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-sdkThe 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
})| Option | Type | Default | Notes |
|---|---|---|---|
baseUrl | string | — | Host root of the IdP (e.g. https://auth.acme.com). |
apiKey | string | — | Bearer API key; must match one of the adminApi.apiKeys on the server. |
apiPrefix | string | '/api/authkit/v1' | Prefix appended to baseUrl for every request. Change this when the server's registerAuthHost uses a custom adminApi: { prefix }. |
fetchImpl | typeof fetch | global fetch | Override 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, oneawait, both topologies. -
createRemoteAuthkit/createEmbeddedAuthkitwhen the driver is fixed and known at the call site. You get a narrower type, you drop themodediscriminant, 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
| Resource | Method | Returns |
|---|---|---|
users | list({ search?, page?, size? }) | { meta: { page, size, total }, data } |
users | get(id) | the user |
users | create({ email, name?, password?, invite? }) | the user + invited |
users | update(id, { globalRoles?, name?, avatarUrl? }) | the updated user |
users | disable(id) / enable(id) | { id, disabled } |
users | resetPassword(id) | { id, sent } |
users | delete(id) | cascade counts + { id, deleted } |
sessions | list(userId) | { canList, sessions, grants } |
sessions | revokeAll(userId) | { sessions, grants, accessTokens, refreshTokens } |
clients | list() | { data, canList } |
clients | get(id) | the client |
clients | create(input) | { clientId, clientSecret } — secret shown once |
clients | update(id, input) | the updated client |
clients | regenerateSecret(id) | { clientId, clientSecret } — new secret once |
clients | delete(id) | { clientId, deleted } |
audit | list({ type?, subject?, page?, size? }) | { meta: { page, size, total }, data } |
stats() | — | MAU, daily sign-ins/sign-ups, totals (see below) |
tokens | verify(token) | introspection result (PAT or opaque access token) |
organizations | list() | { data: AuthkitOrganization[] } |
organizations | create({ name, slug, ownerAccountId, logoUrl? }) | the org |
organizations | get(id) | org + members[] + pendingInvitations[] |
organizations | update(id, { name?, logoUrl? }) | the updated org |
organizations | delete(id) | { id, deleted } |
organizations.members | list(orgId) | AuthkitOrgMember[] |
organizations.members | add(orgId, { accountId, role }) | { orgId, accountId, role, added } |
organizations.members | remove(orgId, accountId) | { orgId, accountId, removed } |
organizations.members | updateRole(orgId, accountId, role) | { orgId, accountId, role, updated } |
organizations.invitations | create(orgId, { email, role }) | the invitation |
organizations.invitations | revoke(orgId, invitationId) | { orgId, invitationId, revoked } |
settings | list() | { data: AuthkitSetting[] } |
settings | get(key) | AuthkitSetting — 404 / Error when absent |
settings | set(key, value) | AuthkitSetting — upserts the row |
settings | delete(key) | { key, deleted: true } |
keys | status() | { ageDays, policy, nextRotationInDays } |
keys | rotate(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 listusers.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.avatarDeletedExample
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
remoteand 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.
Admin REST API
The machine-to-machine management API — users, clients, sessions, organizations, settings, signing keys, audit, token verify.
Testing
Test auth flows in your host app without booting an IdP — mint real signed ID tokens with a local JWKS, fake ctx.auth, fake the account store, and build valid identities.