feat(sso): App Links autoVerify callback + paired-host trust check
All checks were successful
PR Checks / android-build (pull_request) Successful in 20m53s
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:
@@ -48,6 +48,19 @@ android {
|
||||
versionName = (project.findProperty("versionName") as String?)?.takeIf { it.isNotBlank() } ?: "0.1.0"
|
||||
|
||||
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 {
|
||||
|
||||
@@ -39,8 +39,8 @@
|
||||
<!-- Native SSO callback (M9, PLAN.md §4.2). The bridge deep-links the
|
||||
one-time authorization code back to this fixed, app-owned custom
|
||||
scheme; it must match SsoAuthManager.REDIRECT_URI and the backend's
|
||||
MOBILE_AUTH_REDIRECT_URIS allowlist exactly. Custom scheme only for
|
||||
now — HTTPS App Links are deferred (docs/android/APP_LINKS.md). -->
|
||||
MOBILE_AUTH_REDIRECT_URIS allowlist exactly. This is the permanent
|
||||
fallback on every build (docs/android/APP_LINKS.md). -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
@@ -50,6 +50,22 @@
|
||||
android:host="auth"
|
||||
android:path="/callback" />
|
||||
</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>
|
||||
|
||||
<!-- The embedded distributor's persistent ntfy connection (M7, PLAN.md §11).
|
||||
|
||||
@@ -108,14 +108,18 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Route a `runicgateway://auth/callback` VIEW intent into the SSO bridge (M9,
|
||||
* §4.2). Runs on the activity's lifecycle scope; the exchange result surfaces
|
||||
* on `SsoAuthManager.outcome` (success signs the session in; failure is shown
|
||||
* on the login screen). Non-callback intents are ignored.
|
||||
* Route an SSO callback VIEW intent into the bridge (M9, §4.2): either the
|
||||
* custom-scheme `runicgateway://auth/callback` (always) or the verified https
|
||||
* App Link `https://<paired-host>/mobile/callback` (opt-in hardening —
|
||||
* 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?) {
|
||||
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 code = data.getQueryParameter("code")
|
||||
val error = data.getQueryParameter("error")
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
package com.runicgateway.app.core.auth.sso
|
||||
|
||||
import com.runicgateway.app.BuildConfig
|
||||
import com.runicgateway.app.core.auth.SessionManager
|
||||
import com.runicgateway.app.core.net.BaseUrlHolder
|
||||
import com.runicgateway.app.data.api.SsoApi
|
||||
@@ -78,6 +79,14 @@ class SsoAuthManager @Inject constructor(
|
||||
|
||||
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)
|
||||
val outcome: StateFlow<Outcome> = _outcome.asStateFlow()
|
||||
|
||||
@@ -104,15 +113,42 @@ class SsoAuthManager @Inject constructor(
|
||||
.addQueryParameter("provider", providerId)
|
||||
.addQueryParameter("code_challenge", challenge)
|
||||
.addQueryParameter("state", state)
|
||||
.addQueryParameter("redirect_uri", REDIRECT_URI)
|
||||
.addQueryParameter("redirect_uri", redirectUriFor(base.host))
|
||||
.build()
|
||||
.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 =
|
||||
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:
|
||||
* 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).
|
||||
*/
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,4 +179,45 @@ class SsoAuthManagerTest {
|
||||
assertTrue(!mgr.matchesCallback("runicgateway", "auth", "/other"))
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user