feat(auth): persist the trust token returned by the SSO exchange #29
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user