Agora

Quickstart

Two end-to-end walkthroughs — everything in one app, or a standalone IdP with separate client apps.

AuthKit is topology-agnostic: the same server config powers both an embedded provider and a dedicated one. This page gives you two complete, parallel walkthroughs so you can pick the shape that fits your deployment. Every snippet uses real exported APIs. For deeper dives, follow the links to the feature pages.

See Topologies for the conceptual background; switching later is mostly an infrastructure change — keep the same issuer URL and the relying parties don't need to move.

Once a client is wired, Getting Started shows how to read the user in your controllers via ctx.auth.


Topology A — everything in one app

One AdonisJS process serves your product and the OIDC endpoints. The app authenticates against itself: it installs both the server kit (to be the IdP) and the client kit (to consume its own issuer). The provider's SSO cookie and the app's own session coexist on the same origin as two separate cookies.

A1. Install both packages

npm install @adonis-agora/authkit-server @adonis-agora/authkit-client
node ace configure @adonis-agora/authkit-server
node ace configure @adonis-agora/authkit-client

configure publishes the config files, wires the providers, and registers the bundled ace commands (including authkit:eject). AuthKit needs a SQL connection (@adonisjs/lucid) for its adapter and stores.

A2. The account model

Compose an AuthUser from the mixins so the Lucid store has the columns it needs. Two things the mixins do not provide, and that you must add yourself:

  • A primary key. Neither withAuthUser() nor withCredentials() declares an id column or generates one — without a @beforeCreate hook assigning a real id and static selfAssignPrimaryKey = true, Lucid inserts NULL for the id column, or (with only the hook) silently overwrites the id you just assigned with the database's internal auto-increment rowid right after the insert — either way the account is unreachable by its real id on the very next request.
  • fullName. The built-in signup screen collects a "Name" field, and the Lucid store passes it straight to AuthUser.create() — omit this column and the first signup throws Cannot define "fullName" on "AuthUser" model, since it is not defined as a model property.
app/models/auth_user.ts
import { randomUUID } from 'node:crypto'
import { BaseModel, beforeCreate, column } from '@adonisjs/lucid/orm'
import { compose } from '@adonisjs/core/helpers'
import { withAuthUser, withCredentials } from '@adonis-agora/authkit-server'

export default class AuthUser extends compose(
  BaseModel,
  withAuthUser(),
  withCredentials()
) {
  static selfAssignPrimaryKey = true

  @column({ isPrimary: true })
  declare id: string

  @beforeCreate()
  static assignUuid(user: AuthUser) {
    user.id = randomUUID()
  }

  @column()
  declare fullName: string | null
}

node ace configure @adonis-agora/authkit-server also scaffolds the migration for the auth_users table backing this model, at database/migrations/<timestamp>_create_auth_users_table.ts. Its columns match every @column() declared above plus the two mixins' own (email, password, global_roles) — run node ace migration:run before your first signup. See Account Store for the full column list, and add your own migration for any extra column your own @column() declarations introduce later.

database/migrations/xxxx_create_auth_users_table.ts
import { BaseSchema } from '@adonisjs/lucid/schema'

export default class extends BaseSchema {
  protected tableName = 'auth_users'

  async up() {
    this.schema.createTable(this.tableName, (table) => {
      table.string('id').notNullable().primary()

      table.string('email').notNullable().unique()
      table.string('password').notNullable()
      table.json('global_roles').notNullable().defaultTo('[]')

      table.timestamp('email_verified_at', { useTz: true }).nullable()
      table.string('email_verification_token').nullable()
      table.string('password_reset_token').nullable()
      table.timestamp('password_reset_expires_at', { useTz: true }).nullable()

      table.string('full_name').nullable()
    })
  }

  async down() {
    this.schema.dropTable(this.tableName)
  }
}

A3. Server config

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'), // e.g. http://localhost:3333/oidc
  adapter: adapters.database({ connection: 'auth' }),
  jwks: { source: 'managed', algorithm: 'RS256' },
  ttl: { accessToken: '15m', refreshToken: '30d' },
  accountStore: lucidAccountStore(AuthUser),
  mountPath: '/oidc',
  // React preset (after `node ace add --ui=react`). Use the `views` allowlist so screens
  // added by future lib updates fall back to Edge views instead of crashing SSR.
  // Remove the `render` key if you picked --ui=edge (built-in Edge views, zero config).
  render: inertiaRenderer({
    prefix: 'authkit',
    views: [
      'login', 'consent', 'signup', 'forgot', 'reset',
      'verify-email', 'mfa-challenge',
      'account/login', 'account/tokens', 'account/mfa',
    ],
  }),
  admin: { enabled: true },
  adminApi: { enabled: true, apiKeys: [env.get('AUTHKIT_ADMIN_API_KEY')] },
})

export default authServerConfig

issuer is the public provider URL and must end with mountPath — here …/oidc for mountPath: '/oidc'. In this topology the issuer host is the host app's own origin.

No clients: block. Boot the server, then register the host app's own client in the admin console at /admin/clients or via the Admin REST API (see A3a).

A3a. Register the host client

After the first boot, create the acme-web client (the host authenticating against itself):

curl -X POST http://localhost:3333/api/authkit/v1/clients \
  -H "Authorization: Bearer $AUTHKIT_ADMIN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "clientId": "acme-web",
        "redirectUris": ["http://localhost:3333/auth/callback"],
        "postLogoutRedirectUris": ["http://localhost:3333/"],
        "grantTypes": ["authorization_code", "refresh_token"],
        "tokenEndpointAuthMethod": "client_secret_basic"
      }'

Save the clientSecret from the response — it is shown only once. Put it in .env:

.env (additions)
AUTHKIT_CLIENT_ID=acme-web
AUTHKIT_CLIENT_SECRET=<secret from response>
AUTHKIT_REDIRECT_URI=http://localhost:3333/auth/callback

A4. Client config — point at your own issuer

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'),       // same issuer — the host points at itself
  clientId: env.get('AUTHKIT_CLIENT_ID'),  // 'acme-web'
  clientSecret: env.get('AUTHKIT_CLIENT_SECRET'),
  redirectUri: env.get('AUTHKIT_REDIRECT_URI'),
  resolver: resolvers.jwt({ tokenSource: 'session' }),

  resolveUser: async (identity: Identity) => {
    return AppUser.updateOrCreate(
      { id: identity.userId },
      { id: identity.userId, email: identity.email, fullName: identity.profile?.name ?? null }
    )
  },
})

export default authkitClientConfig

A5. Mount the host + register middleware

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

registerAuthHost(router, {
  mountPath: '/oidc',
})

One call mounts the provider, the login / consent / signup interaction, password reset, email verification, and the account console. See Host Kit for the render seam, branding, and ejecting.

Using @adonisjs/shield (default in the web starter kit)? Its CSRF protection blocks POST {mountPath}/token unless you exempt it — see the callout in Getting Started for the authkitCsrfExceptions snippet.

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

The client middleware proactively refreshes the token set (see Refresh Tokens) and attaches an authenticator to ctx.auth.

A6. The login / callback / logout flow

start/routes.ts
import {
  generatePkce,
  buildAuthorizeUrl,
  exchangeCode,
  buildEndSessionUrl,
} from '@adonis-agora/authkit-client'

const cfg = {
  issuer: process.env.AUTHKIT_ISSUER!,
  clientId: process.env.AUTHKIT_CLIENT_ID!,
  clientSecret: process.env.AUTHKIT_CLIENT_SECRET!,
  redirectUri: process.env.AUTHKIT_REDIRECT_URI!,
}

router.get('/login', async ({ session, response }) => {
  const { verifier, challenge } = await generatePkce()
  const state = crypto.randomUUID()
  session.put('pkce', { verifier, state })
  return response.redirect(
    buildAuthorizeUrl({
      ...cfg,
      scopes: ['openid', 'profile', 'email', 'offline_access'],
      state,
      codeChallenge: challenge,
    })
  )
})

router.get('/auth/callback', async ({ request, session, response }) => {
  const stash = session.get('pkce') as { verifier: string; state: string }
  const tokens = await exchangeCode({
    ...cfg,
    code: request.input('code'),
    codeVerifier: stash.verifier,
  })
  session.put('authkit', tokens) // the resolver reads the token set here
  return response.redirect('/')
})

router.get('/logout', async ({ session, response }) => {
  const tokens = session.get('authkit') as { idToken?: string } | undefined
  session.clear()
  return response.redirect(
    buildEndSessionUrl({
      issuer: cfg.issuer,
      idToken: tokens?.idToken,
      postLogoutRedirectUri: 'http://localhost:3333/',
    })
  )
})

A7. Environment

.env
AUTHKIT_ISSUER=http://localhost:3333/oidc
AUTHKIT_ADMIN_API_KEY=high-entropy-key-here
# set after registering the client (step A3a):
AUTHKIT_CLIENT_ID=acme-web
AUTHKIT_CLIENT_SECRET=<from admin API response>
AUTHKIT_REDIRECT_URI=http://localhost:3333/auth/callback

That's the whole single-app setup — the provider and the relying party are the same process.


Topology B — separate apps

Three apps: a dedicated IdP (server kit only) and two pure client apps, acme-web and acme-admin (client kit only), pointing at the IdP's issuer. There is no shared cookie or session between them — auth flows entirely over OIDC redirects, and logout is RP-initiated (with an optional back-channel pointer for server-to-server termination).

The IdP side

B1. Install & configure

npm install @adonis-agora/authkit-server
node ace configure @adonis-agora/authkit-server

B2. The account model

Identical to A2 — compose AuthUser from withAuthUser() and withCredentials(). The IdP owns the user identities.

B3. Server config

config/authkit.ts (IdP)
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'), // https://auth.acme.com/oidc
  adapter: adapters.database({ connection: 'auth' }),
  jwks: { source: 'managed', algorithm: 'RS256' },
  ttl: { accessToken: '15m', refreshToken: '30d' },
  accountStore: lucidAccountStore(AuthUser),
  mountPath: '/oidc',
  // React preset (after `node ace add --ui=react`). Remove this block and omit `render`
  // to use the built-in Edge views instead (no pages needed in the project).
  render: inertiaRenderer({
    prefix: 'authkit',
    views: [
      'login', 'consent', 'signup', 'forgot', 'reset',
      'verify-email', 'mfa-challenge',
      'account/login', 'account/tokens', 'account/mfa',
    ],
  }),
  admin: { enabled: true },
  adminApi: { enabled: true, apiKeys: [env.get('AUTHKIT_ADMIN_API_KEY')] },
})

export default authServerConfig

B4. Mount the host

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

registerAuthHost(router, {
  mountPath: '/oidc',
  adminApi: true,
})

That single call is the entire IdP: provider endpoints, login / consent / signup, password reset, email verification, and the account console.

Using @adonisjs/shield on the IdP app? Exempt POST {mountPath}/token from CSRF — see the authkitCsrfExceptions callout in Getting Started.

.env (IdP)
AUTHKIT_ISSUER=https://auth.acme.com/oidc
AUTHKIT_ADMIN_API_KEY=high-entropy-key-here

B3a. Register the clients

After the first boot, register acme-web and acme-admin via the Admin REST API:

# acme-web
curl -X POST https://auth.acme.com/api/authkit/v1/clients \
  -H "Authorization: Bearer $AUTHKIT_ADMIN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "clientId": "acme-web",
        "redirectUris": ["https://web.acme.com/auth/callback"],
        "postLogoutRedirectUris": ["https://web.acme.com/"],
        "grantTypes": ["authorization_code", "refresh_token"],
        "tokenEndpointAuthMethod": "client_secret_basic"
      }'

# acme-admin
curl -X POST https://auth.acme.com/api/authkit/v1/clients \
  -H "Authorization: Bearer $AUTHKIT_ADMIN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "clientId": "acme-admin",
        "redirectUris": ["https://admin.acme.com/auth/callback"],
        "postLogoutRedirectUris": ["https://admin.acme.com/"],
        "grantTypes": ["authorization_code", "refresh_token"],
        "tokenEndpointAuthMethod": "client_secret_basic"
      }'

Each response includes a clientSecret shown once — copy it into each client app's .env. Back-channel logout URIs and other per-client settings can be edited in the admin console at any time.

The client side (acme-web and acme-admin)

Each client app repeats the same steps with its own clientId, secret and redirect URI. The example below is acme-web; acme-admin is identical with its own credentials.

B5. Install & configure

npm install @adonis-agora/authkit-client
node ace configure @adonis-agora/authkit-client

B6. Client config

config/authkit_client.ts (acme-web)
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'),       // the IdP's issuer — https://auth.acme.com/oidc
  clientId: env.get('AUTHKIT_CLIENT_ID'),  // 'acme-web'
  clientSecret: env.get('AUTHKIT_CLIENT_SECRET'),
  redirectUri: env.get('AUTHKIT_REDIRECT_URI'),
  resolver: resolvers.jwt({ tokenSource: 'session' }),

  resolveUser: async (identity: Identity) => {
    return AppUser.updateOrCreate(
      { id: identity.userId },
      { id: identity.userId, email: identity.email, fullName: identity.profile?.name ?? null }
    )
  },
})

export default authkitClientConfig

B7. Middleware

start/kernel.ts (acme-web)
router.use([
  // ...
  () => import('@adonis-agora/authkit-client/authkit_middleware'),
])

B8. Login / callback / logout

The flow is the same as A6, except the redirects cross hosts — the browser bounces to https://auth.acme.com/oidc/auth and back to https://web.acme.com/auth/callback. There is no shared session: the IdP's SSO cookie lives on auth.acme.com, the app's session cookie lives on web.acme.com.

start/routes.ts (acme-web)
import {
  generatePkce,
  buildAuthorizeUrl,
  exchangeCode,
  buildEndSessionUrl,
} from '@adonis-agora/authkit-client'

const cfg = {
  issuer: process.env.AUTHKIT_ISSUER!,
  clientId: process.env.AUTHKIT_CLIENT_ID!,
  clientSecret: process.env.AUTHKIT_CLIENT_SECRET!,
  redirectUri: process.env.AUTHKIT_REDIRECT_URI!,
}

router.get('/login', async ({ session, response }) => {
  const { verifier, challenge } = await generatePkce()
  const state = crypto.randomUUID()
  session.put('pkce', { verifier, state })
  return response.redirect(
    buildAuthorizeUrl({
      ...cfg,
      scopes: ['openid', 'profile', 'email', 'offline_access'],
      state,
      codeChallenge: challenge,
    })
  )
})

router.get('/auth/callback', async ({ request, session, response }) => {
  const stash = session.get('pkce') as { verifier: string; state: string }
  const tokens = await exchangeCode({
    ...cfg,
    code: request.input('code'),
    codeVerifier: stash.verifier,
  })
  session.put('authkit', tokens)
  return response.redirect('/')
})

// RP-initiated logout: clear the local session, then end the IdP session.
router.get('/logout', async ({ session, response }) => {
  const tokens = session.get('authkit') as { idToken?: string } | undefined
  session.clear()
  return response.redirect(
    buildEndSessionUrl({
      issuer: cfg.issuer,
      idToken: tokens?.idToken,
      postLogoutRedirectUri: 'https://web.acme.com/',
    })
  )
})

For server-initiated termination (logging a user out of every app at once), the IdP POSTs a signed logout_token to each client's backchannelLogoutUri. Wire the receiving endpoint with AuthkitClientManager.handleBackchannelLogout(ctx) — see Back-channel Logout.

.env (acme-web)
AUTHKIT_ISSUER=https://auth.acme.com/oidc
AUTHKIT_CLIENT_ID=acme-web
AUTHKIT_CLIENT_SECRET=web-super-secret
AUTHKIT_REDIRECT_URI=https://web.acme.com/auth/callback

acme-admin uses the same code with AUTHKIT_CLIENT_ID=acme-admin, its own secret, and https://admin.acme.com/auth/callback.


Next steps

  • Topologies — the conceptual standalone vs embedded reference.
  • Customizing auth — recipes for route guards, role gating, user mapping, custom screens, emails, and events.
  • MFA — TOTP and step-up.
  • Admin Console — the built-in dashboard for users, roles, and clients.
  • Device Flowurn:ietf:params:oauth:grant-type:device_code.
  • Resolversjwt / pat / opaque.
  • Security — rate-limiting, lockout, audit, and logout.
  • React — frontend useAuth() and gating.

On this page