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:
@@ -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 |
|
| 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 |
|
| 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 |
|
| 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) |
|
| expires_at | DATETIME NOT NULL | short (~10 min — one redirect round-trip incl. TOTP) |
|
||||||
| created_at / used_at | DATETIME | `used_at` stamped at exchange |
|
| 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
|
`/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`.
|
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 |
|
| Method | Path | Auth | Body / Query | Purpose |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| GET | `/auth/providers` | — | — | **reused** discovery; the app renders provider buttons from this (never exposes secrets) |
|
| 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/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** |
|
| 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/refresh` | — | `{refreshToken}` | **reused** unchanged — rotate the pair |
|
||||||
| POST | `/auth/mobile/logout` | bearer | `{refreshToken?, all?}` | **reused** unchanged — revoke this (or all) refresh token(s) |
|
| 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) |
|
| 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) |
|
||||||
|
|||||||
@@ -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)`.
|
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`
|
### `recovery_codes`
|
||||||
| Column | Type | Notes |
|
| 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
|
`trustToken` the app stores in EncryptedSharedPreferences and replays on a later
|
||||||
login to skip TOTP. Same cap behavior.
|
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)
|
### Self-service (`/auth/me/*`, `requireAuth`, any role)
|
||||||
- `GET /auth/me/trusted-devices` — list active trusted devices (never tokens).
|
- `GET /auth/me/trusted-devices` — list active trusted devices (never tokens).
|
||||||
- `POST /auth/me/trusted-devices` — trust the current browser/device (cap-checked).
|
- `POST /auth/me/trusted-devices` — trust the current browser/device (cap-checked).
|
||||||
|
|||||||
Reference in New Issue
Block a user