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())