auth
Collects the ways a user can prove who they are, and decides what a proof is worth.
A factor proves who you are; the state of the request decides what that means. Login and "confirm it's still you" are the same proof, so the module that ran the check only reports the result:
await proof(ctx, "webauthn", usrId);
If nobody is signed in, this is a login and core creates the session. If the same user is already signed in, it is a fresh proof stored in the session — a step-up. It resolves with nothing on success, otherwise with the factors still missing; an empty list means nothing helps (inactive user, or a factor that isn't allowed here).
Declaring a factor
A module exports authFactors from its plugin, like messaging collects channels:
export const authFactors: Factor[] = [{ name: "webauthn", label: "Passkey", stepUp: true }];
It is a list because a module may bring several (auth.otp has one per channel), or a function of the app if the list is only known at runtime.
stepUp— may also refresh an open session. Federated logins leave it out: the provider answers from its own session and says nothing about who is at the keyboard now.second— only allowed as second factor. auth.backup_codes only finishes a login, so a stolen code is useless without the password.order— sort order when several are offered; the dialog opens the first. Presentation only.has— optional. Without it, the factor counts as available for every user.
factors(app) lists what linked modules declare, userFactors(app, usrId) what one user has set
up. auth knows no factor by name, so a new factor needs no code here and shows up in
cms.backend.superuser.security.auth automatically. core declares
password. Each factor has its own cms.cont.my.* page for setup.
Login with more than one factor
core.loginTwoFactor requires a second factor. The first proof then only stores the identity as
core.pending in the anonymous session for ten minutes; the session rotation on the real login
removes it. No extra table, nothing to clean up. Users without a second factor get in with one.
In the browser this is the step-up dialog again: while a login is pending, core loads
finishLogin.mjs on every page, which calls
GET core/login/missing. So a login that returns via redirect (oauth) is finished on the page it
lands on. The verify verbs use Access.IDENTIFIED — the signed-in user, or the one a pending
login has identified (identified(ctx) in server code). That is why a code is only ever sent to
a user the request already knows.
Brute-force protection
Failed attempts are counted per account, not per method
(attempts.ts): beforeProof() runs before every check of something
guessable, proofFailed() raises the wait, proofPassed() resets it. Three free tries, then a wait
that doubles up to five minutes, forgotten after an hour. A counter per factor would let an
attacker split guesses across password, totp and backup codes.
It is a wait, not a lockout — a lockout could be triggered by anyone who knows an e-mail address. The counter resets when the login is finished, not after each factor; otherwise knowing the password would reset the budget for guessing the second one. auth.webauthn is not counted: a signature can't be guessed.
Requiring a fresh proof
requireStepUp(ctx, { maxAge }) throws StepUpError
(code: "step_up_required", plus the factors this user could use). Only factors with stepUp
count, so remember and login_as never satisfy it.
A verb that always needs it declares it; invoke() checks it after
the access checks and before execute:
requireStepUp: true, // always
requireStepUp: { maxAge: 60 }, // always, with a shorter max age
If it depends on the call, use guard. It sees path params and validated input and runs before
anything happens — important, because the browser repeats the same request after the dialog:
guard: ({ amount }, ctx) => amount <= 1000 || requireStepUp(ctx, { maxAge: 60 }),
The field is still needed: tools listing verbs can only see the field. mcp uses it to
hide those verbs, since an agent cannot answer a dialog. Every verb that adds or removes a factor
has it, because it changes what counts as proof from then on.
A route is not a verb and cannot require a step-up (a StepUpError would show as a 403 page). So
the connect button first calls POST auth.oauth/connect, and oauth/start/<name> uses that result.
Users without any factor pass — otherwise they could never set up their first one. The dry run
(x-api-check: access) only checks who may call a verb and requires nothing.
In the browser, ApiClient offers recover(error): a hook that may
fix a failed request and send it once more. The dialog knows no factor by name — each factor
has a pub/stepup.js exporting prove(root), and the error says which module to load it from.
What a proof is not
Not a permission. access and guard decide whether a user may do something; a proof only
says the person is present right now.
Not account recovery. A link is a ticket, a one-time code is a normal factor.
Not an API key. An auth.api_keys key identifies the user of a request but never opens a session. It is no factor and satisfies no step-up — there is nobody to ask.
Also a log. The session stores core.via.<name> = <when> for every way in, including ones that
prove nothing (remember, login_as). They are visible for auditing but never satisfy a step-up,
since only declared factors are checked.
Storage
How a session was established is stored in the session, so
logout() and the id rotation clear it automatically.
usr_auth_factor holds per-user factor data — one row per secret, so several authenticator apps or
backup codes are rows with the same type. data is the factor's own JSON; auth never reads it.
The interface is store(), stored() and drop(), all keyed by user, so factor modules don't
need to check ownership. A factor that needs real columns has its own table, like
auth.webauthn.
Open
- More policy. Known client: a stolen password is used on an unknown client, and
client_usrcan tell — it could skip the second factor at login (never for step-up), with a mail on login from a new client. Minimum for users without a factor: everyone has mail, but mail is also the reset path. Block or nudge: require a mail code every time, or let them in and push them to set up a factor. - Properties. "Phishing-resistant" becomes a field on
Factoronce a policy needs it. - Code as login factor, and approval instead of a code. auth.otp is step-up only today. Tapping "approve" can't be phished but invites MFA fatigue — the fix is number matching.
- Backend pages act on the own account and call
mod.tsdirectly, bypassing the verbs and theirrequireStepUp. They are form posts and would show aStepUpErroras an error page; thecms.cont.my.*pages are the right place anyway. - A wait reveals that the address exists. Counting per account requires a known user — four wrong tries reveal that, but the owner learns why they can't get in.
- Nothing prevents locking yourself out. Check with
userFactors(app, usrId).