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

config/authkit_client.ts
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(),
})
start/routes.ts
import { registerOidcClient } from '@adonis-agora/authkit-client'

registerOidcClient(router) // /auth/login, /auth/callback, /auth/logout, /auth/backchannel-logout

Endpoint 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 throws OidcDiscoveryError naming the URL and is retried on the next request; a document discovered earlier keeps being served while refetches fail.
  • Checked. The document's issuer must 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/logout ends 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:

app/controllers/oidc_session_controller.ts
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 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

Partner

Your product as the official integration for AuthKit, right here.

Become a partner →