diff --git a/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthUI.kt index 1978eabde..e321ffcf2 100644 --- a/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthUI.kt @@ -20,10 +20,12 @@ import androidx.annotation.MainThread import androidx.annotation.RestrictTo import com.firebase.ui.auth.configuration.AuthUIConfiguration import com.firebase.ui.auth.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.configuration.auth_provider.Provider import com.google.firebase.auth.FirebaseAuthRecentLoginRequiredException import com.firebase.ui.auth.configuration.auth_provider.signOutFromFacebook import com.firebase.ui.auth.configuration.auth_provider.signOutFromGoogle import com.firebase.ui.auth.ui.screens.reauth.toReauthConfiguration +import com.firebase.ui.auth.util.ProviderAvailability import com.google.firebase.Firebase import com.google.firebase.FirebaseApp import com.google.firebase.auth.AuthResult @@ -400,6 +402,11 @@ class FirebaseAuthUI private constructor( * to reflect the change. The operation is performed asynchronously and will emit * appropriate states during the process. * + * It also clears the session held by any social provider linked to the account, so the next + * sign-in starts clean: Google's saved credential state is cleared, meaning the account picker + * is shown again instead of silently re-selecting the previous account, and any Facebook + * session is logged out. Failures there are logged and do not fail the sign-out. + * * **Example:** * ```kotlin * val authUI = FirebaseAuthUI.getInstance() @@ -431,21 +438,39 @@ class FirebaseAuthUI private constructor( // Update state to loading updateAuthState(AuthState.Loading(context.getString(R.string.fui_loading_signing_out))) + // Capture the linked providers before signing out: `auth.signOut()` clears + // `currentUser`, and `FirebaseUser.providerId` is always "firebase" — the + // per-provider ids live in `providerData`. + val linkedProviderIds = auth.currentUser?.providerData + ?.map { it.providerId } + .orEmpty() + // Sign out from Firebase Auth auth.signOut() - .also { - signOutFromGoogle( - auth = auth, - context = context, - credentialManagerProvider = testCredentialManagerProvider - ?: AuthProvider.Google.DefaultCredentialManagerProvider(), - ) - signOutFromFacebook( - auth = auth, - loginManagerProvider = testLoginManagerProvider - ?: AuthProvider.Facebook.DefaultLoginManagerProvider(), - ) - } + + // Clear the provider-side session for each provider linked to the account. This is + // the linked set rather than the provider used for this session, so it can clear a + // little more than strictly necessary — cheap either way, and it never leaves a + // provider session behind. + if (Provider.GOOGLE.id in linkedProviderIds) { + signOutFromGoogle( + context = context, + credentialManagerProvider = testCredentialManagerProvider + ?: AuthProvider.Google.DefaultCredentialManagerProvider(), + ) + } + // Facebook is a `compileOnly` dependency, so an app that doesn't offer Facebook + // sign-in has no Facebook SDK at runtime — and `providerData` can still carry + // `facebook.com` for an account linked on another platform. Touching the Facebook + // extensions at all links the SDK, so the classpath probe, not the account, decides. + if (Provider.FACEBOOK.id in linkedProviderIds && + ProviderAvailability.IS_FACEBOOK_AVAILABLE + ) { + signOutFromFacebook( + loginManagerProvider = testLoginManagerProvider + ?: AuthProvider.Facebook.DefaultLoginManagerProvider(), + ) + } // Update state to idle (user signed out) updateAuthState(AuthState.Idle) diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/FacebookAuthProvider+FirebaseAuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/FacebookAuthProvider+FirebaseAuthUI.kt index 0a8a7212b..c821682d5 100644 --- a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/FacebookAuthProvider+FirebaseAuthUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/FacebookAuthProvider+FirebaseAuthUI.kt @@ -14,7 +14,6 @@ package com.firebase.ui.auth.configuration.auth_provider -import com.google.firebase.auth.FirebaseAuth import android.content.Context import android.util.Log import androidx.activity.compose.rememberLauncherForActivityResult @@ -224,22 +223,21 @@ internal suspend fun AuthFlowScope.signInWithFacebook( } /** - * Signs out the current user from Facebook. + * Logs the user out of their Facebook session via Facebook's LoginManager. * - * Invokes Facebook's LoginManager to log out the user from their Facebook session. - * This method silently catches and ignores any exceptions that may occur during the - * logout process to ensure the sign-out flow continues even if Facebook logout fails. + * Best-effort: failures are logged and swallowed so sign-out continues. [LinkageError] is caught + * alongside [Exception] because the Facebook SDK is `compileOnly`, so an absent or mismatched SDK + * surfaces as an error rather than an exception. * - * This is typically called as part of the overall sign-out flow when a user signs out - * from Firebase Authentication. + * The caller decides whether Facebook applies; reaching this function at all links the SDK. */ internal fun signOutFromFacebook( - auth: FirebaseAuth, loginManagerProvider: AuthProvider.Facebook.LoginManagerProvider = AuthProvider.Facebook.DefaultLoginManagerProvider(), ) { try { - if (Provider.fromId(auth.currentUser?.providerId) != Provider.FACEBOOK) return loginManagerProvider.logOut() + } catch (e: LinkageError) { + Log.e("FacebookAuthProvider", "Facebook SDK not available or mismatched", e) } catch (e: Exception) { Log.e("FacebookAuthProvider", "Error during Facebook sign out", e) } diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/GoogleAuthProvider+FirebaseAuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/GoogleAuthProvider+FirebaseAuthUI.kt index 96ffbb79f..49a8b5f9f 100644 --- a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/GoogleAuthProvider+FirebaseAuthUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/GoogleAuthProvider+FirebaseAuthUI.kt @@ -1,6 +1,5 @@ package com.firebase.ui.auth.configuration.auth_provider -import com.google.firebase.auth.FirebaseAuth import android.content.Context import android.util.Log import androidx.compose.runtime.Composable @@ -214,19 +213,25 @@ internal suspend fun AuthFlowScope.signInWithGoogle( * **Note:** This does not sign out from Firebase Auth itself. Call [com.firebase.ui.auth.FirebaseAuthUI.signOut] * separately if you need to sign out from Firebase. * + * Callers are responsible for deciding whether Google is involved at all — this function does not + * check the signed-in user, and [com.firebase.ui.auth.FirebaseAuthUI.signOut] has already cleared + * it by the time it calls here. + * * @param context Android context for Credential Manager */ internal suspend fun signOutFromGoogle( - auth: FirebaseAuth, context: Context, credentialManagerProvider: AuthProvider.Google.CredentialManagerProvider = AuthProvider.Google.DefaultCredentialManagerProvider(), ) { try { - if (Provider.fromId(auth.currentUser?.providerId) != Provider.GOOGLE) return credentialManagerProvider.clearCredentialState( context = context, credentialManager = CredentialManager.create(context) ) + } catch (e: CancellationException) { + // Must not be swallowed: this suspends, so cancellation has to reach the caller for + // FirebaseAuthUI.signOut to report it rather than emitting Idle for a half-done sign-out. + throw e } catch (e: Exception) { Log.e("GoogleAuthProvider", "Error during Google sign out", e) } diff --git a/auth/src/test/java/com/firebase/ui/auth/FirebaseAuthUITest.kt b/auth/src/test/java/com/firebase/ui/auth/FirebaseAuthUITest.kt index 9bbbb1608..105a0d2a8 100644 --- a/auth/src/test/java/com/firebase/ui/auth/FirebaseAuthUITest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/FirebaseAuthUITest.kt @@ -41,6 +41,7 @@ import org.junit.Test import org.junit.runner.RunWith import org.mockito.Mock import org.mockito.Mockito.anyString +import org.mockito.Mockito.doAnswer import org.mockito.Mockito.doNothing import org.mockito.Mockito.doThrow import org.mockito.Mockito.mock @@ -436,7 +437,6 @@ class FirebaseAuthUITest { val mockUser = mock(FirebaseUser::class.java) val mockUserInfo = mock(UserInfo::class.java) `when`(mockUserInfo.providerId).thenReturn("google.com") - `when`(mockUser.providerId).thenReturn("google.com") `when`(mockUser.providerData).thenReturn(listOf(mockUserInfo)) // Setup mock auth @@ -484,7 +484,6 @@ class FirebaseAuthUITest { val mockUser = mock(FirebaseUser::class.java) val mockUserInfo = mock(UserInfo::class.java) `when`(mockUserInfo.providerId).thenReturn("facebook.com") - `when`(mockUser.providerId).thenReturn("facebook.com") `when`(mockUser.providerData).thenReturn(listOf(mockUserInfo)) // Setup mock auth @@ -524,7 +523,6 @@ class FirebaseAuthUITest { val mockUser = mock(FirebaseUser::class.java) val mockUserInfo = mock(UserInfo::class.java) `when`(mockUserInfo.providerId).thenReturn("password") - `when`(mockUser.providerId).thenReturn("password") `when`(mockUser.providerData).thenReturn(listOf(mockUserInfo)) // Setup mock auth @@ -580,6 +578,87 @@ class FirebaseAuthUITest { verify(mockAuth).signOut() } + @Test + fun `signOut() calls Facebook sign out even though FirebaseAuth clears the user first`() = + runTest { + // Setup mock user with Facebook provider + val mockUser = mock(FirebaseUser::class.java) + val mockUserInfo = mock(UserInfo::class.java) + `when`(mockUserInfo.providerId).thenReturn("facebook.com") + `when`(mockUser.providerData).thenReturn(listOf(mockUserInfo)) + + // Setup mock auth that clears currentUser on signOut(), like the real FirebaseAuth + val mockAuth = mock(FirebaseAuth::class.java) + var signedOut = false + `when`(mockAuth.currentUser).thenAnswer { if (signedOut) null else mockUser } + doAnswer { signedOut = true; null }.`when`(mockAuth).signOut() + + var facebookSignOutCalled = false + val mockLoginManagerProvider = object : AuthProvider.Facebook.LoginManagerProvider { + override fun getCredential(token: String): com.google.firebase.auth.AuthCredential { + throw UnsupportedOperationException("Not used in this test") + } + + override fun logOut() { + facebookSignOutCalled = true + } + } + + val instance = FirebaseAuthUI.create(defaultApp, mockAuth) + instance.testLoginManagerProvider = mockLoginManagerProvider + val context = ApplicationProvider.getApplicationContext() + + instance.signOut(context) + + assertThat(facebookSignOutCalled).isTrue() + assertThat(mockAuth.currentUser).isNull() + } + + @Test + fun `signOut() reads linked providers from providerData not FirebaseUser providerId`() = + runTest { + // The real FirebaseUser.providerId is always "firebase"; the per-provider ids live in + // providerData. + val mockUser = mock(FirebaseUser::class.java) + val mockUserInfo = mock(UserInfo::class.java) + `when`(mockUserInfo.providerId).thenReturn("google.com") + `when`(mockUser.providerId).thenReturn("firebase") + `when`(mockUser.providerData).thenReturn(listOf(mockUserInfo)) + + val mockAuth = mock(FirebaseAuth::class.java) + `when`(mockAuth.currentUser).thenReturn(mockUser) + doNothing().`when`(mockAuth).signOut() + + var googleSignOutCalled = false + val mockCredentialManagerProvider = + object : AuthProvider.Google.CredentialManagerProvider { + override suspend fun getGoogleCredential( + context: Context, + credentialManager: androidx.credentials.CredentialManager, + serverClientId: String, + filterByAuthorizedAccounts: Boolean, + autoSelectEnabled: Boolean, + ): AuthProvider.Google.GoogleSignInResult { + throw UnsupportedOperationException("Not used in this test") + } + + override suspend fun clearCredentialState( + context: Context, + credentialManager: androidx.credentials.CredentialManager, + ) { + googleSignOutCalled = true + } + } + + val instance = FirebaseAuthUI.create(defaultApp, mockAuth) + instance.testCredentialManagerProvider = mockCredentialManagerProvider + val context = ApplicationProvider.getApplicationContext() + + instance.signOut(context) + + assertThat(googleSignOutCalled).isTrue() + } + // ============================================================================================= // Delete Account Tests // ============================================================================================= diff --git a/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/EmailAuthScreenTest.kt b/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/EmailAuthScreenTest.kt index 838bac0f7..f74ce4ad8 100644 --- a/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/EmailAuthScreenTest.kt +++ b/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/EmailAuthScreenTest.kt @@ -6,6 +6,7 @@ import androidx.activity.ComponentActivity import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Button import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider @@ -65,6 +66,8 @@ import org.robolectric.Shadows.shadowOf import org.robolectric.annotation.Config import androidx.credentials.PasswordCredential as AndroidPasswordCredential +private const val SIGN_OUT_BUTTON_LABEL = "SIGN OUT" + @Config(sdk = [34]) @RunWith(RobolectricTestRunner::class) class EmailAuthScreenTest { @@ -336,6 +339,100 @@ class EmailAuthScreenTest { assertThat(authUI.auth.currentUser!!.email).isEqualTo(email) } + @Test + fun `sign out from the authenticated screen clears the Firebase session`() { + val email = "signout-test-${System.currentTimeMillis()}@example.com" + val password = "test123" + + val user = ensureFreshUser(authUI, email, password) + requireNotNull(user) { "Failed to create user" } + + try { + verifyEmailInEmulator(authUI, emulatorApi, user) + } catch (e: Exception) { + Assume.assumeTrue( + "Skipping test: Firebase Auth Emulator OOB codes endpoint not available. " + + "Ensure emulator is running on localhost:9099. Error: ${e.message}", + false + ) + } + + authUI.auth.signOut() + shadowOf(Looper.getMainLooper()).idle() + + val configuration = authUIConfiguration { + context = applicationContext + providers { + provider( + AuthProvider.Email( + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList() + ) + ) + } + isCredentialManagerEnabled = false + } + + var currentAuthState: AuthState = AuthState.Idle + + composeAndroidTestRule.setContent { + CompositionLocalProvider( + LocalAuthUIStringProvider provides DefaultAuthUIStringProvider(applicationContext) + ) { + FirebaseAuthScreen( + configuration = configuration, + authUI = authUI, + onSignInSuccess = { }, + onSignInFailure = { }, + onSignInCancelled = { }, + // Drive FirebaseAuthUI.signOut() through the production callback rather than + // FirebaseAuth.signOut(): this module has no Facebook SDK on its classpath, so + // it also pins that signing out an email-only user touches no Facebook types. + authenticatedContent = { _, uiContext -> + Button(onClick = uiContext.onSignOut) { + Text(SIGN_OUT_BUTTON_LABEL) + } + } + ) + } + val authState by authUI.authStateFlow().collectAsState(AuthState.Idle) + currentAuthState = authState + } + + assertDirectEmailStart() + + composeAndroidTestRule.onNodeWithText(stringProvider.emailHint) + .performScrollTo() + .performTextInput(email) + composeAndroidTestRule.onNodeWithText(stringProvider.passwordHint) + .performScrollTo() + .performTextInput(password) + composeAndroidTestRule.onNodeWithText(stringProvider.signInDefault.uppercase()) + .performScrollTo() + .performClick() + + shadowOf(Looper.getMainLooper()).idle() + composeAndroidTestRule.waitUntil(timeoutMillis = AUTH_STATE_WAIT_TIMEOUT_MS) { + shadowOf(Looper.getMainLooper()).idle() + currentAuthState is AuthState.Success + } + shadowOf(Looper.getMainLooper()).idle() + + composeAndroidTestRule.onNodeWithText(SIGN_OUT_BUTTON_LABEL) + .assertIsDisplayed() + .performClick() + + shadowOf(Looper.getMainLooper()).idle() + composeAndroidTestRule.waitUntil(timeoutMillis = AUTH_STATE_WAIT_TIMEOUT_MS) { + shadowOf(Looper.getMainLooper()).idle() + currentAuthState is AuthState.Idle + } + shadowOf(Looper.getMainLooper()).idle() + + assertThat(currentAuthState).isInstanceOf(AuthState.Idle::class.java) + assertThat(authUI.auth.currentUser).isNull() + } + @Test fun `new email sign-up emits RequiresEmailVerification auth state`() { val name = "Test User"