Harden Google sign-in when backend auth fails
Sign out of Firebase and clear API and local profile if the GraphQL exchange or token save fails after Firebase Google sign-in, so the user is not left in a half-authenticated state. Rename the exchange helper to exchangeFirebaseIdTokenWithBackend for clarity. Login: ignore duplicate Google taps while loading and disable the Google button during the flow. Credential Manager: treat GetCredentialInterruptedException as cancellation and rethrow CancellationException so coroutines cancel correctly. Tests: assert signOut is invoked when the backend exchange fails. Made-with: Cursor
This commit is contained in:
@@ -15,6 +15,7 @@ import androidx.credentials.exceptions.NoCredentialException
|
|||||||
import com.google.android.libraries.identity.googleid.GetGoogleIdOption
|
import com.google.android.libraries.identity.googleid.GetGoogleIdOption
|
||||||
import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential
|
import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential
|
||||||
import com.google.android.libraries.identity.googleid.GoogleIdTokenParsingException
|
import com.google.android.libraries.identity.googleid.GoogleIdTokenParsingException
|
||||||
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
import org.db3.airmq.R
|
import org.db3.airmq.R
|
||||||
|
|
||||||
sealed interface GoogleSignInResult {
|
sealed interface GoogleSignInResult {
|
||||||
@@ -51,12 +52,17 @@ suspend fun launchGoogleSignIn(context: Context): GoogleSignInResult {
|
|||||||
} catch (error: GetCredentialCancellationException) {
|
} catch (error: GetCredentialCancellationException) {
|
||||||
logGoogleSignInError(error)
|
logGoogleSignInError(error)
|
||||||
GoogleSignInResult.Cancelled
|
GoogleSignInResult.Cancelled
|
||||||
|
} catch (error: GetCredentialInterruptedException) {
|
||||||
|
logGoogleSignInError(error)
|
||||||
|
GoogleSignInResult.Cancelled
|
||||||
} catch (error: GetCredentialException) {
|
} catch (error: GetCredentialException) {
|
||||||
logGoogleSignInError(error)
|
logGoogleSignInError(error)
|
||||||
GoogleSignInResult.Error(context.getString(R.string.toast_oauth_failed))
|
GoogleSignInResult.Error(context.getString(R.string.toast_oauth_failed))
|
||||||
} catch (error: GoogleIdTokenParsingException) {
|
} catch (error: GoogleIdTokenParsingException) {
|
||||||
Log.e(GOOGLE_SIGN_IN_TAG, "Google ID token parsing failed", error)
|
Log.e(GOOGLE_SIGN_IN_TAG, "Google ID token parsing failed", error)
|
||||||
GoogleSignInResult.Error(context.getString(R.string.toast_oauth_failed))
|
GoogleSignInResult.Error(context.getString(R.string.toast_oauth_failed))
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
} catch (error: Throwable) {
|
} catch (error: Throwable) {
|
||||||
GoogleSignInResult.Error(error.message ?: context.getString(R.string.toast_oauth_failed))
|
GoogleSignInResult.Error(error.message ?: context.getString(R.string.toast_oauth_failed))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -213,6 +213,7 @@ private fun LoginScreenContent(
|
|||||||
containerColor = Color.White,
|
containerColor = Color.White,
|
||||||
contentColor = Color(0xFF202124),
|
contentColor = Color(0xFF202124),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
enabled = !uiState.isLoading,
|
||||||
onClick = { onEvent(Event.GoogleClicked) }
|
onClick = { onEvent(Event.GoogleClicked) }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ class LoginViewModel @Inject constructor(
|
|||||||
fun onEvent(event: Event) {
|
fun onEvent(event: Event) {
|
||||||
when (event) {
|
when (event) {
|
||||||
Event.GoogleClicked -> {
|
Event.GoogleClicked -> {
|
||||||
|
if (_uiState.value.isLoading) return
|
||||||
_uiState.value = _uiState.value.copy(isLoading = true)
|
_uiState.value = _uiState.value.copy(isLoading = true)
|
||||||
_actions.tryEmit(Action.LaunchGoogleSignIn)
|
_actions.tryEmit(Action.LaunchGoogleSignIn)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,9 +84,25 @@ class FirebaseAuthService @Inject constructor(
|
|||||||
private suspend fun signInWithGoogle(token: String): User {
|
private suspend fun signInWithGoogle(token: String): User {
|
||||||
localEmailAuthStore.clearProfile().getOrThrow()
|
localEmailAuthStore.clearProfile().getOrThrow()
|
||||||
val firebaseSessionUser = firebaseSessionManager.signInWithGoogle(token)
|
val firebaseSessionUser = firebaseSessionManager.signInWithGoogle(token)
|
||||||
val apiToken = exchangeFirebaseTokenWithBackend(firebaseSessionUser.firebaseAccessToken)
|
try {
|
||||||
apiTokenStore.saveToken(apiToken).getOrThrow()
|
val apiToken = exchangeFirebaseIdTokenWithBackend(firebaseSessionUser.firebaseAccessToken)
|
||||||
return firebaseSessionUser.user
|
apiTokenStore.saveToken(apiToken).getOrThrow()
|
||||||
|
return firebaseSessionUser.user
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
rollbackSessionAfterFailedGoogleBackendAuth()
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Firebase may already be signed in when the backend exchange fails; clear everything so the user stays logged out. */
|
||||||
|
private suspend fun rollbackSessionAfterFailedGoogleBackendAuth() {
|
||||||
|
try {
|
||||||
|
firebaseSessionManager.signOut()
|
||||||
|
apiTokenStore.clearToken().getOrThrow()
|
||||||
|
localEmailAuthStore.clearProfile().getOrThrow()
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
// Best-effort; caller still receives the original sign-in failure.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun commitEmailAuthSession(
|
private suspend fun commitEmailAuthSession(
|
||||||
@@ -114,9 +130,9 @@ class FirebaseAuthService @Inject constructor(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun exchangeFirebaseTokenWithBackend(firebaseAccessToken: String): String {
|
private suspend fun exchangeFirebaseIdTokenWithBackend(firebaseIdToken: String): String {
|
||||||
val response = apolloClient
|
val response = apolloClient
|
||||||
.mutation(AuthGoogleNewMutation(accessToken = firebaseAccessToken))
|
.mutation(AuthGoogleNewMutation(accessToken = firebaseIdToken))
|
||||||
.execute()
|
.execute()
|
||||||
response.exception?.let { throw it }
|
response.exception?.let { throw it }
|
||||||
response.errors?.firstOrNull()?.let { gqlError ->
|
response.errors?.firstOrNull()?.let { gqlError ->
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ class FirebaseAuthServiceTest {
|
|||||||
assertTrue(result.isFailure)
|
assertTrue(result.isFailure)
|
||||||
assertEquals("backend failed", result.exceptionOrNull()?.message)
|
assertEquals("backend failed", result.exceptionOrNull()?.message)
|
||||||
assertEquals(null, tokenStore.storedToken)
|
assertEquals(null, tokenStore.storedToken)
|
||||||
|
assertEquals(1, sessionManager.signOutCallCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -131,6 +132,9 @@ private class FakeFirebaseSessionManager(
|
|||||||
private val signInError: Throwable? = null,
|
private val signInError: Throwable? = null,
|
||||||
private val isSignedIn: Boolean = true
|
private val isSignedIn: Boolean = true
|
||||||
) : FirebaseSessionManager {
|
) : FirebaseSessionManager {
|
||||||
|
var signOutCallCount = 0
|
||||||
|
private set
|
||||||
|
|
||||||
override suspend fun getUser(): User? = signInResult?.user
|
override suspend fun getUser(): User? = signInResult?.user
|
||||||
|
|
||||||
override suspend fun isSignedIn(): Boolean = isSignedIn
|
override suspend fun isSignedIn(): Boolean = isSignedIn
|
||||||
@@ -140,7 +144,9 @@ private class FakeFirebaseSessionManager(
|
|||||||
return signInResult ?: error("No signInResult configured")
|
return signInResult ?: error("No signInResult configured")
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun signOut() = Unit
|
override suspend fun signOut() {
|
||||||
|
signOutCallCount++
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private class FakeApiTokenStore(
|
private class FakeApiTokenStore(
|
||||||
|
|||||||
Reference in New Issue
Block a user