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>
104 lines
6.1 KiB
Markdown
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.
|