feat(sso): App Links autoVerify callback + paired-host trust check #17

Merged
whitlocktech merged 2 commits from feat/app-links into main 2026-07-21 00:20:52 +00:00
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"
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 {

View File

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

View File

@@ -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")

View File

@@ -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"
}
}

View File

@@ -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)
}
}