feat(sso): App Links autoVerify callback + paired-host trust check #17
@@ -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 {
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user