Agora

Organizations

Multi-tenancy — organizations, members, invitations, and per-org token claims.

Organizations add multi-tenancy to your IdP: users belong to one or more organizations, can switch their active organization, and receive per-org claims (org_id, org_slug, org_role) in every token. The feature is capability-probed: AuthKit activates it automatically when the three required tables are present in the database — no config flag needed unless you want to tune the defaults.

Database schema

Created automatically. With schema auto-management on (the default), AuthKit creates the three organization tables on boot — nothing to do. If you disabled it (schema: { autoManage: false }), call ensureAuthkitSchema(this.db) in a migration, or create the tables yourself. For reference, the column names the Lucid store expects:

// auth_organizations
schema.createTable('auth_organizations', (table) => {
  table.string('id').primary()
  table.string('name').notNullable()
  table.string('slug').notNullable().unique()
  table.string('logo_url').nullable()
  table.json('metadata').nullable()
  table.timestamps(true, true)
})

// auth_organization_members
schema.createTable('auth_organization_members', (table) => {
  table.string('id').primary()
  table.string('organization_id').notNullable()
    .references('id').inTable('auth_organizations').onDelete('CASCADE')
  table.string('account_id').notNullable()
  table.string('role').notNullable().defaultTo('member')
  table.timestamps(true, true)
  table.unique(['organization_id', 'account_id'])
})

// auth_organization_invitations
schema.createTable('auth_organization_invitations', (table) => {
  table.string('id').primary()
  table.string('organization_id').notNullable()
    .references('id').inTable('auth_organizations').onDelete('CASCADE')
  table.string('email').notNullable()
  table.string('role').notNullable().defaultTo('member')
  table.string('token_hash').notNullable().unique()
  table.string('invited_by').notNullable()  // accountId of the inviter
  table.timestamp('expires_at').notNullable()
  table.timestamp('accepted_at').nullable()
  table.timestamps(true, true)
})

Configuration

Configure organizations in defineConfig. Only the enabled override and claimStrategy belong here — policy fields (roles, allowSelfCreate, invitationTtlHours) are managed via the organizations_policy runtime setting:

config/authkit.ts
defineConfig({
  // …
  organizations: {
    // enabled: true,        // default: auto (on when tables exist)
    claimStrategy: 'active', // emit claims from the session's active org (default)
  },
})
OptionTypeDefaultNotes
enabledbooleanautoundefined = auto (detects tables at runtime).
claimStrategy'active''active'Emit claims from the current active org cookie. The only supported strategy.

Policy fields (roles, allowSelfCreate, invitationTtlHours) are managed via the organizations_policy runtime setting — see Runtime Settings:

node ace authkit:settings:set organizations_policy \
  '{"allowSelfCreate":false,"invitationTtlHours":72,"roles":["owner","admin","member"]}'

Lucid store models

Pass your three model classes to lucidAccountStore:

config/authkit.ts
import { lucidAccountStore } from '@adonis-agora/authkit-server'
import AuthOrganization from '#models/auth_organization'
import AuthOrganizationMember from '#models/auth_organization_member'
import AuthOrganizationInvitation from '#models/auth_organization_invitation'

accountStore: lucidAccountStore(AuthUser, {
  // …your existing options (mfaIssuer, password, …)…
  organizationModels: {
    OrgModel: AuthOrganization,
    MemberModel: AuthOrganizationMember,
    InvitationModel: AuthOrganizationInvitation,
  },
})

When organizationModels is present and the tables exist at runtime, the store returns an OrganizationsCapability that the host kit uses automatically.

Token claims

When a user has an active organization (set via the signed cookie from /account/orgs), these claims are added to every id_token, userinfo response, and JWT access token:

ClaimTypeNotes
org_idstringThe active organization's id.
org_slugstringThe active organization's slug.
org_rolestringThe member's role in the active org (e.g. 'owner').

Org claims are only emitted when the user has an active org. A new login or token refresh is required to see claims for a newly joined organization.

Consuming the tenant in a relying party

On the client side (@adonis-agora/authkit-client) the org claims are promoted to first-class fields on the resolved Identity — you do not have to reach into identity.raw:

const identity = await ctx.auth.getIdentity()
identity.orgId    // 'org-9'  | null
identity.orgSlug  // 'acme'   | null
identity.orgRole  // 'owner'  | null

The derivation also accepts the tenant claims other IdPs emit (active_organization_id, organization_id, tenant_id, tid), so an app behind a third-party IdP (see BYO IdP) reads the same field. active_organization_id wins when several are present.

That same value is published to @adonis-agora/context as tenantId, which is what lets @adonis-agora/authz scope roles and queries per tenant automatically:

config/authz.ts
import { tenantFromContext } from '@adonis-agora/authz'

defineConfig({ resolveTenant: tenantFromContext })

Requiring an active organization

Authenticated is not the same as inside a tenant. A logged-in user with no active org reaches your controller with an undefined tenant — and a tenant-scoped authz check then falls back to the global scope, which is the worst place to land by accident. The requireOrg middleware closes that:

start/kernel.ts
export const middleware = router.named({
  auth: () => import('@adonis-agora/authkit-client/auth_middleware'),
  requireOrg: () => import('@adonis-agora/authkit-client/require_org_middleware'),
})
start/routes.ts
// Web: redirects to the org picker (default `/account/orgs`).
router.group(() => { /* … */ }).use([middleware.auth(), middleware.requireOrg()])

// API: 403 `no_active_organization` instead of a redirect.
router.group(() => { /* … */ }).use([middleware.auth(), middleware.requireOrg({ mode: 'api' })])

// Restrict to a known set of tenants.
router.get('/internal', [C, 'x']).use(middleware.requireOrg({ oneOf: ['org-9'] }))
OptionTypeDefaultNotes
redirectTostring/account/orgsWhere web mode sends a user with no active org.
mode'web' | 'api''web'api responds 403 with a JSON body.
oneOfstring[]Only these org ids pass.

It is fail-closed: no session, or no active org, means no pass.

Account console — /account/orgs

Every authenticated user has access to /account/orgs:

RouteAction
GET /account/orgsList the user's organizations; switch the active one
POST /account/orgsCreate a new organization (requires allowSelfCreate: true)
POST /account/orgs/deactivateClear the active organization cookie
POST /account/orgs/:id/activateSet the active organization
POST /account/orgs/:id/leaveLeave an organization (blocked when last owner)
POST /account/orgs/:id/inviteSend an email invitation to a new member
POST /account/orgs/:id/members/:accountId/removeRemove a member
POST /account/orgs/:id/invitations/:invId/revokeCancel a pending invitation

Invitation flow

Invitations are sent by email (mail.onOrgInvitation) and carry a short-lived signed token (SHA-256 hash stored in the DB, TTL from organizations_policy.invitationTtlHours). The accept URLs are:

GET  /account/orgs/invitations/:token/accept   → accept form (no session required)
POST /account/orgs/invitations/:token/accept   → accept the invitation

The token is validated against the hash, TTL, and the authenticated account's email before creating the membership.

Customize the invitation email

Wiring mail.onOrgInvitation is optional. Without it the host kit sends the invitation itself through the host's default @adonisjs/mail mailer, branded and translated like every other AuthKit email. Provide the hook only to take the message over:

config/authkit.ts
mail: {
  onOrgInvitation: async ({ email, orgName, orgSlug, role, acceptUrl }) => {
    await mailer.send((m) =>
      m.from(env.get('EMAIL_FROM')).to(email)
        .subject(`You have been invited to ${orgName}`)
        .text(`Accept your invitation as ${role}: ${acceptUrl}`)
    )
  },
}

Reacting to organization events

Every organization operation is audited, and each audit event carries orgId as a first-class field. Wire events.onEvent (or a webhook) to keep your own tables in sync without depending on any other Agora library — see Events → Provisioning your own database:

config/authkit_server.ts
events: {
  onEvent: async (event) => {
    if (event.type === 'organization.created') {
      await Company.create({ authOrgId: event.orgId! })
    }
  },
}

If you do use @adonis-agora/authz, its event-driven provisioning subscribes to the same events off the diagnostics bus and can assign tenant-scoped roles directly:

await defineAuthzProvisioning({
  store,
  on: {
    'organization.created': (event, store) =>
      store.assignRole({ type: 'user', id: event.accountId! }, 'org:owner', {
        tenantId: event.orgId!,
      }),
  },
})

Read event.orgId from the top level. The diagnostics bus strips metadata (it may contain PII), so event.metadata.orgId is undefined on that path.

Governance invariants

  • An organization always has at least one owner. Removing or demoting the last owner is rejected with last_owner.
  • LGPD cascade: deleting an account removes all its memberships and pending invitations. The org itself is not deleted (the other members still exist).
  • Data export includes the user's memberships and pending invitations (see Compliance).

Admin console — /admin/orgs

When the admin console is enabled, an Organizations section appears under /admin/orgs:

RouteAction
GET /admin/orgsList all organizations (paginated)
POST /admin/orgsCreate an organization (admin assigns the initial owner)
GET /admin/orgs/:idView detail: members + pending invitations
POST /admin/orgs/:id/deleteDelete the organization and cascade
POST /admin/orgs/:id/membersAdd a member directly (no invitation)
POST /admin/orgs/:id/members/:accountId/removeRemove a member
POST /admin/orgs/:id/invitations/:invId/revokeRevoke an invitation

Admin REST API

All organization operations are available under /api/authkit/v1/organizations. Requires the Admin REST API to be enabled and a Bearer API key.

MethodPathBody / QueryNotes
GET/organizationsList all organizations.
POST/organizations{ name, slug, ownerAccountId, logoUrl? }Create; automatically adds the owner as a member.
GET/organizations/:idDetail with members[] and pendingInvitations[].
PATCH/organizations/:id{ name?, logoUrl? }Update metadata.
DELETE/organizations/:idDelete and cascade members/invitations.
POST/organizations/:id/members{ accountId, role }Add a member directly.
DELETE/organizations/:id/members/:accountIdRemove a member.
PATCH/organizations/:id/members/:accountId{ role }Update a member's role.
POST/organizations/:id/invitations{ email, role }Create and send an invitation.
DELETE/organizations/:id/invitations/:invitationIdRevoke an invitation.
# Create an organization
curl -X POST https://idp.example.com/api/authkit/v1/organizations \
  -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" \
  -d '{ "name": "Acme Corp", "slug": "acme", "ownerAccountId": "acc-42" }'

# Invite a member
curl -X POST https://idp.example.com/api/authkit/v1/organizations/org-1/invitations \
  -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" \
  -d '{ "email": "jane@acme.com", "role": "admin" }'

Backend SDK

const authkit = await createAuthkit({ mode: 'remote', baseUrl, apiKey })

// List
const { data: orgs } = await authkit.organizations.list()

// Create
const org = await authkit.organizations.create({
  name: 'Acme Corp',
  slug: 'acme',
  ownerAccountId: 'acc-42',
})

// Detail (includes members + pending invitations)
const detail = await authkit.organizations.get(org.id)
detail.members     // AuthkitOrgMember[]
detail.pendingInvitations  // AuthkitOrgInvitation[]

// Members
await authkit.organizations.members.add(org.id, { accountId: 'acc-7', role: 'member' })
await authkit.organizations.members.updateRole(org.id, 'acc-7', 'admin')
await authkit.organizations.members.remove(org.id, 'acc-7')

// Invitations
const inv = await authkit.organizations.invitations.create(org.id, {
  email: 'bob@acme.com',
  role: 'member',
})
await authkit.organizations.invitations.revoke(org.id, inv.id)

// Delete
await authkit.organizations.delete(org.id)

See SDK for the full type table.

React

@adonis-agora/authkit-react ships four hooks and two pre-built components for organizations. They fetch data from the JSON endpoints the host kit exposes (/account/orgs/json, /account/orgs/invitations/json, /account/orgs/:id/json) and POST mutations via the standard account routes.

Hooks

import {
  useOrganizations,
  useOrganization,
  useSwitchOrganization,
  useOrgInvitations,
} from '@adonis-agora/authkit-react'

// List the current user's organizations + pending inbound invitations
const orgs = useOrganizations()
// orgs.data: OrgEntry[]   (id, name, slug, logoUrl, role, isActive)
// orgs.invitations: OrgInvitationEntry[]
// orgs.loading, orgs.error
// orgs.actions.create({ name, slug }), orgs.actions.leave(orgId), orgs.actions.accept(token)

// Detail of one organization (members + active invitations)
const org = useOrganization(orgId)
// org.data: ActiveOrgDetail   (includes members: OrgMemberEntry[])
// org.actions.invite({ email, role }), org.actions.removeMember(accountId), org.actions.revokeInvitation(invId)

// Switch the active organization
const switcher = useSwitchOrganization()
// switcher.activate(orgId), switcher.deactivate()
// switcher.loading

// Pending invitations for the current user
const invitations = useOrgInvitations()
// invitations.data: OrgInvitationEntry[]
// invitations.actions.accept(token), invitations.actions.refetch()

Components

import {
  OrganizationSwitcher,
  OrganizationProfile,
} from '@adonis-agora/authkit-react'

// Dropdown to switch the active organization; optionally shows a "Create org" button
<OrganizationSwitcher
  allowCreate={false}  // default; set true when the organizations_policy.allowSelfCreate setting is on
  createLabel="New organization"
  className="my-custom-class"
/>

// Full management card: members, invitations, leave/invite controls
<OrganizationProfile
  orgId={activeOrgId}
  className="my-custom-class"
/>

Both components use the same --authkit-* CSS variables as the rest of the component kit. See React Components for the theming reference.

Audit events

Every organization operation emits an audit event:

EventWhen
organization.createdAn organization is created.
organization.updatedOrganization metadata is updated.
organization.deletedAn organization is deleted.
organization.member_addedA member is added directly.
organization.member_removedA member is removed.
organization.member_role_updatedA member's role is changed.
organization.switchedA user switches their active organization.
organization.deactivatedA user deactivates their active org.
organization.invitation_sentAn email invitation is created.
organization.invitation_acceptedAn invitation is accepted.
organization.invitation_revokedAn invitation is revoked.

On this page