fix(sso): make native SSO discovery legible and survive process death

On-device, the native SSO buttons never appeared and the flow dumped users on
the desktop website login (which can't deep-link a mobile session back), so it
hung. Two app-side causes:

1. Discovery conflated "no providers" with "call failed" (ssoProviders() returned
   emptyList() on any error) and the screen then showed a dead website-login
   hand-off. Now ssoProviders() returns Available/None/Unavailable, retries once,
   and the login screen renders native provider buttons, a loading hint, or a
   retry — never the website login fallback (removed, along with WebsiteUrls.login).

2. The pending {state, verifier} lived only in memory, so a Custom-Tab-induced
   process eviction lost it and the exchange failed STATE_MISMATCH. Persist it via
   a new encrypted PendingSsoStore (EncryptedSharedPreferences, mirrors the token
   store), cleared the moment the callback is consumed so replays still fail closed.

SsoAuthManager stays framework-free (store behind an interface). +1 test proving a
fresh manager on the persisted store completes (process-death sim); 15/15 SSO tests
pass, lint + assembleDebug green (JDK21, -Pksp.incremental=false).

Verified end-to-end against the local site via the dev stub IdP: player and admin
both sign in natively and receive the correct role.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-07-21 15:04:53 -05:00
parent c69d704881
commit 0f93f3dcd3
10 changed files with 238 additions and 57 deletions

View File

@@ -0,0 +1,61 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.core.auth.sso
import android.content.Context
import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
/**
* [PendingSsoStore] backed by Jetpack Security's [EncryptedSharedPreferences]
* (Tink/AES-256-GCM), so the PKCE verifier is encrypted at rest for the brief
* window a flow is in progress. Separate prefs file from the session token store —
* this holds only the transient SSO handshake, cleared as soon as the callback is
* consumed. Lazy, so a device that never signs in via SSO pays no keystore cost.
*/
@Singleton
class EncryptedPendingSsoStore @Inject constructor(
@param:ApplicationContext private val context: Context,
) : PendingSsoStore {
private val prefs: SharedPreferences by lazy {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
EncryptedSharedPreferences.create(
context,
PREFS_NAME,
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
}
override fun save(state: String, verifier: String) {
prefs.edit()
.putString(KEY_STATE, state)
.putString(KEY_VERIFIER, verifier)
.apply()
}
override fun load(): PendingSso? {
val state = prefs.getString(KEY_STATE, null) ?: return null
val verifier = prefs.getString(KEY_VERIFIER, null) ?: return null
return PendingSso(state = state, verifier = verifier)
}
override fun clear() {
prefs.edit().clear().apply()
}
private companion object {
const val PREFS_NAME = "runic_sso_pending"
const val KEY_STATE = "state"
const val KEY_VERIFIER = "verifier"
}
}

View File

@@ -0,0 +1,24 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.core.auth.sso
/**
* Persists the in-flight SSO `{state, verifier}` (PKCE Layer B + CSRF state) across
* the Custom-Tab round trip so the exchange survives process death — a low-memory
* device can evict the app while the Custom Tab is foreground, and the callback then
* returns to a fresh process (PLAN.md §4.2). Kept behind an interface so
* [SsoAuthManager] stays framework-free and unit-tests on the JVM with a fake.
*
* Exactly one flow is pending at a time; [save] overwrites any prior. The verifier
* is a bearer-equivalent secret for the one-time code, so the production impl
* ([EncryptedPendingSsoStore]) encrypts it at rest, mirroring the token store.
*/
interface PendingSsoStore {
fun save(state: String, verifier: String)
fun load(): PendingSso?
fun clear()
}
/** The stashed CSRF state + PKCE verifier for the current SSO attempt. */
data class PendingSso(val state: String, val verifier: String)

View File

@@ -13,7 +13,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import java.io.IOException import java.io.IOException
import java.util.concurrent.atomic.AtomicReference
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -34,20 +33,22 @@ import javax.inject.Singleton
* parses the callback `Uri` (the Android edge) and hands the raw params here, * parses the callback `Uri` (the Android edge) and hands the raw params here,
* so this class stays free of framework types and unit-tests on the JVM. * so this class stays free of framework types and unit-tests on the JVM.
* *
* The pending `{state, verifier}` lives only in memory: if the process is killed * The pending `{state, verifier}` is persisted via [PendingSsoStore] (encrypted at
* while the Custom Tab is foreground it is lost and the exchange **fails closed** * rest), so the exchange survives the process being evicted while the Custom Tab is
* (the user simply retries) — never a security downgrade. * foreground — the callback can land in a fresh process and still complete. It is
* cleared the moment [complete] consumes it, so a lost/duplicate callback still
* **fails closed** as [Failure.STATE_MISMATCH] rather than double-exchanging.
* *
* Threading: [buildStartUrl] runs on the UI thread; [complete] runs on the * Threading: [buildStartUrl] runs on the UI thread; [complete] runs on the
* activity's coroutine scope after a deep link. The pending holder is an * activity's coroutine scope after a deep link. [outcome] is a [StateFlow], so a
* [AtomicReference] and [outcome] a [StateFlow], so a ViewModel/activity recreation * ViewModel/activity recreation while the Custom Tab is open cannot drop a result.
* while the Custom Tab is open cannot drop a result.
*/ */
@Singleton @Singleton
class SsoAuthManager @Inject constructor( class SsoAuthManager @Inject constructor(
private val ssoApi: SsoApi, private val ssoApi: SsoApi,
private val sessionManager: SessionManager, private val sessionManager: SessionManager,
private val baseUrlHolder: BaseUrlHolder, private val baseUrlHolder: BaseUrlHolder,
private val pendingStore: PendingSsoStore,
) { ) {
/** 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. */
@@ -75,10 +76,6 @@ class SsoAuthManager @Inject constructor(
data class Failed(val reason: Failure) : Outcome data class Failed(val reason: Failure) : Outcome
} }
private data class Pending(val state: String, val verifier: String)
private val pending = AtomicReference<Pending?>(null)
/** /**
* The host this build baked an App Link intent-filter for (`BuildConfig.APP_LINK_HOST`, * The host this build baked an App Link intent-filter for (`BuildConfig.APP_LINK_HOST`,
* empty on the generic multi-tenant build — see docs/android/APP_LINKS.md). * empty on the generic multi-tenant build — see docs/android/APP_LINKS.md).
@@ -97,16 +94,16 @@ class SsoAuthManager @Inject constructor(
/** /**
* Build the `/auth/mobile/sso/start` URL for [providerId] and stash the pending * Build the `/auth/mobile/sso/start` URL for [providerId] and stash the pending
* PKCE verifier + CSRF state. Returns null when no shard site is configured yet * PKCE verifier + CSRF state (persisted so it survives process death). Returns
* (the caller then keeps the website hand-off fallback). Also resets [outcome] * null when no shard site is configured yet. Also resets [outcome] to
* to [Outcome.Idle] so a stale prior result can't fire against the new attempt. * [Outcome.Idle] so a stale prior result can't fire against the new attempt.
*/ */
fun buildStartUrl(providerId: String): String? { fun buildStartUrl(providerId: String): String? {
val base = baseUrlHolder.current ?: return null val base = baseUrlHolder.current ?: return null
val verifier = Pkce.newVerifier() val verifier = Pkce.newVerifier()
val challenge = Pkce.challengeOf(verifier) val challenge = Pkce.challengeOf(verifier)
val state = Pkce.newState() val state = Pkce.newState()
pending.set(Pending(state = state, verifier = verifier)) pendingStore.save(state = state, verifier = verifier)
_outcome.value = Outcome.Idle _outcome.value = Outcome.Idle
return base.newBuilder() return base.newBuilder()
.addPathSegments("api/v1/auth/mobile/sso/start") .addPathSegments("api/v1/auth/mobile/sso/start")
@@ -158,7 +155,8 @@ class SsoAuthManager @Inject constructor(
* single-uses the code). * single-uses the code).
*/ */
suspend fun complete(state: String?, code: String?, error: String?) { suspend fun complete(state: String?, code: String?, error: String?) {
val stashed = pending.getAndSet(null) val stashed = pendingStore.load()
pendingStore.clear()
// CSRF: the callback must echo the exact state we generated at /start. // CSRF: the callback must echo the exact state we generated at /start.
if (stashed == null || state.isNullOrEmpty() || state != stashed.state) { if (stashed == null || state.isNullOrEmpty() || state != stashed.state) {

View File

@@ -26,12 +26,8 @@ class WebsiteUrls @Inject constructor(
/** Forgot / reset password (the flow built on the backend before app work, §8). */ /** Forgot / reset password (the flow built on the backend before app work, §8). */
fun forgotPassword(): String? = resolve(FORGOT) fun forgotPassword(): String? = resolve(FORGOT)
/** The website login page — carries the SSO provider buttons (§4.2). */
fun login(): String? = resolve(LOGIN)
private companion object { private companion object {
const val REGISTER = "account/register" const val REGISTER = "account/register"
const val FORGOT = "account/forgot" const val FORGOT = "account/forgot"
const val LOGIN = "account/login"
} }
} }

View File

@@ -13,6 +13,7 @@ import com.runicgateway.app.data.api.dto.MobileTokenResponse
import com.runicgateway.app.data.api.dto.SsoProviderDto import com.runicgateway.app.data.api.dto.SsoProviderDto
import com.runicgateway.app.data.api.dto.TotpRequiredError import com.runicgateway.app.data.api.dto.TotpRequiredError
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import retrofit2.Response import retrofit2.Response
import java.io.IOException import java.io.IOException
@@ -34,17 +35,40 @@ class AuthRepository @Inject constructor(
private val json: Json, private val json: Json,
) { ) {
/** The three outcomes of SSO provider discovery, so the login screen can tell a
* shard that offers no SSO ([None]) apart from a discovery that failed
* ([Unavailable], offer a retry) — the old "empty on any failure" conflation hid
* a broken call behind a dead website hand-off (§4.2). */
sealed interface SsoDiscovery {
/** At least one enabled provider — render a native button per entry. */
data class Available(val providers: List<SsoProviderDto>) : SsoDiscovery
/** Discovery succeeded but the shard has no SSO providers configured. */
data object None : SsoDiscovery
/** The discovery call failed (offline / server error) — surface a retry. */
data object Unavailable : SsoDiscovery
}
/** /**
* The shard's enabled SSO providers for the native login buttons (§4.2). Public * Discover the shard's enabled SSO providers for the native login buttons (§4.2).
* discovery, never secrets. Returns an empty list on any failure — the login * Public discovery, never secrets. Retries once before reporting [Unavailable],
* screen then keeps the website hand-off fallback rather than showing nothing. * so a single transient blip doesn't strand the user.
*/ */
suspend fun ssoProviders(): List<SsoProviderDto> = try { suspend fun ssoProviders(): SsoDiscovery {
ssoApi.providers() var lastFailed = false
} catch (e: CancellationException) { repeat(2) { attempt ->
throw e try {
} catch (_: Exception) { val providers = ssoApi.providers()
emptyList() return if (providers.isEmpty()) SsoDiscovery.None else SsoDiscovery.Available(providers)
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
lastFailed = true
if (attempt == 0) delay(DISCOVERY_RETRY_DELAY_MS)
}
}
return if (lastFailed) SsoDiscovery.Unavailable else SsoDiscovery.None
} }
/** Outcome of a login attempt (§4.1). */ /** Outcome of a login attempt (§4.1). */
@@ -140,4 +164,8 @@ class AuthRepository @Inject constructor(
} catch (_: Exception) { } catch (_: Exception) {
false false
} }
private companion object {
const val DISCOVERY_RETRY_DELAY_MS = 400L
}
} }

View File

@@ -5,13 +5,15 @@ package com.runicgateway.app.di
import com.runicgateway.app.core.auth.EncryptedTokenStore import com.runicgateway.app.core.auth.EncryptedTokenStore
import com.runicgateway.app.core.auth.TokenStore import com.runicgateway.app.core.auth.TokenStore
import com.runicgateway.app.core.auth.sso.EncryptedPendingSsoStore
import com.runicgateway.app.core.auth.sso.PendingSsoStore
import dagger.Binds import dagger.Binds
import dagger.Module import dagger.Module
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton import javax.inject.Singleton
/** Binds the at-rest token store to its EncryptedSharedPreferences impl (§4.3). */ /** Binds the at-rest stores to their EncryptedSharedPreferences impls (§4.3). */
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
abstract class StorageModule { abstract class StorageModule {
@@ -19,4 +21,8 @@ abstract class StorageModule {
@Binds @Binds
@Singleton @Singleton
abstract fun bindTokenStore(impl: EncryptedTokenStore): TokenStore abstract fun bindTokenStore(impl: EncryptedTokenStore): TokenStore
@Binds
@Singleton
abstract fun bindPendingSsoStore(impl: EncryptedPendingSsoStore): PendingSsoStore
} }

View File

@@ -164,26 +164,42 @@ fun LoginScreen(
} }
// ── Native SSO (§4.2, M9): a button per enabled provider that opens the // ── Native SSO (§4.2, M9): a button per enabled provider that opens the
// Custom-Tab bridge and returns the user signed in. Falls back to the // Custom-Tab bridge and returns the user signed in. No website-login
// website login hand-off when the shard exposes no providers. // fallback — that page isn't mobile-formatted and can't deep-link the
if (state.ssoProviders.isNotEmpty()) { // session back; a failed discovery offers a retry instead.
state.ssoProviders.forEach { provider -> when {
OutlinedButton( state.ssoProviders.isNotEmpty() -> {
onClick = { viewModel.onSsoProviderClick(provider) }, state.ssoProviders.forEach { provider ->
enabled = !state.submitting, OutlinedButton(
modifier = Modifier onClick = { viewModel.onSsoProviderClick(provider) },
.fillMaxWidth() enabled = !state.submitting,
.padding(top = 12.dp), modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp),
) {
Text(stringResource(R.string.login_sso_provider, provider.name))
}
}
}
state.ssoDiscovering -> {
Text(
text = stringResource(R.string.login_sso_loading),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 12.dp),
)
}
state.ssoUnavailable -> {
TextButton(
onClick = { viewModel.discoverSsoProviders() },
modifier = Modifier.padding(top = 4.dp),
) { ) {
Text(stringResource(R.string.login_sso_provider, provider.name)) Text(stringResource(R.string.login_sso_retry))
}
}
} else {
viewModel.ssoLoginUrl?.let { url ->
TextButton(onClick = { WebHandoff.open(context, url) }) {
Text(stringResource(R.string.login_sso))
} }
} }
// else: discovery succeeded with no providers — this shard offers no SSO.
} }
// ── Website hand-offs (§4.2): open the site's own pages in a Custom Tab ── // ── Website hand-offs (§4.2): open the site's own pages in a Custom Tab ──

View File

@@ -10,6 +10,7 @@ import com.runicgateway.app.core.web.WebsiteUrls
import com.runicgateway.app.data.api.dto.SsoProviderDto import com.runicgateway.app.data.api.dto.SsoProviderDto
import com.runicgateway.app.data.repository.AuthRepository import com.runicgateway.app.data.repository.AuthRepository
import com.runicgateway.app.data.repository.AuthRepository.LoginResult import com.runicgateway.app.data.repository.AuthRepository.LoginResult
import com.runicgateway.app.data.repository.AuthRepository.SsoDiscovery
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -43,8 +44,12 @@ class LoginViewModel @Inject constructor(
val submitting: Boolean = false, val submitting: Boolean = false,
val error: LoginError? = null, val error: LoginError? = null,
val signedIn: Boolean = false, val signedIn: Boolean = false,
/** The shard's enabled SSO providers (§4.2); empty → website hand-off fallback. */ /** The shard's enabled SSO providers (§4.2); empty until discovery resolves. */
val ssoProviders: List<SsoProviderDto> = emptyList(), val ssoProviders: List<SsoProviderDto> = emptyList(),
/** True while discovery is in flight — the screen shows a spinner, not an empty gap. */
val ssoDiscovering: Boolean = true,
/** True when discovery failed (offline/server) — offer a retry rather than a dead end. */
val ssoUnavailable: Boolean = false,
/** A `/auth/mobile/sso/start` URL the screen should open in a Custom Tab, once. */ /** A `/auth/mobile/sso/start` URL the screen should open in a Custom Tab, once. */
val ssoLaunchUrl: String? = null, val ssoLaunchUrl: String? = null,
) )
@@ -53,11 +58,7 @@ class LoginViewModel @Inject constructor(
val state: StateFlow<UiState> = _state.asStateFlow() val state: StateFlow<UiState> = _state.asStateFlow()
init { init {
// Discover the native SSO providers to render buttons for (§4.2). discoverSsoProviders()
viewModelScope.launch {
val providers = authRepository.ssoProviders()
if (providers.isNotEmpty()) _state.update { it.copy(ssoProviders = providers) }
}
// Consume the SSO bridge outcome: a returned callback completes here even if // Consume the SSO bridge outcome: a returned callback completes here even if
// this ViewModel was recreated while the Custom Tab was foreground (§4.2). // this ViewModel was recreated while the Custom Tab was foreground (§4.2).
viewModelScope.launch { viewModelScope.launch {
@@ -85,8 +86,30 @@ class LoginViewModel @Inject constructor(
val registerUrl: String? get() = websiteUrls.register() val registerUrl: String? get() = websiteUrls.register()
val forgotPasswordUrl: String? get() = websiteUrls.forgotPassword() val forgotPasswordUrl: String? get() = websiteUrls.forgotPassword()
/** Website login hand-off — the fallback when native SSO discovery is empty (§4.2). */ /**
val ssoLoginUrl: String? get() = websiteUrls.login() * Discover the shard's native SSO providers (§4.2). A failure surfaces a retry
* affordance instead of the old dead website-login hand-off, which was never
* mobile-formatted and could not deep-link the session back.
*/
fun discoverSsoProviders() {
_state.update { it.copy(ssoDiscovering = true, ssoUnavailable = false) }
viewModelScope.launch {
when (val result = authRepository.ssoProviders()) {
is SsoDiscovery.Available ->
_state.update {
it.copy(ssoProviders = result.providers, ssoDiscovering = false, ssoUnavailable = false)
}
SsoDiscovery.None ->
_state.update {
it.copy(ssoProviders = emptyList(), ssoDiscovering = false, ssoUnavailable = false)
}
SsoDiscovery.Unavailable ->
_state.update {
it.copy(ssoProviders = emptyList(), ssoDiscovering = false, ssoUnavailable = true)
}
}
}
}
/** /**
* Begin a native SSO flow for [provider]: mint PKCE + state and surface the * Begin a native SSO flow for [provider]: mint PKCE + state and surface the

View File

@@ -60,9 +60,10 @@
<string name="login_button">Sign in</string> <string name="login_button">Sign in</string>
<string name="login_register">Create an account</string> <string name="login_register">Create an account</string>
<string name="login_forgot">Forgot your password?</string> <string name="login_forgot">Forgot your password?</string>
<string name="login_sso">Sign in with Google or Discord (on the website)</string>
<!-- %1$s is the provider name, e.g. "Google" or "Discord" (native SSO, M9). --> <!-- %1$s is the provider name, e.g. "Google" or "Discord" (native SSO, M9). -->
<string name="login_sso_provider">Sign in with %1$s</string> <string name="login_sso_provider">Sign in with %1$s</string>
<string name="login_sso_loading">Loading sign-in options…</string>
<string name="login_sso_retry">Couldn\'t load sign-in options. Tap to retry.</string>
<string name="login_error_credentials">Incorrect username or password.</string> <string name="login_error_credentials">Incorrect username or password.</string>
<string name="login_error_code">That code didn\'t match. Try the current code.</string> <string name="login_error_code">That code didn\'t match. Try the current code.</string>
<string name="login_error_rate_limited">Too many attempts. Please try again shortly.</string> <string name="login_error_rate_limited">Too many attempts. Please try again shortly.</string>

View File

@@ -37,6 +37,14 @@ class SsoAuthManagerTest {
override fun clear() { stored = null } override fun clear() { stored = null }
} }
/** In-memory stand-in for the encrypted pending-SSO store (survives across
* manager instances the way the on-disk store survives process death). */
private class FakePendingSsoStore(var pending: PendingSso? = null) : PendingSsoStore {
override fun save(state: String, verifier: String) { pending = PendingSso(state, verifier) }
override fun load(): PendingSso? = pending
override fun clear() { pending = 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>,
@@ -67,10 +75,11 @@ class SsoAuthManagerTest {
api: SsoApi, api: SsoApi,
session: SessionManager, session: SessionManager,
base: String? = "https://shard.example.com/", base: String? = "https://shard.example.com/",
store: PendingSsoStore = FakePendingSsoStore(),
): 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) return SsoAuthManager(api, session, holder, store)
} }
/** 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. */
@@ -135,6 +144,25 @@ class SsoAuthManagerTest {
assertEquals(SsoAuthManager.Outcome.Failed(SsoAuthManager.Failure.STATE_MISMATCH), mgr.outcome.value) assertEquals(SsoAuthManager.Outcome.Failed(SsoAuthManager.Failure.STATE_MISMATCH), mgr.outcome.value)
} }
@Test fun `pending survives process death — a fresh manager on the same store completes`() = runTest {
// Persist the pending on one instance, then throw that instance away.
val store = FakePendingSsoStore()
val session = SessionManager(FakeTokenStore())
val started = managerWith(FakeSsoApi { Response.success(tokenPair()) }, session, store = store)
val state = startAndState(started)
// A brand-new manager (simulating the app relaunched after eviction) reads the
// persisted pending and completes the exchange — the old in-memory holder would
// have lost it and failed STATE_MISMATCH.
val api = FakeSsoApi { Response.success(tokenPair()) }
val revived = managerWith(api, session, store = store)
revived.complete(state = state, code = "auth-code-1", error = null)
assertEquals(1, api.exchangeCalls)
assertTrue(session.state.value is Session.SignedIn)
assertEquals(SsoAuthManager.Outcome.Success, revived.outcome.value)
}
@Test fun `error callback maps to a declined sign-in and does not exchange`() = runTest { @Test fun `error callback maps to a declined sign-in and does not exchange`() = runTest {
val api = FakeSsoApi { Response.success(tokenPair()) } val api = FakeSsoApi { Response.success(tokenPair()) }
val mgr = managerWith(api, SessionManager(FakeTokenStore())) val mgr = managerWith(api, SessionManager(FakeTokenStore()))