2 Commits

Author SHA1 Message Date
4fe7a7e2a3 Merge pull request 'feat(auth): persist the trust token returned by the SSO exchange' (#29) from feat/sso-trusted-device into main
All checks were successful
sync-project-tree / sync (push) Successful in 8s
SonarQube / analysis (push) Successful in 5m41s
Release APK / release (push) Successful in 9m29s
Reviewed-on: #29
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-28 06:11:30 +00:00
b10dd444b3 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>
2026-07-28 01:01:33 -05:00
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.BuildConfig
import com.runicgateway.app.core.auth.SessionManager 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.core.net.BaseUrlHolder
import com.runicgateway.app.data.api.SsoApi import com.runicgateway.app.data.api.SsoApi
import com.runicgateway.app.data.api.dto.MobileSsoExchangeRequest import com.runicgateway.app.data.api.dto.MobileSsoExchangeRequest
@@ -49,6 +50,7 @@ class SsoAuthManager @Inject constructor(
private val sessionManager: SessionManager, private val sessionManager: SessionManager,
private val baseUrlHolder: BaseUrlHolder, private val baseUrlHolder: BaseUrlHolder,
private val pendingStore: PendingSsoStore, private val pendingStore: PendingSsoStore,
private val trustTokenStore: TrustTokenStore,
) { ) {
/** Why an SSO attempt ended, for a friendly inline message on the login screen. */ /** 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) _outcome.value = Outcome.Failed(Failure.SERVER)
return 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) sessionManager.onSignedIn(body.accessToken, body.refreshToken, body.user)
_outcome.value = Outcome.Success _outcome.value = Outcome.Success
return 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.SessionManager
import com.runicgateway.app.core.auth.StoredSession import com.runicgateway.app.core.auth.StoredSession
import com.runicgateway.app.core.auth.TokenStore 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.core.net.BaseUrlHolder
import com.runicgateway.app.data.api.SsoApi import com.runicgateway.app.data.api.SsoApi
import com.runicgateway.app.data.api.dto.MobileSsoExchangeRequest import com.runicgateway.app.data.api.dto.MobileSsoExchangeRequest
@@ -45,6 +46,17 @@ class SsoAuthManagerTest {
override fun clear() { pending = null } 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. */ /** Records the exchange it was called with and returns a scripted response. */
private class FakeSsoApi( private class FakeSsoApi(
private val exchangeResult: () -> Response<MobileTokenResponse>, private val exchangeResult: () -> Response<MobileTokenResponse>,
@@ -76,10 +88,11 @@ class SsoAuthManagerTest {
session: SessionManager, session: SessionManager,
base: String? = "https://shard.example.com/", base: String? = "https://shard.example.com/",
store: PendingSsoStore = FakePendingSsoStore(), store: PendingSsoStore = FakePendingSsoStore(),
trust: TrustTokenStore = FakeTrustTokenStore(),
): SsoAuthManager { ): SsoAuthManager {
val holder = BaseUrlHolder() val holder = BaseUrlHolder()
if (base != null) holder.set(base.toHttpUrl()) 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. */ /** 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) 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 { @Test fun `state mismatch fails without exchanging`() = runTest {
val api = FakeSsoApi { Response.success(tokenPair()) } val api = FakeSsoApi { Response.success(tokenPair()) }
val session = SessionManager(FakeTokenStore()) val session = SessionManager(FakeTokenStore())