From e4bec0caba6ee06d76e8258265222b3f3435c755 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 28 Jul 2026 01:01:49 -0500 Subject: [PATCH] docs(website): record trusted-device support on the SSO login paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- website/BACKEND_DESIGN.md | 9 ++++++- website/TRUSTED_DEVICES_MFA.md | 49 ++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/website/BACKEND_DESIGN.md b/website/BACKEND_DESIGN.md index 3d0f433..ccb4f0d 100644 --- a/website/BACKEND_DESIGN.md +++ b/website/BACKEND_DESIGN.md @@ -311,6 +311,7 @@ construction, and the authorization code is stored as a **sha256 hash only** (sa | state | VARCHAR(255) NOT NULL | app-generated opaque CSRF value, echoed on the callback for the app to verify | | status | ENUM('pending','completed','consumed') DEFAULT 'pending' | `pending`→`completed` when the code is minted; `consumed` after a successful exchange | | user_id | INT NULL FK→users(id) ON DELETE CASCADE | set once SSO resolves the account | +| trust_device | TINYINT(1) NOT NULL DEFAULT 0 | user ticked "trust this device" on the Custom Tab TOTP form. A **boolean only** — it tells `/exchange` to mint the app's own trust token; the token never rests here (only its sha256 reaches `trusted_devices`) | | expires_at | DATETIME NOT NULL | short (~10 min — one redirect round-trip incl. TOTP) | | created_at / used_at | DATETIME | `used_at` stamped at exchange | @@ -480,12 +481,18 @@ consumer of the existing SSO + mobile-bearer machinery**, not a parallel auth pa `/auth/sso/:provider/*` redirect flow, the link-only + opt-in-provisioning policy, the TOTP gate, and issues the **same** token pair as `/auth/mobile/login`. +The TOTP gate it reuses includes the **trusted-device skip** (see +`TRUSTED_DEVICES_MFA.md` §6). Because the app opens this flow in a Custom Tab, which shares the +system browser's cookie jar, the `rg_trust` cookie set on the TOTP form is presented back on the next +app sign-in — so "don't ask me again" works for native SSO without the app injecting a header into a +tab it does not control, and without a trust token ever appearing in a start URL. + | Method | Path | Auth | Body / Query | Purpose | |---|---|---|---|---| | GET | `/auth/providers` | — | — | **reused** discovery; the app renders provider buttons from this (never exposes secrets) | | GET | `/auth/mobile/sso/start` | — (rate-limited per-IP + per-provider) | `?provider&code_challenge&state&redirect_uri` | validate provider enabled + `redirect_uri` **exact-match** allowlist; insert a `mobile_auth_sessions` row; create the existing `sso_tx` tagged `mode:'mobile'` carrying `session_id`; **302 to the IdP** (existing authorize URL) | | GET | `/auth/sso/:provider/callback` | — (signed `sso_tx`) | `?code&state` | **existing** endpoint; a new branch when `tx.mode==='mobile'`: resolve the account (same policy as web login incl. TOTP), mint a single-use hashed authorization code into `mobile_auth_codes`, mark the session `completed`, and **302 to `redirect_uri?code=…&state=…`** (the app's original `state`) — **no cookie is set** | -| POST | `/auth/mobile/sso/exchange` | — (rate-limited per-IP) | `{code, code_verifier}` | validate the code exists / unexpired / unused (mark used) and `sha256(code_verifier)` matches the stored challenge → issue the existing mobile access + refresh pair (`createMobileSession`) → `{accessToken, refreshToken, expiresIn, user}` | +| POST | `/auth/mobile/sso/exchange` | — (rate-limited per-IP) | `{code, code_verifier}` | validate the code exists / unexpired / unused (mark used) and `sha256(code_verifier)` matches the stored challenge → issue the existing mobile access + refresh pair (`createMobileSession`) → `{accessToken, refreshToken, expiresIn, user}`. When the session carries `trust_device`, also mint a `platform:'mobile'` trusted device and add `trustToken` — minted here, on an authenticated app→server call, so it never travels in the deep link. Best-effort: at the trusted-device cap the response simply omits it rather than failing the sign-in | | POST | `/auth/mobile/refresh` | — | `{refreshToken}` | **reused** unchanged — rotate the pair | | POST | `/auth/mobile/logout` | bearer | `{refreshToken?, all?}` | **reused** unchanged — revoke this (or all) refresh token(s) | | GET | `/auth/me/sessions` · DELETE `…/:id` | cookie / bearer | — | list / revoke own **mobile sessions** (device_name, last_used_at, created_at) — the "Active Devices" surface (distinct from `/auth/me/devices`, which is push endpoints) | diff --git a/website/TRUSTED_DEVICES_MFA.md b/website/TRUSTED_DEVICES_MFA.md index 725690f..d9b5db7 100644 --- a/website/TRUSTED_DEVICES_MFA.md +++ b/website/TRUSTED_DEVICES_MFA.md @@ -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). -- 2.49.1