Bring your own IdP
Use AuthKit's client toolkit and React SDK against any OIDC-compliant identity provider — Keycloak, Auth0, Okta, Entra — without running authkit-server.
Your company already runs an OIDC identity provider? You can skip authkit-server
entirely and still get the AuthKit developer experience: the client toolkit
authenticates your AdonisJS app against any compliant IdP, and the React SDK
keeps working — hiding only the pieces that genuinely need the AuthKit server.
┌────────────┐ OIDC code + PKCE ┌──────────────────────┐
│ Your IdP │ ◄──────────────────► │ AdonisJS app │
│ (Keycloak, │ │ @adonis-agora/authkit-│
│ Auth0…) │ │ client (sessions, │
└────────────┘ │ JWT/JWKS, logout) │
│ + authkit-react (UI) │
└──────────────────────┘1. Server side — @adonis-agora/authkit-client
The client toolkit is a full OIDC relying party: authorization-code + PKCE login with
state and nonce, id_token validation, session management, stateless JWT verification
via remote JWKS, RP-initiated and back-channel logout. Point issuer at your IdP — that is
the whole integration:
import env from '#start/env'
import { defineConfig, resolvers } from '@adonis-agora/authkit-client'
export default defineConfig({
issuer: 'https://keycloak.acme.com/realms/acme',
clientId: env.get('OIDC_CLIENT_ID'),
clientSecret: env.get('OIDC_CLIENT_SECRET'),
redirectUri: 'https://app.acme.com/auth/callback',
resolver: resolvers.jwt(),
})import { registerOidcClient } from '@adonis-agora/authkit-client'
registerOidcClient(router) // /auth/login, /auth/callback, /auth/logout, /auth/backchannel-logoutEndpoint discovery
Third-party IdPs publish their endpoints at {issuer}/.well-known/openid-configuration,
and the paths differ per vendor (Keycloak uses /protocol/openid-connect/auth, Auth0 uses
/authorize…). The client reads that document and uses what it publishes everywhere —
login, callback, logout, token refresh, impersonation, back-channel logout, and the default
jwksUri / introspection URL of the resolvers.
- Lazy, cached, retried. Discovery runs on first use, not at boot, so the app starts
even while the IdP is down. Concurrent first calls share one fetch. The document is cached
for 15 minutes (
discovery: { cacheTtlMs }); after that it keeps being served while one background refetch replaces it. Each attempt is aborted after 10 seconds. A failure throwsOidcDiscoveryErrornaming the URL and is retried on the next request; a document discovered earlier keeps being served while refetches fail. - Checked. The document's
issuermust be the configured one (OIDC Discovery §4.3). Tokens are then checked against the document's exact value, so an issuer with a trailing slash (Auth0:https://tenant.auth0.com/) works however you wrote it in the config. - No guessing. An endpoint the IdP does not publish stays unset. Without an
end_session_endpoint,/auth/logoutends the local session and redirects straight to the post-logout destination.
Override any endpoint field by field — useful behind a proxy, or when a document is incomplete:
defineConfig({
issuer: 'https://acme.us.auth0.com/',
// ...
endpoints: { endSessionEndpoint: 'https://acme.us.auth0.com/oidc/logout' },
})discovery: false skips the network entirely and uses endpoints on top of the AuthKit
provider's route layout (/auth, /token, /jwks, /session/end).
Custom login controllers
When the generated routes do not fit, the manager does the same work one call at a time. Get it the way the first-party packages are used — inject it, or import the service:
import { inject } from '@adonisjs/core'
import type { HttpContext } from '@adonisjs/core/http'
import { AuthkitClientManager, OidcCallbackError } from '@adonis-agora/authkit-client'
@inject()
export default class OidcSessionController {
constructor(protected authkit: AuthkitClientManager) {}
async login({ session, response }: HttpContext) {
const { url, pending } = await this.authkit.createAuthorizationRequest({
extraParams: { prompt: 'login' },
})
session.put('oidc_pending', pending) // { state, nonce, codeVerifier }
return response.redirect(url)
}
async callback(ctx: HttpContext) {
try {
const { tokens, claims } = await this.authkit.completeAuthorization({
pending: ctx.session.pull('oidc_pending'),
callbackParams: ctx.request.qs(),
})
this.authkit.startSession(ctx, tokens)
// claims: the validated id_token payload (sub, email, sid…)
} catch (error) {
if (error instanceof OidcCallbackError && error.restartable) {
return ctx.response.redirect('/auth/login')
}
throw error
}
return ctx.response.redirect('/')
}
}completeAuthorization checks state and the RFC 9207 iss parameter, exchanges the code
with the PKCE verifier, and validates the id_token: signature against the IdP JWKS, iss,
aud, azp, exp, iat and nonce (plus auth_time when you pass maxAge). It throws
OidcCallbackError with a code — state_mismatch, idp_error, invalid_id_token… — and
restartable is true when the right answer is to send the user back to login.
On refresh, a re-issued id_token is validated too and must be about the same user (OIDC Core §12.2); otherwise the refresh is discarded and a warning is logged.
Outside AdonisJS, the same steps are plain functions — discoverEndpoints(issuer, { onError: 'throw' }), createAuthorizationRequest, completeAuthorization, validateIdToken — that
take the issuer, client and endpoints explicitly.
Which resolver against a third-party IdP
resolvers.jwt is the safe default: it verifies the ID token against the IdP's published
JWKS, which every compliant provider exposes. resolvers.opaque also works — RFC 7662
introspection is standard — but check that your IdP actually exposes an introspection
endpoint and that your client is confidential. resolvers.pat is AuthKit-specific and has
no third-party equivalent. See Resolvers.
One detail that bites: globalRoles is read from the claim named by globalRolesClaim
(default roles). Third-party IdPs put roles wherever they like — Keycloak nests them
under realm_access.roles, Auth0 under a namespaced claim. Set globalRolesClaim to
whatever your IdP emits, and if the shape is nested rather than a flat array, read it out
of identity.raw inside resolveUser instead.
See Client toolkit for sessions, middleware, resolvers and back-channel logout.
2. React SDK — idp: 'external'
The React package talks to two very different surfaces. Most of it only needs your app's session, which the client toolkit above already provides. A small set of components is a front-end for the AuthKit server's own REST surface, and there is nothing for them to call when that server isn't there.
| Works with any IdP | Needs authkit-server |
|---|---|
SignInButton, SignOutButton (redirect to your app's login/logout routes) | UserProfile (profile/MFA/sessions panel) |
useAuth, Authenticated, Guest, Avatar, UserButton | OrganizationSwitcher, OrganizationProfile |
hasGlobalRole / hasAnyGlobalRole / hasAllGlobalRoles | AuthorizedApps |
PasswordStrengthMeter, usePasswordStrength |
Tell the provider your IdP is external and the right-column components render null
instead of calling endpoints that don't exist:
import { AuthkitProvider } from '@adonis-agora/authkit-react'
<AuthkitProvider config={{ idp: 'external', loginUrl: '/auth/login' }}>
<App />
</AuthkitProvider>idp is 'authkit' by default, so this flag is the one thing you must remember to set in
a bring-your-own-IdP deployment. Everything else — auth state, sign-in/out buttons, the
global-role helpers — keeps working exactly the same, because those only depend on the
session your app already owns.
Permission gating: useCan and <CanPermission>
useCan(permission, resource?) and <CanPermission permission="…"> are not role
gates and they are not part of the IdP surface. They POST to an authorization
endpoint in your own app — /authz/can by default — with { permission, resource? } and
expect { allowed: boolean } back. That contract is served by
@adonis-agora/authz, which owns
per-app roles, permissions, and resource-level checks.
import { CanPermission, useCan } from '@adonis-agora/authkit-react'
// Component form: render children only if the check passes.
<CanPermission permission="posts.update" resource={post.id} fallback={<ReadOnlyNotice />}>
<EditPostButton post={post} />
</CanPermission>
// Hook form, when you need the state rather than a subtree.
function PublishButton({ post }) {
const { allowed, loading } = useCan('posts.publish', post.id)
if (loading) return <Spinner />
return <button disabled={!allowed}>Publish</button>
}Because the endpoint is yours, this works unchanged against Keycloak, Auth0 or anything
else — the IdP never sees the question. Point it elsewhere with
<AuthkitProvider config={{ canPath: '/api/permissions/check' }}> if your route differs.
Results are cached in memory per (user, permission, resource) and the decision is
fail-closed: a failed request denies, and surfaces the error on useCan().error.
For coarse gating on what the IdP asserted — the globalRoles in the token — use
useAuth() with the hasGlobalRole helpers instead. That needs no endpoint at all,
because the roles already travelled in the session.
What you give up without authkit-server
Profile/MFA self-service, organizations, authorized-apps management, the admin console
and the Admin API are features of the server — your external IdP brings its own
equivalents (Keycloak account console, Auth0 dashboard…). If you later migrate to
authkit-server, flip idp back to 'authkit' and they all light up.