Agora

Customizing auth

Task-oriented recipes for bending AuthKit's behaviour — route guards, role gating, where roles come from, user mapping, custom screens, emails, account stores, events, and token resolution.

A grab-bag of focused recipes for "I want to change how the lib behaves". Each one is a short intro plus working code against the real APIs. They're independent — jump to the one you need.

Protect a route group with a redirect to login

ctx.auth is always present after the client middleware runs, but it never forces a login. For browser routes you want anonymous hits to redirect to your login route rather than throw a 500.

Don't write that middleware. @adonis-agora/authkit-client ships it. Register it as a named middleware and apply it to the group:

start/kernel.ts
export const middleware = router.named({
  // ...existing named middleware
  auth: () => import('@adonis-agora/authkit-client/auth_middleware'),
  silentAuth: () => import('@adonis-agora/authkit-client/silent_auth_middleware'),
})
start/routes.ts
router
  .group(() => {
    router.get('/dashboard', [DashboardController]).as('dashboard')
    router.get('/settings', [SettingsController]).as('settings')
  })
  .prefix('/app')
  .as('app')
  .use(middleware.auth({ redirectTo: '/auth/login' }))

The shipped middleware probes ctx.auth.check() and redirects to redirectTo when there is no session — /auth/login if you omit the option. Its sibling, silent_auth_middleware, resolves the identity without requiring it, which is what you want on public pages that render a "Sign in" button for guests and an avatar for members:

start/routes.ts
router.get('/', [HomeController]).use(middleware.silentAuth())

Two different middleware, two different jobs, and you generally want both. authkit_middleware is registered router-globally by node ace add and is what creates ctx.auth on every request. auth_middleware and silent_auth_middleware are opt-in per route and decide what happens when nobody is logged in. Full details in Client and AdonisJS auth.

When to hand-roll one anyway

Reach for your own middleware only when the shipped behaviour genuinely doesn't fit — you need to remember the originally requested URL, redirect by named route rather than by path, or return a JSON 401 for a mixed HTML/API surface. That is a real customization, not a re-implementation:

app/middleware/require_auth_middleware.ts
import type { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'

/**
 * Same guard as `auth_middleware`, plus: remembers where the visitor was
 * heading, and answers JSON on API requests instead of redirecting.
 */
export default class RequireAuthMiddleware {
  async handle(ctx: HttpContext, next: NextFn) {
    if (await ctx.auth.check()) return next()

    if (ctx.request.accepts(['html', 'json']) === 'json') {
      return ctx.response.unauthorized({ message: 'Not authenticated' })
    }

    ctx.session.put('returnTo', ctx.request.url(true))
    return ctx.response.redirect().toRoute('auth.login')
  }
}

auth.login is the route name from the Getting Started login/callback/logout wiring (registerOidcClient registers it under that name too). For API routes where anonymous access is a programming error, skip the middleware entirely and call auth.authenticate() in the controller — it throws.

Role-gate a group

Same shape, but it checks a role after confirming a login. hasGlobalRole(role) is synchronous and reads identity.globalRoles (the IdP claims), so the identity must be resolved first — stacking it after middleware.auth() guarantees that:

app/middleware/require_global_role_middleware.ts
import type { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'

export default class RequireGlobalRoleMiddleware {
  async handle(ctx: HttpContext, next: NextFn, options: { role: string }) {
    // `middleware.auth()` already ran, so the identity is resolved and non-null.
    if (!ctx.auth.hasGlobalRole(options.role)) {
      return ctx.response.forbidden({ message: 'Access denied' })
    }
    return next()
  }
}
start/kernel.ts
export const middleware = router.named({
  requireGlobalRole: () => import('#middleware/require_global_role_middleware'),
})
start/routes.ts
router
  .group(() => {
    router.get('/admin', [AdminController]).as('admin')
  })
  .use([middleware.auth(), middleware.requireGlobalRole({ role: 'ADMIN' })])

Global roles are the only role surface AuthKit gives you, and that is deliberate. identity.globalRoles is what the identity provider asserted about a person — a platform-wide fact, the same for every relying party that receives the token. There is no per-app role, no permission, and no resource-scoped check on ctx.auth.

Per-app authorization belongs to @adonis-agora/authz, which stores roles and permissions in your own database and integrates with AdonisJS Bouncer, or to Bouncer on its own. AuthKit answers who this is; authz answers what they may do. If you find yourself wanting hasAppRole, that is the seam you are looking for.

For a full admin surface that is already built — users, clients, roles, audit — see the Admin Console.

Where roles come from: resolveTokenRoles

Everything above consumes roles. This is the hook that decides where they come from in the first place.

By default, the roles minted into a token are whatever the account store reports on account.globalRoles — typically a column on your AuthUser model. That is fine until roles stop living there. The moment an external authority owns roles — a permissions service, or @adonis-agora/authz with its own roles / user_roles tables — you have two sources of truth, and the token carries the stale one.

resolveTokenRoles collapses them back into one. Set it on the server config and it becomes the answer to "what roles does this account have", replacing the stored column entirely:

config/authkit.ts
import { defineConfig, adapters, lucidAccountStore } from '@adonis-agora/authkit-server'
import type { AuthAccount } from '@adonis-agora/authkit-server'
import AuthUser from '#models/auth_user'
import RoleAssignment from '#models/role_assignment'

export default defineConfig({
  issuer: env.get('AUTHKIT_ISSUER'),
  adapter: adapters.database({ connection: 'auth' }),
  jwks: 'auto',
  accountStore: lucidAccountStore(AuthUser),
  globalRolesClaim: 'roles',

  // The single source of truth for this account's roles.
  resolveTokenRoles: async (account: AuthAccount, { clientId, activeOrg }) => {
    const rows = await RoleAssignment.query()
      .where('user_id', account.id)
      .preload('role')

    const roles = rows.map((row) => row.role.slug)

    // The context lets you scope what you emit. Here: an org-scoped role
    // travels alongside the global ones when a workspace is active.
    if (activeOrg) roles.push(`org:${activeOrg.orgRole}`)

    return roles
  },
})

The contract

resolveTokenRoles?: (
  account: AuthAccount,
  context: {
    clientId?: string
    activeOrg?: { orgId: string; orgSlug: string; orgRole: string } | null
  },
) => string[] | Promise<string[]>

account is the public AuthAccount DTO (id, email, globalRoles?, name?, avatarUrl?) — not a Lucid model, so the hook stays valid whichever account store you use. context.clientId is the OIDC client the token is being minted for; context.activeOrg is the organization selected in the current session, or null. Return the flat array of role strings to emit. Returning [] is a legitimate answer and means "no roles".

When it is absent, nothing changes: the mint falls back to account.globalRoles ?? []. Adding the hook is opt-in and reversible.

Where it is called

Three places, and it matters that they are the same three:

Minting the roles claim. When the OIDC provider assembles claims for an account, the value of the claim named by globalRolesClaim (default roles) comes from this hook. Both clientId and activeOrg are populated here, so you can emit different roles to different products from one identity provider.
Token exchange (impersonation). The RFC 8693 grant resolves roles through the same hook twice: once for the actor, to decide whether they are allowed to impersonate at all, and once for the target, to build the impersonated token's claim. Without this, a host that keeps roles outside globalRoles could never impersonate — the admin check would always see an empty array. Here activeOrg is null, because a token exchange is not tied to a browser session.
Admin console access. The guard on /admin looks up the account, resolves its roles through this hook, and requires at least one of admin.roles (default ['ADMIN']). The console is neither client- nor org-scoped for role purposes, so the hook is called with an empty context (clientId: undefined, activeOrg: null).

Because the console guard goes through the same hook, resolveTokenRoles is authoritative for console access, not additive. An account with ADMIN sitting in account.globalRoles is locked out if your hook doesn't return ADMIN for them. That is the point — one authority, no shadow path — but it is easy to lock yourself out of your own admin console the first time you wire it up. Make sure your role store actually contains the admin role before you deploy the hook.

How it interacts with globalRolesClaim

The two settings are the what and the where: resolveTokenRoles decides which roles to emit, globalRolesClaim decides which claim carries them (default roles). They are independent — you can change either without touching the other.

The claim name has to agree across the wire. The client resolves identity.globalRoles from the claim named by its own globalRolesClaim in config/authkit_client.ts; if the two names diverge, the roles arrive but land nowhere and every hasGlobalRole check quietly answers false. Keep them equal, or you have a silent failure rather than an error.

The roles claim

Two gates already stand in front of the roles claim before any allowlist is involved. The client has to have been registered by an admin, and it has to ask for scope=roles — the claim group is bound to that scope in the provider, so a client that does not request it never sees roles no matter what else is configured.

firstPartyClients is a third gate, for hosts that register clients they do not own:

// config/authkit.ts
export default defineConfig({
  // Only these two receive `roles`, `org_id`, `org_slug` and `org_role`.
  firstPartyClients: ['acme-web', 'acme-admin'],
  // …
})

Omit the key and every registered client is first-party, which is the right default: the two gates above already stand, and the alternative is a config file that silently decides your relying parties get no roles. Declare it and the list is taken literally — a client outside it never receives roles even when it asks for the scope, and resolveTokenRoles is not even called for it. An explicitly empty list means nobody.

branding.firstParty is still read as a fallback, so a host that already declared it keeps exactly the restriction it has today. Prefer firstPartyClients in new configs: who may receive authorization data is not a theming decision, and keeping it inside a theming block meant a host that never customised its look got a policy it never chose. node ace authkit:doctor tells you which of the two is in effect.

Map the Identity to your own User model

The IdP authenticates a person and hands the client an Identity (claims only). Turn that into the row your app works with via resolveUser in config/authkit_client.ts. Resolve by the stable userId (the sub), and fall back to email to reconcile rows that pre-existed the IdP:

config/authkit_client.ts
import { defineConfig, resolvers } from '@adonis-agora/authkit-client'
import type { Identity } from '@adonis-agora/authkit-client'
import AppUser from '#models/app_user'

export default 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' }),

  resolveUser: async (identity: Identity) => {
    // Prefer the stable IdP subject; fall back to email for pre-existing rows.
    const byId = await AppUser.find(identity.userId)
    if (byId) return byId

    const byEmail = await AppUser.findBy('email', identity.email)
    if (byEmail) {
      byEmail.id = identity.userId // adopt the IdP subject going forward
      await byEmail.save()
      return byEmail
    }

    return AppUser.create({
      id: identity.userId,
      email: identity.email,
      fullName: identity.profile?.name ?? null,
    })
  },

})

resolveUser runs lazily — only when something calls auth.getUser() — and its result is memoised for the rest of the request, so a request that never asks for the user never touches your database. It receives a second argument, { accessToken }, when the session carries one; use it if your mapping needs to call back to the IdP (for example to fetch extra profile fields from a userinfo endpoint).

The Identity shape (userId, email, globalRoles, profile, sessionId, raw) is the same regardless of which resolver produced it — see Resolvers.

There is no companion hook for app-local roles, and adding one would be the wrong shape: a role you compute in the relying party is authorization, and authorization is @adonis-agora/authz's job. Map the identity to a user here; ask authz what that user may do. If instead you want your role store to feed the token itself, that belongs on the server side — see Where roles come from.

Custom screens

AuthKit's login / consent / signup / account screens are rendered through a render hook in config/authkit.ts. There are three modes, and the right one depends on how much you want to customise.

Default (zero setup): Edge views

Out of the box — no render key at all, or edgeRenderer() — AuthKit serves its built-in Edge views from the authkit:: virtual disk. These pages are server-rendered and include the standard Acme-style Tailwind markup, branding tokens, and i18n support. Nothing in your project needs to exist for this to work:

config/authkit.ts
import { defineConfig } from '@adonis-agora/authkit-server'

export default defineConfig({
  // No `render` key → Edge views are used automatically.
})

Or equivalently, using edgeRenderer() explicitly:

config/authkit.ts
import { defineConfig, edgeRenderer } from '@adonis-agora/authkit-server'

export default defineConfig({
  // ...
  render: edgeRenderer(),
})

Both are identical. The Edge renderer calls ctx.view.render('authkit::<view>', props), where each view is a file shipped inside the package — never from your project's disk.

To render the flow screens as React components you need to:

  1. Scaffold the pages into your project. The pages do not ship as React files inside the npm package — there are no .tsx files to import. Instead, node ace add writes starter components into your project that you then own and customise:

    node ace add @adonis-agora/authkit-server --ui=react

    This requires @adonisjs/inertia + React + Vite already configured in your app (run node ace add @adonisjs/inertia first if needed). The command aborts if those prerequisites are missing.

  2. Point inertiaRenderer at the scaffolded pages. The published config stub already includes this — you only need to verify the prefix matches your Inertia pages directory:

    config/authkit.ts
    import { defineConfig, inertiaRenderer } from '@adonis-agora/authkit-server'
    
    export default defineConfig({
      // ...
      render: inertiaRenderer({
        prefix: 'authkit',
        views: [
          'login',
          'consent',
          'signup',
          'forgot',
          'reset',
          'verify-email',
          'mfa-challenge',
          'account/login',
          'account/tokens',
          'account/mfa',
        ],
      }),
    })

What the scaffold generates

--ui=react writes these files into your project, at paths relative to the application root:

FileScreen
inertia/pages/authkit/login.tsxSign-in (identifier step, then password)
inertia/pages/authkit/signup.tsxRegistration form
inertia/pages/authkit/consent.tsxOAuth consent / scope grant
inertia/pages/authkit/forgot.tsxForgot-password request
inertia/pages/authkit/reset.tsxPassword reset form
inertia/pages/authkit/verify-email.tsxEmail verification landing
inertia/pages/authkit/mfa-challenge.tsxTOTP / passkey MFA prompt
inertia/pages/authkit/account/login.tsxAccount console sign-in
inertia/pages/authkit/account/tokens.tsxPersonal Access Tokens management
inertia/pages/authkit/account/mfa.tsxMFA enrolment
inertia/components/auth_shell.tsxShared layout shell the pages above import

Note that the shell lands in inertia/components/, one level up from the pages — it is a component, not a routable page, and Inertia would otherwise try to resolve it as one. Every generated page imports it as ../../components/auth_shell.

These files are yours from the moment they are generated. Edit them freely — they will not be overwritten by future node ace add runs.

The views allowlist and the Edge fallback

views is an explicit set of the screen names you have React pages for — the order of the array is irrelevant, only membership matters. A screen name that is not in the set falls back to the built-in Edge view instead of throwing an SSR error. That covers both directions: screens you deliberately left on Edge, and any screen the library renders that your project has no page for. Watch for typos — a misspelled name simply routes that one screen to Edge, silently.

Requested screen: 'verify-email'
  → listed in views? yes → rendered through Inertia as 'authkit/verify-email'

Requested screen: 'session-expired'
  → listed in views? no  → rendered by the built-in Edge view  [fallback]

If you omit views entirely, every screen is sent to Inertia — including any new screens added by future library updates. That will crash with an SSR error ("Cannot find module") because those pages won't exist in your project yet. Always provide views with the exact set of pages the scaffold generated.

Where the files live, and how prefix maps to them

Two different things decide the final path, and it helps to keep them apart.

The scaffold writes to fixed locations — inertia/pages/authkit/* for the screens and inertia/components/auth_shell.tsx for the shell. It does not read your Inertia config.

The renderer doesn't know about files at all. inertiaRenderer({ prefix: 'authkit' }) turns the screen name login into the Inertia page identifier authkit/login, and hands that string to inertia.render(). Resolving that identifier to a module is entirely @adonisjs/inertia's job — in a default AdonisJS app, the glob in inertia/app/app.tsx maps it to inertia/pages/authkit/login.tsx:

screen name:  'login'
prefix:       'authkit'
→ page id:    'authkit/login'
→ resolved:   inertia/pages/authkit/login.tsx

So if your app keeps pages somewhere other than inertia/pages/, nothing in AuthKit needs to change — adjust your Inertia page resolver, move the generated files to match, and keep prefix pointing at the subdirectory they live in. AuthKit does not own that setting.

Headless (bring your own renderer)

Omit render and drive the flows via the JSON contracts yourself — the routes still mount but respond with JSON payloads rather than rendered HTML. Useful when you want a fully custom SSR setup, a different template engine, or a mobile app consuming the same flows.

Copy-only tweaks: override i18n strings

If you only want to change wording (not layout), don't replace the screens — override strings via i18n.messages. The keys you provide are merged over the active locale's built-in catalogue, so you can change just a few:

config/authkit.ts
export default defineConfig({
  // ...
  i18n: {
    locale: 'en',
    messages: {
      en: {
        'login.title': 'Sign in to Acme',
        'login.submit': 'Continue',
      },
    },
  },
})

The default locale is en; pt-BR is a built-in extra. See i18n for the full key catalogue and adding whole new locales.

Custom emails

Reset-password, email-verification, and magic-link mails go through the host's @adonisjs/mail by default (branded HTML + text fallback; logs the link in dev when mail isn't configured). To take full control of delivery, provide the mail hooks — when present they override the default sender:

config/authkit.ts
import mail from '@adonisjs/mail/services/main'

export default defineConfig({
  // ...
  mail: {
    onPasswordReset: async ({ email, resetUrl, token }) => {
      await mail.send((msg) => {
        msg.to(email).subject('Reset your Acme password').htmlView('emails/reset', { resetUrl })
      })
    },
    onEmailVerification: async ({ email, verifyUrl, token }) => {
      await mail.send((msg) => {
        msg.to(email).subject('Verify your email').htmlView('emails/verify', { verifyUrl })
      })
    },
    onMagicLink: async ({ email, magicUrl, token }) => {
      await mail.send((msg) => {
        msg.to(email).subject('Your sign-in link').htmlView('emails/magic', { magicUrl })
      })
    },
  },
})

Each hook receives the ready-built URL plus the raw token (in case you build your own link). Hooks are best-effort — a throw is swallowed and never breaks the request. The onMagicLink hook only fires when passwordless magic link is enabled.

Dedicated sender (mail.from)

When you rely on the default emails (no onX hook) — reset, verification, magic link, and the security alerts (new device/login, password changed, MFA toggled) — AuthKit needs a from address. It resolves in this order:

  1. mail.from here (AuthKit config) — highest priority;
  2. the host's global from in config/mail.ts;
  3. whatever @adonisjs/mail defaults to.

Set mail.from to give auth mail its own sender — e.g. a security address distinct from your app's general from — without touching config/mail.ts:

config/authkit.ts
export default defineConfig({
  // ...
  mail: {
    // string "Name <email>" or { address, name }
    from: 'Acme Security <no-reply-auth@acme.com>',
  },
})

With no from anywhere, the envelope MAIL FROM is empty and strict providers (e.g. Resend) reject the message with 550 Invalid from. mail.from only affects AuthKit's default emails — your custom onX hooks build their own from.

Custom account store

The accountStore is AuthKit's primary identity contract — it's where the IdP looks up and verifies accounts. lucidAccountStore(AuthUser) covers the common case, but you can point at any backing store by implementing CoreAccountStore yourself. The core surface is mandatory; MFA, passkeys, provider linking, and magic link are opt-in capabilities you mix in only if you want those features:

app/auth/my_account_store.ts
import type {
  CoreAccountStore,
  AuthAccount,
  CreateAccountInput,
  ListAccountsParams,
  Paginated,
} from '@adonis-agora/authkit-server'

export class MyAccountStore implements CoreAccountStore {
  // --- identity (provider-facing) ---
  async findById(id: string): Promise<AuthAccount | null> { /* ... */ }
  async verifyCredentials(email: string, password: string): Promise<AuthAccount | null> { /* ... */ }

  // --- signup / social ---
  async findByEmail(email: string): Promise<AuthAccount | null> { /* ... */ }
  async create(input: CreateAccountInput): Promise<AuthAccount> { /* ... */ }

  // --- password reset ---
  async issuePasswordResetToken(email: string) { /* ... */ }
  async consumePasswordResetToken(token: string, newPassword: string) { /* ... */ }

  // --- email verification ---
  async issueEmailVerificationToken(email: string) { /* ... */ }
  async consumeEmailVerificationToken(token: string) { /* ... */ }

  // --- admin listing/roles (AdminCapability, part of the core contract) ---
  async listAccounts(params: ListAccountsParams): Promise<Paginated<AuthAccount>> { /* ... */ }
  async setGlobalRoles(accountId: string, roles: string[]): Promise<void> { /* ... */ }
}

findById/verifyCredentials return the public AuthAccount DTO (id, email, globalRoles?, name?, avatarUrl?) — never a raw Lucid model. That DTO boundary is what lets the same IdP run over Lucid, over an LDAP directory, or over a legacy table, without anything upstream knowing.

CoreAccountStore extends AdminCapability, so listAccounts and setGlobalRoles are part of the mandatory surface, not extras. Everything else is opt-in: implement MfaCapability / WebauthnCapability on the same class to light up MFA and passkeys. The full contract, every capability, and the published mixins are documented in Account Store.

If an external authority owns roles, setGlobalRoles becomes a write path into a store that is no longer the source of truth. Pair a custom store with resolveTokenRoles so reads and writes agree on who decides.

React to auth events

The IdP audits every meaningful event (signup, login success/failure, password reset, MFA enrol, ...). Observe them in-process with events.onEvent — handy for syncing your own user table on signup/login without polling. It's best-effort and fire-and-forget; a throw is isolated and never reaches the request:

config/authkit.ts
export default defineConfig({
  // ...
  events: {
    onEvent: async (event) => {
      // event: { type, accountId, email, clientId, actorId, ip, metadata }
      if (event.type === 'signup' && event.accountId) {
        await AppUser.firstOrCreate(
          { id: event.accountId },
          { id: event.accountId, email: event.email ?? undefined }
        )
      }
    },
  },
})

For HTTP delivery to an external service (with optional HMAC-SHA256 signing), set events.webhook instead of — or alongside — onEvent. See Events for the event catalogue and webhook details.

Custom token resolution

The client's resolver decides how a request becomes an Identity. Pick by your token model and revocation needs:

  • resolvers.jwt — browser sessions, validated locally against the IdP's JWKS. Fastest (no per-request network call) but only sees revocation at token expiry.
  • resolvers.opaque — browser sessions that must honour immediate revocation; introspects the access token at the IdP per request (requires a confidential client).
  • resolvers.pat — machine-to-machine callers presenting a Personal Access Token, introspected at the IdP.
config/authkit_client.ts
resolver: resolvers.opaque({ tokenSource: 'session', cacheTtlMs: 5000 })

Whichever you pick, resolveUser is unchanged and the Identity it receives has the same shape — the resolver is swappable precisely because nothing downstream depends on the token model. See Resolvers for every option and the choosing matrix.

Recipe: per-client auth UI (one IdP, a face per product)

Problem. You run a single IdP (true SSO) but multiple products redirect to it to log in (say, a writing app and a courses app). With one shared login screen, every product shows the IdP owner's branding.

The OIDC constraint. Login always happens at the IdP — the relying party redirects to /authorize, the IdP authenticates, then redirects back with a code. A client cannot serve its own login page on its own domain and still delegate auth to your IdP; that would require the deprecated password grant (the client proxies the password) and breaks SSO. So the IdP renders — but each product can still own its screens.

The pattern. Keep one IdP, and resolve the auth shell per client. Declare each product's look under branding.clients, keyed by clientId:

config/authkit.ts
export default defineConfig({
  // ...
  branding: {
    company: 'Acme',
    clients: {
      'writing-app': { appName: 'Acme Write', accent: '#1d4ed8', accentSoft: '#3b82f6', tagline: 'Draft together' },
      'courses-app': { appName: 'Acme Learn', accent: '#047857', accentSoft: '#10b981', tagline: 'Learn anything' },
    },
    default: { appName: 'Acme', accent: '#111827', accentSoft: '#374151', tagline: 'One account' },
    firstParty: ['writing-app', 'courses-app'],
  },
})

The host resolves that entry for the client that started the flow and passes it to every rendered screen as brand — including the clientId itself, which is the stable key you want to switch on. String-matching appName works until someone renames a product; clientId never changes.

inertia/components/auth_shell.tsx
import type { ReactNode } from 'react'
import WriteAuthShell from './shells/write_auth_shell'
import LearnAuthShell from './shells/learn_auth_shell'
import DefaultAuthShell from './shells/default_auth_shell'

// Extend the generated `AuthBrand` with the key the host already sends.
export interface AuthBrand {
  clientId?: string
  appName: string
  accent: string
  accentSoft?: string
  company?: string
  tagline?: string
  audienceLabel?: string
}

const REGISTRY: Record<string, typeof DefaultAuthShell> = {
  'writing-app': WriteAuthShell,
  'courses-app': LearnAuthShell,
}

export default function AuthShell({ brand, children }: { brand?: AuthBrand; children: ReactNode }) {
  const Shell = (brand?.clientId && REGISTRY[brand.clientId]) || DefaultAuthShell
  return <Shell brand={brand}>{children}</Shell>
}

Each shells/<product>.tsx owns that product's look (colors, fonts, copy). The pages (login, consent, …) keep importing AuthShell — selection is transparent, and an unknown or missing clientId falls back to the default shell rather than crashing. In a monorepo, promote each product's shells to its own package so each team works in its own area; the IdP just imports and registers them.

branding.firstParty does double duty here. Beyond skipping the consent screen, it is read as a fallback allowlist for the roles and organization claims — see The roles claim. Adding a product to clients without adding it to firstParty gives it your branding but no roles. Declare firstPartyClients to keep the two decisions apart.

On this page