Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4fe7a7e2a3 | |||
| b10dd444b3 |
@@ -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
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
Reference in New Issue
Block a user