Agora

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, session management, stateless JWT verification via remote JWKS, RP-initiated and back-channel logout. Point issuer at your IdP.

Endpoint discovery

Third-party IdPs publish their endpoints at {issuer}/.well-known/openid-configuration — paths differ per vendor (Keycloak uses /protocol/openid-connect/auth, Auth0 uses /authorize…). The flow helpers all default to the AuthKit provider's own route conventions, so against a third-party IdP you resolve the real endpoints once with discoverEndpoints and pass them through:

import {
  discoverEndpoints,
  generatePkce,
  buildAuthorizeUrl,
  exchangeCode,
  buildEndSessionUrl,
} from '@adonis-agora/authkit-client'

const endpoints = await discoverEndpoints(issuer) // cached per issuer (15 min)

// 1. Login
const { verifier, challenge } = await generatePkce()
const url = buildAuthorizeUrl({
  issuer, clientId, redirectUri, state,
  scopes: ['openid', 'profile', 'email'],
  codeChallenge: challenge,
  authorizationEndpoint: endpoints.authorizationEndpoint,
})

// 2. Callback
const tokens = await exchangeCode({
  issuer, clientId, clientSecret, redirectUri,
  code, codeVerifier: verifier,
  tokenEndpoint: endpoints.tokenEndpoint,
})

// 3. Logout
buildEndSessionUrl({
  issuer,
  idToken: tokens.idToken,
  postLogoutRedirectUri: 'https://app.example.com/',
  endSessionEndpoint: endpoints.endSessionEndpoint,
})

Notes:

  • Fail-safe: if the discovery document is unreachable, discoverEndpoints silently falls back to the AuthKit provider's route conventions — so against authkit-server everything keeps working even offline. The fallback is cached too, so a flapping discovery endpoint does not turn into a refetch loop.
  • Manual override: discoverEndpoints(issuer, { overrides: { tokenEndpoint: '…' } }) wins field-by-field, ahead of the discovery document — useful behind proxies that rewrite hostnames.
  • The resolvers already accept jwksUri / introspectionUrl directly — feed them endpoints.jwksUri / endpoints.introspectionEndpoint.

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 IdPNeeds authkit-server
SignInButton, SignOutButton (redirect to your app's login/logout routes)UserProfile (profile/MFA/sessions panel)
useAuth, Authenticated, Guest, Avatar, UserButtonOrganizationSwitcher, OrganizationProfile
hasGlobalRole / hasAnyGlobalRole / hasAllGlobalRolesAuthorizedApps
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.

On this page