package social.grain import android.net.Uri import androidx.test.ext.junit.runners.AndroidJUnit4 import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.runBlocking import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import social.grain.data.api.DPoP import social.grain.data.api.getFeed import social.grain.data.api.XrpcException import social.grain.data.auth.AuthManager import social.grain.data.auth.SecureStore import social.grain.data.auth.StoredAccount import social.grain.data.auth.TokenStorage import social.grain.support.FakeAndroidKeyStore import social.grain.support.StubServer import social.grain.support.appContext import social.grain.support.assertFails import 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 { 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 { 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 { assertFails { 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 { server.on("par", """{"error":"nope"}""", code = 500) assertFails { 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 { 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 { auth.completeLogin( Uri.parse("grain://oauth/callback?error=server_error&error_description=Boom"), ) } assertEquals("Boom", error.message) } @Test fun aCallbackWithNoCodeAtAllIsAFailure() = runBlocking { server.on("par", """{"request_uri":"urn:abc"}""") val auth = manager() auth.beginLogin() assertFails { auth.completeLogin(Uri.parse("grain://oauth/callback")) } } @Test fun aCallbackWithNoSignInInFlightIsUnauthorized() = runBlocking { assertFails { 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")) } }