# Two-factor authentication

A realm can ask for a second factor after the password. Two are built in, and they share one mechanism, so switching between them is a settings change rather than a rewrite.

| Method      | Where the code comes from                | Enrollment                       | Needs                                                 |
| ----------- | ---------------------------------------- | -------------------------------- | ----------------------------------------------------- |
| `totp`      | An authenticator app on the user's phone | Scan a QR code, confirm one code | Nothing                                               |
| `emailCode` | An email sent at sign-in                 | None                             | `features.notifications` and a verified email address |

TOTP is the stronger of the two and works with no infrastructure at all. Email codes need no enrollment, which is what makes them workable for a public audience you cannot ask to install an app.

When both are enabled, a user with an active TOTP enrollment is only ever offered TOTP; email codes apply to everyone else who has a verified address. Offering the weaker factor next to the stronger one would let an attacker pick it.

⚠️ **Email codes and password reset share a mailbox.** If password reset also goes through email, a compromised mailbox is both the reset channel and the second factor. The second factor then adds nothing against the attacker who matters. Prefer TOTP wherever you can ask users to install an app.

## Turning it on

```typescript
import { $realm } from "alepha/api/users";

class AuthService {
  realm = $realm({
    settings: {
      mfa: {
        totp: "required",
        emailCode: "disabled",
      },
    },
  });
}
```

Each method takes `"disabled"`, `"optional"` or `"required"`:

- `disabled` (the default): never offered. Enrollment is refused server-side, and the account page hides the row.
- `optional`: users may enroll from their account page, and are challenged once they have.
- `required`: the same, plus your UI should push an unenrolled user through enrollment.

Turning `totp` back to `disabled` does not delete the enrollments already made. Those accounts are no longer challenged, so the factor stops applying, but the row stays on the account page saying the site no longer asks for a code, with the option to remove it. Hiding it would leave a user with a factor they can neither see nor clear.

⚠️ **`required` does not block an unenrolled user.** An account with nothing enrolled has no second factor to be challenged on, so it signs in on the password alone. This is deliberate: enforcing it at the login route would lock out every existing account the moment you switched the setting on, which is nobody's intended rollout.

Enforce it in your UI instead. `settings.mfa.totp === "required"` in the realm config (`GET /api/realms/config`, public) tells you the policy, and `GET /api/users/me/mfa` tells you whether this user has satisfied it (`totp.enabled`). Send them to enrollment when they have not.

## The sign-in flow

`POST /_auth/token` behaves exactly as before for a realm with no second factor. When one is owed, it answers `401` with `name: "MFA_REQUIRED"` and the challenge in `data` instead of tokens:

```json
{
  "name": "MFA_REQUIRED",
  "message": "A second authentication factor is required",
  "data": {
    "status": 401,
    "challenge": "eyJzdWIiOi...",
    "methods": ["totp"],
    "sentTo": "a**@example.com"
  }
}
```

```mermaid
sequenceDiagram
  participant C as Client
  participant S as Server
  participant M as Mailbox
  C->>S: POST /_auth/token (password)
  alt no second factor owed
    S-->>C: 200 tokens
  else second factor owed
    opt emailCode
      S->>M: code (sent inline)
    end
    S-->>C: 401 MFA_REQUIRED + challenge
    C->>S: POST /_auth/mfa (challenge, code)
    S-->>C: 200 tokens
  end
```

The `challenge` is a signed, five-minute assertion that the password was verified. It grants nothing on its own. `sentTo` is a masked destination, present only for a factor whose code was sent somewhere.

`POST /_auth/mfa` with `{ challenge, code }` mints the real session and answers with the same body a plain sign-in does, so a client only branches once.

`POST /_auth/mfa/resend` with `{ challenge }` sends an out-of-band code again and answers `{ sentTo }`. It is a no-op for TOTP, whose code never leaves the user's device. While the last code is still on its resend cooldown, no new one is sent and the code the user already has stays valid.

The email is sent [`inline`](https://latest.alepha.dev/docs/guides-features-background-jobs.md#inline-when-a-retry-is-worse-than-a-failure), not queued: a code lives 300 seconds by default and a queued retry would arrive after it expired, so a failed send fails the sign-in in front of the user, who can try again.

### From React

```typescript
// inside a React component
import { MfaRequiredError, useAuth } from "alepha/react/auth";

const { login, loginMfa } = useAuth();

try {
  await login("credentials", { username, password });
} catch (error) {
  if (error instanceof MfaRequiredError) {
    // error.data is typed: { challenge, methods, sentTo? }. Show a code field, then:
    await loginMfa(error.data.challenge, code);
  } else {
    throw error;
  }
}
```

`MfaRequiredError` is an `UnauthorizedError.extend(...)` (see [Typed errors](https://latest.alepha.dev/docs/guides-server-typed-errors.md)), so `HttpClient` rebuilds the class from the response body and `instanceof` holds in the browser. It replaces the `isMfaRequired()` helper, which is gone.

`useAuth()` also returns `resendMfaCode(challenge)`, the client side of the resend route.

`@alepha/ui`'s `AuthLogin` already does this: it swaps its form for the code step on a challenge, and offers a resend button when the method is `emailCode`. Applications with a hand-rolled login page use the calls above.

## Enrollment

`MyMfaController` exposes the self-service endpoints, all scoped to the caller. Every one but the status read requires a signed-in session: an API key or other machine credential is refused with a `403`.

| Endpoint                                     | Body       | What it does                                                           |
| -------------------------------------------- | ---------- | ---------------------------------------------------------------------- |
| `GET /api/users/me/mfa`                      |            | `{ totp: { enabled, pending, recoveryCodesLeft } }`                    |
| `POST /api/users/me/mfa/totp/enroll`         |            | Returns the secret, the `otpauth://` URI, and a QR code as inline SVG  |
| `POST /api/users/me/mfa/totp/activate`       | `{ code }` | Confirms a code and returns the recovery codes, once                   |
| `DELETE /api/users/me/mfa/totp`              | `{ code }` | Turns it off; the code is a current one or a recovery code             |
| `POST /api/users/me/mfa/totp/recovery-codes` | `{ code }` | Issues a fresh set, retiring the old one; same proof as turning it off |

Enrollment is refused with a `400` while `mfa.totp` is `"disabled"`, and when TOTP is already active. Starting again on a `pending` enrollment (one never confirmed with a code) replaces it, so a user who scanned the wrong QR can simply restart. A pending enrollment gates nothing at sign-in.

The QR is rendered on the server, so an application does not need a QR encoder in its own bundle.

`@alepha/ui`'s account security page has the whole dialog already. Nothing to build if you use it.

### What the authenticator app shows

The scanned entry is labelled `<issuer>: <account>`, and both come from the realm:

- **issuer** is `settings.displayName`, falling back to the realm's internal name.
- **account** is the user's email, or their username, or their id, in that order.

Set `displayName`. Without it the issuer is the realm name, which for every single-realm application is `users`, so the phone lists the entry as "users". That identifies nothing, and it collides with the next application that skips it too.

```typescript
realm = $realm({
  settings: {
    displayName: "Customer Portal", // ← what the phone will show
    mfa: { totp: "required", emailCode: "disabled" },
  },
});
```

An `otpauth://` URI is consumed once, at scan time, so changing `displayName` later only affects new enrollments. Entries already on a phone keep the label they were created with.

### Why disabling asks for a code

A live session is not proof that the person at the keyboard still holds the second factor. Without the check, an unattended signed-in browser is enough to strip the factor off an account and come back later. Regenerating recovery codes asks for the same proof: new recovery codes are a way into the account, and a borrowed session must not be able to mint its own.

## Recovery codes

Activation returns ten single-use codes. Any of them is accepted wherever a TOTP code is (at sign-in, to disable, to regenerate), and each works exactly once. They are stored hashed, so that response is the only time they can ever be displayed, and a user who does not keep them has no way back in without an administrator.

An administrator resets a locked-out user from the "Two-factor authentication" card on that user's Security tab in the admin UI. It is deliberately its own card rather than a row among the connected accounts: an authenticator app is not a way to sign in, and removing it does not take a way in away, it takes a check away.

Underneath it is `AdminIdentityController`, deleting the user's `totp` identity row on the `admin:identity:delete` permission, and the deletion is recorded in the audit log.

## What is stored

Everything lives in the existing `identities` row, so **turning this on needs no migration**:

- `provider: "totp"`, `providerUserId: null`
- `providerData`: the secret (encrypted with the application secret), the status, the last accepted time step, and the hashed recovery codes

## Notes on the implementation

- Codes are RFC 6238, SHA-1, six digits, thirty-second steps, with one step of tolerance either side to absorb clock drift.
- A time step is single use. A code seen over someone's shoulder cannot be replayed inside its own validity window.
- The clock comes from `DateTimeProvider`, so tests drive it with `travel()`.
- An emailed code is refused on its second use, even though the underlying verification record would still accept it: idempotency is right for confirming an address and wrong for a login factor.
- Second-factor attempts are rate limited on their own counter (`mfa`), separate from password attempts, with the realm's `loginRateLimit.accountMaxAttempts` per `windowMs`. A code is counted before it is compared and a passing code gives its slot back. The counter is actor state: per instance without `REDIS_URL`, shared across instances with it, a Durable Object on workerd. See [the topology rule](https://latest.alepha.dev/docs/guides-persistence-actors.md).
