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

@@ -37,6 +37,14 @@ class SsoAuthManagerTest {
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. */
private class FakeSsoApi(
private val exchangeResult: () -> Response<MobileTokenResponse>,
@@ -67,10 +75,11 @@ class SsoAuthManagerTest {
api: SsoApi,
session: SessionManager,
base: String? = "https://shard.example.com/",
store: PendingSsoStore = FakePendingSsoStore(),
): SsoAuthManager {
val holder = BaseUrlHolder()
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. */
@@ -135,6 +144,25 @@ class SsoAuthManagerTest {
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 {
val api = FakeSsoApi { Response.success(tokenPair()) }
val mgr = managerWith(api, SessionManager(FakeTokenStore()))