Files
docs/android/APP_LINKS.md
wtclaude 752793f6c3 docs(android): spec Android App Links (assetlinks.json + build-time host)
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
2026-07-20 18:37:04 -05:00

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:autoVerify needs 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:

  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.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):

    [
      {
        "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://<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.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:

<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 HTTPS redirect_uri https://<pairedHost>/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)/exchangeSessionManager.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=<host>) — 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://<host>/.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://<host>/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.