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:
@@ -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()))
|
||||
|
||||
Reference in New Issue
Block a user