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>
6.1 KiB
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 (the endpoints/tables), and
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:
- 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. - 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/callbackURL 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/callbackcannot be scoped to a runtime host in the manifest (intent-filters are static). Options, in order of preference:- 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-schemeredirect_uriat/start. This keeps a single code path and avoids trusting an unverifiedhttpscallback. - Register a broad
httpsautoVerify 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.
- 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
- Decision to make at build time: whether to ship the
httpsautoVerify 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.jsonroute, 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.jsonbehind 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.