Something went wrong. Try again.
This repository has no description
Something went wrong. Try again.
7.9 kB · 200 lines
Kotlin
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201package social.grain
import androidx.test.ext.junit.runners.AndroidJUnit4import androidx.test.platform.app.InstrumentationRegistryimport kotlinx.coroutines.CoroutineScopeimport kotlinx.coroutines.Dispatchersimport kotlinx.coroutines.SupervisorJobimport kotlinx.coroutines.runBlockingimport kotlinx.serialization.json.Jsonimport okhttp3.OkHttpClientimport org.junit.Afterimport org.junit.Assert.assertEqualsimport org.junit.Assert.assertNotEqualsimport org.junit.Assert.assertNotNullimport org.junit.Assert.assertTrueimport org.junit.Beforeimport org.junit.Testimport org.junit.runner.RunWithimport social.grain.data.api.DPoPimport social.grain.data.auth.AuthManagerimport social.grain.data.auth.SecureStoreimport social.grain.data.auth.StoredAccountimport social.grain.data.auth.TokenStorage
/** * A stored session has to survive a restart. * * Regression test for a port bug that signed the user out on *every* launch: * iOS's one-shot "your grant is missing a scope, re-authenticate" migration was * carried over verbatim, but the server's token response omits `scope`, so the * check read every required scope as missing and wiped the credentials each * time `AuthManager` was constructed. */@RunWith(AndroidJUnit4::class)class SessionPersistenceTest {
private val context = InstrumentationRegistry.getInstrumentation().targetContext private val store = SecureStore(context) private val tokenStorage = TokenStorage(store)
private val fakeDid = "did:plc:sessiontestaccount"
// The test writes into the same secure store the app uses, so whatever was // there is put back afterwards rather than left clobbered. private var savedActiveDid: String? = null private var savedAccounts: List<StoredAccount> = emptyList()
@Before fun saveExistingState() { savedActiveDid = tokenStorage.activeDid savedAccounts = tokenStorage.accounts }
@After fun restoreExistingState() { tokenStorage.removeAccount(fakeDid) tokenStorage.accounts = savedAccounts tokenStorage.activeDid = savedActiveDid }
private fun seedSession() { tokenStorage.storeDpopKey(fakeDid, DPoP.createEphemeral().encodedKeyPair()) tokenStorage.storeTokens( did = fakeDid, accessToken = "access-token-value", refreshToken = "refresh-token-value", handle = "tester.grain.social", expiresAtMillis = System.currentTimeMillis() + 3_600_000, // The real server omits this, which is what made the ported // migration misfire. Seed it the same way. scope = null, ) tokenStorage.activeDid = fakeDid tokenStorage.upsertAccount(StoredAccount(fakeDid, "tester.grain.social", null)) }
private fun newAuthManager() = AuthManager( tokenStorage = tokenStorage, http = OkHttpClient(), json = Json { ignoreUnknownKeys = true }, scope = CoroutineScope(SupervisorJob() + Dispatchers.Default), )
@Test fun storedSessionSurvivesAFreshAuthManager() { seedSession()
val auth = newAuthManager()
assertTrue("Restored session should be authenticated", auth.state.value.isAuthenticated) assertEquals(fakeDid, auth.state.value.did) assertEquals("tester.grain.social", auth.state.value.handle) }
@Test fun credentialsAreStillOnDiskAfterRestoring() { seedSession()
// Construct repeatedly: the bug wiped credentials on each launch, so one // pass could look fine while the second came up empty. repeat(3) { newAuthManager() }
assertNotNull("Access token was wiped", tokenStorage.accessToken(fakeDid)) assertNotNull("Refresh token was wiped", tokenStorage.refreshToken(fakeDid)) assertNotNull("DPoP key was wiped", tokenStorage.dpopKey(fakeDid)) assertTrue(tokenStorage.hasCredentials(fakeDid)) }
@Test fun anAccountSwitchDuringRefreshDoesNotMixTokenAndKey() { // Pins the invariant, and does not currently fail without the fix: // when a terminal refresh signs an account out it also deletes that // account's token, so reading it with a stale DID returns null rather // than a mismatched pair. The mismatch needs a *successful* refresh // racing an account switch, which this app has no UI to trigger yet. // // Kept because the invariant is the thing that matters — a token and // the key that signs for it must come from the same DID — and an // account switcher would make the race reachable. val doomed = "did:plc:sessiontestdoomed" val survivor = "did:plc:sessiontestsurvivor" try { tokenStorage.storeDpopKey(survivor, DPoP.createEphemeral().encodedKeyPair()) tokenStorage.storeTokens( did = survivor, accessToken = "survivor-access-token", refreshToken = "survivor-refresh-token", handle = "survivor.grain.social", expiresAtMillis = System.currentTimeMillis() + 3_600_000, scope = null, ) tokenStorage.upsertAccount(StoredAccount(survivor, "survivor.grain.social", null))
tokenStorage.storeDpopKey(doomed, DPoP.createEphemeral().encodedKeyPair()) tokenStorage.storeTokens( did = doomed, accessToken = "doomed-access-token", refreshToken = "not-a-real-refresh-token", handle = "doomed.grain.social", expiresAtMillis = System.currentTimeMillis() - 1_000, scope = null, ) tokenStorage.upsertAccount(StoredAccount(doomed, "doomed.grain.social", null)) tokenStorage.activeDid = doomed
val auth = newAuthManager() val context = runBlocking { auth.authContext() }
// Whatever comes back must be internally consistent: never the // signed-out account's token. if (context != null) { assertNotEquals( "returned the signed-out account's token", "doomed-access-token", context.accessToken, ) val activeDid = auth.state.value.did assertNotNull("an account should have taken over", activeDid) assertEquals( "token must belong to the account that is now active", tokenStorage.accessToken(activeDid!!), context.accessToken, ) val expectedKey = DPoP.fromEncoded(tokenStorage.dpopKey(activeDid)!!) assertEquals( "key must belong to the same account as the token", expectedKey.thumbprint, context.dpop.thumbprint, ) } } finally { tokenStorage.removeAccount(doomed) tokenStorage.removeAccount(survivor) } }
@Test fun aRefreshWithoutARotatedTokenKeepsTheExistingRefreshToken() { seedSession()
// A refresh response that doesn't rotate omits refresh_token. Writing // that null through would delete the only credential able to renew the // session. tokenStorage.storeTokens( did = fakeDid, accessToken = "new-access-token", refreshToken = null, handle = null, expiresAtMillis = System.currentTimeMillis() + 3_600_000, scope = null, )
assertEquals("new-access-token", tokenStorage.accessToken(fakeDid)) assertEquals( "Refresh token should survive a non-rotating refresh", "refresh-token-value", tokenStorage.refreshToken(fakeDid), ) }}