feat(auth): persist the trust token returned by the SSO exchange #29

Merged
whitlocktech merged 1 commits from feat/sso-trusted-device into main 2026-07-28 06:11:32 +00: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())