Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 38 additions & 13 deletions auth/src/main/java/com/firebase/ui/auth/FirebaseAuthUI.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Comment on lines 237 to 243

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Catching Throwable is generally discouraged as it swallows critical JVM errors such as OutOfMemoryError, StackOverflowError, or ThreadDeath. Since the Facebook SDK is a compileOnly dependency, missing classes will throw a NoClassDefFoundError (which extends LinkageError). Catching LinkageError and Exception separately allows you to safely handle both the missing SDK scenario and any runtime exceptions without swallowing critical system errors.

    try {
        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)
    }

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, narrowed to LinkageError plus Exception. That covers both compileOnly failure modes (absent SDK, version mismatch) without swallowing OutOfMemoryError and friends.

Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
}
Expand Down
85 changes: 82 additions & 3 deletions auth/src/test/java/com/firebase/ui/auth/FirebaseAuthUITest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<Context>()

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<Context>()

instance.signOut(context)

assertThat(googleSignOutCalled).isTrue()
}

// =============================================================================================
// Delete Account Tests
// =============================================================================================
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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"
Expand Down