# Android App Links — implementation spec Status: **implementation spec (M9 follow-up).** Stacks on the native SSO bridge (M9 Part 2): the app already handles the **custom-scheme** callback `runicgateway://auth/callback`, and that stays the permanent default and universal fallback. App Links are an **opt-in hardening** layered on top — a verified `https://` callback that only the domain's real owner can claim. Read alongside: the "Mobile SSO Authorization Bridge" section of [`../website/BACKEND_DESIGN.md`](../website/BACKEND_DESIGN.md) (endpoints/tables/allowlist), and [`PLAN.md`](./PLAN.md) §4.2 / §9 (the app milestone). This spec matches what ships on the `feat/mobile-app-links` (website) and `feat/app-links` (android) branches. --- ## 1. The problem it solves 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 internal client, but it is not *owned* by anyone: any other Android app can register an intent-filter for `runicgateway://auth/callback` and, if chosen by the user, intercept the callback. The code is single-use, PKCE-bound (Layer B), 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**. That is only half the problem. The other half is an Android platform constraint that decides the whole shape of the app side: > **`android:autoVerify` needs a *literal* host at build time.** An intent-filter's `` > is a static string in the merged manifest; there is no "any host" or runtime host. A **single > published multi-tenant APK therefore cannot autoVerify an open-ended set of shard domains** — the set > is not known when the APK is built. So App Links here are **not** a drop-in replacement for the custom scheme. They split into two pieces that ship independently: 1. **Server (`assetlinks.json`) — shippable now, benefits any App-Links-capable build.** Every shard can auto-serve its Digital Asset Links statement behind an admin toggle. This is a pure add and is implemented on `feat/mobile-app-links`. 2. **App (`autoVerify` intent-filter) — a *build-time* opt-in.** Because the host must be baked in, App Links are available to: - a **white-label / first-party build** that bakes one shard's host (`-PappLinkHost=play.myshard.com`); - a future **canonical relay domain** (`runicgateway.app`, PLAN §14 — *not yet secured*) that all shards could bounce their final callback through, autoVerified by the generic build. The **generic multi-tenant build bakes no host and stays custom-scheme-only** — correct and safe. The custom scheme is never removed. It is the fallback on every build, for every shard, always. ## 3. Server design — `feat/mobile-app-links` ### 3.1 Auto-served `assetlinks.json` - **Route:** `GET /.well-known/assetlinks.json`, served at the **web root** (outside `/api/v1`, before the SPA catch-all) in `server/src/app.js`. - **Gate:** the 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. - **Body:** the Digital Asset Links statement for the fixed package `com.runicgateway.app` and the release signing cert SHA-256 fingerprint(s): ```json [ { "relation": ["delegate_permission/common.handle_all_urls"], "target": { "namespace": "android_app", "package_name": "com.runicgateway.app", "sha256_cert_fingerprints": ["AB:CD:…"] } } ] ``` - **Fingerprint source:** env `MOBILE_APP_CERT_SHA256` — comma-separated (supports **cert rotation** and a debug + release cert during testing). It is a **constant of the published app**, identical for every shard, so it is a shipped/env default, not something each owner types. The package name is likewise fixed (`MOBILE_APP_PACKAGE`, default `com.runicgateway.app`). - **Enabled but no fingerprint configured ⇒ 404** (+ a one-time warn): serving a statement with no fingerprint asserts nothing and would only mislead the verifier. - Response is `application/json`, `Cache-Control: public, max-age=3600` (the Play verifier and the OS re-fetch it; it changes only on a cert rotation). ### 3.2 Redirect-URI allowlist extension `mobileSso.controller` validates the app's `redirect_uri` by **exact match** against `MOBILE_AUTH_REDIRECT_URIS` (default `runicgateway://auth/callback`). App Links add exactly one more acceptable value, and **only when the toggle is on**: - When `mobile_app_links_enabled`, `/start` additionally accepts the **self-origin** HTTPS callback `https:///mobile/callback` (derived from the request/`APP_BASE_URL`, never from attacker-controlled input). Still **exact match** — never a prefix match. - The static custom-scheme allowlist is never narrowed; the HTTPS entry is *additive*. - No new table or schema: the check reads the one boolean setting. ### 3.3 Public settings advertise the capability `settings.getPublic()` gains `mobileAppLinks: ` (mirrors the toggle) so a client can tell whether a shard opted in before requesting an HTTPS `redirect_uri` (a white-label build uses it to avoid asking for a callback the server would reject). ## 4. App design — `feat/app-links` ### 4.1 Build-time host (`appLinkHost`) - Gradle property `appLinkHost` (default empty). Wired in `app/build.gradle.kts` into **both**: - `BuildConfig.APP_LINK_HOST` — read by `SsoAuthManager` to decide the redirect; - `manifestPlaceholders["appLinkHost"]` — substituted into the App Link intent-filter's host. - **Default (generic build):** empty ⇒ `BuildConfig.APP_LINK_HOST = ""` and the placeholder falls back to the reserved sentinel `runic-gateway.invalid` (RFC 6761 — never resolves), so the `autoVerify` filter is **inert**: it matches no real link and verification simply never succeeds. No custom-scheme behaviour changes. - **White-label build:** `./gradlew assembleRelease -PappLinkHost=play.myshard.com` bakes that one host into the filter and enables the HTTPS redirect for that host. ### 4.2 Manifest A second intent-filter on `MainActivity`, alongside the unchanged custom-scheme one: ```xml ``` ### 4.3 `SsoAuthManager` (pure Kotlin, unit-tested on the JVM) - **Redirect selection in `buildStartUrl`:** request the HTTPS `redirect_uri` `https:///mobile/callback` **iff** `BuildConfig.APP_LINK_HOST` is non-blank *and* equals the paired base-URL host (case-insensitive); otherwise the fixed custom-scheme `REDIRECT_URI`. A white-label build that bakes the host is responsible for enabling the server toggle too (§3.2). - **Verified-callback matcher + host-trust check:** a new `matchesAppLinkCallback(scheme, host, path)` accepts only `scheme == https`, `path == /mobile/callback`, and **`host == the paired base-URL host`**. The paired-host equality is defense-in-depth: even though `autoVerify` already means only a real, opted-in shard domain can route here, the app still refuses any HTTPS callback whose host isn't the shard it is currently paired to. - The rest is unchanged: both matchers feed the *same* `complete(state, code, error)` → `/exchange` → `SessionManager.onSignedIn`. There is no second auth path. ### 4.4 `MainActivity` `handleSsoCallback` routes a VIEW intent through **`matchesCallback(...) || matchesAppLinkCallback(...)`**; everything downstream (state check, exchange, sign-in) is shared. Custom-scheme and App Link callbacks are indistinguishable past the edge. ## 5. Turning it on for a shard 1. Publish/point the app build at the shard host (`-PappLinkHost=`) — or use the generic build and leave App Links off. 2. Set `MOBILE_APP_CERT_SHA256` (release cert fingerprint) in the website env. 3. Admin → Shard/Settings: enable **App Links** (`mobile_app_links_enabled`). 4. Verify `https:///.well-known/assetlinks.json` returns the statement; confirm Android verifies (`adb shell pm get-app-links com.runicgateway.app`). If any step is skipped the app transparently keeps using the custom scheme — nothing breaks. ## 6. Testing - **Server (`node --test`):** route 404s when the toggle is off; 404s when on but no fingerprint; returns the correct statement + content-type when on and configured; the redirect allowlist accepts `https:///mobile/callback` only when enabled and rejects it otherwise (custom scheme always accepted). - **App (JVM unit tests):** `matchesAppLinkCallback` accepts only https + `/mobile/callback` + the paired host and rejects a foreign host / http / wrong path; `buildStartUrl` requests the HTTPS redirect only when the baked host matches the paired host, else the custom scheme. ## 7. What does *not* change - The bridge's server design (PKCE Layer A/B, single-use codes, `/start` + `/exchange`) is untouched; App Links are *one more allowlist entry* + *one static file route*. That is the whole point of keeping the allowlist exact-match and configurable from day one. - The custom scheme remains on every build and is the permanent fallback. - No change to `servuo-plugins/` — App Links are entirely a website ↔ app concern.