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

103
android/APP_LINKS.md Normal file
View File

@@ -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://<shard-domain>/.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://<domain>/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.

View File

@@ -1,6 +1,6 @@
# Android App — Plan
Status: **M0M7 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: **M0M7 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 (M6M8) 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)).
---

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 |
|---|---|---|