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

Commit 488a08bd authored by Abhishek Aggarwal's avatar Abhishek Aggarwal
Browse files

fix(auth): harden credential persistence and refresh failure handling

parent bc17f753
Loading
Loading
Loading
Loading
+13 −5
Original line number Diff line number Diff line
@@ -22,6 +22,7 @@ import foundation.e.apps.domain.auth.AuthSession
import foundation.e.apps.domain.auth.AuthSessionRepository
import foundation.e.apps.domain.auth.AuthStore
import foundation.e.apps.domain.auth.PlayStoreLoginMode
import foundation.e.apps.domain.auth.describe
import foundation.e.apps.domain.login.AnonymousLoginUseCase
import foundation.e.apps.domain.login.GoogleLoginUseCase
import foundation.e.apps.domain.login.MicrogLoginUseCase
@@ -272,16 +273,23 @@ class LoginWorkflowCoordinator @Inject constructor(
        }

        Timber.e(rollbackFailure, "Login rollback failed after %s", rollbackContext)
        rollbackFailure.apply {
            if (failure is LoginWorkflowFailure.SubmissionFailed) {
                addSuppressed(failure.throwable)
            }
        }
        rollbackFailure.addSuppressed(failure.toThrowable())
        return LoginWorkflowResult.Failure(
            LoginWorkflowFailure.SubmissionFailed(rollbackFailure)
        )
    }

    private fun LoginWorkflowFailure.toThrowable(): Throwable {
        return when (this) {
            is LoginWorkflowFailure.SubmissionFailed -> throwable
            is LoginWorkflowFailure.RefreshValidationFailed ->
                IllegalStateException(
                    "Original login refresh validation failed for $preferredStore: " +
                        (authError?.describe() ?: "unknown auth error")
                )
        }
    }

    private suspend fun captureRollbackState(): LoginRollbackState {
        return LoginRollbackState(
            session = authSessionRepository.resolveCurrentSession(),
+5 −0
Original line number Diff line number Diff line
@@ -358,6 +358,11 @@ class LoginWorkflowCoordinatorTest {
            .isEqualTo("Failed to rollback login after refresh validation failure for PLAY_STORE")
        assertThat(throwable.cause).isInstanceOf(IllegalStateException::class.java)
        assertThat(throwable.cause?.message).isEqualTo("rollback failed")
        assertThat(throwable.suppressed).hasLength(1)
        assertThat(throwable.suppressed.first().message)
            .isEqualTo(
                "Original login refresh validation failed for PLAY_STORE: Google login failed"
            )
    }

    private fun playStoreSnapshot(loginMode: PlayStoreLoginMode): AuthRefreshSnapshot {
+33 −7
Original line number Diff line number Diff line
@@ -28,20 +28,46 @@ import foundation.e.apps.domain.auth.describe
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import timber.log.Timber
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton

@Singleton
class PlayStoreTokenRefreshHandler @Inject constructor(
    private val authDataCache: AuthDataCache,
    private val playStoreAuthenticator: PlayStoreAuthenticator,
    private val playStoreStoredAuthPolicy: PlayStoreStoredAuthPolicy,
) : TokenRefreshHandler {
class PlayStoreTokenRefreshHandler : TokenRefreshHandler {

    companion object {
        private const val RECENT_REFRESH_WINDOW_IN_MILLIS = 5_000L
    }

    private val authDataCache: AuthDataCache
    private val playStoreAuthenticator: PlayStoreAuthenticator
    private val playStoreStoredAuthPolicy: PlayStoreStoredAuthPolicy
    private val monotonicClockMs: () -> Long

    @Inject
    constructor(
        authDataCache: AuthDataCache,
        playStoreAuthenticator: PlayStoreAuthenticator,
        playStoreStoredAuthPolicy: PlayStoreStoredAuthPolicy,
    ) : this(
        authDataCache = authDataCache,
        playStoreAuthenticator = playStoreAuthenticator,
        playStoreStoredAuthPolicy = playStoreStoredAuthPolicy,
        monotonicClockMs = { TimeUnit.NANOSECONDS.toMillis(System.nanoTime()) },
    )

    constructor(
        authDataCache: AuthDataCache,
        playStoreAuthenticator: PlayStoreAuthenticator,
        playStoreStoredAuthPolicy: PlayStoreStoredAuthPolicy,
        monotonicClockMs: () -> Long,
    ) {
        this.authDataCache = authDataCache
        this.playStoreAuthenticator = playStoreAuthenticator
        this.playStoreStoredAuthPolicy = playStoreStoredAuthPolicy
        this.monotonicClockMs = monotonicClockMs
    }

    private val refreshMutex = Mutex()
    private var lastSuccessfulAuthToken: String? = null
    private var lastSuccessfulRefreshTimestamp: Long = 0L
@@ -79,13 +105,13 @@ class PlayStoreTokenRefreshHandler @Inject constructor(
    private fun wasRecentlyRefreshed(currentAuthData: AuthData?): Boolean {
        val authToken = currentAuthData?.authToken ?: return false
        return authToken == lastSuccessfulAuthToken &&
            System.currentTimeMillis() - lastSuccessfulRefreshTimestamp <=
            monotonicClockMs() - lastSuccessfulRefreshTimestamp <=
            RECENT_REFRESH_WINDOW_IN_MILLIS
    }

    private fun markRefreshSuccessful(authData: AuthData) {
        lastSuccessfulAuthToken = authData.authToken
        lastSuccessfulRefreshTimestamp = System.currentTimeMillis()
        lastSuccessfulRefreshTimestamp = monotonicClockMs()
    }

    private fun shouldEvictPersistedAuth(error: AuthError): Boolean {
+15 −5
Original line number Diff line number Diff line
@@ -138,11 +138,12 @@ class PlayStoreCredentialsDataStore @Inject constructor(
        playStoreAuthSourceFlow.first()

    override suspend fun saveAuthData(authData: AuthData?) {
        val encodedAuthData = authData?.let(::encodeAuthData)
        dataStore.edit {
            if (authData == null) {
            if (encodedAuthData == null) {
                it.remove(AUTHDATA)
            } else {
                it[AUTHDATA] = json.encodeToString(authData)
                it[AUTHDATA] = encodedAuthData
            }
        }
    }
@@ -188,11 +189,12 @@ class PlayStoreCredentialsDataStore @Inject constructor(
    }

    override suspend fun restoreCredentials(snapshot: PlayStoreAuthStore.CredentialsSnapshot) {
        val encodedAuthData = snapshot.authData?.let(::encodeAuthData)
        dataStore.edit { preferences ->
            if (snapshot.authData == null) {
            if (encodedAuthData == null) {
                preferences.remove(AUTHDATA)
            } else {
                preferences[AUTHDATA] = json.encodeToString(snapshot.authData)
                preferences[AUTHDATA] = encodedAuthData
            }

            if (snapshot.email.isBlank()) {
@@ -223,12 +225,20 @@ class PlayStoreCredentialsDataStore @Inject constructor(

    private fun decodeAuthData(rawAuthData: String): AuthData? {
        return try {
            json.decodeFromString<AuthData>(rawAuthData)
            json.decodeFromString(AuthData.serializer(), rawAuthData)
        } catch (exception: SerializationException) {
            null
        }
    }

    private fun encodeAuthData(authData: AuthData): String {
        return try {
            json.encodeToString(AuthData.serializer(), authData)
        } catch (exception: SerializationException) {
            throw IllegalStateException("Failed to encode Play Store auth data", exception)
        }
    }

    private fun buildAccount(authData: AuthData?, email: String): PlayStoreAccount? {
        val resolvedEmail = email.ifBlank { authData?.email.orEmpty() }
        return if (authData == null && resolvedEmail.isBlank()) {
+1 −0
Original line number Diff line number Diff line
@@ -98,6 +98,7 @@ class SessionPreferencesDataStore @Inject constructor(
            "NO_GOOGLE" -> PersistedLoginIntent.OPEN_SOURCE
            "ANONYMOUS" -> PersistedLoginIntent.PLAY_ANONYMOUS
            "GOOGLE" -> PersistedLoginIntent.PLAY_GOOGLE
            "MICROG" -> PersistedLoginIntent.PLAY_MICROG
            else -> PersistedLoginIntent.NONE
        }
    }
Loading