Agora

Resolvers

jwt, pat, and opaque — how a request becomes an Identity.

The client's resolver decides how an incoming request is turned into an Identity. AuthKit ships three factories under resolvers, picked by your token model and your revocation needs.

Every resolver is a factory: it does not receive the issuer, the client id, or the session key from you. defineConfig already knows those, and the client manager passes them down when it builds the resolver on first use. That is why the samples below only carry the handful of options that are genuinely resolver-specific — everything else is inherited from the surrounding client config.

resolvers.jwt

For browser / user sessions. Validates the OIDC token locally against the IdP's JWKS, checking issuer and audience (your clientId). No network call per request beyond the JWKS fetch, which jose caches.

config/authkit_client.ts
import { resolvers } from '@adonis-agora/authkit-client'

resolver: resolvers.jwt({ tokenSource: 'session' })
OptionTypeDefaultNotes
tokenSource'session' | 'bearer''session'Where to read the token. 'session' reads the ID token out of the token set the login flow stored under sessionKey; 'bearer' reads the raw JWT from the Authorization header.
jwksUristring${issuer}/jwksExplicit JWKS endpoint. The default is the AuthKit provider's standard route — point it at endpoints.jwksUri from discoverEndpoints for a third-party IdP.

Signature algorithms are restricted to the asymmetric set (RS/PS/ES/EdDSA), so an IdP cannot downgrade a token to a symmetric algorithm you'd then verify with a public key. A token without a sub claim is rejected even if the signature checks out.

Because validation is stateless, a JWT resolver does not see revocation until the token expires. If you need immediate revocation, use resolvers.opaque.

resolvers.pat

For machine-to-machine callers presenting a Personal Access Token in the Authorization header. There is no local validation to do — the token is opaque — so the resolver calls the IdP's PAT introspection endpoint with a shared secret and builds the Identity from the response. See Personal Access Tokens.

resolver: resolvers.pat({
  introspectionUrl: `${env.get('AUTHKIT_ISSUER')}/authkit/pat/introspect`,
  introspectionSecret: env.get('PAT_INTROSPECTION_SECRET'),
})
OptionTypeNotes
introspectionUrlstringThe IdP's PAT introspection endpoint.
introspectionSecretstringShared secret authenticating the introspection call (sent as a bearer credential, not as the token being introspected).

Roles for a PAT always come from the literal roles claim of the introspection response — a PAT is minted by the IdP itself, so there is no per-client claim name to negotiate.

resolvers.opaque

For opaque OIDC access tokens introspected on every request (RFC 7662). Unlike jwt, which is stateless and cannot see a revocation, the access token issued by the OIDC flow is introspected at the IdP per request — so revoking at the IdP drops the app session immediately (on the next request, or after cacheTtlMs).

resolver: resolvers.opaque({ tokenSource: 'session' })
OptionTypeDefaultNotes
tokenSource'session' | 'bearer''session''session' introspects the access token stored in the token set (not the ID token, which is what resolvers.jwt reads); 'bearer' introspects the Authorization header token (APIs).
introspectionUrlstring${issuer}/token/introspectionThe provider's standard introspection endpoint.
cacheTtlMsnumber0In-memory TTL (ms) for active: true responses. 0 introspects every request (revocation is immediate); raise it to trade immediacy for fewer round-trips.

resolvers.opaque requires a confidential client (a clientSecret): the introspection call authenticates with HTTP Basic (clientId:clientSecret). It throws at resolve time if no secret is configured.

The cache is keyed by the token itself and lives in the process, so a rolling deploy or a multi-instance topology gives each instance its own window. Treat cacheTtlMs as an upper bound on how stale a revocation decision can be, not as a shared cache.

The Identity

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

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

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

All three resolvers build that object through the same claim mapping: subuserId, nameprofile.name, pictureprofile.avatarUrl, sidsessionId, iat/exp converted from seconds to milliseconds, and the untouched claim set in raw. So resolveUser can rely on identity.profile?.avatarUrl and identity.sessionId regardless of which token model produced the identity — the rest of your app never knows which resolver ran.

The one thing that does vary is the claim the roles are read from: jwt and opaque honour the globalRolesClaim you set on the client config (default 'roles'), and it must match the globalRolesClaim the server mints under. See Customizing auth for the emission side of that contract.

globalRoles is what the IdP asserted about the person. It is authentication data carried across the wire, not your application's permission model. For per-app roles, permissions, and resource-level checks, use @adonis-agora/authz — AuthKit deliberately stops at "who is this".

Choosing a resolver

ScenarioResolverRevocation
Browser session, low latencyresolvers.jwtOn token expiry only
Browser session, must honour immediate revocationresolvers.opaqueImmediate (or after cacheTtlMs)
CI / scripts / integrationsresolvers.patImmediate (introspected)

A client config carries exactly one resolver, and the client manager builds it once and caches it. The choice is therefore per application, not per route — there is no "jwt for the browser, pat for the API" switch inside a single config. Pick the model that matches how the app is actually called, and let a separate service own the other one.

On this page