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:
103
android/APP_LINKS.md
Normal file
103
android/APP_LINKS.md
Normal 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.
|
||||
Reference in New Issue
Block a user