Agora

Client

Wire a relying party with @adonis-agora/authkit-client.

The client kit makes an AdonisJS app a relying party of an AuthKit IdP: it resolves an Identity per request, maps it to your app's user, and owns the login / callback / logout routes so you never hand-write the OIDC dance.

Install & configure

npm install @adonis-agora/authkit-client
node ace configure @adonis-agora/authkit-client

The configure command writes config/authkit_client.ts, registers the provider, registers authkit_middleware as a router-global middleware, and adds the four env validations (AUTHKIT_ISSUER, AUTHKIT_CLIENT_ID, AUTHKIT_CLIENT_SECRET, AUTHKIT_REDIRECT_URI).

config/authkit_client.ts
import env from "#start/env";
import { defineConfig, resolvers, lucidMirror } from "@adonis-agora/authkit-client";
import AppUser from "#models/app_user";

const authkitClientConfig = defineConfig({
  issuer: env.get("AUTHKIT_ISSUER"),
  clientId: env.get("AUTHKIT_CLIENT_ID"),
  clientSecret: env.get("AUTHKIT_CLIENT_SECRET"),
  redirectUri: env.get("AUTHKIT_REDIRECT_URI"),
  resolver: resolvers.jwt({ tokenSource: "session" }),

  // Mirror the IdP identity into a local Lucid row on every login.
  resolveUser: lucidMirror(AppUser, {
    sync: (identity) => ({
      email: identity.email,
      fullName: identity.profile?.name ?? null,
    }),
    preload: ["roles"],
    injectGlobalRoles: true,
  }),
});

export default authkitClientConfig;

AuthKit authenticates. It does not carry your app's roles or permissions — that is @adonis-agora/authz's job. The only roles the client knows about are the IdP's global roles, read from the globalRolesClaim (default roles).

The full config surface

KeyTypeDefaultWhat it does
issuerstringThe IdP's issuer URL. Every other endpoint is derived from it.
clientIdstringThis RP's client id, also the expected aud.
clientSecretstring?Set for confidential clients; omit for public ones.
redirectUristringMust match the callback route you register and the URI registered on the client.
resolverResolverFactoryHow a request becomes an Identity. See below.
resolveUser(identity, ctx) => Promise<unknown>Maps the identity to your app's user object.
sessionKeystringauthkitSession key the token set is stored under.
scopesstring[]openid profile email offline_access rolesScopes requested at the authorize endpoint.
globalRolesClaimstringrolesClaim the global roles are read from.
backchannelLogout{ store }Batteries-included back-channel logout.
sessionIndexSessionIndex?Manual sid/sub-to-local-session map, for server-side session stores.
onBackchannelLogout(event) => voidExtra hook run after a valid logout_token arrives.
resilienceResiliencePolicy?Wraps outbound IdP calls. See below.

Resolvers: jwt, pat, opaque

The resolver decides how an incoming request is turned into an Identity. There are three factories — see Resolvers for the full option tables.

  • resolvers.jwt({ tokenSource?, jwksUri? }) — for browser/user sessions. Validates the OIDC token locally against the IdP's JWKS (${issuer}/jwks by default), checking issuer and audience (your clientId). tokenSource is 'session' by default (the token from the login flow); 'bearer' reads it from the request header.
  • resolvers.pat({ introspectionUrl, introspectionSecret }) — for machine-to-machine callers presenting a Personal Access Token. Validates by calling the IdP's introspection endpoint with the shared secret. See PAT.
  • resolvers.opaque({ tokenSource?, cacheTtlMs? }) — introspects an opaque access token at the IdP per request, so a revocation drops the session immediately. Requires a confidential client. See Resolvers.
// Browser sessions (local JWKS validation, no network per request)
resolver: resolvers.jwt({ tokenSource: "session" });

// Machine-to-machine (introspection)
resolver: resolvers.pat({
  introspectionUrl: `${env.get("AUTHKIT_ISSUER")}/authkit/pat/introspect`,
  introspectionSecret: env.get("PAT_INTROSPECTION_SECRET"),
});

// Opaque access token, introspected per request (immediate revocation)
resolver: resolvers.opaque({ tokenSource: "session" });

The Identity

Every resolver yields the same shape (claims only — no app domain):

interface Identity {
  userId: string; // sub
  email: string;
  globalRoles: string[]; // from the configured claim, default 'roles'
  profile?: { name?: string; avatarUrl?: string }; // avatarUrl from 'picture'
  sessionId?: string; // sid
  issuedAt: number; // iat (ms)
  expiresAt: number; // exp (ms)
  raw: Record<string, unknown>;
}

All three resolvers populate profile.avatarUrl (from picture) and sessionId (from sid), so these fields are available regardless of token model.

Mapping the identity to your user

resolveUser is the seam between the IdP's claims and your domain. It receives the validated Identity and a small context object carrying the session's accessToken, and whatever it returns becomes the value of ctx.auth.getUser(). There are three ready-made shapes, and hand-writing your own is the fourth.

lucidMirror — mirror the identity into a local table

Almost every relying party wants the same thing: a local row keyed by the IdP's sub, kept in sync with the token's claims on every login, with a couple of relations preloaded. lucidMirror is exactly that, as a factory:

import { lucidMirror } from "@adonis-agora/authkit-client";
import AppUser from "#models/app_user";

resolveUser: lucidMirror(AppUser, {
  sync: (identity) => ({
    email: identity.email,
    fullName: identity.profile?.name ?? null,
  }),
  preload: ["roles", "organization"],
  injectGlobalRoles: true,
  idColumn: "id",
});

On each resolution it runs Model.updateOrCreate({ [idColumn]: identity.userId }, { ...sync(identity) }), then loads the relations you listed, then — if you asked for it — assigns the IdP's global roles onto a non-persisted property of the instance so policies can read them without a second lookup.

OptionTypeDefaultNotes
sync(identity) => Record<string, unknown>{ email }Columns refreshed from the token on every login.
preloadstring[]Lucid relations loaded on the resolved instance.
injectGlobalRolesboolean | stringfalsetrue assigns identity.globalRoles to globalRoles; pass a string to name the property yourself.
idColumnstringidColumn matched against identity.userId (the sub claim).

The only requirement on the model is a static updateOrCreate — any Lucid model qualifies. @adonisjs/lucid is an optional peer dependency, so an app that never calls lucidMirror never needs it installed.

identityToUser — claims only, no database

When the ID token is fat enough to be the user (typical in a separate-databases topology), skip the round-trip entirely:

import { identityToUser } from "@adonis-agora/authkit-client";

resolveUser: identityToUser;

It returns a ClaimsUser: { id, email, name?, avatarUrl?, globalRoles }.

createUserinfoResolver — ask the IdP

When the app needs profile data the token does not carry, fetch it from the IdP's userinfo endpoint with the session's access token:

import { createUserinfoResolver } from "@adonis-agora/authkit-client";

resolveUser: createUserinfoResolver({ issuer: env.get("AUTHKIT_ISSUER") });

Point it at the issuer and it derives ${issuer}/me, the AuthKit IdP's userinfo route; pass userinfoEndpoint explicitly for a third-party IdP whose path differs (take it from discovery). fetchImpl lets tests inject a fake fetch.

The resolved object is identityToUser(identity) merged with the userinfo response, with the response winning on conflicts. When there is no access token to present — a bearer token source carrying only an ID token, for instance — it falls back to identityToUser rather than failing the request. A non-2xx userinfo response throws.

This resolver runs on every request that resolves a session, so it puts the IdP on your request path. Prefer lucidMirror (one write at login, local reads afterwards) unless you genuinely need live IdP data per request.

Writing your own

The escape hatch is a plain function — reach for it when the mapping is not an updateOrCreate (multi-tenant lookups, soft-deleted accounts, provisioning side effects):

import type { Identity } from "@adonis-agora/authkit-client";

resolveUser: async (identity: Identity, { accessToken }) => {
  const user = await AppUser.findBy("externalId", identity.userId);
  if (!user) return null; // fail closed: no local account, no session user
  if (user.suspendedAt) return null;
  return user;
};

Returning null is meaningful: getUser() yields null and getUserOrFail() throws.

Middleware

The client ships five middleware. Which ones you register depends on whether ctx.auth belongs to AuthKit or to @adonisjs/auth — see Native @adonisjs/auth for the second path.

ModuleKindResponsibility
authkit_middlewarerouter-globalRefreshes the token set, puts an Authenticator on ctx.auth.
authkit_context_middlewarerouter-globalRefreshes the token set, puts an Authenticator on ctx.authkit, leaves ctx.auth alone.
backchannel_revocation_middlewarerouter-globalDrops the local session when the IdP revoked it out-of-band.
auth_middlewarenamedRequires a session; redirects anonymous hits to your login route.
silent_auth_middlewarenamedResolves the session without requiring it.

The global pair

authkit_middleware is the one node ace configure registers. Per request it resolves the authkit.client manager, proactively refreshes the token set (maybeRefresh — see Refresh Tokens), and attaches the Authenticator to ctx.auth.

start/kernel.ts
router.use([
  () => import("@adonisjs/session/session_middleware"),
  () => import("@adonis-agora/authkit-client/authkit_middleware"),
  () => import("@adonis-agora/authkit-client/backchannel_revocation_middleware"),
]);

authkit_context_middleware is its sibling for hosts that hand ctx.auth to @adonisjs/auth. It performs the same refresh and resolution but memoizes the Authenticator on ctx.authkit, so the AuthKit-only surface (getIdentity(), hasGlobalRole(), toSharedProps()) stays reachable on every route — public ones included — while the framework owns ctx.auth.

Register one of the two. Both together resolve the session twice into two different objects, and the two would disagree the moment one of them refreshes the token set.

backchannel_revocation_middleware belongs after whichever of the two you chose (it needs the manager) and before your named auth middleware. It is a no-op unless backchannelLogout: { store } is configured — see Back-Channel Logout.

The named pair

auth_middleware requires a session on a browser route. It calls ctx.auth.check() and, on a miss, redirects instead of throwing — a 302 to a login screen is what a human wants, not a 500:

start/kernel.ts
export const middleware = router.named({
  auth: () => import("@adonis-agora/authkit-client/auth_middleware"),
  silentAuth: () => import("@adonis-agora/authkit-client/silent_auth_middleware"),
});
start/routes.ts
router
  .group(() => {
    router.get("/dashboard", [DashboardController]);
    router.get("/settings", [SettingsController]);
  })
  .use(middleware.auth({ redirectTo: "/auth/login" }));

// Marketing pages that render a "log in" or "your account" affordance:
router.get("/", [HomeController]).use(middleware.silentAuth());

redirectTo defaults to /auth/login, which is what registerOidcClient registers, so the option only matters when you moved the prefix. silent_auth_middleware takes no options: it resolves the session so the view can branch on it and never redirects.

Neither middleware does authorization. Role checks belong on the route, in a policy, or in @adonis-agora/authz.

The login / callback / logout flow

registerOidcClient registers the whole relying-party surface in one call: authorization code + PKCE, a state nonce round-tripped through the session, the code exchange, the RP-initiated logout, and the back-channel logout endpoint.

start/routes.ts
import router from "@adonisjs/core/services/router";
import { registerOidcClient } from "@adonis-agora/authkit-client";
import { middleware } from "#start/kernel";

registerOidcClient(router, {
  loginMiddleware: middleware.guest(),
  redirects: {
    byGlobalRole: { ADMIN: "/admin", SUPPORT: "/support" },
    default: "/dashboard",
  },
});

That single call registers four routes:

Method & pathRoute nameWhat it does
GET /auth/loginauth.loginGenerates PKCE + state, stores them in the session, redirects to the IdP.
GET /auth/callbackauth.callbackVerifies state, exchanges the code, starts the session, redirects.
POST /auth/logoutauth.logoutEnds the local session and redirects to the IdP's end-session endpoint.
POST /auth/backchannel-logoutauth.backchannel_logoutReceives the IdP's logout_token.

redirectUri in config/authkit_client.ts must point at the callback route — with the default prefix that is https://your-app.example.com/auth/callback — and the same URI must be registered on the client at the IdP.

Options

registerOidcClient(router, {
  prefix: "/session", // -> /session/login, /session/callback, ...
  loginMiddleware: middleware.guest(),
  passthroughParams: ["audience", "organization"],
  redirects: { byGlobalRole: { ADMIN: "/admin" }, default: "/" },
  postLogoutRedirect: (ctx) => `https://${ctx.request.host()}/goodbye`,
  backchannelLogout: false,

  afterLogin: async (ctx, identity) => {
    if (!identity) return;
    await AuditLog.create({ userId: identity.userId, event: "login" });
    const invite = ctx.session.pull("pending_invite");
    if (invite) return `/invites/${invite}`; // returning a string wins
  },
});
OptionTypeDefaultNotes
prefixstring/authPath prefix for all four routes.
loginMiddlewaremiddlewareApplied to the login route only, in the shape .use() accepts.
passthroughParamsstring[]['audience']Query params forwarded from the login request to the authorize URL.
afterLogin(ctx, identity) => string | voidPost-exchange hook. Return a path to redirect there.
redirectsPostLoginRedirectsRole-based destination when afterLogin returns nothing.
postLogoutRedirectstring | (ctx) => stringorigin of redirectUri + /Where the IdP sends the browser after logout.
backchannelLogoutbooleantrueSet false to skip the back-channel endpoint.

The redirect decision runs in a fixed order: an afterLogin that returns a string wins; otherwise redirects.byGlobalRole is scanned in declaration order and the first global role the identity carries decides; otherwise redirects.default; otherwise /.

PostLoginRedirects.byGlobalRole reads the IdP's global roles from the token. Anything that depends on your app's own roles or permissions belongs in afterLogin, where you can query whatever you like and return a path.

What the generated routes get right

These are the details the hand-written version tends to miss, and the reason to prefer the generated routes:

Escape hatch: build your own flow

If your host needs a flow the generated routes do not express — a multi-tenant issuer chosen per request, a step-up re-authentication, an authorize URL with parameters that are not simple passthroughs — the primitives are exported and you can assemble the flow yourself:

import {
  generatePkce,
  buildAuthorizeUrl,
  exchangeCode,
  buildEndSessionUrl,
} from "@adonis-agora/authkit-client";
import authkit from "@adonis-agora/authkit-client/services/main";
import { randomUUID } from "node:crypto";

// 1. Login: generate PKCE + state, stash both in the session, redirect to the IdP
const { verifier, challenge } = await generatePkce();
const state = randomUUID();
ctx.session.put("my_pkce", { verifier, state });
return ctx.response.redirect(
  buildAuthorizeUrl({
    issuer,
    clientId,
    redirectUri,
    scopes: ["openid", "profile", "email", "offline_access"],
    state,
    codeChallenge: challenge,
  }),
);

// 2. Callback: verify state yourself, exchange the code, start the session
const stashed = ctx.session.pull("my_pkce");
if (!stashed || stashed.state !== ctx.request.input("state")) {
  return ctx.response.redirect("/auth/login");
}
const tokens = await exchangeCode({
  issuer,
  clientId,
  clientSecret,
  redirectUri,
  code: ctx.request.input("code"),
  codeVerifier: stashed.verifier,
});
authkit.startSession(ctx, tokens);

// 3. Logout: end the local session, then the IdP's
const idToken = authkit.getIdToken(ctx);
authkit.endSession(ctx);
return ctx.response.redirect(
  buildEndSessionUrl({
    issuer,
    idToken,
    postLogoutRedirectUri: "https://my-app.example.com/",
  }),
);

Going manual means you now own the state comparison, the stale-callback path, the session teardown ordering, and the back-channel route. Use startSession / endSession rather than writing the session key directly: they are the two methods that also clear a parked impersonation credential, and skipping them is how a live refresh token outlives the session that stashed it.

buildEndSessionUrl targets ${issuer}/session/end. Passing idToken as the id_token_hint lets the IdP skip its confirmation page; the postLogoutRedirectUri must be registered on the client. See Security for the logout details.

The Authenticator

ctx.auth (or ctx.authkit, on the @adonisjs/auth path) is an Authenticator. It resolves the session once per request and memoizes both the identity and the user, so calling these repeatedly across a controller and its views costs nothing extra.

const identity = await ctx.auth.getIdentity(); // Identity | null
const identity = await ctx.auth.authenticate(); // Identity, throws when anonymous
const signedIn = await ctx.auth.check(); // boolean, never throws
const isAdmin = ctx.auth.hasGlobalRole("ADMIN"); // sync — resolve first

const user = await ctx.auth.getUser(); // TUser | null
const user = await ctx.auth.getUserOrFail(); // TUser, throws when anonymous

The class is generic in your user type — Authenticator<AppUser> — so a host that pins the generic gets AppUser | null out of getUser() at every call site with no casts.

Typing ctx.auth

There are two ways, and you take exactly one.

Declare it yourself, parameterised over your model — this is what most apps want, and what Getting started shows:

types/authkit.ts
import type { Authenticator } from '@adonis-agora/authkit-client'
import type AppUser from '#models/app_user'

declare module '@adonisjs/core/http' {
  interface HttpContext {
    auth: Authenticator<AppUser>
  }
}

Or opt into the library's own declaration, which leaves the generic at its unknown default:

anywhere in your app
import '@adonis-agora/authkit-client/types'

Never do both. HttpContext.auth declared twice with different types does not merge — TypeScript keeps whichever declaration it reached first and raises the conflict on the other, inside a .d.ts, where every AdonisJS app's skipLibCheck throws it away. The symptom is silent and global: getUser() starts returning unknown everywhere, and nothing points at the cause.

This is why importing a middleware never brings the declaration with it. Registering authkit_middleware types the authkit.client container binding and nothing else — what ctx.auth is stays your decision.

getUser() versus getUserOrFail()

The pair mirrors getIdentity() / authenticate(), one level up: getIdentity() gives you the IdP's claims, these give you your user.

  • getUser() is the honest answer: null when there is no session, and also null when the session is valid but resolveUser produced nothing. Use it on routes that render for visitors and members alike.
  • getUserOrFail() is fail-closed: it throws rather than hand you a null you might forget to check. Use it on routes that already sit behind auth_middleware, where null would mean the route is misconfigured rather than that a visitor arrived.
export default class InvoicesController {
  // Behind middleware.auth() — a null user here is a bug, not a visitor.
  async index({ auth }: HttpContext) {
    const user = await auth.getUserOrFail();
    return Invoice.query().where("userId", user.id);
  }
}

getUserOrFail() throws a plain error, not an HTTP response. It is a guard rail against a misconfigured route, not a replacement for the auth middleware: put the middleware in front so anonymous visitors get a redirect and only genuine misconfiguration reaches the throw.

toSharedProps()

Frontends need the same three facts on every page — is anyone signed in, who, and what global roles do they carry. toSharedProps() assembles exactly the object @adonis-agora/authkit-react consumes, so the Inertia share is one line:

config/inertia.ts
import { defineConfig } from "@adonisjs/inertia";

export default defineConfig({
  sharedData: {
    auth: (ctx) => ctx.auth.toSharedProps(),
  },
});

It returns null when there is no session, and { user, globalRoles } when there is — user being whatever resolveUser produced, globalRoles the IdP claims. Nothing about your app's own authorization appears in it; that stays with @adonis-agora/authz. See React for the consuming side.

Reaching the manager

Everything the client does at runtime hangs off AuthkitClientManager. Import the singleton the same way you would db or mail:

import authkit from "@adonis-agora/authkit-client/services/main";

authkit.clientConfig.issuer; // the resolved config
authkit.getIdToken(ctx); // the session's id_token, for id_token_hint
authkit.startSession(ctx, tokenSet); // install a freshly-minted token set
authkit.endSession(ctx); // tear the session down completely
await authkit.maybeRefresh(ctx); // refresh if the access token is near expiry
await authkit.createAuthenticator(ctx); // a fresh Authenticator for this request
await authkit.handleBackchannelLogout(ctx); // the logout_token endpoint handler

The singleton is bound once the application has booted, which covers every HTTP request, command, and job. Resolve authkit.client from the container instead in the two places where the singleton does not yet exist: inside a service provider's register() or boot(), and inside config/*.ts files, both of which run before the app is booted. In an HTTP handler ctx.containerResolver.make('authkit.client') also works and is what the library's own middleware use, but the singleton reads better and needs no await on the container.

Impersonation

The manager implements the token side of impersonation — the RFC 8693 token exchange plus the session bookkeeping that keeps the operator's own credential safe while they are wearing someone else's identity.

import authkit from "@adonis-agora/authkit-client/services/main";

export default class ImpersonationController {
  async start(ctx: HttpContext) {
    // Authorization is YOURS. The manager does not check who may impersonate whom.
    await ctx.bouncer.authorize("impersonate", ctx.params.userId);

    await authkit.impersonate(ctx, ctx.params.userId);
    return ctx.response.redirect("/");
  }

  async stop(ctx: HttpContext) {
    const restored = await authkit.stopImpersonating(ctx);
    // false means the credential could not be attributed to this session — it was
    // discarded and the session dropped, so send the operator back through login.
    return ctx.response.redirect(restored ? "/admin" : "/auth/login");
  }
}
  • impersonate(ctx, requestedSubject) exchanges the current session's access token for one issued for requestedSubject, parks the original token set, and swaps the session over. It throws when there is no active session to impersonate from.
  • stopImpersonating(ctx) returns true only when the operator was genuinely restored. A refusal is fail-closed: the parked credential is discarded and the session dropped, so nobody is left stuck impersonating and no live refresh token is handed to whoever holds the cookie jar later.
  • isImpersonating(ctx) is a synchronous boolean for rendering the "you are viewing as…" banner.

Authorization is entirely the caller's responsibility. Gate impersonate() behind Bouncer or @adonis-agora/authz before calling it — the manager will happily exchange the token for any subject the IdP allows.

Gating an embedded dashboard

Sibling libs that embed an admin console (@adonis-agora/durable, @adonis-agora/media, @adonis-agora/agent, @adonis-agora/telescope) each want the gate in a slightly different shape. authkitDashboardAuthorize and authkitDashboardMiddleware wrap the same hasGlobalRole('ADMIN') check as the two shapes those configs expect, so you don't hand-roll it per app:

config/durable_dashboard.ts
import { authkitDashboardAuthorize } from "@adonis-agora/authkit-client";

export default defineConfig({
  authorize: authkitDashboardAuthorize(), // authorize: (ctx) => boolean
});
config/media_dashboard.ts
import { authkitDashboardMiddleware } from "@adonis-agora/authkit-client";

export default defineConfig({
  middleware: authkitDashboardMiddleware(), // middleware: (ctx, next) => Promise<void>
});

Pass { role: 'STAFF' } if your host uses a different global role name for admin access. The underlying predicate, isAuthkitAdmin(ctx, options?), is exported too if you need the boolean directly.

Endpoint discovery

The flow helpers derive their URLs from the issuer using the AuthKit IdP's own layout (/auth, /token, /jwks, /session/end, /me, /token/introspection). That layout is exported as conventionEndpoints(issuer), a pure synchronous function that does no I/O:

import { conventionEndpoints, discoverEndpoints } from "@adonis-agora/authkit-client";

// Synchronous, no network — the AuthKit IdP's own route layout.
const known = conventionEndpoints("https://auth.acme.com/oidc");

// Reads /.well-known/openid-configuration, cached 15 min per issuer.
const endpoints = await discoverEndpoints("https://keycloak.acme.com/realms/acme", {
  overrides: { endSessionEndpoint: "https://keycloak.acme.com/custom/logout" },
});

discoverEndpoints is the one to use against a third-party IdP — Keycloak, Auth0, Okta, Entra — because none of them lay their routes out the way the AuthKit IdP does. It caches per issuer for 15 minutes (tune with cacheTtlMs), so calling it per request is fine, and overrides win field by field over whatever the document says.

The two are not alternatives so much as layers: discoverEndpoints falls back to conventionEndpoints when the discovery document is unreachable or malformed, and it caches that fallback too so a down IdP does not turn into a refetch loop. Call conventionEndpoints directly only when you want the AuthKit layout with certainty and no network at all — a test fixture, a build-time constant, an air-gapped deployment. See BYO IdP for the third-party wiring.

Refresh & back-channel logout

  • Refresh token rotation is handled for you: the global middleware refreshes the session before it expires and persists the rotated refresh token. See Refresh Tokens.

  • Back-channel logout lets the IdP terminate this RP's sessions out-of-band. For a cookie-based session — the default in AdonisJS — configure a revocation store and register the revocation middleware:

    config/authkit_client.ts
    import { defineConfig, lucidRevocationStore } from "@adonis-agora/authkit-client";
    
    export default defineConfig({
      // ...
      backchannelLogout: { store: lucidRevocationStore({ connection: "auth" }) },
    });

    registerOidcClient already exposes the endpoint the IdP posts to. The full walkthrough — the table, the middleware ordering, the sid versus sub semantics, and the manual SessionIndex path for server-side session stores — is in Back-Channel Logout.

Resilience for outbound calls

The client makes outbound HTTP calls to the IdP — OIDC discovery, JWKS, and the token endpoints (code exchange, refresh, token exchange). A slow or flaky IdP can stall those requests. Opt-in, you can wrap every outbound call in a resilience policy from @adonis-agora/resilience — pass the composed policy as resilience in defineConfig:

config/authkit_client.ts
import { defineConfig, resolvers } from "@adonis-agora/authkit-client";
import { wrap, timeout, retry, circuitBreaker } from "@adonis-agora/resilience";

const authkitClientConfig = defineConfig({
  issuer: env.get("AUTHKIT_ISSUER"),
  clientId: env.get("AUTHKIT_CLIENT_ID"),
  clientSecret: env.get("AUTHKIT_CLIENT_SECRET"),
  redirectUri: env.get("AUTHKIT_REDIRECT_URI"),
  resolver: resolvers.jwt({ tokenSource: "session" }),

  // Opt-in: cap each IdP call at 2s and retry transient failures up to 3 times.
  resilience: wrap(timeout(2000), retry({ attempts: 3 })),
});

export default authkitClientConfig;

resilience accepts the value returned by wrap(...) — anything with an execute(fn) method. Compose timeout, retry, circuitBreaker, and failover as needed.

@adonis-agora/resilience is an optional peer dependency: without resilience configured, the client's outbound calls are a plain fetch (no behavior change), and the package never needs to be installed.

resilientFetch and ResiliencePolicy

The same primitive the client uses internally is exported, so you can wrap your own outbound calls (an IdP userinfo lookup, a webhook) in the very policy you configured:

import { resilientFetch, type ResiliencePolicy } from "@adonis-agora/authkit-client";
import { wrap, timeout, retry } from "@adonis-agora/resilience";

const policy: ResiliencePolicy = wrap(timeout(2000), retry({ attempts: 3 }));

// With a policy → runs inside policy.execute(() => fetch(...)).
const res = await resilientFetch("https://idp.acme.com/oidc/userinfo", { headers }, policy);

// Without a policy → a plain passthrough fetch (zero behavior change).
const plain = await resilientFetch("https://idp.acme.com/oidc/userinfo", { headers });

ResiliencePolicy is typed structurally — anything with an execute<T>(fn: () => Promise<T>): Promise<T> method satisfies it — which is exactly what wrap(...) returns. That's why the client never hard-imports the resilience package: the duck-typed shape matches. The fourth argument (fetchImpl) lets tests inject a fake fetch.

JWT access token verification

When the IdP emits JWT access tokens, a resource server can verify them locally without calling /introspect:

import { verifyJwtAccessToken } from "@adonis-agora/authkit-client";

const claims = await verifyJwtAccessToken(bearerToken, {
  issuer: "https://idp.acme.com/oidc",
  jwksUri: "https://idp.acme.com/oidc/jwks",
  audience: "https://api.acme.com",
});
// claims.sub, claims.scope, claims.client_id, claims.exp, …

The function uses jose's createRemoteJWKSet (JWKS cached and re-fetched on new kid). It validates the signature, iss, aud, and enforces typ: at+jwt (RFC 9068 §2.1).

OptionTypeDefaultNotes
issuerstringExpected iss claim.
jwksUristringThe IdP's JWKS endpoint (${issuer}/jwks or from discovery).
audiencestring | string[]Expected aud claim — your API's URI.
algorithmsstring[]all asymmetricAccepted signing algorithms.
allowAnyTypbooleanfalseSkip the typ: at+jwt check.

Throws a JWTVerifyError (from jose) when the token is invalid. Calling this function is stateless — no round-trip to the IdP per request.

Next

On this page