Console session & sudo mode
Reuse the console account session outside the console, and gate sensitive operations behind sudo mode and its pluggable confirmation methods.
The AuthKit console (/account/*, /auth/admin) keeps its own account
session. Sometimes you want to reuse that session outside the
console: protecting an admin route of your own, or gating a third-party
package's dashboard to signed-in users only.
For that, @adonis-agora/authkit-server exports public helpers. Use them instead of
reaching into ctx.session yourself: how the session records the account is an
implementation detail, and the helpers keep working if it changes.
import {
getAccountId, // string | null — the account this request is acting as
realAccountId, // string | null — the human behind the request (see below)
hasAccountSession, // boolean
consoleLoginUrl, // login URL with an optional return_to
} from '@adonis-agora/authkit-server'Protecting your own route
router
.get('/reports', [ReportsController])
.use(async (ctx, next) => {
if (!hasAccountSession(ctx)) {
return ctx.response.redirect(consoleLoginUrl('/reports'))
}
return next()
})After login, return_to sends the user back to /reports.
Recipe: protecting adonis-telescope
@dudousxd/adonis-telescope
accepts an authorize hook that may return { redirectTo } — a perfect
fit for the console session:
// config/telescope.ts
import { defineConfig } from '@dudousxd/adonis-telescope'
import { hasAccountSession, consoleLoginUrl } from '@adonis-agora/authkit-server'
export default defineConfig({
enabled: true,
/**
* Humans come in through the console session: with no session, redirect
* to the AuthKit login and return to /telescope after authenticating.
*/
authorize: async (ctx) =>
hasAccountSession(ctx) ? true : { redirectTo: consoleLoginUrl('/telescope') },
/**
* Agents (MCP) have no browser session — they use their own Bearer
* token, separate from the human flow.
*/
mcp: { token: process.env.TELESCOPE_TOKEN },
})Restricting by role
Use realAccountId() for the check, not getAccountId(). The two differ during an
impersonation: getAccountId() answers "which account is this request acting as", which is
the impersonated user, while realAccountId() answers "who is actually driving this
session" — the admin behind it. A role check asked of the impersonated account hands the
admin's privileges to whoever is being impersonated, and strips the real admin of their own
access.
import { realAccountId, consoleLoginUrl } from '@adonis-agora/authkit-server'
authorize: async (ctx) => {
const accountId = realAccountId(ctx)
if (!accountId) return { redirectTo: consoleLoginUrl('/telescope') }
const store = await ctx.containerResolver.make('authkit.accountStore')
const account = await store.findById(accountId)
return account?.globalRoles?.includes('admin') ?? false
}Sudo mode (step-up authentication)
Some operations should require a fresh proof of identity even when the user already has a
perfectly valid session: deleting an account, exporting personal data, minting a Personal
Access Token, disabling MFA, changing an email address. AuthKit ships sudo mode for
exactly that. The user confirms their identity on the console's confirmation screen, the
grant is stamped on the session, and it stays valid for a grace window — 15 minutes by
default. The sudo_mode runtime setting carries both knobs (enabled and graceMinutes)
and has its own card in the admin console, so the policy can change without a redeploy — see
Runtime settings.
import {
requireSudo, // gate: returns true, or the redirect to the confirmation screen
isSudoActive, // boolean check, no side effects
resolveEffectiveSudoMode, // reads the `sudo_mode` runtime setting
SUDO_SESSION_KEY, // session key holding the grant timestamp
SUDO_ACCOUNT_SESSION_KEY, // session key binding the grant to one account
SUDO_MODE_DEFAULTS, // { enabled: true, graceMinutes: 15 }
} from '@adonis-agora/authkit-server'Gating one of your own routes
requireSudo takes two arguments: the HTTP context and a settings capability to read the
sudo_mode policy from. It returns true when sudo is active (or disabled), and otherwise
returns the redirect to the confirmation screen, with the current URL preserved as
return_to.
import type { HttpContext } from '@adonisjs/core/http'
import db from '@adonisjs/lucid/services/db'
import { RuntimeSettings, requireSudo } from '@adonis-agora/authkit-server'
export default class DeleteAccountController {
async handle(ctx: HttpContext) {
const settings = new RuntimeSettings(db)
const gate = await requireSudo(ctx, settings)
if (gate !== true) return gate
// ... proceed with the deletion
}
}RuntimeSettings is the bundled implementation of SettingsCapability over the optional
auth_settings table — the same class the package uses internally, exported for this. If
your AuthKit tables live on a named Lucid connection, pass it:
new RuntimeSettings(db, { connection: 'auth' }). If the table does not exist, every read
returns null and the defaults apply, so there is nothing to install before this works.
Passing null instead is legitimate and means "do not consult the runtime setting": the
gate runs against SUDO_MODE_DEFAULTS — enabled, 15-minute grace. Use it when you have no
settings store at hand, and be aware of the consequence: turning sudo mode off in the admin
console will not turn your gate off.
Resolving the setting is fail-safe by design. A settings store that is down, a malformed
value, a migration in flight — all of those fall back to the defaults instead of throwing,
because the question being asked here is "is this policy switched on?", not "is this person
who they claim to be". The identity half of the decision, in isSudoActive, is fail-closed.
Checking without redirecting
For an API route, an XHR endpoint or anything that must answer with JSON, read the policy and check the mark yourself:
import db from '@adonisjs/lucid/services/db'
import { RuntimeSettings, isSudoActive, resolveEffectiveSudoMode } from '@adonis-agora/authkit-server'
const { enabled, graceMinutes } = await resolveEffectiveSudoMode(new RuntimeSettings(db))
if (enabled && !isSudoActive(ctx, graceMinutes)) {
return ctx.response.unauthorized({ error: 'sudo_required' })
}The grant is bound to one account
A timestamp on its own only says "somebody in this session proved their identity recently" —
it does not say who. That matters because an Adonis session survives an account switch:
regenerate() swaps the cookie id and migrates the data rather than dropping it. So the
grant is stored together with the id of the account that produced it, and isSudoActive
checks that binding before it looks at the grace window: a mark with no binding is
refused, and a mark bound to a different account than the one currently signed in is refused
too. Only then is the timestamp compared against the window.
The ordering is deliberate. A mark from another account is not "expired sudo", it is sudo that never applied here. And the guarantee is structural rather than a cleanup someone has to remember: any path that changes the session's account — a shared browser where one person signs out and another signs in, starting or stopping an impersonation — invalidates the grant without new code. The cost is that the user reconfirms, which is cheap.
Confirmation methods
What the confirmation screen offers is a pluggable list. Four methods ship with the package,
exposed through the sudoMethods factory object:
| Method | Factory | What it needs | How the screen renders it |
|---|---|---|---|
| Password | sudoMethods.password() | An account with a usable password, and a store that implements verifyCredentials. | A password field, posted to the confirmation screen. |
| Passkey | sudoMethods.passkey() | A store that supports passkeys, and at least one passkey registered on the account. | A WebAuthn handshake in the browser, then a POST of the assertion. Requires JavaScript. |
| Magic link | sudoMethods.magicLink() | An email address on the account. Delivery goes through the mail.onSudoLink hook when you declare one, and through the host kit's default mailer otherwise. | A single button that emails a confirmation link. |
| OIDC step-up | sudoMethods.oidcStepUp({ url }) | A route of your own that re-authenticates the user against your IdP, and a callback that grants the sudo. | A redirect to your URL. |
Two availability details are worth knowing. The password method asks the store "does this
account have a password hash?", which is not the same question as "does this user know a
password" — a host that fills a NOT NULL column with a random unusable hash will see the
method offered and the field unfillable; the fix is a nullable column, or leaving password()
out of the list. And the sudo magic link is a sudo-scoped credential, never the login
magic link: it grants sudo and never authenticates, it is single-use, it expires in five
minutes, it only works in the browser session that requested it, and it is bound to the
account that requested it.
Configuring the method list
The list lives in config/authkit.ts. Array order is display order, and the method the user
succeeded with last time is promoted to the top on the next visit:
import { defineConfig, sudoMethods } from '@adonis-agora/authkit-server'
export default defineConfig({
// ...
sudo: {
methods: [
sudoMethods.oidcStepUp({ url: '/auth/step-up' }),
sudoMethods.magicLink(),
sudoMethods.passkey(),
],
},
})Leaving the key out is not the same as writing the default list. When sudo.methods is
absent, the effective list is derived against the rest of your config:
- a host with passwords gets
[password, passkey]; - a host that declared
authMethods: { password: false }gets[passkey, magicLink], since in that deployment a password field is an option that cannot succeed, and the magic link is the one method an account with no pre-registered credential can satisfy.
Route mounting and offering are two different decisions, taken at two different times.
registerAuthHost mounts the endpoints for [password, passkey, magicLink] before the
config resolves — mounting is not offering — and the derivation above then decides which of
them the screen offers and which the handlers accept. Both sides read the same function, so
they cannot drift: the screen never offers a method whose endpoint 404s, and never hides one
that works.
Declaring the key turns the derivation off. The list becomes yours, taken literally, in both directions. That is the promise of the option — it replaces the defaults rather than adding to them — and it is also the trap:
A passwordless host that declares sudo.methods without oidcStepUp() or magicLink()
leaves its users with no way to satisfy sudo at all. Both remaining built-ins require a
credential registered beforehand, which is precisely what such an account does not
have — and enrolling a passkey is itself behind sudo. The result is a lockout from every
operation gated by requireSudo: exporting and deleting one's own data, MFA, Personal
Access Tokens, changing an email address.
That is what the boot-time satisfiability warning shouts about. It fires when all four of
these hold: you declared sudo.methods explicitly; the deployment has accounts without a
usable password (authMethods: { password: false }, or passwordless: { signup: true },
whose public signup creates accounts with no usable password); none of the declared methods
is one of the two that need no pre-registered credential; and every declared method is a
built-in, so the package can actually prove the list is unsatisfiable. It warns rather than
throwing: the failure mode is degradation that already fails closed, the fix is not always a
one-line config change, and a heuristic that refuses to boot would eventually take down a
correct host.
sudo.methods is a policy option. Declaring it in defineConfig locks the
sudoMethods argument of registerAuthHost: the config value wins, and the ignored
argument is reported through AuthHostRouteMap.overriddenByConfig and a boot warning. See
Config locks and Host Kit.
Granting sudo from your own callback
oidcStepUp registers no routes at all — the flow leaves the package. The user is redirected
to your URL, your code re-authenticates them against your IdP, and the grant happens in your
callback. Three exports make that possible:
import { sudoContextFrom, completeSudo, failSudo } from '@adonis-agora/authkit-server'sudoContextFrom(ctx)builds theSudoContext—{ ctx, cfg, accountId, account, returnTo }— by resolving the AuthKit service, loading the signed-in account (which can legitimately benullfor a live session whose account was deleted or anonymised) and validating thereturn_to, accepting internal paths only. Hand-rolling the context means hand-rolling that validation, which is an open redirect waiting to happen.completeSudo(context, methodId)is the single point in the package that grants sudo. It refuses when the account could not be loaded, stamps the mark bound to the account, records the method so the screen can promote it next time, writes asudo.confirmedaudit event with the method in its metadata, and redirects to the validatedreturn_to(or toaccountHome). Reaching formarkSudoinstead grants the privilege with none of that — no audit trail, no remembered method, no redirect.failSudo(context, messageKey)flashes a localized error and sends the user back to the confirmation screen with the destination preserved.
buildAuthorizeUrl and exchangeAndValidate below are your own OIDC client — the package
has no opinion about how you talk to your IdP.
router.get('/auth/step-up', async (ctx) => {
const accountId = getAccountId(ctx)
if (!accountId) return ctx.response.redirect(consoleLoginUrl(ctx.request.url()))
// The flag lives in the SESSION and carries the account id.
ctx.session.put('step_up', { accountId })
return ctx.response.redirect(await buildAuthorizeUrl({ prompt: 'login' }))
})
router.get('/auth/callback', async (ctx) => {
// Consume the flag FIRST, before any branch that can fail.
const pending = ctx.session.get('step_up') as { accountId: string } | undefined
ctx.session.forget('step_up')
const c = await sudoContextFrom(ctx)
if (!pending || pending.accountId !== c.accountId) {
return failSudo(c, 'account.confirm.error')
}
// Your OIDC client: validate state, PKCE and nonce before anything else.
const grant = await exchangeAndValidate(ctx)
if (!grant) return failSudo(c, 'account.confirm.error')
return completeSudo(c, 'oidc-step-up')
})Four rules that callback has to follow, and none of them can be enforced for you — the flag is written by your code and the package never sees it:
- The flag lives in the session, never in the query string. On the URL, anyone could forge a callback that grants sudo.
- Grant only after the grant is fully validated. It is
prompt=loginthat guarantees the provider actually re-authenticated the user instead of reusing the existing session. - Consume the flag at the top of the callback, before any branch that can fail — not only on the success path. A flag that survives a failed callback turns the user's next ordinary login into a silent sudo grant. Treat it as single-use.
- The flag carries the account id, and the callback refuses when it does not match the
account signed in on return. A bare boolean is not enough: rule 3 limits the flag to one
use, not to one account, and that one use can be the wrong person's. A missing flag is a
refusal too — tolerating
undefinedreopens the same hole from the other side.
Writing your own method
A SudoMethod is a small object: a stable id (it lands in the audit metadata and in the
remembered preference), isAvailable(context), describe(context) returning how the screen
should render the step (kind is 'form', 'action', 'redirect' or 'webauthn', plus an
endpoint and optional fields), and an optional register(router, helpers) for methods
that need endpoints of their own.
The central rule: a method never grants sudo. It decides only whether it verified
something, and calls helpers.completeSudo(context, id) to let the runtime grant, audit and
redirect. Spreading the grant across N methods would multiply by N the chances of someone
granting without having verified.
The router handed to register is wrapped. Every route the method registers is
automatically refused when sudo.methods does not include the method — a config that only
hid the option from the screen while the endpoint kept granting sudo would be worse than no
config — and every route also gets the sudo rate-limit bucket, which is deliberately separate
from the login bucket. The wrapper can only do that to function handlers, so registering
a [Controller, 'method'] tuple, or calling router.resource(), throws at boot with the
fix: wrap the controller in a one-line function.
Why the SPI exists
It exists to keep a passwordless host out of a sudo deadlock. Such a user has no password, and no passkey either until they enroll one — but enrolling a passkey is itself an operation behind sudo. The only methods that need no pre-registered credential are a link sent by email and a re-authentication against the host's own IdP, and neither can be a take-it-or-leave-it detail: if the email delivery depended on a mail hook someone had to write by hand, the emergency exit would be opt-in, and a host that never wrote the hook would ship the deadlock.
So the pieces fit together: delivery of the sudo link falls back to the host kit's own
mailer, the derived default list carries that method exactly on the hosts that need it, the
boot-time check warns when an explicitly declared list closes the exit again, and a host that
prefers re-authentication over email plugs in oidcStepUp — or writes a method of its own.