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