Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 08302130 authored by Jonathan Klee's avatar Jonathan Klee
Browse files

fix: fix potential stale tokens in microg login mode

parent 66fb3596
Loading
Loading
Loading
Loading
+2 −2
Original line number Diff line number Diff line
@@ -52,8 +52,8 @@ class MicrogLoginManager @Inject constructor(

    override suspend fun login(): AuthData? {
        val oldToken = playStoreAuthStore.awaitOauthToken()
        val shouldRefresh = hasMicrogAccount() && oldToken.startsWith(MICROG_TOKEN_PREFIX)
        val oauthToken = if (shouldRefresh) {
        val shouldRefreshToken = hasMicrogAccount() && oldToken.startsWith(MICROG_TOKEN_PREFIX)
        val oauthToken = if (shouldRefreshToken) {
            fetchRefreshedToken(oldToken)
        } else {
            oldToken
+25 −20
Original line number Diff line number Diff line
@@ -209,7 +209,7 @@ class PlayStoreRepository @Inject constructor(
                throw exception
            }

            if (!isEmulator() && appDetails.versionCode == 0L && isAnonymousUser()) {
            if (!isEmulator() && appDetails.versionCode == 0L) {
                // Google Play returns limited result ( i.e. version code being 0) with a stale token,
                // so we need to refresh authentication to get a new token.
                Timber.i("Version code is 0 for ${appDetails.packageName}.")
@@ -242,14 +242,18 @@ class PlayStoreRepository @Inject constructor(
    }

    private suspend fun <T> retryOnUnauthorized(block: suspend () -> T): T {
        return try {
        return withContext(gPlayHttpClient.requestResponseCodeContext) {
            try {
                gPlayHttpClient.resetResponseCode()
                block()
            } catch (exception: Exception) {
                val requestResponseCode = gPlayHttpClient.getRequestResponseCode()
                val isUnauthorized: Boolean = when (exception) {
                    is GplayHttpRequestException ->
                    exception.status == HttpURLConnection.HTTP_UNAUTHORIZED
                        exception.status == HttpURLConnection.HTTP_UNAUTHORIZED ||
                            requestResponseCode == HttpURLConnection.HTTP_UNAUTHORIZED
                    is InternalException.AppNotFound ->
                    gPlayHttpClient.responseCode.value == HttpURLConnection.HTTP_UNAUTHORIZED
                        requestResponseCode == HttpURLConnection.HTTP_UNAUTHORIZED
                    else -> false
                }

@@ -266,6 +270,7 @@ class PlayStoreRepository @Inject constructor(
                block()
            }
        }
    }

    suspend fun getAppDetailsWeb(packageName: String): Application? {
        val webAppDetailsHelper = WebAppDetailsHelper().using(gPlayHttpClient)
+21 −1
Original line number Diff line number Diff line
@@ -26,6 +26,7 @@ import foundation.e.apps.data.event.EventBus
import foundation.e.apps.data.login.core.AuthObject
import foundation.e.apps.data.system.SystemInfoProvider
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.asContextElement
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -46,6 +47,7 @@ import java.net.SocketTimeoutException
import java.util.Locale
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import kotlin.coroutines.CoroutineContext

class GPlayHttpClient @Inject constructor(
    loggingInterceptor: HttpLoggingInterceptor
@@ -65,6 +67,9 @@ class GPlayHttpClient @Inject constructor(
    }

    private val _responseCode = MutableStateFlow(INITIAL_RESPONSE_CODE)
    private val requestResponseCode = ThreadLocal.withInitial {
        RequestResponseCodeState(INITIAL_RESPONSE_CODE)
    }
    override val responseCode: StateFlow<Int>
        get() = _responseCode.asStateFlow()

@@ -168,9 +173,21 @@ class GPlayHttpClient @Inject constructor(
        return headersWithLocale
    }

    fun resetResponseCode() {
        _responseCode.value = 0
        checkNotNull(requestResponseCode.get()).value = 0
    }

    fun getRequestResponseCode(): Int {
        return checkNotNull(requestResponseCode.get()).value
    }

    val requestResponseCodeContext: CoroutineContext
        get() = requestResponseCode.asContextElement(RequestResponseCodeState(0))

    private fun processRequest(request: Request): PlayResponse {
        // Reset response code as flow doesn't sends the same value twice
        _responseCode.value = 0
        resetResponseCode()
        var response: Response? = null
        return try {
            val call = okHttpClient.newCall(request)
@@ -237,8 +254,11 @@ class GPlayHttpClient @Inject constructor(
            responseBytes = responseBytes,
        ).withErrorString(errorMessage).apply {
            _responseCode.value = response.code
            checkNotNull(requestResponseCode.get()).value = response.code
        }
    }
}

private data class RequestResponseCodeState(var value: Int)

class GplayHttpRequestException(val status: Int, message: String) : Exception(message)
+24 −0
Original line number Diff line number Diff line
@@ -244,6 +244,30 @@ class MicrogLoginManagerTest {
        org.mockito.kotlin.verify(playStoreAuthStore).saveAasToken("")
    }

    @Test
    fun `login uses existing oauth token when microg account exists but token is not a microg token`() = runBlocking {
        val account = Account("user@gmail.com", MicrogCertUtil.GOOGLE_ACCOUNT_TYPE)
        val accountManager = mock<AccountManager>()
        whenever(accountManager.getAccountsByType(eq(MicrogCertUtil.GOOGLE_ACCOUNT_TYPE)))
            .thenReturn(arrayOf(account))
        val oauthAuthDataBuilder = mock<OauthAuthDataBuilder>()
        val playStoreAuthStore = mock<PlayStoreAuthStore>()
        whenever(playStoreAuthStore.awaitOauthToken()).thenReturn("non-ya29-stale-token")
        whenever(playStoreAuthStore.awaitEmail()).thenReturn(account.name)
        val authData = com.aurora.gplayapi.data.models.AuthData(email = account.name)
        whenever(oauthAuthDataBuilder.build("non-ya29-stale-token")).thenReturn(authData)

        val result = buildMicrogLoginManager(accountManager, oauthAuthDataBuilder, playStoreAuthStore).login()

        assertEquals(authData, result)
        org.mockito.kotlin.verify(accountManager, org.mockito.kotlin.never())
            .invalidateAuthToken(any(), any())
        org.mockito.kotlin.verify(playStoreAuthStore, org.mockito.kotlin.never())
            .saveGoogleLogin(any(), any())
        org.mockito.kotlin.verify(playStoreAuthStore, org.mockito.kotlin.never())
            .saveAasToken(any())
    }

    @Test
    fun `login throws when token missing`() {
        val accountManager = mock<AccountManager>()
+124 −0
Original line number Diff line number Diff line
@@ -12,6 +12,7 @@ import com.aurora.gplayapi.helpers.AppDetailsHelper
import com.aurora.gplayapi.helpers.web.WebTopChartsHelper
import com.google.common.truth.Truth.assertThat
import foundation.e.apps.R
import java.net.HttpURLConnection
import foundation.e.apps.data.ResultSupreme
import foundation.e.apps.data.application.ApplicationDataManager
import foundation.e.apps.data.application.ApplicationRepository
@@ -46,6 +47,7 @@ import org.mockito.kotlin.whenever
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.mockito.kotlin.verify as mockitoVerify
import kotlin.coroutines.EmptyCoroutineContext

@RunWith(RobolectricTestRunner::class)
@Config(sdk = [30])
@@ -55,12 +57,23 @@ class PlayStoreRepositoryTest {
    private val gPlayHttpClient = mockk<GPlayHttpClient>()
    private val applicationDataManager = mockk<ApplicationDataManager>(relaxed = true)
    private val playStoreSearchHelper = mockk<PlayStoreSearchHelper>()
    private lateinit var responseCodeFlow: MutableStateFlow<Int>
    private var requestResponseCode: Int = 0
    private lateinit var repository: PlayStoreRepository

    @Before
    fun setUp() {
        mockkObject(SystemInfoProvider)
        every { SystemInfoProvider.getSystemProperty("ro.boot.qemu") } returns "0"
        responseCodeFlow = MutableStateFlow(0)
        requestResponseCode = 0
        every { gPlayHttpClient.responseCode } returns responseCodeFlow
        every { gPlayHttpClient.resetResponseCode() } answers {
            responseCodeFlow.value = 0
            requestResponseCode = 0
        }
        every { gPlayHttpClient.getRequestResponseCode() } answers { requestResponseCode }
        every { gPlayHttpClient.requestResponseCodeContext } returns EmptyCoroutineContext
        mockkConstructor(AppDetailsHelper::class)
        mockkConstructor(WebTopChartsHelper::class)
        every { anyConstructed<AppDetailsHelper>().using(gPlayHttpClient) } answers {
@@ -133,6 +146,94 @@ class PlayStoreRepositoryTest {
        verify(exactly = 2) { anyConstructed<AppDetailsHelper>().getAppByPackageName("pkg.test") }
    }

    @Test
    fun `getAppDetails refreshes auth for google user when version code is zero`() = runTest {
        val authData = AuthData(email = "user@gmail.com", isAnonymous = false)
        val staleApp = App(packageName = "pkg.test", versionCode = 0)
        val refreshedApp = App(packageName = "pkg.test", versionCode = 4)
        val playStoreAuthManager = mock<PlayStoreAuthManager>()
        val storeAuthCoordinator = mock<StoreAuthCoordinator>()
        val playStoreAuthStore = createPlayStoreAuthStore(authData)

        repository = createRepository(playStoreAuthManager, playStoreAuthStore, storeAuthCoordinator)

        whenever(playStoreAuthManager.getGPlayAuthOrThrow()).thenReturn(authData)
        whenever(storeAuthCoordinator.fetchAuthObjects(listOf(StoreType.PLAY_STORE)))
            .thenReturn(emptyList())

        every {
            anyConstructed<AppDetailsHelper>().getAppByPackageName("pkg.test")
        } returns staleApp andThen refreshedApp

        val result = repository.getAppDetails("pkg.test")

        assertThat(result.latest_version_code).isEqualTo(4)
        mockitoVerify(storeAuthCoordinator).fetchAuthObjects(listOf(StoreType.PLAY_STORE))
        verify(exactly = 2) { anyConstructed<AppDetailsHelper>().getAppByPackageName("pkg.test") }
    }

    @Test
    fun `getAppDetails retries after auth refresh when gplay returns 404 with underlying 401`() = runTest {
        val authData = AuthData(email = "user@gmail.com")
        val app = App(packageName = "pkg.test", versionCode = 2)
        val playStoreAuthManager = mock<PlayStoreAuthManager>()
        var requestCount = 0

        repository = createRepository(playStoreAuthManager)

        whenever(playStoreAuthManager.getGPlayAuthOrThrow()).thenReturn(authData)
        whenever(playStoreAuthManager.getValidatedAuthData()).thenReturn(ResultSupreme.Success(authData))
        every {
            anyConstructed<AppDetailsHelper>().getAppByPackageName("pkg.test")
        } answers {
            requestCount += 1
            if (requestCount == 1) {
                responseCodeFlow.value = HttpURLConnection.HTTP_UNAUTHORIZED
                requestResponseCode = HttpURLConnection.HTTP_UNAUTHORIZED
                throw GplayHttpRequestException(404, "not found")
            }

            app
        }

        val result = repository.getAppDetails("pkg.test")

        assertThat(result.package_name).isEqualTo("pkg.test")
        mockitoVerify(playStoreAuthManager).getValidatedAuthData()
        verify(exactly = 2) { anyConstructed<AppDetailsHelper>().getAppByPackageName("pkg.test") }
    }

    @Test
    fun `getAppDetails retries after auth refresh when gplay returns 404 with underlying 401 for anonymous user`() = runTest {
        val authData = AuthData(email = "anon@example.com", isAnonymous = true)
        val app = App(packageName = "pkg.test", versionCode = 2)
        val playStoreAuthManager = mock<PlayStoreAuthManager>()
        var requestCount = 0

        repository = createRepository(playStoreAuthManager)

        whenever(playStoreAuthManager.getGPlayAuthOrThrow()).thenReturn(authData)
        whenever(playStoreAuthManager.getValidatedAuthData()).thenReturn(ResultSupreme.Success(authData))
        every {
            anyConstructed<AppDetailsHelper>().getAppByPackageName("pkg.test")
        } answers {
            requestCount += 1
            if (requestCount == 1) {
                responseCodeFlow.value = HttpURLConnection.HTTP_UNAUTHORIZED
                requestResponseCode = HttpURLConnection.HTTP_UNAUTHORIZED
                throw GplayHttpRequestException(404, "not found")
            }

            app
        }

        val result = repository.getAppDetails("pkg.test")

        assertThat(result.package_name).isEqualTo("pkg.test")
        mockitoVerify(playStoreAuthManager).getValidatedAuthData()
        verify(exactly = 2) { anyConstructed<AppDetailsHelper>().getAppByPackageName("pkg.test") }
    }

    @Test
    fun `getAppDetails throws AppNotFound on 404`() = runTest {
        val authData = AuthData(email = "user@gmail.com")
@@ -152,6 +253,29 @@ class PlayStoreRepositoryTest {
        assertThat(exception).isInstanceOf(InternalException.AppNotFound::class.java)
    }

    @Test
    fun `getAppDetails does not retry on true 404 when a previous call left 401 in state`() = runTest {
        val authData = AuthData(email = "user@gmail.com")
        val playStoreAuthManager = mock<PlayStoreAuthManager>()

        repository = createRepository(playStoreAuthManager)

        whenever(playStoreAuthManager.getGPlayAuthOrThrow()).thenReturn(authData)
        responseCodeFlow.value = HttpURLConnection.HTTP_UNAUTHORIZED
        requestResponseCode = 0
        every {
            anyConstructed<AppDetailsHelper>().getAppByPackageName("missing.pkg")
        } throws GplayHttpRequestException(404, "not found")

        val exception = kotlin.test.assertFailsWith<InternalException.AppNotFound> {
            repository.getAppDetails("missing.pkg")
        }

        assertThat(exception).isInstanceOf(InternalException.AppNotFound::class.java)
        mockitoVerify(playStoreAuthManager, org.mockito.kotlin.never()).getValidatedAuthData()
        verify(exactly = 1) { anyConstructed<AppDetailsHelper>().getAppByPackageName("missing.pkg") }
    }

    @Test
    fun `getAppsDetails refreshes auth when anonymous and all versions zero`() = runTest {
        val authData = AuthData(email = "anon@example.com", isAnonymous = true)