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:
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'),
})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:
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:
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:
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()
}
}export const middleware = router.named({
requireGlobalRole: () => import('#middleware/require_global_role_middleware'),
})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:
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:
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.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 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:
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:
import { defineConfig } from '@adonis-agora/authkit-server'
export default defineConfig({
// No `render` key → Edge views are used automatically.
})Or equivalently, using edgeRenderer() explicitly:
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.
React / Inertia (recommended for branded UX)
To render the flow screens as React components you need to:
-
Scaffold the pages into your project. The pages do not ship as React files inside the npm package — there are no
.tsxfiles to import. Instead,node ace addwrites starter components into your project that you then own and customise:node ace add @adonis-agora/authkit-server --ui=reactThis requires
@adonisjs/inertia+ React + Vite already configured in your app (runnode ace add @adonisjs/inertiafirst if needed). The command aborts if those prerequisites are missing. -
Point
inertiaRendererat 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:
| File | Screen |
|---|---|
inertia/pages/authkit/login.tsx | Sign-in (identifier step, then password) |
inertia/pages/authkit/signup.tsx | Registration form |
inertia/pages/authkit/consent.tsx | OAuth consent / scope grant |
inertia/pages/authkit/forgot.tsx | Forgot-password request |
inertia/pages/authkit/reset.tsx | Password reset form |
inertia/pages/authkit/verify-email.tsx | Email verification landing |
inertia/pages/authkit/mfa-challenge.tsx | TOTP / passkey MFA prompt |
inertia/pages/authkit/account/login.tsx | Account console sign-in |
inertia/pages/authkit/account/tokens.tsx | Personal Access Tokens management |
inertia/pages/authkit/account/mfa.tsx | MFA enrolment |
inertia/components/auth_shell.tsx | Shared 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.tsxSo 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:
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:
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:
mail.fromhere (AuthKit config) — highest priority;- the host's global
frominconfig/mail.ts; - whatever
@adonisjs/maildefaults 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:
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:
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:
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.
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:
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.
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.