feat(auth): persist the trust token returned by the SSO exchange
All checks were successful
PR Checks / android-build (pull_request) Successful in 7m55s

Pairs with website feat/sso-trusted-device, which makes "trust this device" work
for SSO sign-ins. Two things reach this device when the user ticks the box:

  1. The rg_trust COOKIE in the Custom Tab. Custom Tabs share the system
     browser's cookie jar, so that alone makes the next SSO sign-in skip the
     TOTP step — no app change needed for that half.
  2. A trustToken in the /auth/mobile/sso/exchange response, which is what this
     commit stores. That covers the app's NATIVE password login on the same
     device, which reads the token back out of TrustTokenStore and replays it as
     X-Trust-Token.

MobileTokenResponse already carried trustToken (the native login path has always
persisted it) — SsoAuthManager simply dropped it on the floor. Save it scoped to
the signed-in username, exactly like AuthRepository.login does, so it is never
replayed for a different account on a shared device; and save it before
onSignedIn so a process death mid-callback can't lose it.

Tests: 2 new cases in SsoAuthManagerTest (token persisted + scoped to its owner;
absent token leaves the store untouched), with an in-memory FakeTrustTokenStore
matching the file's existing fake style. Full unit suite green: 266 tests.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-07-28 01:01:33 -05:00
parent f3da6ea618
commit b10dd444b3
2 changed files with 56 additions and 1 deletions

View File

@@ -5,6 +5,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.auth.TrustTokenStore
import com.runicgateway.app.core.net.BaseUrlHolder
import com.runicgateway.app.data.api.SsoApi
import com.runicgateway.app.data.api.dto.MobileSsoExchangeRequest
@@ -49,6 +50,7 @@ class SsoAuthManager @Inject constructor(
private val sessionManager: SessionManager,
private val baseUrlHolder: BaseUrlHolder,
private val pendingStore: PendingSsoStore,
private val trustTokenStore: TrustTokenStore,
) {
/** Why an SSO attempt ended, for a friendly inline message on the login screen. */
@@ -193,6 +195,14 @@ class SsoAuthManager @Inject constructor(
_outcome.value = Outcome.Failed(Failure.SERVER)
return
}
// The user ticked "trust this device" on the TOTP form inside the Custom
// Tab. That tab's cookie already covers future SSO sign-ins; persisting
// the token the exchange handed back is what lets a native PASSWORD login
// on this device skip the code too (TRUSTED_DEVICES_MFA.md). Scoped to the
// username exactly like the password path, so it is never replayed for a
// different account on a shared device. Saved BEFORE onSignedIn so a
// process death mid-callback can't lose it.
body.trustToken?.let { trustTokenStore.save(body.user.username, it) }
sessionManager.onSignedIn(body.accessToken, body.refreshToken, body.user)
_outcome.value = Outcome.Success
return

View File

@@ -7,6 +7,7 @@ import com.runicgateway.app.core.auth.Session
import com.runicgateway.app.core.auth.SessionManager
import com.runicgateway.app.core.auth.StoredSession
import com.runicgateway.app.core.auth.TokenStore
import com.runicgateway.app.core.auth.TrustTokenStore
import com.runicgateway.app.core.net.BaseUrlHolder
import com.runicgateway.app.data.api.SsoApi
import com.runicgateway.app.data.api.dto.MobileSsoExchangeRequest
@@ -45,6 +46,17 @@ class SsoAuthManagerTest {
override fun clear() { pending = null }
}
/** In-memory stand-in for the encrypted trust-token store, scoped by username
* the same way the production impl is. */
private class FakeTrustTokenStore : TrustTokenStore {
var owner: String? = null
var token: String? = null
override fun tokenFor(username: String): String? =
if (owner.equals(username, ignoreCase = true)) token else null
override fun save(username: String, token: String) { owner = username; this.token = token }
override fun clear() { owner = null; token = null }
}
/** Records the exchange it was called with and returns a scripted response. */
private class FakeSsoApi(
private val exchangeResult: () -> Response<MobileTokenResponse>,
@@ -76,10 +88,11 @@ class SsoAuthManagerTest {
session: SessionManager,
base: String? = "https://shard.example.com/",
store: PendingSsoStore = FakePendingSsoStore(),
trust: TrustTokenStore = FakeTrustTokenStore(),
): SsoAuthManager {
val holder = BaseUrlHolder()
if (base != null) holder.set(base.toHttpUrl())
return SsoAuthManager(api, session, holder, store)
return SsoAuthManager(api, session, holder, store, trust)
}
/** Build a start URL and pull the generated `state` back out of it. */
@@ -120,6 +133,38 @@ class SsoAuthManagerTest {
assertEquals(SsoAuthManager.Outcome.Success, mgr.outcome.value)
}
// Trusted devices over SSO (TRUSTED_DEVICES_MFA.md). Ticking "trust this device"
// on the TOTP form inside the Custom Tab trusts that browser via cookie; the
// exchange additionally hands the APP its own token so a native password login
// on this device skips the code too. Before this, SSO ignored trust entirely.
@Test fun `a trustToken on the exchange response is persisted for the signed-in user`() = runTest {
val api = FakeSsoApi { Response.success(tokenPair().copy(trustToken = "opaque-trust")) }
val session = SessionManager(FakeTokenStore())
val trust = FakeTrustTokenStore()
val mgr = managerWith(api, session, trust = trust)
val state = startAndState(mgr)
mgr.complete(state = state, code = "auth-code-1", error = null)
assertEquals(SsoAuthManager.Outcome.Success, mgr.outcome.value)
assertEquals("opaque-trust", trust.tokenFor("alice"))
// Scoped to the account that minted it — never replayed for someone else.
assertNull(trust.tokenFor("mallory"))
}
@Test fun `no trustToken on the response leaves the store untouched`() = runTest {
val api = FakeSsoApi { Response.success(tokenPair()) }
val session = SessionManager(FakeTokenStore())
val trust = FakeTrustTokenStore()
val mgr = managerWith(api, session, trust = trust)
val state = startAndState(mgr)
mgr.complete(state = state, code = "auth-code-1", error = null)
assertEquals(SsoAuthManager.Outcome.Success, mgr.outcome.value)
assertNull(trust.tokenFor("alice"))
}
@Test fun `state mismatch fails without exchanging`() = runTest {
val api = FakeSsoApi { Response.success(tokenPair()) }
val session = SessionManager(FakeTokenStore())