docs(auth): design the mobile SSO authorization bridge (M9)

Record the plan before coding: native Android "Sign in with Google/Discord"
via a Mobile SSO Authorization Bridge that extends the existing /auth/sso/*
redirect flow and terminates in the existing mobile bearer tokens.

- BACKEND_DESIGN.md: mobile_auth_sessions / mobile_auth_codes schema, the
  /auth/mobile/sso/{start,exchange} contract, the two PKCE layers, state/CSRF,
  exact-match redirect-URI allowlist, TOTP parity, and the documented
  revocation-latency window.
- android/PLAN.md: promote §4.2's "possible later enhancement" to milestone M9
  (backend-first, mirroring M7); status note.
- android/APP_LINKS.md: new architecture note on the per-shard assetlinks.json
  / pairing multi-tenancy question (App Links deferred; custom scheme only now).

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-07-20 16:41:23 -05:00
parent 5a7bbc26fa
commit 1aba1ff93d
3 changed files with 231 additions and 9 deletions

View File

@@ -178,6 +178,51 @@ A DB read never yields a usable reset link. See §4 `/auth/password/*`.
`PRIMARY KEY(user_id, stream_id)`. Subscriptions are per-user (applied to every device); a PUT
replaces the whole set. Nothing is pushed unless the user subscribed.
### mobile_auth_sessions / mobile_auth_codes — mobile SSO bridge (M9)
Two short-lived, self-pruning tables that bridge a browser SSO redirect flow to a native client. They
carry the **app ↔ website** PKCE + CSRF state (a *second* PKCE layer, distinct from the website ↔ IdP
PKCE the `sso_tx` cookie already carries) and the one-time authorization code the app exchanges for
bearer tokens. Neither holds a secret in the clear — the PKCE `code_challenge` is a hash by
construction, and the authorization code is stored as a **sha256 hash only** (same pattern as
`user_invites` / `password_resets` / `mobile_refresh_tokens`).
`mobile_auth_sessions` — one row per `/auth/mobile/sso/start`:
| col | type | notes |
|---|---|---|
| id | INT PK AUTO_INCREMENT | |
| session_id | CHAR(36) UNIQUE | opaque uuid; carried inside the signed `sso_tx` (mode `mobile`) so the callback can find this row |
| provider | VARCHAR(40) NOT NULL | provider id validated enabled at `/start` |
| code_challenge | VARCHAR(255) NOT NULL | app-supplied PKCE S256 challenge (base64url); verified at `/exchange` |
| redirect_uri | VARCHAR(255) NOT NULL | the requested app callback — **exact-match** against the allowlist (never prefix) |
| 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 |
| expires_at | DATETIME NOT NULL | short (~10 min — one redirect round-trip incl. TOTP) |
| created_at / used_at | DATETIME | `used_at` stamped at exchange |
`mobile_auth_codes` — one row per completed SSO callback (the code the app redeems):
| col | type | notes |
|---|---|---|
| id | INT PK AUTO_INCREMENT | |
| code_hash | CHAR(64) UNIQUE | sha256 hex of the opaque ≥128-bit code; the raw code never touches the DB |
| user_id | INT NOT NULL FK→users(id) ON DELETE CASCADE | the authenticated account |
| session_id | CHAR(36) NOT NULL | the owning `mobile_auth_sessions.session_id` (ties the code to its PKCE challenge) |
| expires_at | DATETIME NOT NULL | very short (~5 min) |
| used_at | DATETIME NULL | set on first successful exchange — **single use** (a reused code fails) |
| created_at | DATETIME | |
Both self-prune (indexed `expires_at`): a best-effort sweep runs at boot beside the existing
`revoked_sessions` prune, and each bridge write opportunistically deletes expired rows — so no cron
infra is added (same approach as `revoked_sessions`).
**`mobile_refresh_tokens` additions (M9).** Two nullable columns are added to support the device
list/revoke surface: `device_name VARCHAR(100) NULL` (a friendly label) and `last_used_at DATETIME
NULL` (bumped on each refresh). Existing rows get them via the schema's ALTER section; the token model
is otherwise unchanged.
---
## 4. API contract
@@ -238,6 +283,58 @@ create/publish-post path for `news.post`. The stream catalog + event→stream ma
- ntfy is treated as an **untrusted relay** — no per-user accounts, unguessable topics; an optional
`NTFY_PUBLISH_TOKEN` hardens backend→ntfy publishes but is not required. See docs/android/PLAN.md §11.
### Mobile SSO Authorization Bridge (`/auth/mobile/sso/*`, M9)
Native "Sign in with Google/Discord" for the Android app **without shipping any OAuth secret in the
app**. The website stays the identity authority: each shard owner's provider credentials live in
`auth_providers` (encrypted at rest) and are only ever used server-side. The bridge is a **new
consumer of the existing SSO + mobile-bearer machinery**, not a parallel auth path — it reuses the
`/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`.
| 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/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) |
**Two PKCE layers (do not conflate).**
- *Layer A (existing):* website ↔ IdP. The `code_verifier` is generated at `/start`, kept only in the
httpOnly `sso_tx` cookie, sent to the IdP token endpoint at the callback. Unchanged.
- *Layer B (new):* app ↔ website. The **app** generates `code_verifier`/`code_challenge`; the
challenge is stored in `mobile_auth_sessions` at `/start`; the verifier is presented at `/exchange`.
This is what stops an intercepted callback code from being redeemed by anyone but the real app.
**State / CSRF.** The app-generated `state` is stored at `/start`, echoed on the callback redirect,
and **verified by the app** before it calls `/exchange` — a CSRF guard independent of both PKCE
layers (a different app instance triggering `/start` cannot complete someone else's flow).
**Redirect-URI allowlist.** `/start` and the callback validate `redirect_uri` by **exact match**
against a configured allowlist (`MOBILE_AUTH_REDIRECT_URIS`, default the one fixed application-owned
callback `runicgateway://auth/callback`) — **never prefix match** (prefix matching on custom schemes
is a known open-redirect vector). Tokens are **never** placed in the callback URL — only the
short-lived authorization code. HTTPS App Link URIs can be appended to the allowlist later per shard;
that is the *only* server change App Links require (see docs/android/APP_LINKS.md).
**TOTP through the bridge.** A 2FA account keeps full parity: the callback stages the existing
pending-TOTP cookie (now also carrying the bridge `session_id`) and bounces the Custom Tab through the
web TOTP form; on a correct code the completion mints the authorization code and deep-links back to
the app — it never mints a session cookie for a mobile flow.
**Revocation latency (documented tradeoff).** Revoking a refresh token (device revoke / logout) stops
future renewals but does **not** invalidate an already-issued access token until it expires — up to
the access-token lifetime (`MOBILE_ACCESS_TTL`, default 15 min) of continued access. This is an
accepted tradeoff given the short lifetime. If instant revocation is ever required, add an
access-token (jti) blocklist check on the `requireAuth` path — the same `revoked_sessions` mechanism
web sessions already use.
**Authorization code.** Cryptographically random, ≥128 bits, stored **hash-only**, single-use, short
expiry (~5 min); `/exchange` is rate-limited per-IP. The bridge tables self-prune (§3).
### /public (public.routes.js → public.controller.js) — all GET, no auth
| Method | Path | Notes |
|---|---|---|