# Multi-factor authentication

> TOTP enrolment in iam.svc, the MFA gate that returns a pending challenge instead of tokens, and how the second factor is verified.

<!-- source: https://docs.kis.ai/blocks/baas/iam/authn/mfa/ -->

`iam.svc` implements exactly one second factor: TOTP (SHA-1, 6 digits, 30-second step).
The `methods` array in every challenge response is the literal `["totp"]` — there is no
SMS or email OTP, no recovery or backup codes, and no passkey-as-second-factor. The
method list is fixed in the binary; no configuration key adds to it.

MFA is per user and opt-in. A user is challenged because they hold an active TOTP
enrolment, and nothing in the product's `iam/` files or the tenant's configuration can
require one. There is no tenant-wide, realm-wide or role-wide "MFA required" policy.

## Routes

| Method | Path | Auth | Rate limit | Purpose |
|--------|------|------|-----------|---------|
| `POST` | `/auth/mfa/totp/setup` | Bearer | none | Generate a pending TOTP secret |
| `POST` | `/auth/mfa/totp/enable` | Bearer | none | Confirm the pending secret with a live code |
| `POST` | `/auth/mfa/totp/disable` | Bearer | none | Remove the enrolment (requires a live code) |
| `POST` | `/auth/mfa/verify` | the pending token | `auth_mfa` | Complete a login's second step |

Routes carry no `/v2` prefix. All four require the four tenancy headers
(`X-Customer`, `X-Product`, `X-Env`, `X-Tenant`); without them the response is
`400 no_tenant`. Full request and response shapes are in the
[IAM API reference](/blocks/baas/iam/api/).

## Enrolment

### Start

`POST /auth/mfa/totp/setup` needs an authenticated caller and no body (an empty object
is fine). It generates a fresh 16-byte (128-bit) base32 secret and stores it as a
**pending** enrolment.

```json
{
  "secret": "JBSWY3DPEHPK3PXP…",
  "otpauth_url": "otpauth://totp/<issuer>%3AU01K…?algorithm=SHA1&digits=6&issuer=<issuer>&period=30&secret=JBSWY3DPEHPK3PXP…"
}
```

The URI parameters are fixed:

| Parameter | Value |
|-----------|-------|
| `algorithm` | `SHA1` |
| `digits` | `6` |
| `period` | `30` |
| `issuer` | a single constant compiled into the service |
| label | `<issuer>:<subject id>` |

Re-running setup before enable rotates the pending secret. Re-running it after enable
returns `409 already_enabled` — disable first to re-enrol.

:::caution
The `issuer` label is one service-wide constant. It is not derived from the tenant, the
product, or the realm, so every user of every tenant sees the same account name in their
authenticator app. Show your own product name alongside the QR code rather than relying
on the label.
:::

:::note
The enrolment row is keyed on the **authenticated principal's subject**. A request
authenticated with `Authorization: ApiKey` sets that subject to the API-key row id, not
the user, so enrolment must be driven with a user access token. Build your enrolment
screen behind a Bearer credential.
:::

### Confirm

`POST /auth/mfa/totp/enable` with `{"code": "123456"}` verifies the code against the
pending secret and marks the enrolment active. From that moment every gated flow
challenges the user.

Enable deliberately does **not** record the consumed time step, so a login inside the
same 30-second window is not rejected as a replay.

Errors: `400 no_pending_setup` when nothing is pending, `409 already_enabled` when it is
already active, `401 invalid_code` when the code does not verify.

### Remove

`POST /auth/mfa/totp/disable` with `{"code": "123456"}` deletes the enrolment. A valid
current code is required — proving possession of the second factor means a hijacked
session alone cannot strip MFA. When nothing is enrolled the call is an idempotent
`200 {"ok": true}`.

:::caution
There is no recovery path for a lost authenticator. Disable is the only route that
removes an enrolment and it demands a live code; no operator or admin endpoint clears one.
The `user_mfa_request` entity restricts read, create, update and delete to the service's
own identity, so the external `/rest` and `/admin` surfaces are denied it — a tenant admin
cannot clear an enrolment through the generic entity API either.
:::

:::caution
The TOTP shared secret is declared `compliances: encrypted`, but the field-encryption
hooks are installed **only when a vault client is registered** for the deployment. On a
keyless or dev boot the hooks are skipped and the secret is stored in plaintext. See
[operating the block](/blocks/baas/iam/operating/configuration/).
:::

## The MFA gate

After a primary factor succeeds, the flow looks for an active TOTP enrolment for the
authenticated user. If one exists it returns `200` with a **challenge instead of
credentials** — no access token, no refresh token, no session reference.

```json
{
  "mfa_required": true,
  "mfa_token": "v4.public.…",
  "methods": ["totp"]
}
```

The status code is `200`, not `401`. Branch your client on the `mfa_required` field.

Enrolment is detected by a single lookup on `(user id, provider = "totp")` with the
active flag set. Rows carrying any other provider value are invisible to the gate.

### Flows the gate covers

| Flow | Route | Shipped behaviour |
|------|-------|------------------|
| Password login | `POST /auth/login` | Gate fires; the pending token carries the login realm |
| Magic-link verify | `POST /auth/magic/verify` | Gate fires; the pending token carries the realm `users` |
| Passkey assertion | `POST /auth/webauthn/assert/finish` | Gate is in the handler, but the route answers `503 webauthn_disabled` in the shipping binary |
| Federated callback | `GET /auth/oauth/:provider/callback` | Gate is in the handler, but the route answers `404 unknown_provider` in the shipping binary |

The deployable service wires neither a WebAuthn relying party nor an OAuth provider, and
there is no configuration path that does. In practice the gate is reachable on password
login and magic-link verify. See [password login](/blocks/baas/iam/authn/password/) and
[passwordless login](/blocks/baas/iam/authn/passwordless/) for what those two routes
return when the user holds no enrolment.

### Flows the gate does not cover

| Flow | Route |
|------|-------|
| Control-plane operator login | `POST /auth/login` with `realm: "superadmin"` |
| Access-token refresh | `POST /auth/refresh` |
| First-superadmin bootstrap | `POST /auth/bootstrap` |
| API-key authentication | any route accepting `Authorization: ApiKey` |
| Service-token exchange | `POST /auth/token` |
| Managed-session exchange | `POST /internal/session/exchange` |
| Operator impersonation | `POST /superadmin/impersonate` |

Control-plane login carries no MFA at all: the plane holds no enrolment storage, and that
path also skips realm resolution and the provider gate. See
[superadmin and impersonation](/blocks/baas/iam/operating/configuration/) for what does guard it.

:::caution
For federated login the second factor is enforced by this service's own callback handler
against a local TOTP enrolment. Nothing is asserted to or required from the external
identity provider — no `acr` or `amr` claim is read and no `id_token` is validated. An IdP
that performed its own MFA does not satisfy this gate, and an IdP that performed none is
not detected.
:::

## The pending token

The `mfa_token` is a PASETO v4.public signed with the same per-tenant Ed25519 key as
access tokens. It carries only:

| Claim | Value |
|-------|-------|
| `iss` | `iam` |
| `sub` | the identity's row id |
| `tenant` | the tenancy key the login was made against |
| `realm` | the realm the identity was authenticated in |
| `type` | `mfa_pending` |
| `iat` / `nbf` / `exp` | issued-at, not-before, and expiry |

It lives **5 minutes** and carries no roles and no custom claims.

The auth middleware rejects `type: mfa_pending` explicitly on every protected route with
`401 invalid_token`, after the signature, tenant-pin and temporal checks have already
passed. `POST /auth/mfa/verify` is the only endpoint that accepts it. Token formats,
claims and verification are covered under [tokens](/blocks/baas/iam/tokens/).

:::note
Password login stamps the realm the user actually logged into, so verify re-reads the
identity from that realm's entity. Magic link, passkey assertion and the federated
callback stamp the literal realm name `users` — not the tenant's configured default realm
— because they authenticate against the base `users` entity regardless of realm
configuration. If a layer has deactivated the `users` realm, verify after one of those
flows fails with `401 unknown_realm`.
:::

## Completing the challenge

`POST /auth/mfa/verify` is public — the pending token is the credential.

```json
{ "mfa_token": "v4.public.…", "code": "123456" }
```

Checks run in order, each with its own refusal:

1. Signature against the tenant's keyring.
2. The `tenant` claim equals the request's tenancy key.
3. The token is inside its validity window.
4. `type` is `mfa_pending`.
5. The subject still holds an active TOTP enrolment.
6. The code verifies against the stored secret.
7. The code's 30-second step has not already been consumed.
8. The realm named on the token still resolves.

On success the consumed step is recorded and the flow mints exactly what a
single-factor login would have returned:

```json
{
  "token": "v4.public.…",
  "refresh_token": "01J…ULID.<secret>",
  "expires_in": 900,
  "user": { "id": "U01K…", "email": "user@example.com", "displayname": "…" }
}
```

On a tenant using managed sessions the response is instead `{session_id, expires_at, user}`
with the `kis_session` cookie, and the session row records assurance level 2 and auth
method `totp`. Login short-circuits to the challenge *before* its own session branch,
which is why the completed second step is what creates the session — see
[sessions](/blocks/baas/iam/tokens/sessions/).

`POST /auth/mfa/verify` is rate-limited under the `auth_mfa` profile: a per-(tenant, IP)
token bucket of capacity 30, refilling 0.5 per second. The three enrolment routes are not
rate-limited.

## Codes, steps and replay

The verifier accepts **only the current 30-second step**. There is no skew window, so a
device whose clock has drifted more than a few seconds fails with `401 invalid_code`.

A TOTP code stays valid for its whole step, which makes an observed code replayable
inside that window. Verify records the consumed step on the enrolment and refuses a
second use of the same step with `401 code_reused` ("this code was already used; wait for
the next one"). Enable and disable verify the code but do not record the step.

## Errors

| Code | HTTP | Where | Meaning |
|------|------|-------|---------|
| `auth_required` | 401 | setup, enable, disable | No authenticated principal on the request |
| `no_tenant` | 400 | all | Tenancy headers missing |
| `missing_fields` | 400 | enable, verify | `code`, or `mfa_token` and `code`, absent |
| `no_pending_setup` | 400 | enable | No pending enrolment — call setup first |
| `already_enabled` | 409 | setup, enable | TOTP is already active for this subject |
| `invalid_code` | 401 | enable, disable, verify | The code did not verify for the current step |
| `code_reused` | 401 | verify | That step was already consumed |
| `invalid_mfa_token` | 401 | verify | Bad signature, another tenant, outside the 5-minute window, or `type` is not `mfa_pending` |
| `mfa_not_enabled` | 401 | verify | The token's subject holds no active enrolment |
| `unknown_realm` | 401 | verify | The realm on the pending token no longer resolves |
| `invalid_token` | 401 | protected routes | An `mfa_pending` token was presented outside verify |
| `rate_limited` | 429 | verify | The `auth_mfa` bucket is empty |
| `lookup_failed` | 500 | setup | Could not read the existing enrolment |
| `mfa_lookup_failed` | 500 | gated flows | Could not decide whether MFA is required |
| `persist_failed` | 500 | setup, enable | Could not write the enrolment row |
| `engine_unavailable` | 500 | all | The tenant datastore could not be reached |
| `user_lookup_failed` | 500 | verify | The identity row could not be loaded |
| `sign_failed` | 500 | gated flows, verify | Token signing failed |
| `issue_failed` | 500 | verify | The refresh-token row could not be persisted |

`disable` never returns `missing_fields`: a body it cannot decode is treated as an empty
code, which then fails the code check. The block-wide table is on the
[error reference](/blocks/baas/iam/error/).

## Events

Three auth-flow events fire on this surface: `after_mfa_enable`, `after_mfa_disable`
(emitted on both the success and the wrong-code path) and `after_mfa_verify` (emitted on
success, on a wrong code, and on a reused code). Each payload carries `user_id` and
`method: "totp"`; failures add a `reason`. A successful verify also emits
`after_login_success` with `flow: "mfa_verify"`.

:::note
`before_mfa_enable`, `before_mfa_disable` and `before_mfa_verify` are declared as event
names but no handler emits them. A hook registered on one of those never runs.
:::

## Not implemented

None of the following exists in the shipping binary, and no configuration enables any of
them:

- A second method of any kind — SMS OTP, email OTP, or a passkey used as a second factor.
- Recovery codes, backup codes, or trusted-device / "remember this browser" bypass.
- A tenant, realm, or role policy that requires enrolment.
- An operator or admin endpoint that clears a lost enrolment.
- Step-up re-authentication on a live session. The assurance level is recorded on managed
  session rows but is not a token claim, so no route can require it from a token.
- MFA on the control-plane operator login.