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>
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:
- 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.
- 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.
- 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).
- 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 (JWThttpOnlycookie, 1d) and mobile (15m access JWT + 30d opaque refresh token).requireAuthaccepts either viatoken.extractToken(). - TOTP is opt-in per user (
users.totp_secret/totp_enabled), demanded on every login. Web uses a staged 5-minstage:'totp'challenge; mobile uses a single-request401 { totpRequired }. No recovery codes exist today. - Device tracking exists only on mobile (
mobile_refresh_tokensrows withdevice_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 +revokeAllForUserfor 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 atoken_hash UNIQUEindex, 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 andbcrypt.compareeach, 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:
- shows the existing trusted devices,
- requires revoking ≥1 before continuing,
- completes via
POST /auth/me/trusted-devices(trust current device), and - offers Cancel, which returns without creating any trust entry.
6. API additions
Auth (login paths)
POST /auth/login— after password verify, if a valid unrevokedrg_trustcookie matches a livetrusted_devicesrow for this user → skip TOTP, issue the session, logauth.login.trusted_device, stamplast_used_at. Otherwise unchanged ({ totpRequired, challenge }).POST /auth/login/totp— gains optionaltrustDevice+deviceName, and accepts a recovery code as an alternative to the TOTP code (single-use). On success withtrustDevice, mint the opaque trust token, set therg_trustcookie, insert the row (subject to the cap →409signal).POST /auth/mobile/login— gainstrustDevice/recoveryCode; returns atrustTokenthe 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, orX-Trust-Token) and, if it belongs to this user, skip the code, stamplast_used_at, and logauth.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 optionaltrustDevice+deviceName, mints the trust and sets therg_trustcookie on success. At the cap the sign-in still completes and the response carries{ trustLimitReached, devices }, matchingPOST /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_trustcookie:httpOnly,sameSite=Lax,secureper-request (reusecookieSecure),path=/,maxAge30d, 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 adminadmin.trusted_device.revoke/.revoke_all,admin.user.totp.reset— each with actor, target user, and device id indetail.
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
- Schema + models —
trusted_devices(sha256, cap-checked) +recovery_codes(bcrypt);.db.js/.model.jspairs mirroringmobileSessions. - Session service — trust-token mint/sha256/verify + recovery-code
generate/bcrypt-verify/consume helpers (pure, DB-free); shared
assertUnderTrustCap(). - Web login — trust-cookie skip in
/auth/login;trustDevice/ recovery handling + cap409in/auth/login/totp; set/clearrg_trust. - Mobile login —
trustDevice/trustToken/recoveryCode, same cap. - Self-service + admin backend —
/auth/me/trusted-devices*+ recovery-code endpoints;/admin/users/:id/trusted-devices*+ MFA reset (all admin-gated). - Invalidation wiring — password change/reset & TOTP-disable revoke trust + delete recovery codes.
- Web client UI — "Trust this device" checkbox; cap-reached TOTP-styled modal (revoke-to-continue / cancel); user Trusted Devices + Recovery Codes screens.
- Admin front-end UI — admin Trusted Devices management & revocation screens (per-user list, revoke one / revoke all, MFA reset), wired to step 5.
- Android — record the app-side trust/recovery flow in
docs/android/PLAN.md; app implementation sequenced after the backend lands. - OpenAPI + docs —
#swagger.*on every new/modified route + regenerateserver/swagger/swagger-output.json; updateBACKEND_DESIGN.md§3/§4/§6. - Automated tests — trusted-device login skip (valid / missing / expired /
revoked), token mint+hash, recovery-code single-use consume + wrong-code
backoff, cap
409behavior, 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.