From b10dd444b394b73005b55e3e6942d63f5343f793 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 28 Jul 2026 01:01:33 -0500 Subject: [PATCH] feat(auth): persist the trust token returned by the SSO exchange MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../app/core/auth/sso/SsoAuthManager.kt | 10 ++++ .../app/core/auth/sso/SsoAuthManagerTest.kt | 47 ++++++++++++++++++- 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/runicgateway/app/core/auth/sso/SsoAuthManager.kt b/app/src/main/java/com/runicgateway/app/core/auth/sso/SsoAuthManager.kt index a3dcc50..25c56d8 100644 --- a/app/src/main/java/com/runicgateway/app/core/auth/sso/SsoAuthManager.kt +++ b/app/src/main/java/com/runicgateway/app/core/auth/sso/SsoAuthManager.kt @@ -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 diff --git a/app/src/test/java/com/runicgateway/app/core/auth/sso/SsoAuthManagerTest.kt b/app/src/test/java/com/runicgateway/app/core/auth/sso/SsoAuthManagerTest.kt index c7af12b..58a68cd 100644 --- a/app/src/test/java/com/runicgateway/app/core/auth/sso/SsoAuthManagerTest.kt +++ b/app/src/test/java/com/runicgateway/app/core/auth/sso/SsoAuthManagerTest.kt @@ -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, @@ -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())