Agora

Account Store

The AccountStore contract, its twelve optional capabilities, the Lucid default, and the model mixins.

The AccountStore is AuthKit's primary identity contract. defineConfig derives findAccount and verifyCredentials from it, and the host-kit controllers use it for signup, password reset, email verification, and MFA. AuthKit never sees your Lucid model directly — only the AuthAccount DTO the store returns.

The contract

The store is a mandatory core plus a set of optional capabilities. The AccountStore type used by the config is the core intersected with a Partial<> of every capability, so a store that implements nothing but the core still type-checks as an AccountStore.

interface AuthAccount {
  id: string
  email: string
  globalRoles?: string[]
  name?: string
  avatarUrl?: string
}

// Always present. Identity, signup, password reset, email verification, and admin.
interface CoreAccountStore extends AdminCapability {
  // Provider-facing
  findById(id: string): Promise<AuthAccount | null>
  verifyCredentials(email: string, password: string): Promise<AuthAccount | null>
  // Signup / social
  findByEmail(email: string): Promise<AuthAccount | null>
  create(input: CreateAccountInput): Promise<AuthAccount>
  // Password reset
  issuePasswordResetToken(email: string): Promise<{ token: string; account: AuthAccount } | null>
  consumePasswordResetToken(token: string, newPassword: string): Promise<boolean>
  // Email verification (writes the state)
  issueEmailVerificationToken(email: string): Promise<{ token: string; account: AuthAccount } | null>
  consumeEmailVerificationToken(token: string): Promise<boolean>
}

// Folded into the core: the admin console and the Admin REST API call these directly.
interface AdminCapability {
  listAccounts(params: ListAccountsParams): Promise<Paginated<AuthAccount>>
  setGlobalRoles(accountId: string, roles: string[]): Promise<void>
  /** Optional fast path — see below. */
  countByGlobalRole?(role: string): Promise<number>
}

countByGlobalRole is the one optional member of the core. AuthKit cannot write a generic count query for it, because the shape of globalRoles is yours — a JSON column, a join table, a claim from somewhere else. Implement it if you know how to count efficiently in your own schema (the "last admin" invariant is the caller that asks); leave it out and the caller falls back to paging through listAccounts.

Everything else is a capability: a named interface that a store either implements in full or omits entirely.

type AccountStore = CoreAccountStore &
  Partial<
    MfaCapability & WebauthnCapability & ProviderIdentityCapability &
    AccountSecurityCapability & AccountStatusCapability & ProfileCapability &
    MagicLinkCapability & OtpLoginCapability & EmailVerificationStatusCapability &
    AccountDeletionCapability & AccountImportCapability & OrganizationsCapability
  >

A capability is all-or-nothing: a store either implements every method of MfaCapability or omits the capability completely — there are no half-present methods that throw. That rule is what makes the whole design work, because AuthKit decides at runtime which features exist by probing for a method, not by reading a flag you set.

The capability probe

Every optional capability ships an exported type guard. Each guard checks one representative method and narrows the store type on the true branch:

import { supportsMfa } from '@adonis-agora/authkit-server'

if (supportsMfa(store)) {
  await store.getMfaState(accountId) // typed as AccountStore & MfaCapability here
}

This is why a missing capability must mean missing methods rather than methods that throw. If you stub getMfaState with a placeholder that throws, supportsMfa returns true, AuthKit renders the MFA challenge, and the flow breaks at request time instead of degrading quietly.

Degradation is the point. When a capability is absent AuthKit does not error — it removes the feature: the UI hides the section, the flow skips the step, and the REST/admin surfaces answer with an explicit "unsupported" status. The table below is the full map from guard to feature; each capability is detailed underneath.

GuardCapabilityProbesLights upWith lucidAccountStore
supportsMfaMfaCapabilitygetMfaStateTOTP second factor at login and enrollment on /account/securityalways
supportsPasskeysWebauthnCapabilitylistPasskeysPasskey registration and passkey loginneeds webauthnCredentialModel
supportsProviderIdentityProviderIdentityCapabilityfindByProviderIdentitySocial account linking, plus identity cascade on delete/exportneeds providerIdentityModel
supportsAccountSecurityAccountSecurityCapabilitychangePasswordChange password / change email on /account/securityalways
supportsAccountStatusAccountStatusCapabilitydisableAccountAdmin Disable / Enable; both login paths reject disabled accountsneeds a disabled_at column
supportsProfileProfileCapabilityupdateProfileThe Profile section (name / avatar)needs full_name and/or avatar_url
supportsMagicLinkMagicLinkCapabilityissueMagicLinkTokenPasswordless "email me a link" loginalways
supportsOtpLoginOtpLoginCapabilityissueMagicLinkWithCode + verifyLoginCodeThe typable one-time code that rides along with the magic linkalways
supportsEmailVerificationStatusEmailVerificationStatusCapabilityisEmailVerifiedThe login.requireVerifiedEmail gateneeds an email_verified_at column
supportsAccountDeletionAccountDeletionCapabilitydeleteAccountThe account danger zone, DELETE /users/:id, and the LGPD/GDPR cascadealways
supportsAccountImportAccountImportCapabilityimportAccountThe authkit:users:import commandalways
supportsOrganizationsOrganizationsCapabilitycreateOrgMulti-tenancy: orgs, members, invitations, org claimsneeds organizationModels

"always" means the Lucid store mounts the capability for any model composed from the AuthKit mixins — no extra models, no extra columns, no configuration. The three column-probed rows are detected through Model.$columnsDefinitions, so adding the column in your own migration is all it takes.

The capabilities in detail

The Lucid default

lucidAccountStore(Model, options?) implements the contract against a Lucid model:

config/authkit.ts
import AuthUser from '#models/auth_user'
import { lucidAccountStore } from '@adonis-agora/authkit-server'

accountStore: lucidAccountStore(AuthUser, { mfaIssuer: 'Acme' })

The mfaIssuer option is the label shown in authenticator apps; the store uses it to build the TOTP otpauth URI.

The store is assembled per capability. The core, MFA, account security, magic link, OTP login, account import and account deletion are mounted for every model. The rest are conditional, and when a condition is not met the methods are genuinely absent from the returned object — which is precisely what makes the supports* guards tell the truth:

CapabilityMounted when
WebauthnCapabilityyou pass webauthnCredentialModel
ProviderIdentityCapabilityyou pass providerIdentityModel
OrganizationsCapabilityyou pass all three organizationModels
AccountStatusCapabilitythe model has a disabled_at column
ProfileCapabilitythe model has full_name and/or avatar_url
EmailVerificationStatusCapabilitythe model has an email_verified_at column

Columns are probed through Model.$columnsDefinitions, so you add them with your own migration and the capability appears on the next boot. Nothing else changes.

lucidStores — the same wiring, declared once

lucidStores builds the account store plus the PAT store and audit sink from one map of models, so mfaIssuer / webauthn / encrypter are declared a single time:

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

const { accountStore, patStore, audit } = lucidStores(
  {
    account: AuthUser,
    pat: PersonalAccessToken,
    audit: AuditLog,
    providerIdentity: ProviderIdentity,
    webauthnCredential: WebauthnCredential,
    organizations: {
      OrgModel: Organization,
      MemberModel: OrganizationMember,
      InvitationModel: OrganizationInvitation,
    },
  },
  { mfaIssuer: 'Acme' },
)

It delegates to lucidAccountStore, so the capability rules are identical: pass the webauthnCredential model and passkeys light up, leave organizations out and supportsOrganizations is false. patStore and audit are returned only when their models are supplied.

The model mixins

Compose your AuthUser model from AuthKit's mixins so it has the columns and methods the store expects:

  • withAuthUser — base identity columns (email, password hash, globalRoles). It does not declare a primary key or generate one — see below.
  • withCredentials — email verification + password-reset token plumbing.
  • withPersonalAccessToken — relation/helpers for PAT.
  • withMfa — TOTP secret, mfaEnabledAt, and recovery codes for MFA.
  • withAuditLog — typed AuditEventType columns for an audit-log model.

None of the mixins declare an id column or a fullName column, and both are required:

  • id needs a @beforeCreate hook assigning a real value (e.g. randomUUID()) and static selfAssignPrimaryKey = true. Without the hook, Lucid inserts NULL for this string column. Without the flag, Lucid still overwrites the id the hook just assigned with whatever the raw INSERT returns (the database's internal auto-increment rowid) the instant the row is saved. Either way the account becomes unreachable by its real id on the very next request.
  • fullName is required because the built-in signup screen always collects a "Name" field, and the Lucid store passes it straight to AuthUser.create().

node ace configure @adonis-agora/authkit-server scaffolds both the model below and a matching migration at database/migrations/<timestamp>_create_auth_users_table.ts — run node ace migration:run once before the first signup. Its columns are exactly the ones this page documents: id (string primary key, not auto-increment — the @beforeCreate hook needs somewhere to put the UUID), email (unique), password, global_roles (JSON, defaults to []), the four withCredentials columns (email_verified_at, email_verification_token, password_reset_token, password_reset_expires_at), and full_name. If you add your own @column()s to AuthUser (e.g. disabled_at for AccountStatusCapability, avatar_url for ProfileCapability), extend that migration yourself with an alterTablenode ace configure only scaffolds the columns the minimal AuthUser needs.

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, withMfa } from '@adonis-agora/authkit-server'

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

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

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

  @column()
  declare fullName: string | null
}

Bringing your own store

If your users don't live in a Lucid model — or you need custom logic — implement CoreAccountStore yourself and pass it to accountStore. As long as the core methods fulfil the contract above, every host-kit flow keeps working; you simply start with the smallest AuthKit and add features by adding capabilities.

app/auth/directory_account_store.ts
import type { AccountStore, AuthAccount } from '@adonis-agora/authkit-server'

export function directoryAccountStore(): AccountStore {
  return {
    async findById(id) { /* … */ },
    async findByEmail(email) { /* … */ },
    async verifyCredentials(email, password) { /* … */ },
    async create(input) { /* … */ },
    async issuePasswordResetToken(email) { /* … */ },
    async consumePasswordResetToken(token, newPassword) { /* … */ },
    async issueEmailVerificationToken(email) { /* … */ },
    async consumeEmailVerificationToken(token) { /* … */ },
    async listAccounts(params) { /* … */ },
    async setGlobalRoles(accountId, roles) { /* … */ },

    // One capability, implemented whole. Now `supportsProfile(store)` is true and
    // the Profile card appears on /account/security.
    async updateProfile(accountId, patch) { /* … */ },
  }
}

Never stub a capability method with a throwing placeholder to "satisfy the interface". The supports* guards — and therefore the UI, the REST responses and the login flow — read method presence. A present-but-throwing method advertises a feature you do not have, and the failure surfaces to a user mid-flow instead of never being offered.

On this page