# Trusted Devices & MFA Improvements — Design & Implementation Plan

> Reference plan for the trusted-device + MFA hardening work. Approved 2026-07-21.
> This document is the contract the implementation builds against; keep it in sync
> with `BACKEND_DESIGN.md` (§3 schema, §4 API, §6 security) as code lands.

## 1. Goal & scope

Reduce 2FA friction without weakening the second-factor boundary, and close the
2FA-lockout gap. Four deliverables:

1. **Trusted devices** — an opt-in "Trust this device" that lets a browser or the
   Android app **skip the TOTP step** (never the password) on future logins for a
   fixed window.
2. **Recovery / backup codes** — single-use codes generated at 2FA enrollment so a
   user who loses their authenticator can self-recover instead of needing an admin
   reset.
3. **Admin-managed revocation** — staff can view and revoke a user's trusted
   devices and reset their MFA, with full audit logging (**backend endpoints _and_
   admin front-end screens**).
4. **Step-up (password) for sensitive operations** — reusing the existing
   `currentPassword`-verification pattern; disabling TOTP keeps its stronger
   current-TOTP-code requirement.

Touches `website/` (server + client), `docs/`, and `android-app/` (plan only in
this pass). **No** `link/` or `servuo-plugins/` change — no wire-protocol impact.

### Approved decisions

| Decision | Value |
|---|---|
| Trust duration | **30 days** (matches mobile refresh-token lifetime) |
| Roles eligible | **All roles** (no staff carve-out) |
| Opt-in model | **Explicit "Trust this device" checkbox, default off** |
| Recovery codes | **10 codes**, shown **once**, single-use |
| Trusted-device cap | **10 per user, no silent pruning** (see §5) |
| Trust-token hashing | **sha256** |
| Recovery-code hashing | **bcrypt** (cost 10) |

## 2. Current state (starting point)

- **One session service** (`server/src/auth/session.service.js`) backs web (JWT
  `httpOnly` cookie, 1d) and mobile (15m access JWT + 30d opaque refresh token).
  `requireAuth` accepts either via `token.extractToken()`.
- **TOTP** is opt-in per user (`users.totp_secret` / `totp_enabled`), demanded on
  **every** login. Web uses a staged 5-min `stage:'totp'` challenge; mobile uses a
  single-request `401 { totpRequired }`. **No recovery codes** exist today.
- **Device tracking exists only on mobile** (`mobile_refresh_tokens` rows with
  `device_name` / `device_hash` / `user_agent` / `last_used_at`). Web JWTs are
  stateless with no per-session row.
- **Revocation is mature:** `revoked_sessions` (jti denylist) + `tokens_valid_after`
  (per-user cutoff) for web; per-token rows + `revokeAllForUser` for mobile.
- **No trusted-device or step-up concept exists anywhere.**

## 3. Hashing rationale

The repo already splits hashing by secret entropy, and this plan follows it:

- **sha256** — every high-entropy machine-generated opaque token
  (`mobile_refresh_tokens`, `mobile_auth_codes`, `user_invites`, `password_resets`,
  SSO PKCE). **Trusted-device tokens use sha256:** they are 256-bit random values
  (nothing to brute-force) looked up **by a `token_hash UNIQUE` index**, which
  requires a deterministic hash — bcrypt's per-row salt would break the lookup and
  truncates input at 72 bytes.
- **bcrypt (`bcryptjs`, cost 10)** — the repo uses it only for **passwords**, the
  one human-chosen low-entropy secret. **Recovery codes use bcrypt:** they are a
  human-typed, lower-entropy fallback credential that grants a login (the closest
  analogue to a password), and there is no hash-lookup constraint — we fetch the
  identified user's ≤10 code rows and `bcrypt.compare` each, exactly like password
  verification.

## 4. Database (additive, idempotent — matches `schema.sql` style)

### `trusted_devices`
Pattern-identical to `mobile_refresh_tokens`; stores only the token hash.

| Column | Type | Notes |
|---|---|---|
| id | INT PK AUTO_INCREMENT | |
| user_id | INT NOT NULL | FK → users, `ON DELETE CASCADE` |
| token_hash | CHAR(64) NOT NULL UNIQUE | sha256 hex of the opaque trust token |
| platform | ENUM('web','mobile') NOT NULL DEFAULT 'web' | |
| device_name | VARCHAR(100) NULL | friendly label |
| device_hash | VARCHAR(32) NULL | best-effort UA+IP, **display only** |
| user_agent | VARCHAR(255) NULL | |
| created_at | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | |
| last_used_at | DATETIME NULL | stamped when trust is honored at login |
| expires_at | DATETIME NOT NULL | created_at + 30d |
| revoked_at | DATETIME NULL | |

Indices: `idx_td_user (user_id)`, `idx_td_expires (expires_at)`.

### `mobile_auth_sessions.trust_device` (SSO bridge)

| Column | Type | Notes |
|---|---|---|
| trust_device | TINYINT(1) NOT NULL DEFAULT 0 | user ticked "trust this device" on the Custom Tab TOTP form |

A **boolean only**. It records the user's choice so `POST /auth/mobile/sso/exchange`
knows to mint the app's own trust token over that authenticated app→server call; the
token itself is never written here (only its sha256 reaches `trusted_devices`). Set
only while the session is still `pending` and unexpired, for the same reason
`completeSession` is guarded — a replayed TOTP post must not re-arm a consumed
session.

### `recovery_codes`
| Column | Type | Notes |
|---|---|---|
| id | INT PK AUTO_INCREMENT | |
| user_id | INT NOT NULL | FK → users, `ON DELETE CASCADE` |
| code_hash | VARCHAR(72) NOT NULL | **bcrypt** hash of one code |
| used_at | DATETIME NULL | single-use marker |
| created_at | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | |

Index: `idx_rc_user (user_id)`.

No new `users` column: password change/reset and TOTP-disable **bulk-revoke**
`trusted_devices` rows and **delete** `recovery_codes` (consistent with
`revokeAllForUser`), so no "trust epoch" column is needed.

## 5. Trusted-device cap — no silent pruning

Cap = **10**. A shared `assertUnderTrustCap(userId)` guards both entry points (the
login/TOTP trust path and the authenticated "trust this device" path). On the 11th
attempt the backend **refuses to create the row** and returns
`409 { error: 'trusted_device_limit', devices: [...] }`. Login itself still
succeeds — only the trust marker is withheld. The web client then renders a modal
**in the same visual pattern as the TOTP entry flow** that:

1. shows the existing trusted devices,
2. requires revoking ≥1 before continuing,
3. completes via `POST /auth/me/trusted-devices` (trust current device), and
4. offers **Cancel**, which returns without creating any trust entry.

## 6. API additions

### Auth (login paths)
- `POST /auth/login` — after password verify, if a valid unrevoked `rg_trust`
  cookie matches a live `trusted_devices` row for this user → **skip TOTP**, issue
  the session, log `auth.login.trusted_device`, stamp `last_used_at`. Otherwise
  unchanged (`{ totpRequired, challenge }`).
- `POST /auth/login/totp` — gains optional `trustDevice` + `deviceName`, and
  accepts a **recovery code** as an alternative to the TOTP code (single-use). On
  success with `trustDevice`, mint the opaque trust token, set the `rg_trust`
  cookie, insert the row (subject to the cap → `409` signal).
- `POST /auth/mobile/login` — gains `trustDevice` / `recoveryCode`; returns a
  `trustToken` the app stores in EncryptedSharedPreferences and replays on a later
  login to skip TOTP. Same cap behavior.

#### SSO login paths

SSO is **not** exempt: an account with TOTP on is asked for a code after a
Google/Discord sign-in exactly as it is after a password one, and a trusted device
skips that code exactly the same way. (Originally SSO consulted trust nowhere, so a
user who signed in with an external identity was asked for a code on *every* sign-in
no matter how many times they had ticked "trust this device".)

- `GET /auth/sso/:provider/callback` — once the account is resolved and before a
  TOTP challenge is staged, resolve the presented trust (cookie, or `X-Trust-Token`)
  and, if it belongs to **this** user, skip the code, stamp `last_used_at`, and log
  `auth.login.trusted_device`. The first factor is the IdP authentication that just
  succeeded, so this is the same posture as the password path. A store error falls
  through to the challenge — fail **closed** to asking for the code.
- `POST /auth/sso/totp` — gains optional `trustDevice` + `deviceName`, mints the
  trust and sets the `rg_trust` cookie on success. At the cap the sign-in still
  completes and the response carries `{ trustLimitReached, devices }`, matching
  `POST /auth/login/totp`. Recovery codes remain password-login only: this step
  verifies an authenticator code against the staged challenge.

**How this reaches the Android app.** The app's SSO runs in a Custom Tab, which
shares the system browser's cookie jar, so both halves land in the same place: the
`rg_trust` cookie set on the Custom Tab TOTP form is presented back on the *next*
app SSO sign-in and skips the code — no app change, and no trust token smuggled
through a start URL where it would leak into query strings and logs.

To cover the app's **native** password login on the same device as well, ticking
the box also sets `mobile_auth_sessions.trust_device` (a boolean — never the
token), and `POST /auth/mobile/sso/exchange` then mints a `platform: 'mobile'`
trust and returns `{ trustToken }` in its JSON body. Minting at exchange time is
deliberate: it is an authenticated app→server call, so the raw token never travels
in the deep link and never rests in the bridge row. One tick therefore produces two
independently-revocable rows (the browser and the app) — which is honest, since they
are two distinct credentials on one device. If the user is at the cap, the exchange
simply returns no token; it never turns a successful sign-in into an error.

### Self-service (`/auth/me/*`, `requireAuth`, any role)
- `GET /auth/me/trusted-devices` — list active trusted devices (never tokens).
- `POST /auth/me/trusted-devices` — trust the current browser/device (cap-checked).
- `DELETE /auth/me/trusted-devices/:id` — revoke one (ownership-scoped).
- `DELETE /auth/me/trusted-devices` — revoke all ("untrust everywhere").
- `POST /auth/me/account/recovery-codes/generate` — **password step-up required**;
  returns the codes **once**.
- `GET /auth/me/account/recovery-codes/status` — remaining count only.

### Admin (`requireRole('admin')`)
- `GET /admin/users/:id/trusted-devices` — list a user's trusted devices.
- `DELETE /admin/users/:id/trusted-devices/:deviceId` — revoke one.
- `DELETE /admin/users/:id/trusted-devices` — revoke all.
- MFA reset control (revoke trust + disable TOTP + clear recovery codes).

## 7. Cookie / refresh / JWT interaction

- New **`rg_trust`** cookie: `httpOnly`, `sameSite=Lax`, `secure` per-request
  (reuse `cookieSecure`), `path=/`, `maxAge` 30d, opaque 256-bit base64url,
  sha256-hashed server-side. **Separate from the session cookie and deliberately
  survives logout** (so the next login skips 2FA); only untrust / password-change /
  TOTP-disable revoke it.
- **JWTs stay stateless and unchanged** — trust is a server-side cookie+row, never
  a JWT claim, so it remains revocable.
- **Refresh flow untouched** — trust is consulted only at the login/password step,
  never at token refresh; the two stores stay independent.

## 8. Security & invalidation

- Trust **only ever gates the second factor**; password is always required.
- Recovery-code entry reuses the login brute-force stack (backoff + bot scoring +
  rate limits); recovery codes are single-use.
- **Password change/reset and TOTP-disable clear trust and recovery codes.**
- **Audit logging** via existing `activity.log` / `activity_log`:
  `auth.login.trusted_device`, `account.trusted_device.add` / `.revoke` /
  `.revoke_all`, `account.recovery_codes.generate`, `account.recovery_code.consume`,
  and admin `admin.trusted_device.revoke` / `.revoke_all`, `admin.user.totp.reset`
  — each with actor, target user, and device id in `detail`.

## 9. Backwards compatibility

Fully additive. With no `rg_trust` cookie the behavior is exactly today's (TOTP
every login). Recovery codes exist only for users who generate them. No existing
session or login flow changes shape. New tables via `CREATE TABLE IF NOT EXISTS`
and columns via `ALTER TABLE … ADD COLUMN IF NOT EXISTS`.

## 10. Implementation roadmap

1. **Schema + models** — `trusted_devices` (sha256, cap-checked) + `recovery_codes`
   (bcrypt); `.db.js` / `.model.js` pairs mirroring `mobileSessions`.
2. **Session service** — trust-token mint/sha256/verify + recovery-code
   generate/bcrypt-verify/consume helpers (pure, DB-free); shared
   `assertUnderTrustCap()`.
3. **Web login** — trust-cookie skip in `/auth/login`; `trustDevice` / recovery
   handling + cap `409` in `/auth/login/totp`; set/clear `rg_trust`.
4. **Mobile login** — `trustDevice` / `trustToken` / `recoveryCode`, same cap.
5. **Self-service + admin backend** — `/auth/me/trusted-devices*` + recovery-code
   endpoints; `/admin/users/:id/trusted-devices*` + MFA reset (all admin-gated).
6. **Invalidation wiring** — password change/reset & TOTP-disable revoke trust +
   delete recovery codes.
7. **Web client UI** — "Trust this device" checkbox; cap-reached TOTP-styled modal
   (revoke-to-continue / cancel); user Trusted Devices + Recovery Codes screens.
8. **Admin front-end UI** — admin Trusted Devices management & revocation screens
   (per-user list, revoke one / revoke all, MFA reset), wired to step 5.
9. **Android** — record the app-side trust/recovery flow in
   `docs/android/PLAN.md`; app implementation sequenced after the backend lands.
10. **OpenAPI + docs** — `#swagger.*` on every new/modified route + regenerate
    `server/swagger/swagger-output.json`; update `BACKEND_DESIGN.md` §3/§4/§6.
11. **Automated tests** — trusted-device login skip (valid / missing / expired /
    revoked), token mint+hash, recovery-code single-use consume + wrong-code
    backoff, cap `409` behavior, revocation (self + admin), invalidation on
    password-change / TOTP-disable, and permission checks (admin routes reject
    non-admins; self routes ownership-scoped); web client pure-logic tests; Android
    JVM DTO/repository tests.
