Agora

Getting Started

Stand up an Authorization Server, wire a relying party, and read the user in your controllers via ctx.auth.

This is the complete happy path: install the packages, write the two config files, mount the host, wire login / callback / logout, and read the authenticated user in your controllers. By the end you have a working IdP and a relying party that knows who's logged in.

If you want the two end-to-end topology walkthroughs side by side (everything-in-one-app vs. a standalone IdP with separate client apps), see the Quickstart instead — this page is the linear single-app path.

Install & scaffold

Use node ace add — it installs the package and runs its configure hook in one step. Run it once per package:

node ace add @adonis-agora/authkit-server
node ace add @adonis-agora/authkit-client

AuthKit needs a SQL connection for its adapter and stores. The database adapter and lucidAccountStore use Lucid, so @adonisjs/lucid must be installed and configured.

What the server add scaffolds

node ace add @adonis-agora/authkit-server prompts for the UI preset of the login/consent screens — edge | react | headless (pass --ui=react to skip the prompt) — and then:

  • Publishes config/authkit.ts — a starter config (Redis adapter, managed JWKS, lucidAccountStore(AuthUser)). Edit it to taste.
  • Publishes the app/models/auth_user.ts model, composed from the withAuthUser() + withCredentials() mixins.
  • Registers the provider in adonisrc.ts and the bundled ace commands (including authkit:eject) via the package's adonisjs block.
  • Adds an env validation for AUTHKIT_ISSUER and writes a default value (http://localhost:3333/oidc) to .env.

What each --ui preset does:

PresetUI files publishedRender mode
edge (default)NoneBuilt-in Edge views from the authkit:: disk — zero setup, works out of the box
reactReact pages under inertia/pages/authkit/ (login, signup, consent, forgot, reset, verify-email, mfa-challenge, account/login, account/tokens, account/mfa) + auth_shell.tsxinertiaRenderer({ prefix: 'authkit', views: [...] }) pre-configured in the published config
headlessNoneNo render key; flows respond with JSON contracts for you to drive

The lib does not ship .tsx React files — there are no pre-built React pages inside the npm package. With --ui=react the scaffold writes starter pages into your project; they are yours to edit from that moment on. With edge or headless, the authentication flow screens are served from the package's built-in Edge views (authkit:: disk) and your project disk has nothing extra.

Choosing --ui=react requires @adonisjs/inertia + react + Vite already configured in the app, or the command aborts. Run node ace add @adonisjs/inertia (with React) first.

Tune the server config (manual)

The published config/authkit.ts is a Redis-adapter starter. For the Lucid-backed single-app path in this guide, edit it to use the database adapter, point at the AuthUser store, and set the mountPath/renderer. Clients are no longer declared here — create them in the admin console after first boot (see below):

config/authkit.ts
import env from '#start/env'
import AuthUser from '#models/auth_user'
import {
  defineConfig,
  adapters,
  lucidAccountStore,
  inertiaRenderer,
} from '@adonis-agora/authkit-server'

const authServerConfig = defineConfig({
  issuer: env.get('AUTHKIT_ISSUER'),
  adapter: adapters.database({ connection: 'auth' }),
  jwks: { source: 'managed', algorithm: 'RS256' },
  ttl: { accessToken: '15m', refreshToken: '30d' },
  accountStore: lucidAccountStore(AuthUser),
  mountPath: '/oidc',
  // React preset: point at the pages written into your project by `node ace add --ui=react`.
  // The `views` list is an allowlist; screens not listed here fall back to Edge views.
  // Remove the `render` key entirely if you chose --ui=edge (built-in Edge views, no setup).
  render: inertiaRenderer({
    prefix: 'authkit',
    views: [
      'login', 'consent', 'signup', 'forgot', 'reset',
      'verify-email', 'mfa-challenge',
      'account/login', 'account/tokens', 'account/mfa',
    ],
  }),
  admin: { enabled: true },    // enables the admin console (React SPA, pre-bundled in the package)
  adminApi: {
    enabled: true,
    apiKeys: [env.get('AUTHKIT_ADMIN_API_KEY')],
  },
})

export default authServerConfig

The issuer is the public URL of the provider and must end with mountPath — e.g. https://auth.example.com/oidc for mountPath: '/oidc'. The published model already composes the mixins the Lucid store needs; add columns to it as your domain grows.

No clients: block needed at boot. The server starts fine with zero static clients. You register your first client through the admin console or the Admin API after the first start (see Registering your first client).

What the client add scaffolds

node ace add @adonis-agora/authkit-client (no prompts):

  • Publishes config/authkit_client.ts — issuer, client credentials, and a JWT resolver reading the token set from the session.
  • Publishes the app/controllers/oidc_session_controller.ts stub — the login / callback / logout flow over the client helpers.
  • Registers the provider in adonisrc.ts.
  • Registers authkit_middleware as a router-global middleware automatically — this is what makes ctx.auth exist on every request. No manual kernel edit needed.
  • Adds env validations for AUTHKIT_ISSUER, AUTHKIT_CLIENT_ID, AUTHKIT_CLIENT_SECRET, and AUTHKIT_REDIRECT_URI. It does not write their values — fill them in .env yourself (see Environment variables).

The remaining client work is manual: tune the config below, and wire the login / callback / logout routes (the controller is scaffolded but its routes are not).

Client config

Edit the published config/authkit_client.ts to declare the issuer, client credentials, the resolver, and how to map an IdP identity to your user record:

config/authkit_client.ts
import env from '#start/env'
import { defineConfig, resolvers } from '@adonis-agora/authkit-client'
import type { Identity } 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' }),

  // Map the IdP identity (claims) to YOUR user record.
  resolveUser: async (identity: Identity) => {
    return AppUser.updateOrCreate(
      { id: identity.userId },
      { id: identity.userId, email: identity.email, fullName: identity.profile?.name ?? null }
    )
  },
})

export default authkitClientConfig

resolveUser runs lazily, only when you call auth.getUser(). For the conceptual model — the Identity shape, resolvers, and reconciling pre-existing rows by email — see Client.

Mount the host (manual)

registerAuthHost is not scaffolded — add it yourself. It mounts everything in one call — the OIDC provider wildcard, the login / consent / signup interaction pages, password reset, the account console, and (opt-in) social, PAT introspection, and rate-limit middleware.

start/routes.ts
import router from '@adonisjs/core/services/router'
import { registerAuthHost } from '@adonis-agora/authkit-server'

registerAuthHost(router, {
  mountPath: '/oidc',
  // social: { providers: ['google'] }, // opt-in
  // rateLimit: { enabled: true },       // opt-in
})

registerAuthHost reads the resolved config/authkit.ts that the provider stashed at boot, so registerAuthHost(router) with no options already picks up your mountPath, social, rateLimit, admin and adminApi. The options argument exists to override structural choices — where things mount. Policy switches you declared in defineConfig are locked: passing them here is ignored and warned about at boot, so config/authkit.ts stays auditable without reading start/routes.ts. See Config locks.

If your app uses @adonisjs/shield (on by default in the web starter kit), its CSRF protection intercepts the OIDC provider's own machine-to-machine routes mounted above — POST {mountPath}/token in particular. Without an exemption, exchangeCode() gets shield's HTML CSRF-denial page back instead of a JSON token response (SyntaxError: Unexpected token '<' ... is not valid JSON). Use the exported authkitCsrfExceptions helper in config/shield.ts — it stays in sync with whatever mountPath you pass to registerAuthHost/defineConfig, so you don't have to hand-write the route-pattern regex yourself:

config/shield.ts
import { defineConfig } from '@adonisjs/shield'
import { authkitCsrfExceptions } from '@adonis-agora/authkit-server'

export default defineConfig({
  csrf: {
    enabled: true,
    exceptRoutes: (ctx) =>
      authkitCsrfExceptions(ctx.request.url(), { mountPath: '/oidc' }),
  },
  // ...rest of your shield config
})

This exempts only the IdP's OIDC protocol surface ({mountPath}/*), the PAT introspection endpoint, and the client's back-channel logout route — the interactive login/consent/signup pages (under /auth/interaction/*) are a different prefix and keep full CSRF protection. See Reference for the full option list.

The client middleware (already registered)

authkit_middleware is what makes ctx.auth exist. node ace add already registered it as a router-global middleware in start/kernel.ts, so every route gets an authenticator — you don't have to add it yourself. For reference, the registration it wrote looks like:

start/kernel.ts
import router from '@adonisjs/core/services/router'

router.use([
  // ...existing middleware
  () => import('@adonis-agora/authkit-client/authkit_middleware'),
])

Per request it proactively refreshes the token set (maybeRefresh, see Refresh Tokens) and then attaches the authenticator (ctx.auth = await manager.createAuthenticator(ctx)).

ctx.auth is always present after the middleware runs — it never throws on an anonymous request. Whether someone is actually logged in is decided by getIdentity() resolving to a non-null value. Use authenticate() / check() for that (below).

If you'd rather opt routes in explicitly, swap the router-global registration for a named middleware instead:

start/kernel.ts
export const middleware = router.named({
  // ...
  authkit: () => import('@adonis-agora/authkit-client/authkit_middleware'),
})
start/routes.ts
router.get('/dashboard', [DashboardController]).use(middleware.authkit())

Don't confuse this middleware with the one that enforces a login. authkit_middleware only makes ctx.auth available; requiring a session is a separate, opt-in middleware covered under Protecting a route group.

Login, callback, logout

Login is OIDC authorization-code + PKCE. The bundled OidcSessionController stub was already published to app/controllers/oidc_session_controller.ts by node ace add — it's the whole flow in three short methods over the client helpers (shown here with an RP-initiated logout; the scaffolded logout does a simpler local-only session drop):

app/controllers/oidc_session_controller.ts
import type { HttpContext } from '@adonisjs/core/http'
import {
  buildAuthorizeUrl,
  exchangeCode,
  generatePkce,
  buildEndSessionUrl,
} from '@adonis-agora/authkit-client'
import { randomUUID } from 'node:crypto'

export default class OidcSessionController {
  async login(ctx: HttpContext) {
    const manager = await ctx.containerResolver.make('authkit.client')
    const cfg = manager.clientConfig
    const { verifier, challenge } = await generatePkce()
    const state = randomUUID()
    // Stash PKCE verifier + state so we can verify the callback.
    ctx.session.put('authkit_pkce', { verifier, state })
    const url = buildAuthorizeUrl({
      issuer: cfg.issuer, clientId: cfg.clientId, redirectUri: cfg.redirectUri,
      scopes: cfg.scopes, state, codeChallenge: challenge,
    })
    return ctx.response.redirect(url)
  }

  async callback(ctx: HttpContext) {
    const manager = await ctx.containerResolver.make('authkit.client')
    const cfg = manager.clientConfig
    const { code, state } = ctx.request.qs()
    const pkce = ctx.session.get('authkit_pkce')
    if (!pkce || pkce.state !== state) return ctx.response.badRequest({ error: 'invalid state' })
    const tokenSet = await exchangeCode({
      issuer: cfg.issuer, clientId: cfg.clientId, clientSecret: cfg.clientSecret,
      redirectUri: cfg.redirectUri, code, codeVerifier: pkce.verifier,
    })
    // Persist the token set as a FRESH session — this is what
    // resolvers.jwt({ tokenSource: 'session' }) reads on later requests.
    // Prefer startSession over a raw session.put: it also clears any credential
    // parked by a previous login in the same cookie jar.
    manager.startSession(ctx, tokenSet)
    ctx.session.forget('authkit_pkce')
    return ctx.response.redirect('/')
  }

  async logout(ctx: HttpContext) {
    const manager = await ctx.containerResolver.make('authkit.client')
    const cfg = manager.clientConfig
    // RP-initiated logout: end the IdP session, then drop the local one.
    // id_token_hint lets the IdP skip its confirmation page.
    const idToken = manager.getIdToken(ctx)
    ctx.session.forget(cfg.sessionKey)
    return ctx.response.redirect(
      buildEndSessionUrl({
        issuer: cfg.issuer,
        idToken,
        clientId: cfg.clientId,
        postLogoutRedirectUri: 'http://localhost:3333/',
      })
    )
  }
}

Wire the routes (the /auth/callback path is the redirectUri you registered for the client):

start/routes.ts
const OidcSessionController = () => import('#controllers/oidc_session_controller')

router.get('/auth/login', [OidcSessionController, 'login']).as('auth.login')
router.get('/auth/callback', [OidcSessionController, 'callback']).as('auth.callback')
router.post('/auth/logout', [OidcSessionController, 'logout']).as('auth.logout')

The sequence: login redirects the browser to the IdP's /authorize; the user authenticates there and is bounced back to redirectUri with code + state; callback verifies state, swaps the code for a token set via exchangeCode, and stores it with manager.startSession(ctx, tokenSet). The stored token set is the login — there's nothing else to persist on the RP. For the simpler local-only logout and the full end-session details, see Security and Back-Channel Logout for IdP-initiated termination.

If you don't need to customise any of that, registerOidcClient(router) registers the same three routes (plus back-channel logout) with this exact flow already implemented, and adds post-login redirects by global role. Keep the scaffolded controller when you want to own the flow; drop it for registerOidcClient when you don't. See Client.

Registering your first client

After the first boot, open the admin console at /admin/clients (log in with an account that has the ADMIN role) and click Create client. Alternatively, use the Admin REST API directly — no browser session required:

# Create a confidential client for your app
curl -X POST http://localhost:3333/api/authkit/v1/clients \
  -H "Authorization: Bearer $AUTHKIT_ADMIN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "clientId": "my-app",
        "redirectUris": ["http://localhost:3333/auth/callback"],
        "postLogoutRedirectUris": ["http://localhost:3333/"],
        "grantTypes": ["authorization_code", "refresh_token"],
        "tokenEndpointAuthMethod": "client_secret_basic"
      }'

The response includes clientSecretsave it now, it is not recoverable afterwards:

{ "clientId": "my-app", "clientSecret": "s3cr3t-shown-once" }

Set the credentials in .env and wire them into your client config:

.env
AUTHKIT_CLIENT_ID=my-app
AUTHKIT_CLIENT_SECRET=s3cr3t-shown-once
AUTHKIT_REDIRECT_URI=http://localhost:3333/auth/callback

From this point the client is stored in the adapter/DB and survives restarts. You can update its redirect URIs, regenerate its secret, or delete it at any time from the console or API — no config change or redeploy required.

Environment variables

.env
AUTHKIT_ISSUER=http://localhost:3333/oidc
AUTHKIT_ADMIN_API_KEY=high-entropy-key-here
# set after creating the first client (see above):
AUTHKIT_CLIENT_ID=my-app
AUTHKIT_CLIENT_SECRET=super-secret
AUTHKIT_REDIRECT_URI=http://localhost:3333/auth/callback

Using it in controllers

This is the part you'll use every day. After the middleware runs, ctx.auth is an Authenticator; destructure it from the context.

Authenticator is generic over your user type, and it defaults to unknown because the library cannot know your model. Fix it once, in an app-level type declaration, and every getUser() call site is typed without a cast:

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>
  }
}

That single declaration is what makes the examples below read as cleanly as they do.

Only declare it once. The library ships the same declaration, unparameterised, at @adonis-agora/authkit-client/types — but nothing imports it on your behalf. Two declarations of HttpContext.auth with different types do not merge: TypeScript keeps whichever it reached first and reports the conflict inside a .d.ts, where skipLibCheck discards it. So pick one. Declare your own as above when you want getUser() typed as your model, or import '@adonis-agora/authkit-client/types' if you are happy with the default and want no app-level file — never both.

Identity vs User. Two distinct things come out of the authenticator:

  • Identity — the OIDC claims, app-agnostic (userId = sub, email, globalRoles, profile, sessionId, raw). Same shape for every resolver.
  • Useryour domain model, produced by resolveUser(identity). The Identity says who the IdP authenticated; resolveUser turns that into the row your app works with.

Require a login — authenticate()

authenticate() resolves the identity and throws if there isn't one. Use it where anonymous access is a programming error:

app/controllers/dashboard_controller.ts
import type { HttpContext } from '@adonisjs/core/http'

export default class DashboardController {
  async handle({ auth, inertia }: HttpContext) {
    const identity = await auth.authenticate() // throws if not logged in
    return inertia.render('dashboard', { email: identity.email })
  }
}

Get your user — getUser()

getUser() returns whatever resolveUser produced (or null if not authenticated) — the usual entry point, since you almost always want your model rather than raw claims. With the declaration above it is already typed as AppUser | null:

async handle({ auth, response }: HttpContext) {
  const user = await auth.getUser()
  if (!user) return response.unauthorized({ message: 'Not authenticated' })
  return user
}

The result is memoised per request: resolveUser runs at most once no matter how many times you call getUser() in a request, so there's no reason to thread the user through your own locals.

Behind a route that is already guarded by the auth middleware, null is not a visitor — it's a misconfigured route. getUserOrFail() says that out loud: same resolution, but it throws instead of handing you a null you'd only have to re-check:

async handle({ auth }: HttpContext) {
  const user = await auth.getUserOrFail() // AppUser, never null
  return user
}

authenticate() and getUserOrFail() are the same idea at the two levels: the first guarantees an Identity, the second guarantees your user.

Optional auth — check()

check() returns a boolean and never throws — for pages that render differently for guests vs. members:

async handle({ auth, inertia }: HttpContext) {
  if (await auth.check()) {
    return inertia.render('home', { user: await auth.getUser() })
  }
  return inertia.render('home', { user: null })
}

Raw claims — getIdentity()

When you need the claims directly (the sub, sessionId, anything in raw) without hitting your DB, use getIdentity() — it returns Identity | null:

const identity = await auth.getIdentity()
if (identity) console.log(identity.userId, identity.sessionId, identity.raw)

Hand the session to the frontend — toSharedProps()

If your UI uses @adonis-agora/authkit-react, its useAuth() reads a single Inertia shared prop named authkit. toSharedProps() builds exactly that object — { user, globalRoles }, or null when there is no session — so the two halves stay in sync without you re-deriving the shape by hand:

config/inertia.ts
sharedData: {
  authkit: (ctx) => ctx.auth.toSharedProps(),
},

React covers the frontend half and the anonymous-request nuance.

Global roles — hasGlobalRole

The authenticator exposes exactly one role check, and it is deliberately narrow. hasGlobalRole(role) is synchronous and reads identity.globalRoles — the roles the IdP asserted in the token, under the claim named by globalRolesClaim. Being synchronous, it reads the identity that has already been resolved; it does not resolve one for you. So call authenticate(), check() or getIdentity() first, otherwise it always answers false:

async handle({ auth, response }: HttpContext) {
  await auth.authenticate()                 // ensure the identity is resolved
  if (!auth.hasGlobalRole('ADMIN')) {       // sync, straight from the claims
    return response.forbidden({ message: 'Access denied' })
  }
  // ...
}

AuthKit authenticates; it does not authorize. A global role is a coarse, IdP-wide fact — "this person is a platform admin" — carried in the token so every relying party sees the same answer. It is not a permission model: there is no per-app role, no resource-scoped check, and no policy engine on ctx.auth, by design. For "can this user update this post", use @adonis-agora/authz, which owns roles, permissions and resource checks in your own database, or AdonisJS Bouncer directly. The two compose cleanly: AuthKit answers who, authz answers may they.

The connection runs the other way too — an external role authority can become the source of the globalRoles claim itself, via resolveTokenRoles on the server. See Customizing auth.

globalRoles needs the roles scope. The provider binds the roles claim to that scope, so a client only receives roles when it asks for it — which the client kit's default scopes already do. If you register clients you do not own, restrict the claim further with firstPartyClients in config/authkit.ts; see The roles claim.

Protecting a route group

For browser routes you usually want a redirect to login, not a 500. Don't write that middleware — the client package ships it. Register it as a named middleware:

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

It calls ctx.auth.check() and redirects anonymous visitors to redirectTo (default /auth/login) instead of letting authenticate() throw. Note that this is a different middleware from authkit_middleware: that one runs router-globally and creates ctx.auth on every request; this one is opt-in per route and enforces that a session exists. You need both, and they stack in that order.

There is a companion for the other half of the problem — @adonis-agora/authkit-client/silent_auth_middleware resolves the identity without requiring it, for public pages that render differently once someone is logged in. Both are covered in detail in Client and AdonisJS auth.

That's the whole single-app setup — provider, relying party, and ctx.auth all wired. Continue with Topologies to decide how to deploy it, Account Store to point AuthKit at your user table, Client for the deeper resolver model, or Customizing auth for recipes on role gating, custom screens, emails, and events.

On this page