Promote APP_LINKS.md from a deferred design note into an implementation spec matching the website `feat/mobile-app-links` and android `feat/app-links` branches: the server-side `/.well-known/assetlinks.json` route + `mobile_app_links_enabled` toggle + additive redirect-allowlist entry, and the app-side `autoVerify` intent-filter driven by a build-time `appLinkHost` (a single multi-tenant APK cannot autoVerify open-ended shard domains, so App Links are a white-label / first-party build opt-in; the custom scheme stays the permanent fallback). Update PLAN.md §9 (M9 follow-up) and the §14 open item, and the redirect-URI allowlist section of website/BACKEND_DESIGN.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
10 KiB
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 (endpoints/tables/allowlist), and
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://<shard-domain>/.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:autoVerifyneeds a literal host at build time. An intent-filter's<data android:host>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:
-
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 onfeat/mobile-app-links. -
App (
autoVerifyintent-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.
- a white-label / first-party build that bakes one shard's host (
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) inserver/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.appand the release signing cert SHA-256 fingerprint(s):[ { "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, defaultcom.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,/startadditionally accepts the self-origin HTTPS callbackhttps://<request-host>/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: <bool> (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 inapp/build.gradle.ktsinto both:BuildConfig.APP_LINK_HOST— read bySsoAuthManagerto 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 sentinelrunic-gateway.invalid(RFC 6761 — never resolves), so theautoVerifyfilter 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.combakes 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:
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https"
android:host="${appLinkHost}"
android:path="/mobile/callback" />
</intent-filter>
4.3 SsoAuthManager (pure Kotlin, unit-tested on the JVM)
- Redirect selection in
buildStartUrl: request the HTTPSredirect_urihttps://<pairedHost>/mobile/callbackiffBuildConfig.APP_LINK_HOSTis non-blank and equals the paired base-URL host (case-insensitive); otherwise the fixed custom-schemeREDIRECT_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 onlyscheme == https,path == /mobile/callback, andhost == the paired base-URL host. The paired-host equality is defense-in-depth: even thoughautoVerifyalready 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
- Publish/point the app build at the shard host (
-PappLinkHost=<host>) — or use the generic build and leave App Links off. - Set
MOBILE_APP_CERT_SHA256(release cert fingerprint) in the website env. - Admin → Shard/Settings: enable App Links (
mobile_app_links_enabled). - Verify
https://<host>/.well-known/assetlinks.jsonreturns 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 acceptshttps://<host>/mobile/callbackonly when enabled and rejects it otherwise (custom scheme always accepted). - App (JVM unit tests):
matchesAppLinkCallbackaccepts only https +/mobile/callback+ the paired host and rejects a foreign host / http / wrong path;buildStartUrlrequests 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.