feat(sso): App Links autoVerify callback + paired-host trust check
All checks were successful
PR Checks / android-build (pull_request) Successful in 20m53s

Add the app side of Android App Links (M9 follow-up, docs/android/APP_LINKS.md),
layered on the M9 Part 2 native SSO callback:

- Build-time `appLinkHost` Gradle property -> BuildConfig.APP_LINK_HOST +
  manifestPlaceholders["appLinkHost"]. autoVerify needs a literal host, so the
  generic multi-tenant build leaves it empty (placeholder falls back to the
  reserved runic-gateway.invalid sentinel, making the filter inert); a
  white-label build bakes one host with -PappLinkHost=play.myshard.com.
- Manifest: an autoVerify https `/mobile/callback` intent-filter beside the
  unchanged custom-scheme one (the permanent fallback).
- SsoAuthManager: request the https App Link redirect_uri iff the baked host
  matches the paired shard host; matchesAppLinkCallback() enforces a paired-host
  trust check (host must equal the currently-paired base URL host) as
  defense-in-depth. Both matchers feed the same complete()/exchange path.
- MainActivity routes custom-scheme and App Link callbacks identically.

+5 JVM tests (SsoAuthManagerTest -> 14). Built green (JDK 21,
-Pksp.incremental=false); white-label host substitution verified in the merged
manifest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
This commit is contained in:
2026-07-20 18:37:32 -05:00
parent 7665975d59
commit 987ddb54f8
5 changed files with 126 additions and 9 deletions

View File

@@ -48,6 +48,19 @@ android {
versionName = (project.findProperty("versionName") as String?)?.takeIf { it.isNotBlank() } ?: "0.1.0" versionName = (project.findProperty("versionName") as String?)?.takeIf { it.isNotBlank() } ?: "0.1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
// Android App Links host (docs/android/APP_LINKS.md). autoVerify needs a
// *literal* host at build time, so a single multi-tenant APK cannot verify
// open-ended shard domains: App Links are a build-time opt-in. Left empty for
// the generic build (custom scheme only); a white-label/first-party build
// bakes one host with `-PappLinkHost=play.myshard.com`.
// • BuildConfig.APP_LINK_HOST — SsoAuthManager reads it to pick the redirect.
// • manifestPlaceholder appLinkHost — substituted into the intent-filter host;
// empty falls back to the reserved `.invalid` sentinel so the autoVerify
// filter is inert (matches no real link, never verifies).
val appLinkHost = (project.findProperty("appLinkHost") as String?)?.trim().orEmpty()
buildConfigField("String", "APP_LINK_HOST", "\"$appLinkHost\"")
manifestPlaceholders["appLinkHost"] = appLinkHost.ifBlank { "runic-gateway.invalid" }
} }
signingConfigs { signingConfigs {

View File

@@ -39,8 +39,8 @@
<!-- Native SSO callback (M9, PLAN.md §4.2). The bridge deep-links the <!-- Native SSO callback (M9, PLAN.md §4.2). The bridge deep-links the
one-time authorization code back to this fixed, app-owned custom one-time authorization code back to this fixed, app-owned custom
scheme; it must match SsoAuthManager.REDIRECT_URI and the backend's scheme; it must match SsoAuthManager.REDIRECT_URI and the backend's
MOBILE_AUTH_REDIRECT_URIS allowlist exactly. Custom scheme only for MOBILE_AUTH_REDIRECT_URIS allowlist exactly. This is the permanent
now — HTTPS App Links are deferred (docs/android/APP_LINKS.md). --> fallback on every build (docs/android/APP_LINKS.md). -->
<intent-filter> <intent-filter>
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
@@ -50,6 +50,22 @@
android:host="auth" android:host="auth"
android:path="/callback" /> android:path="/callback" />
</intent-filter> </intent-filter>
<!-- App Links hardening (docs/android/APP_LINKS.md): a verified https
callback that only the domain's real owner can claim. autoVerify
needs a literal host, so ${appLinkHost} is baked at build time
(build.gradle.kts). The generic build leaves it as the reserved
runic-gateway.invalid sentinel — the filter then matches no real
link and never verifies. A white-label build sets -PappLinkHost. -->
<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>
</activity> </activity>
<!-- The embedded distributor's persistent ntfy connection (M7, PLAN.md §11). <!-- The embedded distributor's persistent ntfy connection (M7, PLAN.md §11).

View File

@@ -108,14 +108,18 @@ class MainActivity : ComponentActivity() {
} }
/** /**
* Route a `runicgateway://auth/callback` VIEW intent into the SSO bridge (M9, * Route an SSO callback VIEW intent into the bridge (M9, §4.2): either the
* §4.2). Runs on the activity's lifecycle scope; the exchange result surfaces * custom-scheme `runicgateway://auth/callback` (always) or the verified https
* on `SsoAuthManager.outcome` (success signs the session in; failure is shown * App Link `https://<paired-host>/mobile/callback` (opt-in hardening —
* on the login screen). Non-callback intents are ignored. * docs/android/APP_LINKS.md). Both feed the *same* exchange; the result surfaces
* on `SsoAuthManager.outcome` (success signs the session in; failure shows on the
* login screen). Non-callback intents are ignored.
*/ */
private fun handleSsoCallback(intent: Intent?) { private fun handleSsoCallback(intent: Intent?) {
val data: Uri = intent?.takeIf { it.action == Intent.ACTION_VIEW }?.data ?: return val data: Uri = intent?.takeIf { it.action == Intent.ACTION_VIEW }?.data ?: return
if (!ssoAuthManager.matchesCallback(data.scheme, data.host, data.path)) return val isCallback = ssoAuthManager.matchesCallback(data.scheme, data.host, data.path) ||
ssoAuthManager.matchesAppLinkCallback(data.scheme, data.host, data.path)
if (!isCallback) return
val state = data.getQueryParameter("state") val state = data.getQueryParameter("state")
val code = data.getQueryParameter("code") val code = data.getQueryParameter("code")
val error = data.getQueryParameter("error") val error = data.getQueryParameter("error")

View File

@@ -3,6 +3,7 @@
*/ */
package com.runicgateway.app.core.auth.sso package com.runicgateway.app.core.auth.sso
import com.runicgateway.app.BuildConfig
import com.runicgateway.app.core.auth.SessionManager import com.runicgateway.app.core.auth.SessionManager
import com.runicgateway.app.core.net.BaseUrlHolder import com.runicgateway.app.core.net.BaseUrlHolder
import com.runicgateway.app.data.api.SsoApi import com.runicgateway.app.data.api.SsoApi
@@ -78,6 +79,14 @@ class SsoAuthManager @Inject constructor(
private val pending = AtomicReference<Pending?>(null) private val pending = AtomicReference<Pending?>(null)
/**
* The host this build baked an App Link intent-filter for (`BuildConfig.APP_LINK_HOST`,
* empty on the generic multi-tenant build — see docs/android/APP_LINKS.md).
* `internal var` only so unit tests can exercise the App Link path without a build
* flavor; production never reassigns it.
*/
internal var appLinkHost: String = BuildConfig.APP_LINK_HOST
private val _outcome = MutableStateFlow<Outcome>(Outcome.Idle) private val _outcome = MutableStateFlow<Outcome>(Outcome.Idle)
val outcome: StateFlow<Outcome> = _outcome.asStateFlow() val outcome: StateFlow<Outcome> = _outcome.asStateFlow()
@@ -104,15 +113,42 @@ class SsoAuthManager @Inject constructor(
.addQueryParameter("provider", providerId) .addQueryParameter("provider", providerId)
.addQueryParameter("code_challenge", challenge) .addQueryParameter("code_challenge", challenge)
.addQueryParameter("state", state) .addQueryParameter("state", state)
.addQueryParameter("redirect_uri", REDIRECT_URI) .addQueryParameter("redirect_uri", redirectUriFor(base.host))
.build() .build()
.toString() .toString()
} }
/** True if a deep link's scheme/host/path are our fixed SSO callback. */ /**
* The `redirect_uri` to request for a shard on [pairedHost]: the verified https
* App Link callback **iff** this build baked an App Link host that matches the
* paired host (a white-label/first-party build for exactly this shard — which is
* also responsible for enabling `mobile_app_links_enabled` server-side); otherwise
* the fixed custom-scheme callback, which every build/shard always supports.
*/
private fun redirectUriFor(pairedHost: String): String =
if (appLinkHost.isNotBlank() && appLinkHost.equals(pairedHost, ignoreCase = true)) {
"https://$pairedHost$APP_LINK_CALLBACK_PATH"
} else {
REDIRECT_URI
}
/** True if a deep link's scheme/host/path are our fixed custom-scheme SSO callback. */
fun matchesCallback(scheme: String?, host: String?, path: String?): Boolean = fun matchesCallback(scheme: String?, host: String?, path: String?): Boolean =
scheme == CALLBACK_SCHEME && host == CALLBACK_HOST && path == CALLBACK_PATH scheme == CALLBACK_SCHEME && host == CALLBACK_HOST && path == CALLBACK_PATH
/**
* True if a deep link is a verified https App Link callback for the shard we are
* **currently paired to**. The `host == pairedHost` check is defense-in-depth:
* `autoVerify` already means only a real, opted-in shard domain can route here,
* but the app still refuses an https callback whose host isn't the paired shard.
* Returns false before a shard is configured (no paired host to trust).
*/
fun matchesAppLinkCallback(scheme: String?, host: String?, path: String?): Boolean {
val pairedHost = baseUrlHolder.current?.host ?: return false
return scheme == "https" && path == APP_LINK_CALLBACK_PATH &&
host != null && host.equals(pairedHost, ignoreCase = true)
}
/** /**
* Handle the parsed callback params from a returned [REDIRECT_URI] deep link: * Handle the parsed callback params from a returned [REDIRECT_URI] deep link:
* verify `state`, map an `error`, else exchange the `code` and sign in. * verify `state`, map an `error`, else exchange the `code` and sign in.
@@ -191,5 +227,12 @@ class SsoAuthManager @Inject constructor(
* the intent-filter in `AndroidManifest.xml` exactly (PLAN.md §4.2). * the intent-filter in `AndroidManifest.xml` exactly (PLAN.md §4.2).
*/ */
const val REDIRECT_URI = "$CALLBACK_SCHEME://$CALLBACK_HOST$CALLBACK_PATH" const val REDIRECT_URI = "$CALLBACK_SCHEME://$CALLBACK_HOST$CALLBACK_PATH"
/**
* Path of the verified https App Link callback (`https://<shard-host>/mobile/callback`).
* Must match the app's `autoVerify` intent-filter in `AndroidManifest.xml` and the
* backend's self-origin allowlist entry (docs/android/APP_LINKS.md §3.2/§4.2).
*/
const val APP_LINK_CALLBACK_PATH = "/mobile/callback"
} }
} }

View File

@@ -179,4 +179,45 @@ class SsoAuthManagerTest {
assertTrue(!mgr.matchesCallback("runicgateway", "auth", "/other")) assertTrue(!mgr.matchesCallback("runicgateway", "auth", "/other"))
assertTrue(!mgr.matchesCallback("runicgateway", "evil", "/callback")) assertTrue(!mgr.matchesCallback("runicgateway", "evil", "/callback"))
} }
// ── App Links (docs/android/APP_LINKS.md) ────────────────────────────────
@Test fun `matchesAppLinkCallback accepts only https, the app-link path, and the paired host`() {
val mgr = managerWith(FakeSsoApi { Response.success(tokenPair()) }, SessionManager(FakeTokenStore()))
// Paired to shard.example.com (managerWith default base).
assertTrue(mgr.matchesAppLinkCallback("https", "shard.example.com", "/mobile/callback"))
// Host-trust: a foreign host is refused even over https + right path.
assertTrue(!mgr.matchesAppLinkCallback("https", "evil.example.com", "/mobile/callback"))
// Wrong scheme / wrong path.
assertTrue(!mgr.matchesAppLinkCallback("http", "shard.example.com", "/mobile/callback"))
assertTrue(!mgr.matchesAppLinkCallback("https", "shard.example.com", "/callback"))
// Host match is case-insensitive.
assertTrue(mgr.matchesAppLinkCallback("https", "SHARD.EXAMPLE.COM", "/mobile/callback"))
}
@Test fun `matchesAppLinkCallback is false before a shard is paired`() {
val mgr = managerWith(FakeSsoApi { Response.success(tokenPair()) }, SessionManager(FakeTokenStore()), base = null)
assertTrue(!mgr.matchesAppLinkCallback("https", "shard.example.com", "/mobile/callback"))
}
@Test fun `buildStartUrl requests the custom scheme when no app-link host is baked`() {
val mgr = managerWith(FakeSsoApi { Response.success(tokenPair()) }, SessionManager(FakeTokenStore()))
// Generic build: appLinkHost defaults to BuildConfig.APP_LINK_HOST ("" in tests).
val redirect = mgr.buildStartUrl("google")!!.toHttpUrl().queryParameter("redirect_uri")
assertEquals(SsoAuthManager.REDIRECT_URI, redirect)
}
@Test fun `buildStartUrl requests the https app-link callback when the baked host matches the paired host`() {
val mgr = managerWith(FakeSsoApi { Response.success(tokenPair()) }, SessionManager(FakeTokenStore()))
mgr.appLinkHost = "shard.example.com" // white-label build baked this shard's host
val redirect = mgr.buildStartUrl("google")!!.toHttpUrl().queryParameter("redirect_uri")
assertEquals("https://shard.example.com/mobile/callback", redirect)
}
@Test fun `buildStartUrl falls back to the custom scheme when the baked host does not match the paired shard`() {
val mgr = managerWith(FakeSsoApi { Response.success(tokenPair()) }, SessionManager(FakeTokenStore()))
mgr.appLinkHost = "other-shard.example.com" // built for a different shard than the paired one
val redirect = mgr.buildStartUrl("google")!!.toHttpUrl().queryParameter("redirect_uri")
assertEquals(SsoAuthManager.REDIRECT_URI, redirect)
}
} }