Something went wrong. Try again.
This repository has no description
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457package social.grain
import android.net.Uriimport androidx.test.ext.junit.runners.AndroidJUnit4import kotlinx.coroutines.CoroutineScopeimport kotlinx.coroutines.Dispatchersimport kotlinx.coroutines.SupervisorJobimport kotlinx.coroutines.asyncimport kotlinx.coroutines.coroutineScopeimport kotlinx.coroutines.runBlockingimport org.junit.Afterimport org.junit.Assert.assertEqualsimport org.junit.Assert.assertFalseimport org.junit.Assert.assertNotNullimport org.junit.Assert.assertNullimport org.junit.Assert.assertTrueimport org.junit.Beforeimport org.junit.Testimport org.junit.runner.RunWithimport social.grain.data.api.DPoPimport social.grain.data.api.getFeedimport social.grain.data.api.XrpcExceptionimport social.grain.data.auth.AuthManagerimport social.grain.data.auth.SecureStoreimport social.grain.data.auth.StoredAccountimport social.grain.data.auth.TokenStorageimport social.grain.support.FakeAndroidKeyStoreimport social.grain.support.StubServerimport social.grain.support.appContextimport social.grain.support.assertFailsimport social.grain.support.testJson
/** * The session: sign-in, refresh, several accounts at once, and signing out. * * Every branch here fails the same way on screen — the user finds themselves * signed out — so the distinctions have to be made in a test. In particular a * refresh rejected with a 4xx must sign the account out, and one that dies on a * 5xx must leave the credentials exactly where they are. */@RunWith(AndroidJUnit4::class)class AuthManagerTest {
private lateinit var storage: TokenStorage private lateinit var server: StubServer private lateinit var scope: CoroutineScope
private val me = "did:plc:me" private val other = "did:plc:other"
@Before fun setUp() { FakeAndroidKeyStore.install() storage = TokenStorage(SecureStore(appContext)) clearAccounts() server = StubServer() scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) }
@After fun tearDown() { clearAccounts() scope.coroutineContext[kotlinx.coroutines.Job]?.cancel() server.shutdown() }
private fun clearAccounts() { storage.accounts.forEach { storage.removeAccount(it.did) } storage.removeAccount(me) storage.removeAccount(other) storage.activeDid = null }
private fun seed( did: String, handle: String = "$did.test", expiresInMillis: Long = 3_600_000, refreshToken: String? = "refresh-$did", ) { storage.storeDpopKey(did, DPoP.createEphemeral().encodedKeyPair()) storage.storeTokens( did = did, accessToken = "access-$did", refreshToken = refreshToken, handle = handle, expiresAtMillis = System.currentTimeMillis() + expiresInMillis, scope = null, ) storage.upsertAccount(StoredAccount(did, handle, null)) }
private fun manager() = AuthManager(storage, server.client(), testJson, scope)
// MARK: - Restore
@Test fun aFreshInstallStartsSignedOut() { val auth = manager() assertFalse(auth.state.value.isAuthenticated) assertNull(auth.state.value.did) assertTrue(auth.state.value.accounts.isEmpty()) }
@Test fun storedCredentialsAreRestoredOnLaunch() { seed(me) storage.activeDid = me
val auth = manager()
assertTrue(auth.state.value.isAuthenticated) assertEquals(me, auth.state.value.did) assertEquals("$me.test", auth.state.value.handle) assertEquals(1, auth.state.value.accounts.size) }
@Test fun anAccountMissingHalfItsCredentialsIsNotResumed() { seed(me, refreshToken = null) storage.activeDid = me
val auth = manager()
assertFalse("A session with no refresh token can't be resumed", auth.state.value.isAuthenticated) assertEquals("It is still offered as an account to sign back into", 1, auth.state.value.accounts.size) }
// MARK: - Auth context
@Test fun anAuthContextIsBuiltFromTheStoredTokenAndKey() = runBlocking { seed(me) storage.activeDid = me
val context = manager().authContext()
assertNotNull(context) assertEquals("access-$me", context!!.accessToken) assertEquals(3, context.dpop.createProof("GET", "https://x/y").split('.').size) }
@Test fun thereIsNoAuthContextWhenNobodyIsSignedIn() = runBlocking { assertNull(manager().authContext()) }
@Test fun aTokenAboutToExpireIsRefreshedBeforeItIsUsed() = runBlocking { seed(me, expiresInMillis = 10_000) storage.activeDid = me server.on("token", """{"access_token":"fresh","expires_in":3600,"sub":"$me"}""")
val context = manager().authContext()
assertEquals("fresh", context!!.accessToken) assertEquals(1, server.countOf("token")) }
@Test fun aTokenWithPlentyOfLifeLeftIsNotRefreshed() = runBlocking { seed(me) storage.activeDid = me
manager().authContext()
assertEquals(0, server.countOf("token")) }
// MARK: - Refresh
@Test fun arefreshStoresTheNewTokenAndKeepsTheOldRefreshTokenWhenNoneComesBack() = runBlocking { seed(me) storage.activeDid = me server.on("token", """{"access_token":"fresh","expires_in":3600}""")
manager().refresh()
assertEquals("fresh", storage.accessToken(me)) assertEquals( "A response with no refresh token must not delete the one we hold", "refresh-$me", storage.refreshToken(me), ) }
@Test fun concurrentRefreshesAreCoalescedIntoOneRequest() = runBlocking { seed(me) storage.activeDid = me server.on("token", """{"access_token":"fresh","expires_in":3600}""") val auth = manager()
coroutineScope { listOf( async { auth.refresh() }, async { auth.refresh() }, async { auth.refresh() }, ).forEach { it.await() } }
assertEquals(1, server.countOf("token")) }
@Test fun aRefreshRejectedWithA4xxSignsTheAccountOut() = runBlocking { seed(me) storage.activeDid = me server.on("token", """{"error":"invalid_grant"}""", code = 400) val auth = manager()
assertFails<XrpcException.Http> { auth.refresh() }
assertFalse(auth.state.value.isAuthenticated) assertNull(storage.accessToken(me)) }
@Test fun aRefreshThatDiesOnA5xxLeavesTheCredentialsAlone() = runBlocking { seed(me) storage.activeDid = me server.on("token", """{"error":"oops"}""", code = 503) val auth = manager()
assertFails<XrpcException.Http> { auth.refresh() }
assertTrue("A bad minute on the server is not a dead grant", auth.state.value.isAuthenticated) assertEquals("access-$me", storage.accessToken(me)) }
@Test fun refreshingWithNoSessionIsUnauthorized() = runBlocking<Unit> { assertFails<XrpcException.Unauthorized> { manager().refresh() } }
// MARK: - Sign-in
@Test fun beginningALoginRunsParAndBuildsTheAuthorizeUrl() = runBlocking { server.on("par", """{"request_uri":"urn:ietf:params:oauth:request_uri:abc"}""")
val url = manager().beginLogin("alice.grain.social")
assertTrue(url.startsWith("${AuthManager.SERVER_URL}/oauth/authorize")) assertTrue(url.contains("request_uri=urn")) val par = server.lastCall("par") assertTrue(par.body.contains("code_challenge_method=S256")) assertTrue(par.body.contains("response_type=code")) assertTrue(par.body.contains("login_hint=alice.grain.social")) }
/** The token endpoint answers the first proof of a session with a 400. */ @Test fun parReplaysOnceWithTheNonceTheServerAsksFor() = runBlocking { server .on( "par", """{"error":"use_dpop_nonce"}""", code = 400, headers = mapOf("DPoP-Nonce" to "n1"), ) .then("par", """{"request_uri":"urn:abc"}""")
manager().beginLogin()
assertEquals(2, server.countOf("par")) }
@Test fun aParThatFailsOutrightIsReportedAsAnHttpError() = runBlocking<Unit> { server.on("par", """{"error":"nope"}""", code = 500)
assertFails<XrpcException.Http> { manager().beginLogin() } }
@Test fun theCallbackExchangesTheCodeAndStartsTheSession() = runBlocking { server .on("par", """{"request_uri":"urn:abc"}""") .on( "token", """{"access_token":"a1","refresh_token":"r1","expires_in":3600,""" + """"sub":"$me","handle":"me.grain.social"}""", ) val auth = manager() auth.beginLogin("me.grain.social")
auth.completeLogin(Uri.parse("grain://oauth/callback?code=abc123"))
assertTrue(auth.state.value.isAuthenticated) assertEquals(me, auth.state.value.did) assertEquals("me.grain.social", auth.state.value.handle) assertEquals(me, storage.activeDid) assertTrue(server.lastCall("token").body.contains("code_verifier=")) }
@Test fun aDeniedAuthorizationIsACancellationRatherThanAFailure() = runBlocking { server.on("par", """{"request_uri":"urn:abc"}""") val auth = manager() auth.beginLogin()
assertFails<XrpcException.AuthorizationDenied> { auth.completeLogin(Uri.parse("grain://oauth/callback?error=access_denied")) } assertFalse(auth.state.value.isAuthenticated) }
@Test fun anyOtherOauthErrorCarriesItsDescription() = runBlocking { server.on("par", """{"request_uri":"urn:abc"}""") val auth = manager() auth.beginLogin()
val error = assertFails<XrpcException.AuthorizationFailed> { auth.completeLogin( Uri.parse("grain://oauth/callback?error=server_error&error_description=Boom"), ) } assertEquals("Boom", error.message) }
@Test fun aCallbackWithNoCodeAtAllIsAFailure() = runBlocking<Unit> { server.on("par", """{"request_uri":"urn:abc"}""") val auth = manager() auth.beginLogin()
assertFails<XrpcException.AuthorizationFailed> { auth.completeLogin(Uri.parse("grain://oauth/callback")) } }
@Test fun aCallbackWithNoSignInInFlightIsUnauthorized() = runBlocking<Unit> { assertFails<XrpcException.Unauthorized> { manager().completeLogin(Uri.parse("grain://oauth/callback?code=abc")) } }
// MARK: - Several accounts
@Test fun switchingAccountsMovesTheWholeSessionAcross() { seed(me) seed(other, handle = "other.test") storage.activeDid = me val auth = manager()
auth.switchTo(other)
assertEquals(other, auth.state.value.did) assertEquals("other.test", auth.state.value.handle) assertEquals(other, storage.activeDid) }
@Test fun switchingToAnAccountWithNoCredentialsDoesNothing() { seed(me) storage.activeDid = me val auth = manager()
auth.switchTo("did:plc:stranger")
assertEquals(me, auth.state.value.did) }
@Test fun signingOutHandsTheSessionToWhicheverAccountIsLeft() { seed(me) seed(other, handle = "other.test") storage.activeDid = me val auth = manager()
auth.signOut()
assertTrue(auth.state.value.isAuthenticated) assertEquals(other, auth.state.value.did) }
@Test fun signingOutOfTheLastAccountLandsOnTheLoginScreen() { seed(me) storage.activeDid = me val auth = manager()
auth.signOut()
assertFalse(auth.state.value.isAuthenticated) assertTrue(auth.state.value.accounts.isEmpty()) assertNull(storage.accessToken(me)) }
@Test fun signingOutWithNobodySignedInIsANoOp() { val auth = manager() auth.signOut() assertFalse(auth.state.value.isAuthenticated) }
// MARK: - Profile details
@Test fun theAvatarAndHandleCanBeFilledInAfterTheProfileLands() { seed(me) storage.activeDid = me val auth = manager()
auth.updateAvatar("https://cdn.grain.test/me.jpg") auth.updateHandle("renamed.test")
assertEquals("https://cdn.grain.test/me.jpg", auth.state.value.avatar) assertEquals("renamed.test", auth.state.value.handle) assertEquals("https://cdn.grain.test/me.jpg", storage.avatar(me)) assertEquals("renamed.test", storage.accounts.single().handle) }
@Test fun profileDetailsForNobodyAreDroppedRatherThanStored() { val auth = manager() auth.updateAvatar("https://cdn.grain.test/nobody.jpg") auth.updateHandle("nobody.test")
assertNull(auth.state.value.avatar) assertTrue(storage.accounts.isEmpty()) }
@Test fun theReauthReasonCanBeCleared() { val auth = manager() auth.clearReauthReason() assertNull(auth.state.value.reauthReason) }
@Test fun theClientItBuildsRefreshesTheTokenOnA401() = runBlocking { seed(me) storage.activeDid = me server .on("dev.hatk.getFeed", """{"error":"expired"}""", code = 401) .then("dev.hatk.getFeed", """{"items":[]}""") .on("token", """{"access_token":"fresh","expires_in":3600}""") val auth = manager()
auth.makeClient().getFeed(feed = "recent", auth = auth.authContext())
assertEquals(2, server.countOf("dev.hatk.getFeed")) assertEquals("DPoP fresh", server.lastCall("dev.hatk.getFeed").authorization) }
@Test fun theRequiredScopeListNamesEveryCollectionTheAppWritesTo() { assertTrue(AuthManager.REQUIRED_SCOPES.contains("atproto")) assertTrue(AuthManager.REQUIRED_SCOPES.contains("repo:social.grain.gallery")) assertTrue(AuthManager.REQUIRED_SCOPES.contains("repo:social.grain.story")) }}