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-clientAuthKit 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.tsmodel, composed from thewithAuthUser()+withCredentials()mixins. - Registers the provider in
adonisrc.tsand the bundled ace commands (includingauthkit:eject) via the package'sadonisjsblock. - Adds an env validation for
AUTHKIT_ISSUERand writes a default value (http://localhost:3333/oidc) to.env.
What each --ui preset does:
| Preset | UI files published | Render mode |
|---|---|---|
edge (default) | None | Built-in Edge views from the authkit:: disk — zero setup, works out of the box |
react | React pages under inertia/pages/authkit/ (login, signup, consent, forgot, reset, verify-email, mfa-challenge, account/login, account/tokens, account/mfa) + auth_shell.tsx | inertiaRenderer({ prefix: 'authkit', views: [...] }) pre-configured in the published config |
headless | None | No 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):
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 authServerConfigThe 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.tsstub — the login / callback / logout flow over the client helpers. - Registers the provider in
adonisrc.ts. - Registers
authkit_middlewareas a router-global middleware automatically — this is what makesctx.authexist on every request. No manual kernel edit needed. - Adds env validations for
AUTHKIT_ISSUER,AUTHKIT_CLIENT_ID,AUTHKIT_CLIENT_SECRET, andAUTHKIT_REDIRECT_URI. It does not write their values — fill them in.envyourself (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:
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 authkitClientConfigresolveUser 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.
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:
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:
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:
export const middleware = router.named({
// ...
authkit: () => import('@adonis-agora/authkit-client/authkit_middleware'),
})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):
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):
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 clientSecret — save 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:
AUTHKIT_CLIENT_ID=my-app
AUTHKIT_CLIENT_SECRET=s3cr3t-shown-once
AUTHKIT_REDIRECT_URI=http://localhost:3333/auth/callbackFrom 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
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/callbackUsing 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:
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. - User — your domain model, produced by
resolveUser(identity). The Identity says who the IdP authenticated;resolveUserturns 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:
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:
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:
export const middleware = router.named({
// ...existing named middleware
auth: () => import('@adonis-agora/authkit-client/auth_middleware'),
})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.