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>
This commit is contained in:
2026-07-28 01:01:49 -05:00
parent b2c27fb285
commit e4bec0caba
2 changed files with 57 additions and 1 deletions

View File

@@ -90,6 +90,19 @@ Pattern-identical to `mobile_refresh_tokens`; stores only the token hash.
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 |
|---|---|---|
@@ -134,6 +147,42 @@ succeeds — only the trust marker is withheld. The web client then renders a mo
`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).