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

Commit 2432b292 authored by Jonathan Klee's avatar Jonathan Klee
Browse files

Merge branch '0000-a16-fix-token-refresh-microg' into 'main'

Implement microG token refresh

See merge request !675
parents fbf6bae4 9828417d
Loading
Loading
Loading
Loading
Loading
+4 −2
Original line number Diff line number Diff line
package foundation.e.apps.data.login.api

interface LoginManager<T> {
    suspend fun login(): T
import com.aurora.gplayapi.data.models.AuthData

interface LoginManager {
    suspend fun login(): AuthData?
}
+185 −0
Original line number Diff line number Diff line
@@ -16,7 +16,7 @@
 *
 */

package foundation.e.apps.data.login.playstore
package foundation.e.apps.data.login.microg

import android.accounts.Account
import android.accounts.AccountManager
@@ -27,9 +27,12 @@ import android.os.Bundle
import android.os.Parcelable
import android.util.Base64
import androidx.core.os.BundleCompat
import com.aurora.gplayapi.data.models.AuthData
import dagger.hilt.android.qualifiers.ApplicationContext
import foundation.e.apps.data.login.api.LoginManager
import foundation.e.apps.data.login.microg.MicrogCertUtil
import foundation.e.apps.data.login.playstore.OauthAuthDataBuilder
import foundation.e.apps.data.preference.AppLoungeDataStore
import foundation.e.apps.data.preference.getSync
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import javax.inject.Inject
@@ -41,10 +44,12 @@ data class MicrogAccount(
)

@Singleton
class MicrogAccountManager @Inject constructor(
class MicrogLoginManager @Inject constructor(
    @ApplicationContext val context: Context,
    private val accountManager: AccountManager
) : LoginManager<MicrogAccountManager.FetchResult> {
    private val accountManager: AccountManager,
    private val oauthAuthDataBuilder: OauthAuthDataBuilder,
    private val appLoungeDataStore: AppLoungeDataStore
) : LoginManager {
    sealed interface FetchResult {
        data class Success(val microgAccount: MicrogAccount) : FetchResult
        data class RequiresUserAction(val intent: Intent) : FetchResult
@@ -55,11 +60,25 @@ class MicrogAccountManager @Inject constructor(
        return accountManager.getAccountsByType(MicrogCertUtil.GOOGLE_ACCOUNT_TYPE).isNotEmpty()
    }

    override suspend fun login(): FetchResult {
        return fetchMicrogAccount()
    override suspend fun login(): AuthData? {
        val oldToken = appLoungeDataStore.oauthToken.getSync()
        val shouldRefresh = hasMicrogAccount() && oldToken.startsWith(MICROG_TOKEN_PREFIX)
        val oauthToken = if (shouldRefresh) {
            fetchRefreshedToken(oldToken)
        } else {
            oldToken
        }

        if (oauthToken.isBlank()) {
            error(MICROG_TOKEN_MISSING)
        }

        return oauthAuthDataBuilder.build(oauthToken)
            ?: error(MICROG_TOKEN_MISSING)
    }

    suspend fun fetchMicrogAccount(
        accountName: String? = null
        accountName: String = ""
    ): FetchResult = withContext(Dispatchers.IO) {
        val accounts = accountManager.getAccountsByType(MicrogCertUtil.GOOGLE_ACCOUNT_TYPE)
        if (accounts.isEmpty()) {
@@ -68,21 +87,14 @@ class MicrogAccountManager @Inject constructor(
            )
        }

        val account = accountName?.let { name ->
            accounts.firstOrNull { it.name == name }
        } ?: accounts.first()
        val account = resolveAccount(accounts, accountName)

        return@withContext runCatching {
            val options = createAuthTokenOptions()
            val bundle = accountManager.getAuthToken(
                account,
                MicrogCertUtil.PLAY_AUTH_SCOPE,
                Bundle().apply {
                    putString("overridePackage", MicrogCertUtil.GOOGLE_PLAY_PACKAGE)
                    putByteArray(
                        "overrideCertificate",
                        Base64.decode(MicrogCertUtil.GOOGLE_PLAY_CERT_BASE64, Base64.DEFAULT)
                    )
                },
                options,
                false,
                null,
                null
@@ -104,6 +116,59 @@ class MicrogAccountManager @Inject constructor(
        }
    }

    suspend fun invalidateAuthToken(accountName: String, oldToken: String) {
        if (oldToken.isBlank()) {
            return
        }

        withContext(Dispatchers.IO) {
            val accounts =
                accountManager.getAccountsByType(MicrogCertUtil.GOOGLE_ACCOUNT_TYPE)

            if (accounts.isNotEmpty()) {
                val account = resolveAccount(accounts, accountName)
                accountManager.invalidateAuthToken(account.type, oldToken)
            }
        }
    }

    private suspend fun fetchRefreshedToken(oldToken: String): String {
        val accountName = appLoungeDataStore.emailData.getSync().ifBlank { "" }
        invalidateAuthToken(accountName, oldToken)
        val result = fetchMicrogAccount(accountName)
        return when (result) {
            is FetchResult.Success -> {
                appLoungeDataStore.saveGoogleLogin(
                    result.microgAccount.account.name,
                    result.microgAccount.oauthToken
                )

                appLoungeDataStore.saveAasToken("")
                result.microgAccount.oauthToken
            }
            is FetchResult.RequiresUserAction -> error(MICROG_TOKEN_REFRESH_FAILURE)
            is FetchResult.Error -> error(MICROG_TOKEN_REFRESH_FAILURE)
        }
    }

    private fun resolveAccount(accounts: Array<Account>, accountName: String): Account {
        return if (accountName.isNotBlank()) {
            accounts.firstOrNull { it.name == accountName } ?: accounts.first()
        } else {
            accounts.first()
        }
    }

    private fun createAuthTokenOptions(): Bundle {
        return Bundle().apply {
            putString("overridePackage", MicrogCertUtil.GOOGLE_PLAY_PACKAGE)
            putByteArray(
                "overrideCertificate",
                Base64.decode(MicrogCertUtil.GOOGLE_PLAY_CERT_BASE64, Base64.DEFAULT)
            )
        }
    }

    private fun <T : Parcelable> Bundle.parcelableCompat(key: String, clazz: Class<T>): T? {
        return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
            getParcelable(key, clazz)
@@ -111,4 +176,10 @@ class MicrogAccountManager @Inject constructor(
            BundleCompat.getParcelable(this, key, clazz)
        }
    }

    companion object {
        const val MICROG_TOKEN_PREFIX = "ya29."
        const val MICROG_TOKEN_MISSING = "MicroG token is missing"
        const val MICROG_TOKEN_REFRESH_FAILURE = "MicroG refresh failed"
    }
}
+1 −21
Original line number Diff line number Diff line
@@ -19,16 +19,13 @@
package foundation.e.apps.data.login.playstore

import com.aurora.gplayapi.data.models.AuthData
import com.aurora.gplayapi.data.models.PlayResponse
import com.aurora.gplayapi.helpers.AuthHelper
import foundation.e.apps.data.login.api.LoginManager
import foundation.e.apps.data.login.core.Auth
import foundation.e.apps.data.playstore.utils.CustomAuthValidator
import foundation.e.apps.data.playstore.utils.GPlayHttpClient
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import timber.log.Timber
import java.net.HttpURLConnection
import java.util.Locale
import java.util.Properties
@@ -38,7 +35,7 @@ class AnonymousLoginManager @Inject constructor(
    private val gPlayHttpClient: GPlayHttpClient,
    private val nativeDeviceProperty: Properties,
    private val json: Json,
) : LoginManager<AuthData?> {
) : LoginManager {

    companion object {
        private const val TOKEN_DISPENSER_URL = "https://eu.gtoken.ecloud.global"
@@ -77,21 +74,4 @@ class AnonymousLoginManager @Inject constructor(
        }
        return authData
    }

    suspend fun validate(authData: AuthData?): PlayResponse {
        if (authData == null) {
            return PlayResponse()
        }
        var result = PlayResponse()
        withContext(Dispatchers.IO) {
            try {
                val authValidator = CustomAuthValidator(authData).using(gPlayHttpClient)
                result = authValidator.getValidityResponse()
            } catch (e: Exception) {
                Timber.e(e, "AuthData validation failed for anonymous login")
                throw e
            }
        }
        return result
    }
}
+0 −116
Original line number Diff line number Diff line
/*
 * Copyright (C) 2026 e Foundation
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 *
 */

package foundation.e.apps.data.login.playstore

import com.aurora.gplayapi.data.models.AuthData
import foundation.e.apps.data.ResultSupreme
import foundation.e.apps.data.preference.AppLoungeDataStore
import foundation.e.apps.data.preference.getSync

interface AuthDataProvider {
    suspend fun fetch(): ResultSupreme<AuthData?>
}

class AnonymousAuthDataProvider(
    private val loginProvider: suspend () -> ResultSupreme<AuthData?>,
    private val formatter: (AuthData) -> AuthData
) : AuthDataProvider {
    override suspend fun fetch(): ResultSupreme<AuthData?> {
        return loginProvider().run {
            if (isSuccess()) {
                ResultSupreme.Success(formatter(this.data!!))
            } else {
                this
            }
        }
    }
}

class GoogleAasAuthDataProvider(
    private val appLoungeDataStore: AppLoungeDataStore,
    private val loginProvider: suspend () -> ResultSupreme<AuthData?>
) : AuthDataProvider {
    override suspend fun fetch(): ResultSupreme<AuthData?> {
        val aasToken = appLoungeDataStore.aasToken.getSync()
        if (aasToken.isNotBlank()) {
            return loginProvider()
        }
        return ResultSupreme.Error("AAS token is missing")
    }
}

class GoogleOauthAuthDataProvider(
    private val appLoungeDataStore: AppLoungeDataStore,
    private val loginProvider: suspend () -> ResultSupreme<AuthData?>,
    private val googleLoginManager: GoogleLoginManager,
    private val aasTokenProvider: suspend (GoogleLoginManager, String, String) -> ResultSupreme<String>,
    private val formatter: (AuthData) -> AuthData
) : AuthDataProvider {
    override suspend fun fetch(): ResultSupreme<AuthData?> {
        val email = appLoungeDataStore.emailData.getSync()
        val oauthToken = appLoungeDataStore.oauthToken.getSync()
        val aasTokenResponse = aasTokenProvider(googleLoginManager, email, oauthToken)

        return if (aasTokenResponse.isSuccess()) {
            fetchWithAasToken(aasTokenResponse.data.orEmpty())
        } else {
            fetchWithOauthFallback(aasTokenResponse, oauthToken)
        }
    }

    private suspend fun fetchWithAasToken(aasTokenFetched: String): ResultSupreme<AuthData?> {
        if (aasTokenFetched.isBlank()) {
            return ResultSupreme.Error("Fetched AAS Token is blank")
        }
        appLoungeDataStore.saveAasToken(aasTokenFetched)
        return loginProvider().run {
            if (isSuccess()) {
                ResultSupreme.Success(formatter(this.data!!))
            } else {
                this
            }
        }
    }

    private suspend fun fetchWithOauthFallback(
        aasTokenResponse: ResultSupreme<String>,
        oauthToken: String
    ): ResultSupreme<AuthData?> {
        val fallbackAuth = googleLoginManager.buildAuthDataFromOauthToken(oauthToken)
        return if (fallbackAuth != null) {
            ResultSupreme.Success(formatter(fallbackAuth))
        } else {
            ResultSupreme.replicate(aasTokenResponse, null)
        }
    }
}

class GoogleAuthDataProvider(
    private val appLoungeDataStore: AppLoungeDataStore,
    private val aasProvider: AuthDataProvider,
    private val oauthProvider: AuthDataProvider
) : AuthDataProvider {
    override suspend fun fetch(): ResultSupreme<AuthData?> {
        return if (appLoungeDataStore.aasToken.getSync().isNotBlank()) {
            aasProvider.fetch()
        } else {
            oauthProvider.fetch()
        }
    }
}
+4 −58
Original line number Diff line number Diff line
@@ -18,43 +18,20 @@
package foundation.e.apps.data.login.playstore

import com.aurora.gplayapi.data.models.AuthData
import com.aurora.gplayapi.data.models.PlayResponse
import com.aurora.gplayapi.helpers.AuthHelper
import foundation.e.apps.data.login.api.LoginManager
import foundation.e.apps.data.playstore.utils.AC2DMTask
import foundation.e.apps.data.playstore.utils.CustomAuthValidator
import foundation.e.apps.data.playstore.utils.GPlayHttpClient
import foundation.e.apps.data.preference.AppLoungeDataStore
import foundation.e.apps.data.preference.getSync
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.util.Properties
import javax.inject.Inject

class GoogleLoginManager @Inject constructor(
    private val gPlayHttpClient: GPlayHttpClient,
    private val nativeDeviceProperty: Properties,
    private val aC2DMTask: AC2DMTask,
    private val appLoungeDataStore: AppLoungeDataStore,
) : LoginManager<AuthData?> {

    /**
     * Get PlayResponse for AC2DM Map. This allows us to get an error message too.
     *
     * An aasToken is extracted from this map. This is passed to [login]
     * to generate AuthData. This token is very important as it cannot be regenerated,
     * hence it must be saved for future use.
     *
     * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5680
     */
    suspend fun getAC2DMResponse(email: String, oauthToken: String): PlayResponse {
        var response: PlayResponse
        withContext(Dispatchers.IO) {
            response = aC2DMTask.getAC2DMResponse(email, oauthToken)
        }
        return response
    }
    private val oauthAuthDataBuilder: OauthAuthDataBuilder
) : LoginManager {

    /**
     * Login
@@ -68,10 +45,7 @@ class GoogleLoginManager @Inject constructor(

        var authData: AuthData?
        withContext(Dispatchers.IO) {
            // Prefer AAS if present, otherwise fall back to an AUTH token (microG ya29.*).
            val (token, tokenType) = when {
                aasToken.isNotBlank() && aasToken.startsWith("ya29.") ->
                    aasToken to AuthHelper.Token.AUTH
                aasToken.isNotBlank() ->
                    aasToken to AuthHelper.Token.AAS
                oauthToken.isNotBlank() ->
@@ -90,35 +64,7 @@ class GoogleLoginManager @Inject constructor(
        return authData
    }

    suspend fun buildAuthDataFromOauthToken(oauthToken: String): AuthData? {
        val email = appLoungeDataStore.emailData.getSync()
        var authData: AuthData?
        withContext(Dispatchers.IO) {
            authData = AuthHelper.build(
                email = email,
                token = oauthToken,
                tokenType = AuthHelper.Token.AUTH,
                isAnonymous = false,
                properties = nativeDeviceProperty
            )
        }
        return authData
    }

    suspend fun validate(authData: AuthData?): PlayResponse {
        if (authData == null) {
            return PlayResponse()
        }
        var result = PlayResponse()
        withContext(Dispatchers.IO) {
            try {
                val authValidator = CustomAuthValidator(authData).using(gPlayHttpClient)
                result = authValidator.getValidityResponse()
            } catch (e: Exception) {
                Timber.e(e, "AuthData validation failed")
                throw e
            }
        }
        return result
    suspend fun loginWithOauthToken(oauthToken: String): AuthData? {
        return oauthAuthDataBuilder.build(oauthToken)
    }
}
Loading