Loading data/src/main/java/foundation/e/apps/data/login/playstore/PlayStoreStoredAuthPolicy.kt +11 −1 Original line number Diff line number Diff line Loading @@ -69,7 +69,7 @@ class PlayStoreStoredAuthPolicy { } is PlayStoreAuthValidationResult.InvalidAuth -> { clearPersistedAuth() clearRejectedAuthAndBootstrapState() Resolution.RefreshRequired } Loading Loading @@ -114,6 +114,16 @@ class PlayStoreStoredAuthPolicy { suspend fun clearPersistedAuth() { playStoreAuthStore.saveAuthData(null) clearCachedValidation() } suspend fun clearRejectedAuthAndBootstrapState() { playStoreAuthStore.saveAuthData(null) playStoreAuthStore.saveAasToken("") clearCachedValidation() } private suspend fun clearCachedValidation() { cachedValidationMutex.withLock { cachedValidation = null } Loading data/src/main/java/foundation/e/apps/data/login/playstore/PlayStoreTokenRefreshHandler.kt +1 −1 Original line number Diff line number Diff line Loading @@ -108,7 +108,7 @@ class PlayStoreTokenRefreshHandler : TokenRefreshHandler { val failure = authResult.toAuthError(AuthStore.PLAY_STORE) if (shouldEvictPersistedAuth(failure)) { playStoreStoredAuthPolicy.clearPersistedAuth() playStoreStoredAuthPolicy.clearRejectedAuthAndBootstrapState() } Timber.e("Play Store token refresh failed: %s", failure.describe()) AuthResult.Failure(failure) Loading data/src/main/java/foundation/e/apps/data/login/repository/AuthenticatorRepository.kt +1 −14 Original line number Diff line number Diff line Loading @@ -107,10 +107,8 @@ class AuthenticatorRepository @Inject constructor( when (val resolution = playStoreStoredAuthPolicy.resolveStoredAuth()) { PlayStoreStoredAuthPolicy.Resolution.Missing, PlayStoreStoredAuthPolicy.Resolution.RefreshRequired -> { clearInvalidPlayStoreBootstrapState() PlayStoreStoredAuthPolicy.Resolution.RefreshRequired -> rebuildPlayStoreAuth(authenticator) } is PlayStoreStoredAuthPolicy.Resolution.UseStoredAuth -> ResultSupreme.Success<AuthData?>(resolution.authData) Loading @@ -137,17 +135,6 @@ class AuthenticatorRepository @Inject constructor( return persistedAuthResult.toAuthDataResult() } private suspend fun clearInvalidPlayStoreBootstrapState() { if (sessionRepository.awaitLoginIntent().toPlayStoreLoginModeOrNull() == null) { return } // If validated Play auth is definitively invalid, the persisted AAS token can be stale. // Clearing it forces rebuild to prefer a fresh oauth->AAS conversion (or fail fast) // instead of silently recreating auth from the same rejected bootstrap token. playStoreAuthStore.saveAasToken("") } private suspend fun persistAuthData( storeType: AuthStore, authResult: StoreAuthResult, Loading data/src/test/java/foundation/e/apps/data/login/playstore/PlayStoreAuthenticatorTest.kt +64 −0 Original line number Diff line number Diff line Loading @@ -847,6 +847,70 @@ class PlayStoreAuthenticatorTest { coVerify(exactly = 1) { anonymousLoginManager.login() } } @Test fun login_clearsStaleAasTokenBeforeRebuildingInvalidGoogleAuth() = runTest { val context = applicationContext() val savedAuth = AuthData(email = "user@gmail.com", authToken = "stale-token") val freshAuth = AuthData(email = "user@gmail.com", authToken = "fresh-token") val sessionRepository = FakeSessionRepository( initialLoginIntent = PersistedLoginIntent.PLAY_GOOGLE, currentAuthData = savedAuth, persistedAuthData = savedAuth, email = "user@gmail.com", oauthToken = "oauth-token", aasToken = "stale-aas-token", playStoreAuthSource = PlayStoreAuthSource.GOOGLE, ) val googleLoginManager = mockk<GoogleLoginManager>() val aasTokenConverter = mock<OauthToAasTokenConverter>() val userProfileFetcher = mockk<UserProfileFetcher>() whenever(aasTokenConverter.convert(mockitoAny(), mockitoAny())) .thenReturn(ResultSupreme.Success("fresh-aas-token")) coEvery { googleLoginManager.login( email = "user@gmail.com", aasToken = "fresh-aas-token", oauthToken = "", ) } returns freshAuth coEvery { userProfileFetcher.fetch(any()) } returns null val authenticator = playStoreAuthenticator( context = context, sessionRepository = sessionRepository, playStoreAuthStore = sessionRepository, sourceSelectionRepository = sourceSelectionRepository(), authDataCache = AuthDataCache(sessionRepository, json), googleLoginManager = googleLoginManager, microgLoginManager = mockk(), anonymousLoginManager = mockk(), aasTokenConverter = aasTokenConverter, userProfileFetcher = userProfileFetcher, successfulLoginNotifier = NoOpSuccessfulLoginNotifier, networkRetryPolicy = NetworkRetryPolicy(), playStoreAuthValidator = FakePlayStoreAuthValidator { PlayStoreAuthValidationResult.InvalidAuth("stale auth") }, ) val result = authenticator.login() assertThat(result.requirePlayStoreAuthData().authToken).isEqualTo("fresh-token") assertThat(result.authDataToPersist?.authToken).isEqualTo("fresh-token") assertThat(result.aasTokenToPersist).isEqualTo("fresh-aas-token") assertThat(sessionRepository.awaitAasToken()).isEmpty() mockitoVerify(aasTokenConverter).convert(mockitoAny(), mockitoAny()) coVerify(exactly = 0) { googleLoginManager.login() } coVerify(exactly = 1) { googleLoginManager.login( email = "user@gmail.com", aasToken = "fresh-aas-token", oauthToken = "", ) } } @Test fun login_keepsSavedAuthWhenValidationFailsTransiently() = runTest { val context = applicationContext() Loading data/src/test/java/foundation/e/apps/data/login/playstore/PlayStoreStoredAuthPolicyTest.kt +21 −7 Original line number Diff line number Diff line Loading @@ -96,9 +96,14 @@ class PlayStoreStoredAuthPolicyTest { } @Test fun resolveStoredAuth_clearsPersistedAuthWhenValidationMarksItInvalid() = runTest { fun resolveStoredAuth_clearsPersistedAuthAndAasTokenWhenValidationMarksItInvalid() = runTest { val authData = buildAuthData() val authStore = InMemoryPlayStoreAuthStore(authData) val authStore = InMemoryPlayStoreAuthStore( initialAuthData = authData, initialEmail = "user@example.com", initialOauthToken = "oauth-token", initialAasToken = "stale-aas-token", ) val validator = CountingFakePlayStoreAuthValidator { PlayStoreAuthValidationResult.InvalidAuth("stale auth") } Loading @@ -108,13 +113,19 @@ class PlayStoreStoredAuthPolicyTest { assertThat(resolution).isEqualTo(PlayStoreStoredAuthPolicy.Resolution.RefreshRequired) assertThat(authStore.awaitAuthData()).isNull() assertThat(authStore.awaitAasToken()).isEmpty() assertThat(authStore.awaitEmail()).isEqualTo("user@example.com") assertThat(authStore.awaitOauthToken()).isEqualTo("oauth-token") assertThat(validator.validationCount).isEqualTo(1) } @Test fun resolveStoredAuth_returnsValidationFailureAndKeepsPersistedAuthOnTransientFailure() = runTest { val authData = buildAuthData() val authStore = InMemoryPlayStoreAuthStore(authData) val authStore = InMemoryPlayStoreAuthStore( initialAuthData = authData, initialAasToken = "cached-aas-token", ) val validator = CountingFakePlayStoreAuthValidator { PlayStoreAuthValidationResult.TransientFailure( message = "timeout", Loading @@ -136,6 +147,7 @@ class PlayStoreStoredAuthPolicyTest { ) ) assertThat(authStore.awaitAuthData()).isEqualTo(authData) assertThat(authStore.awaitAasToken()).isEqualTo("cached-aas-token") } private fun buildAuthData(): AuthData { Loading Loading @@ -165,11 +177,14 @@ class PlayStoreStoredAuthPolicyTest { private class InMemoryPlayStoreAuthStore( initialAuthData: AuthData? = null, initialEmail: String = initialAuthData?.email.orEmpty(), initialOauthToken: String = "", initialAasToken: String = "", ) : PlayStoreAuthStore { private val authDataState = MutableStateFlow(initialAuthData) private val emailState = MutableStateFlow(initialAuthData?.email.orEmpty()) private val oauthTokenState = MutableStateFlow("") private val aasTokenState = MutableStateFlow("") private val emailState = MutableStateFlow(initialEmail) private val oauthTokenState = MutableStateFlow(initialOauthToken) private val aasTokenState = MutableStateFlow(initialAasToken) private val playStoreAuthSourceState = MutableStateFlow<PlayStoreAuthSource?>(null) override val authData: StateFlow<AuthData?> = authDataState Loading @@ -191,7 +206,6 @@ class PlayStoreStoredAuthPolicyTest { override suspend fun saveAuthData(authData: AuthData?) { authDataState.value = authData emailState.value = authData?.email.orEmpty() } override suspend fun destroyCredentials() { Loading Loading
data/src/main/java/foundation/e/apps/data/login/playstore/PlayStoreStoredAuthPolicy.kt +11 −1 Original line number Diff line number Diff line Loading @@ -69,7 +69,7 @@ class PlayStoreStoredAuthPolicy { } is PlayStoreAuthValidationResult.InvalidAuth -> { clearPersistedAuth() clearRejectedAuthAndBootstrapState() Resolution.RefreshRequired } Loading Loading @@ -114,6 +114,16 @@ class PlayStoreStoredAuthPolicy { suspend fun clearPersistedAuth() { playStoreAuthStore.saveAuthData(null) clearCachedValidation() } suspend fun clearRejectedAuthAndBootstrapState() { playStoreAuthStore.saveAuthData(null) playStoreAuthStore.saveAasToken("") clearCachedValidation() } private suspend fun clearCachedValidation() { cachedValidationMutex.withLock { cachedValidation = null } Loading
data/src/main/java/foundation/e/apps/data/login/playstore/PlayStoreTokenRefreshHandler.kt +1 −1 Original line number Diff line number Diff line Loading @@ -108,7 +108,7 @@ class PlayStoreTokenRefreshHandler : TokenRefreshHandler { val failure = authResult.toAuthError(AuthStore.PLAY_STORE) if (shouldEvictPersistedAuth(failure)) { playStoreStoredAuthPolicy.clearPersistedAuth() playStoreStoredAuthPolicy.clearRejectedAuthAndBootstrapState() } Timber.e("Play Store token refresh failed: %s", failure.describe()) AuthResult.Failure(failure) Loading
data/src/main/java/foundation/e/apps/data/login/repository/AuthenticatorRepository.kt +1 −14 Original line number Diff line number Diff line Loading @@ -107,10 +107,8 @@ class AuthenticatorRepository @Inject constructor( when (val resolution = playStoreStoredAuthPolicy.resolveStoredAuth()) { PlayStoreStoredAuthPolicy.Resolution.Missing, PlayStoreStoredAuthPolicy.Resolution.RefreshRequired -> { clearInvalidPlayStoreBootstrapState() PlayStoreStoredAuthPolicy.Resolution.RefreshRequired -> rebuildPlayStoreAuth(authenticator) } is PlayStoreStoredAuthPolicy.Resolution.UseStoredAuth -> ResultSupreme.Success<AuthData?>(resolution.authData) Loading @@ -137,17 +135,6 @@ class AuthenticatorRepository @Inject constructor( return persistedAuthResult.toAuthDataResult() } private suspend fun clearInvalidPlayStoreBootstrapState() { if (sessionRepository.awaitLoginIntent().toPlayStoreLoginModeOrNull() == null) { return } // If validated Play auth is definitively invalid, the persisted AAS token can be stale. // Clearing it forces rebuild to prefer a fresh oauth->AAS conversion (or fail fast) // instead of silently recreating auth from the same rejected bootstrap token. playStoreAuthStore.saveAasToken("") } private suspend fun persistAuthData( storeType: AuthStore, authResult: StoreAuthResult, Loading
data/src/test/java/foundation/e/apps/data/login/playstore/PlayStoreAuthenticatorTest.kt +64 −0 Original line number Diff line number Diff line Loading @@ -847,6 +847,70 @@ class PlayStoreAuthenticatorTest { coVerify(exactly = 1) { anonymousLoginManager.login() } } @Test fun login_clearsStaleAasTokenBeforeRebuildingInvalidGoogleAuth() = runTest { val context = applicationContext() val savedAuth = AuthData(email = "user@gmail.com", authToken = "stale-token") val freshAuth = AuthData(email = "user@gmail.com", authToken = "fresh-token") val sessionRepository = FakeSessionRepository( initialLoginIntent = PersistedLoginIntent.PLAY_GOOGLE, currentAuthData = savedAuth, persistedAuthData = savedAuth, email = "user@gmail.com", oauthToken = "oauth-token", aasToken = "stale-aas-token", playStoreAuthSource = PlayStoreAuthSource.GOOGLE, ) val googleLoginManager = mockk<GoogleLoginManager>() val aasTokenConverter = mock<OauthToAasTokenConverter>() val userProfileFetcher = mockk<UserProfileFetcher>() whenever(aasTokenConverter.convert(mockitoAny(), mockitoAny())) .thenReturn(ResultSupreme.Success("fresh-aas-token")) coEvery { googleLoginManager.login( email = "user@gmail.com", aasToken = "fresh-aas-token", oauthToken = "", ) } returns freshAuth coEvery { userProfileFetcher.fetch(any()) } returns null val authenticator = playStoreAuthenticator( context = context, sessionRepository = sessionRepository, playStoreAuthStore = sessionRepository, sourceSelectionRepository = sourceSelectionRepository(), authDataCache = AuthDataCache(sessionRepository, json), googleLoginManager = googleLoginManager, microgLoginManager = mockk(), anonymousLoginManager = mockk(), aasTokenConverter = aasTokenConverter, userProfileFetcher = userProfileFetcher, successfulLoginNotifier = NoOpSuccessfulLoginNotifier, networkRetryPolicy = NetworkRetryPolicy(), playStoreAuthValidator = FakePlayStoreAuthValidator { PlayStoreAuthValidationResult.InvalidAuth("stale auth") }, ) val result = authenticator.login() assertThat(result.requirePlayStoreAuthData().authToken).isEqualTo("fresh-token") assertThat(result.authDataToPersist?.authToken).isEqualTo("fresh-token") assertThat(result.aasTokenToPersist).isEqualTo("fresh-aas-token") assertThat(sessionRepository.awaitAasToken()).isEmpty() mockitoVerify(aasTokenConverter).convert(mockitoAny(), mockitoAny()) coVerify(exactly = 0) { googleLoginManager.login() } coVerify(exactly = 1) { googleLoginManager.login( email = "user@gmail.com", aasToken = "fresh-aas-token", oauthToken = "", ) } } @Test fun login_keepsSavedAuthWhenValidationFailsTransiently() = runTest { val context = applicationContext() Loading
data/src/test/java/foundation/e/apps/data/login/playstore/PlayStoreStoredAuthPolicyTest.kt +21 −7 Original line number Diff line number Diff line Loading @@ -96,9 +96,14 @@ class PlayStoreStoredAuthPolicyTest { } @Test fun resolveStoredAuth_clearsPersistedAuthWhenValidationMarksItInvalid() = runTest { fun resolveStoredAuth_clearsPersistedAuthAndAasTokenWhenValidationMarksItInvalid() = runTest { val authData = buildAuthData() val authStore = InMemoryPlayStoreAuthStore(authData) val authStore = InMemoryPlayStoreAuthStore( initialAuthData = authData, initialEmail = "user@example.com", initialOauthToken = "oauth-token", initialAasToken = "stale-aas-token", ) val validator = CountingFakePlayStoreAuthValidator { PlayStoreAuthValidationResult.InvalidAuth("stale auth") } Loading @@ -108,13 +113,19 @@ class PlayStoreStoredAuthPolicyTest { assertThat(resolution).isEqualTo(PlayStoreStoredAuthPolicy.Resolution.RefreshRequired) assertThat(authStore.awaitAuthData()).isNull() assertThat(authStore.awaitAasToken()).isEmpty() assertThat(authStore.awaitEmail()).isEqualTo("user@example.com") assertThat(authStore.awaitOauthToken()).isEqualTo("oauth-token") assertThat(validator.validationCount).isEqualTo(1) } @Test fun resolveStoredAuth_returnsValidationFailureAndKeepsPersistedAuthOnTransientFailure() = runTest { val authData = buildAuthData() val authStore = InMemoryPlayStoreAuthStore(authData) val authStore = InMemoryPlayStoreAuthStore( initialAuthData = authData, initialAasToken = "cached-aas-token", ) val validator = CountingFakePlayStoreAuthValidator { PlayStoreAuthValidationResult.TransientFailure( message = "timeout", Loading @@ -136,6 +147,7 @@ class PlayStoreStoredAuthPolicyTest { ) ) assertThat(authStore.awaitAuthData()).isEqualTo(authData) assertThat(authStore.awaitAasToken()).isEqualTo("cached-aas-token") } private fun buildAuthData(): AuthData { Loading Loading @@ -165,11 +177,14 @@ class PlayStoreStoredAuthPolicyTest { private class InMemoryPlayStoreAuthStore( initialAuthData: AuthData? = null, initialEmail: String = initialAuthData?.email.orEmpty(), initialOauthToken: String = "", initialAasToken: String = "", ) : PlayStoreAuthStore { private val authDataState = MutableStateFlow(initialAuthData) private val emailState = MutableStateFlow(initialAuthData?.email.orEmpty()) private val oauthTokenState = MutableStateFlow("") private val aasTokenState = MutableStateFlow("") private val emailState = MutableStateFlow(initialEmail) private val oauthTokenState = MutableStateFlow(initialOauthToken) private val aasTokenState = MutableStateFlow(initialAasToken) private val playStoreAuthSourceState = MutableStateFlow<PlayStoreAuthSource?>(null) override val authData: StateFlow<AuthData?> = authDataState Loading @@ -191,7 +206,6 @@ class PlayStoreStoredAuthPolicyTest { override suspend fun saveAuthData(authData: AuthData?) { authDataState.value = authData emailState.value = authData?.email.orEmpty() } override suspend fun destroyCredentials() { Loading