Files
docs/android/APP_LINKS.md
wtclaude 1aba1ff93d 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>
2026-07-20 17:01:57 -05:00

104 lines
6.1 KiB
Markdown

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