Files
docs/website/TRUSTED_DEVICES_MFA.md
wtclaude e4bec0caba docs(website): record trusted-device support on the SSO login paths
Doc side of website + Android-app feat/sso-trusted-device.

TRUSTED_DEVICES_MFA.md §6 gains an "SSO login paths" subsection: SSO is not
exempt from the second factor, and a trusted device skips it exactly as on the
password path (previously SSO consulted trust nowhere, so an external-identity
user was asked for a code on every sign-in). Documents the callback-side skip,
the new trustDevice/deviceName on POST /auth/sso/totp, and why recovery codes
stay password-login only.

Also writes down how this reaches the Android app, since it is not obvious: the
app's SSO runs in a Custom Tab that shares the system browser's cookie jar, so
the rg_trust cookie covers native SSO with no app change and no trust token in a
start URL (which would leak a secret into query strings and logs). The app's own
token is minted at /auth/mobile/sso/exchange instead — an authenticated
app→server call — so it never travels in the deep link, and the bridge row holds
only a boolean. Notes that one tick yields two independently-revocable rows.

§4 documents the new mobile_auth_sessions.trust_device column; BACKEND_DESIGN.md
gets the same column in its bridge table, the trust note on the /exchange row,
and a pointer from the bridge intro to the Custom Tab cookie model.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-28 01:01:49 -05:00

14 KiB

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/generatepassword 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).
  • 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 + modelstrusted_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 logintrustDevice / 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.