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