From 1aba1ff93df059157225262212227e3b6ccfd38b Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 20 Jul 2026 16:41:23 -0500 Subject: [PATCH] docs(auth): design the mobile SSO authorization bridge (M9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- android/APP_LINKS.md | 103 ++++++++++++++++++++++++++++++++++++++ android/PLAN.md | 40 +++++++++++---- website/BACKEND_DESIGN.md | 97 +++++++++++++++++++++++++++++++++++ 3 files changed, 231 insertions(+), 9 deletions(-) create mode 100644 android/APP_LINKS.md diff --git a/android/APP_LINKS.md b/android/APP_LINKS.md new file mode 100644 index 0000000..336b9bb --- /dev/null +++ b/android/APP_LINKS.md @@ -0,0 +1,103 @@ +# Architecture note — App Links & the multi-tenant callback problem + +Status: **design note; not yet implemented.** Written before the App Links work begins so the +multi-tenancy question is decided on paper first (per the mobile-SSO spec). The native SSO bridge +ships with the **custom-scheme** callback only (`runicgateway://auth/callback`); everything below is +the *later* hardening path and its open design questions. + +Read alongside: the "Mobile SSO Authorization Bridge" section of +[`../website/BACKEND_DESIGN.md`](../website/BACKEND_DESIGN.md) (the endpoints/tables), and +[`PLAN.md`](./PLAN.md) §4.2 / §9 (the app milestone). + +--- + +## 1. The problem + +The mobile SSO bridge redirects the browser back to the app with a one-time code: + +``` +runicgateway://auth/callback?code=…&state=… +``` + +A **custom URI scheme** is fine for a self-hosted, single-tenant, internal client, but it is *not* +owned by anyone: any other Android app can also register an intent-filter for +`runicgateway://auth/callback` and, if chosen by the user (or if it registers more specifically), +intercept the callback. The code is single-use, PKCE-bound, and short-lived — so an interceptor +still cannot complete `/exchange` without the app's `code_verifier` — but a hijacked callback is +still a denial-of-service and a phishing surface we would rather close. + +**Android App Links** (verified `https://` deep links) close it: the OS only routes an `https://` +link to an app that has proven, via a file served from *that domain*, that it owns the app. An +attacker cannot serve that file on a domain they do not control. + +## 2. Why this is harder here than in a normal app + +RunicGateway is **self-hosted per shard**. There is no single canonical domain — every shard owner +runs the website on **their own** domain (`play.exampleshard.com`, `uo.anothershard.net`, …). App +Links verification is **per-domain**: the domain must serve + +``` +https:///.well-known/assetlinks.json +``` + +asserting the Android app's **package name** + **signing-certificate SHA-256 fingerprint**. The one +published app binary (one package name, one signing cert) must therefore be verifiable against +**every** shard domain that wants App Links — a domain set that is open-ended and not known at build +time. + +Two consequences: + +1. **The shard must serve `assetlinks.json`.** Shard owners will not hand-edit a JSON file with a + cert fingerprint. The website has to **auto-serve** it from an admin setting. +2. **The app must know which shard domain it is paired to** before it can trust an App Link for that + domain. This is a *pairing/bootstrapping* problem, not just a callback-security detail — it is the + part that makes App Links more than a drop-in swap for the custom scheme. + +## 3. Proposed shape (when we build it) + +### 3.1 Server: auto-served `assetlinks.json` + +- One published app ⇒ one package name (`com.runicgateway.app`) and one release signing cert. Its + SHA-256 fingerprint is a **constant of the published app**, not shard-specific. +- Add a website route `GET /.well-known/assetlinks.json` (served at the **web root**, outside + `/api/v1`) that emits the Digital Asset Links statement for that fixed package + fingerprint. +- Gate it behind an admin setting `mobile_app_links_enabled` (default **off**). Off ⇒ the route 404s + and the app stays on the custom scheme for that shard. On ⇒ the shard opts into App Links. +- The fingerprint is the same for every shard, so it can be a shipped constant / env default + (`MOBILE_APP_CERT_SHA256`) rather than something each owner types. The **only** per-shard action is + flipping the setting on. +- When enabled, the shard also registers its `https:///mobile/callback` URL into the mobile + redirect-URI allowlist (see the bridge's exact-match allowlist) **in addition to** the custom + scheme — the custom scheme is never removed, it is the universal fallback. + +### 3.2 App: which domain do I trust? + +- The app already stores the shard **base URL** it is paired to (first-run connect flow, PLAN §3). + That base URL's host is the *only* domain the app should accept an App Link callback from. +- The intent-filter for `https://…/mobile/callback` cannot be scoped to a runtime host in the + manifest (intent-filters are static). Options, in order of preference: + 1. **Custom scheme stays the default**; App Links are an *opt-in* the app only relies on after it + has (a) a paired base URL and (b) confirmed that host serves a valid `assetlinks.json`. Until + both hold, the app requests the custom-scheme `redirect_uri` at `/start`. This keeps a single + code path and avoids trusting an unverified `https` callback. + 2. Register a broad `https` autoVerify intent-filter and **reject at runtime** any callback whose + host ≠ the paired base-URL host. AutoVerify only succeeds for domains that actually serve the + file, so in practice only real, opted-in shard domains route to the app; the runtime host check + is defense-in-depth. +- **Decision to make at build time:** whether to ship the `https` autoVerify intent-filter at all in + v1 of the native SSO client, or defer it entirely and ship custom-scheme-only. Given the spec's + guidance ("custom scheme is the practical default; App Links can be layered on per-instance"), + **custom-scheme-only for the first native-SSO release** is the recommended path. + +## 4. Recommendation + +- **This round:** custom scheme only. No `assetlinks.json` route, no autoVerify intent-filter, no + pairing changes. The bridge's redirect-URI allowlist contains exactly the one fixed + application-owned callback (`runicgateway://auth/callback`). +- **Follow-up (opt-in hardening), only if/when the app is published publicly:** implement §3.1 + (auto-served `assetlinks.json` behind an admin toggle) and §3.2 option 1 (App Links relied on only + after the paired host is verified). Keep the custom scheme as the permanent fallback. + +Nothing in the bridge's server design has to change to add App Links later: it is purely *more +entries in the redirect-URI allowlist* plus a static file route. That is the point of keeping the +allowlist exact-match and configurable from day one. diff --git a/android/PLAN.md b/android/PLAN.md index c6f3305..7c16117 100644 --- a/android/PLAN.md +++ b/android/PLAN.md @@ -1,6 +1,6 @@ # Android App — Plan -Status: **M0–M7 landed; M7 (push notifications) both parts done — Part 1 backend (website#78) and Part 2 app (Android-app#15) plus a small `push.ntfyUrl` settings addition (website#79). Remaining: set the shard's `NTFY_*` deploy config so push lights up, and cut the v1 tag.** This document is the +Status: **M0–M7 landed; M7 (push notifications) both parts done — Part 1 backend (website#78) and Part 2 app (Android-app#15) plus a small `push.ntfyUrl` settings addition (website#79). Remaining: set the shard's `NTFY_*` deploy config so push lights up, and cut the v1 tag. M9 (native SSO login) is now underway backend-first — the Mobile SSO Authorization Bridge is being built in `website/` + `docs/` ahead of the app-side client (§4.2, §9 M9); custom-scheme callback only for now, App Links deferred (see [`APP_LINKS.md`](./APP_LINKS.md)).** This document is the design contract for the `RunicGateway/Android-app` repo. It was written before implementation so the API changes it depends on could be landed in `website/` and `docs/` first. The authoritative API reference is the committed OpenAPI spec at `website/server/swagger/swagger-output.json` (regenerated @@ -467,14 +467,19 @@ completes them in a Custom Tab, then returns and signs in natively (§4.1): new username + password. (No mobile register/invite endpoints needed.) - **Forgot / reset password** — the app links to the website's reset page (the flow being built in §8 before app work). The user resets there, then signs into the app. (No mobile reset endpoint needed.) -- **SSO (Google / Discord / OIDC)** — SSO stays the website's browser redirect flow (`/auth/sso/*`), - **link-only** (no auto-provisioning). For v1 the app does **not** do one-tap in-app SSO; instead an - SSO user links their identity and sets a password on the website (the existing "set initial password" - path for SSO-provisioned accounts), then uses password login in the app. `GET /auth/sso/providers` - can still be shown so the login screen can direct users to "sign in with … on the website." - - *Possible later enhancement (out of v1):* true in-app SSO via a Custom-Tab flow that hands a - one-time code back to an app link, exchanged for mobile tokens — a small new backend endpoint. Only - build it if password-for-SSO-users proves too clunky. +- **SSO (Google / Discord / OIDC)** — **v1** shipped this as a website browser hand-off: an SSO user + links their identity and sets a password on the website, then uses password login in the app. + `GET /auth/providers` is shown so the login screen can direct users to "sign in with … on the website." + - **Native in-app SSO — now being built (M9), post-v1 additive.** The "possible later enhancement" + noted here is now the **Mobile SSO Authorization Bridge**: a Custom-Tab flow that hands a one-time + code back to the app's fixed callback (`runicgateway://auth/callback`), exchanged for the *existing* + mobile bearer tokens. It **extends** the existing `/auth/sso/*` redirect flow rather than adding a + parallel auth path — same PKCE-vs-IdP, same link-only + opt-in-provisioning policy, same TOTP gate, + same token shape as `/auth/mobile/login`. The bridge adds a **second** PKCE layer (app ↔ website) + and an app-generated `state` (CSRF, verified by the app before exchange). Backend + docs land first + (this document's canonical API ref is `../website/BACKEND_DESIGN.md` → "Mobile SSO Authorization + Bridge"); the native app client is M9. Custom-scheme callback only for now — App Links are deferred + (see [`APP_LINKS.md`](./APP_LINKS.md)). ### 4.3 Session model (all paths) - **Refresh:** `POST /auth/mobile/refresh` `{ refreshToken }` → new pair. **Single-use / rotated:** store @@ -704,6 +709,23 @@ push, and Play (M6–M8) follow the designed app. its `NTFY_*` deploy config (§13). 9. **M8 — Google Play**: Play Console listing, signing/upload key, and (optionally) an FCM build flavor — after the direct-APK release is stable. +10. **M9 — Native SSO login** (post-v1, additive; independent of M8): in-app "Sign in with Google / + Discord" via the **Mobile SSO Authorization Bridge** (§4.2). **Backend-first**, mirroring M7's + split: + - **Part 1 — backend + docs (in progress):** `mobile_auth_sessions` + `mobile_auth_codes` bridge + tables; `GET /auth/mobile/sso/start` (seeds a bridge session, reuses the existing SSO redirect + tagged `mode:'mobile'`); a mobile branch in the SSO callback + TOTP-completion that mints a + single-use, hashed, PKCE-bound authorization code and redirects to the fixed app callback instead + of setting a cookie; `POST /auth/mobile/sso/exchange` (code + PKCE verifier → the existing mobile + bearer token pair); an exact-match redirect-URI allowlist; boot-time + opportunistic cleanup of + the bridge tables. Reuses `GET /auth/providers` for discovery and `POST /auth/mobile/{refresh, + logout}` unchanged. See `../website/BACKEND_DESIGN.md`. + - **Part 2 — app client:** register the `runicgateway://auth/callback` intent-filter; generate + `code_verifier`/`code_challenge` + `state`; open the Custom Tab at `/auth/mobile/sso/start`; + verify `state` on the callback; `POST …/exchange`; store the returned pair in the existing + `TokenStore` (M3). No new token-storage or refresh code — it feeds the M3 session machinery. + - **Deferred:** App Links / per-shard `assetlinks.json` / pairing — custom scheme only for now + ([`APP_LINKS.md`](./APP_LINKS.md)). --- diff --git a/website/BACKEND_DESIGN.md b/website/BACKEND_DESIGN.md index ce9aba2..986a831 100644 --- a/website/BACKEND_DESIGN.md +++ b/website/BACKEND_DESIGN.md @@ -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 | |---|---|---| -- 2.49.1