diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7403aaf50..77b9c786b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -55,6 +55,12 @@ android:label="Custom Slots & Theming Demo" android:exported="false" android:theme="@style/Theme.FirebaseUIAndroid" /> + + diff --git a/app/src/main/java/com/firebaseui/android/demo/CredentialLinkingDemoActivity.kt b/app/src/main/java/com/firebaseui/android/demo/CredentialLinkingDemoActivity.kt new file mode 100644 index 000000000..c901084cc --- /dev/null +++ b/app/src/main/java/com/firebaseui/android/demo/CredentialLinkingDemoActivity.kt @@ -0,0 +1,186 @@ +package com.firebaseui.android.demo + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.firebase.ui.auth.AuthException +import com.firebase.ui.auth.AuthState +import com.firebase.ui.auth.FirebaseAuthUI +import com.firebase.ui.auth.configuration.authUIConfiguration +import com.firebase.ui.auth.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.ui.screens.AuthRoute +import com.firebase.ui.auth.ui.screens.AuthSuccessUiContext +import com.firebase.ui.auth.ui.screens.FirebaseAuthScreen + +class CredentialLinkingDemoActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + + val authUI = FirebaseAuthUI.getInstance() + + val configuration = authUIConfiguration { + context = applicationContext + isCredentialLinkingEnabled = true + providers { + provider( + AuthProvider.Email( + isNewAccountsAllowed = true, + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList(), + ) + ) + provider( + AuthProvider.Google( + scopes = listOf("email"), + serverClientId = "406099696497-a12gakvts4epfk5pkio7dphc1anjiggc.apps.googleusercontent.com", + ) + ) + provider( + AuthProvider.Phone( + defaultNumber = null, + defaultCountryCode = null, + allowedCountries = emptyList(), + timeout = 120L, + ) + ) + } + } + + setContent { + MaterialTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + FirebaseAuthScreen( + configuration = configuration, + authUI = authUI, + onSignInSuccess = {}, + onSignInFailure = { _: AuthException -> }, + onSignInCancelled = {}, + authenticatedContent = { state, uiContext -> + CredentialLinkingAuthenticatedContent(state, uiContext) + } + ) + } + } + } + } +} + +@Composable +private fun CredentialLinkingAuthenticatedContent( + state: AuthState, + uiContext: AuthSuccessUiContext, +) { + when (state) { + is AuthState.Success -> { + val user = state.user + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = "Signed in", + style = MaterialTheme.typography.headlineSmall, + ) + Spacer(modifier = Modifier.height(16.dp)) + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text("UID: ${user.uid}", style = MaterialTheme.typography.bodySmall) + Text("Email: ${user.email ?: "—"}") + Text("Phone: ${user.phoneNumber ?: "—"}") + Text( + "Providers: ${user.providerData.map { it.providerId }}", + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Start + ) + } + } + Spacer(modifier = Modifier.height(24.dp)) + Button( + modifier = Modifier.fillMaxWidth(), + onClick = { uiContext.onNavigate(AuthRoute.MethodPicker) } + ) { + Text("Add sign-in method") + } + Spacer(modifier = Modifier.height(8.dp)) + OutlinedButton( + modifier = Modifier.fillMaxWidth(), + onClick = uiContext.onSignOut + ) { + Text("Sign out") + } + } + } + + is AuthState.RequiresEmailVerification -> { + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = "Verify your email", + style = MaterialTheme.typography.headlineSmall, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "A verification link was sent to ${state.email}. Once verified, tap the button below.", + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(24.dp)) + Button( + modifier = Modifier.fillMaxWidth(), + onClick = uiContext.onReloadUser + ) { + Text("I've verified my email") + } + Spacer(modifier = Modifier.height(8.dp)) + OutlinedButton( + modifier = Modifier.fillMaxWidth(), + onClick = uiContext.onSignOut + ) { + Text("Sign out") + } + } + } + + else -> {} + } +} diff --git a/app/src/main/java/com/firebaseui/android/demo/MainActivity.kt b/app/src/main/java/com/firebaseui/android/demo/MainActivity.kt index 300014174..b1fbd486e 100644 --- a/app/src/main/java/com/firebaseui/android/demo/MainActivity.kt +++ b/app/src/main/java/com/firebaseui/android/demo/MainActivity.kt @@ -94,6 +94,9 @@ class MainActivity : ComponentActivity() { onCustomSlotsClick = { startActivity(Intent(this, CustomSlotsThemingDemoActivity::class.java)) }, + onCredentialLinkingClick = { + startActivity(Intent(this, CredentialLinkingDemoActivity::class.java)) + }, isEmulatorMode = USE_AUTH_EMULATOR ) } @@ -107,6 +110,7 @@ fun ChooserScreen( onHighLevelApiClick: () -> Unit, onLowLevelApiClick: () -> Unit, onCustomSlotsClick: () -> Unit, + onCredentialLinkingClick: () -> Unit = {}, isEmulatorMode: Boolean = false ) { val scrollState = rememberScrollState() @@ -272,6 +276,32 @@ fun ChooserScreen( } } + // Credential Linking Card + Card( + modifier = Modifier.fillMaxWidth(), + onClick = onCredentialLinkingClick + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = "🔗 Credential Linking", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.primary + ) + Text( + text = "isCredentialLinkingEnabled", + style = MaterialTheme.typography.titleMedium + ) + Text( + text = "Sign in with one provider, then add another to the same account without losing your UID.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + Spacer(modifier = Modifier.height(16.dp)) // Info card diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/AuthUIConfiguration.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/AuthUIConfiguration.kt index 3fa7f394b..bff7c72f9 100644 --- a/auth/src/main/java/com/firebase/ui/auth/configuration/AuthUIConfiguration.kt +++ b/auth/src/main/java/com/firebase/ui/auth/configuration/AuthUIConfiguration.kt @@ -42,6 +42,7 @@ class AuthUIConfigurationBuilder { var isCredentialManagerEnabled: Boolean = true var isMfaEnabled: Boolean = true var isAnonymousUpgradeEnabled: Boolean = false + var isCredentialLinkingEnabled: Boolean = false var tosUrl: String? = null var privacyPolicyUrl: String? = null var logo: AuthUIAsset? = null @@ -107,6 +108,7 @@ class AuthUIConfigurationBuilder { isCredentialManagerEnabled = isCredentialManagerEnabled, isMfaEnabled = isMfaEnabled, isAnonymousUpgradeEnabled = isAnonymousUpgradeEnabled, + isCredentialLinkingEnabled = isCredentialLinkingEnabled, tosUrl = tosUrl, privacyPolicyUrl = privacyPolicyUrl, logo = logo, @@ -164,6 +166,13 @@ class AuthUIConfiguration( */ val isAnonymousUpgradeEnabled: Boolean = false, + /** + * Allows linking a new credential to an already authenticated (non-anonymous) user. + * When enabled, signing in via FirebaseUI while a user is already signed in will link + * the new credential to the existing account instead of creating a new one. + */ + val isCredentialLinkingEnabled: Boolean = false, + /** * The URL for the terms of service. */ diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/AuthProvider.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/AuthProvider.kt index 5cf392a8c..d32ad3f08 100644 --- a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/AuthProvider.kt +++ b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/AuthProvider.kt @@ -990,6 +990,13 @@ abstract class AuthProvider(open val providerId: String, open val providerName: && currentUser.isAnonymous } + internal fun canLinkCredential(config: AuthUIConfiguration, auth: FirebaseAuth): Boolean { + val currentUser = auth.currentUser + return config.isCredentialLinkingEnabled + && currentUser != null + && !currentUser.isAnonymous + } + /** * Merges profile information (display name and photo URL) with the current user's profile. * diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt index 8d4bae6d1..20e0cce96 100644 --- a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt @@ -22,6 +22,7 @@ import com.firebase.ui.auth.AuthException import com.firebase.ui.auth.AuthState import com.firebase.ui.auth.FirebaseAuthUI import com.firebase.ui.auth.configuration.AuthUIConfiguration +import com.firebase.ui.auth.configuration.auth_provider.AuthProvider.Companion.canLinkCredential import com.firebase.ui.auth.configuration.auth_provider.AuthProvider.Companion.canUpgradeAnonymous import com.firebase.ui.auth.configuration.auth_provider.AuthProvider.Companion.mergeProfile import com.firebase.ui.auth.credentialmanager.PasswordCredentialCancelledException @@ -126,12 +127,14 @@ internal suspend fun FirebaseAuthUI.createOrLinkUserWithEmailAndPassword( credentialProvider: AuthProvider.Email.CredentialProvider = AuthProvider.Email.DefaultCredentialProvider(), ): AuthResult? { val canUpgrade = canUpgradeAnonymous(config, auth) + val canLink = canLinkCredential(config, auth) + val shouldLinkCredential = canUpgrade || canLink val pendingCredential = - if (canUpgrade) credentialProvider.getCredential(email, password) else null + if (shouldLinkCredential) credentialProvider.getCredential(email, password) else null try { - // Check if new accounts are allowed (only for non-upgrade flows) - if (!canUpgrade && !provider.isNewAccountsAllowed) { + // Check if new accounts are allowed (only for non-upgrade/non-linking flows) + if (!shouldLinkCredential && !provider.isNewAccountsAllowed) { throw AuthException.UserNotFoundException( message = context.getString(R.string.fui_error_email_does_not_exist) ) @@ -156,7 +159,7 @@ internal suspend fun FirebaseAuthUI.createOrLinkUserWithEmailAndPassword( } updateAuthState(AuthState.Loading("Creating user...")) - val result = if (canUpgrade) { + val result = if (shouldLinkCredential) { auth.currentUser?.linkWithCredential(requireNotNull(pendingCredential))?.await() } else { auth.createUserWithEmailAndPassword(email, password).await() @@ -205,10 +208,10 @@ internal suspend fun FirebaseAuthUI.createOrLinkUserWithEmailAndPassword( message = "An account already exists with this email. " + "Please sign in with your existing account.", email = e.email ?: email, - credential = if (canUpgrade) { - e.updatedCredential ?: pendingCredential - } else { - null + credential = when { + canUpgrade -> e.updatedCredential ?: pendingCredential + canLink -> pendingCredential + else -> null }, cause = e ) @@ -548,7 +551,7 @@ internal suspend fun FirebaseAuthUI.signInAndLinkWithCredential( ): AuthResult? { try { updateAuthState(AuthState.Loading("Signing in user...")) - return if (canUpgradeAnonymous(config, auth)) { + return if (canUpgradeAnonymous(config, auth) || canLinkCredential(config, auth)) { auth.currentUser?.linkWithCredential(credential)?.await() } else { auth.signInWithCredential(credential).await() diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreen.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreen.kt index 5a065400c..83251e039 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreen.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreen.kt @@ -82,6 +82,7 @@ import com.google.firebase.auth.AuthCredential import com.google.firebase.auth.AuthResult import com.google.firebase.auth.MultiFactorResolver import kotlinx.coroutines.launch +import kotlinx.coroutines.tasks.await /** * High-level authentication screen that wires together provider selection, individual provider @@ -383,27 +384,22 @@ fun FirebaseAuthScreen( coroutineScope.launch { try { // Reload user to get fresh data from server - authUI.getCurrentUser()?.reload() - authUI.getCurrentUser()?.getIdToken(true) - - // Check the user's email verification status after reload - val user = authUI.getCurrentUser() - if (user != null) { - // If email is now verified, transition to Success state - if (user.isEmailVerified) { + authUI.getCurrentUser()?.let { + it.reload().await() + it.getIdToken(true).await() + if (it.isEmailVerified) { authUI.updateAuthState( AuthState.Success( result = null, - user = user, + user = it, isNewUser = false ) ) } else { - // Email still not verified, keep showing verification screen authUI.updateAuthState( AuthState.RequiresEmailVerification( - user = user, - email = user.email ?: "" + user = it, + email = it.email ?: "" ) ) } diff --git a/auth/src/test/java/com/firebase/ui/auth/configuration/AuthUIConfigurationTest.kt b/auth/src/test/java/com/firebase/ui/auth/configuration/AuthUIConfigurationTest.kt index 4afcfa84b..5d9efdb75 100644 --- a/auth/src/test/java/com/firebase/ui/auth/configuration/AuthUIConfigurationTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/configuration/AuthUIConfigurationTest.kt @@ -458,6 +458,7 @@ class AuthUIConfigurationTest { "isCredentialManagerEnabled", "isMfaEnabled", "isAnonymousUpgradeEnabled", + "isCredentialLinkingEnabled", "tosUrl", "privacyPolicyUrl", "logo", diff --git a/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/AnonymousAuthProviderFirebaseAuthUITest.kt b/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/AnonymousAuthProviderFirebaseAuthUITest.kt index 53f465b9a..45d0ea777 100644 --- a/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/AnonymousAuthProviderFirebaseAuthUITest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/AnonymousAuthProviderFirebaseAuthUITest.kt @@ -41,6 +41,7 @@ import org.junit.runner.RunWith import org.mockito.ArgumentMatchers import org.mockito.Mock import org.mockito.Mockito.mock +import org.mockito.Mockito.never import org.mockito.Mockito.verify import org.mockito.Mockito.`when` import org.mockito.MockitoAnnotations @@ -313,4 +314,53 @@ class AnonymousAuthProviderFirebaseAuthUITest { assertThat(result).isNotNull() verify(mockAnonymousUser).linkWithCredential(credential) } + + // ============================================================================================= + // Credential Linking for Authenticated (Non-Anonymous) Users Tests + // ============================================================================================= + + @Test + fun `createOrLinkUserWithEmailAndPassword - links email credential to authenticated non-anonymous user when isCredentialLinkingEnabled`() = runTest { + val authenticatedUser = mock(FirebaseUser::class.java) + `when`(authenticatedUser.isAnonymous).thenReturn(false) + `when`(mockFirebaseAuth.currentUser).thenReturn(authenticatedUser) + + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setResult(mock(AuthResult::class.java)) + `when`(authenticatedUser.linkWithCredential(ArgumentMatchers.any(AuthCredential::class.java))) + .thenReturn(taskCompletionSource.task) + // Stub createUserWithEmailAndPassword so the test fails at verify, not with NPE + `when`(mockFirebaseAuth.createUserWithEmailAndPassword( + ArgumentMatchers.anyString(), + ArgumentMatchers.anyString() + )).thenReturn(taskCompletionSource.task) + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val emailProvider = AuthProvider.Email( + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList() + ) + val config = authUIConfiguration { + context = applicationContext + providers { + provider(emailProvider) + } + isCredentialLinkingEnabled = true + } + + instance.createOrLinkUserWithEmailAndPassword( + context = applicationContext, + config = config, + provider = emailProvider, + name = null, + email = "test@example.com", + password = "Pass@123" + ) + + verify(authenticatedUser).linkWithCredential(ArgumentMatchers.any(AuthCredential::class.java)) + verify(mockFirebaseAuth, never()).createUserWithEmailAndPassword( + ArgumentMatchers.anyString(), + ArgumentMatchers.anyString() + ) + } } diff --git a/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/EmailAuthProviderFirebaseAuthUITest.kt b/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/EmailAuthProviderFirebaseAuthUITest.kt index dc027e3dc..a5a8722b2 100644 --- a/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/EmailAuthProviderFirebaseAuthUITest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/EmailAuthProviderFirebaseAuthUITest.kt @@ -608,11 +608,47 @@ class EmailAuthProviderFirebaseAuthUITest { assertThat(e.credential).isEqualTo(updatedCredential) assertThat(e.cause).isEqualTo(collisionException) } + } + + @Test + fun `signInAndLinkWithCredential - links credential to authenticated non-anonymous user when isCredentialLinkingEnabled`() = runTest { + val authenticatedUser = mock(FirebaseUser::class.java) + `when`(authenticatedUser.isAnonymous).thenReturn(false) + `when`(mockFirebaseAuth.currentUser).thenReturn(authenticatedUser) + + val credential = GoogleAuthProvider.getCredential("google-id-token", null) + val mockAuthResult = mock(AuthResult::class.java) + `when`(mockAuthResult.user).thenReturn(authenticatedUser) + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setResult(mockAuthResult) + `when`(authenticatedUser.linkWithCredential(credential)) + .thenReturn(taskCompletionSource.task) + // Also stub signInWithCredential so the test fails at the verify assertion, + // not with a NPE from an unmocked call + `when`(mockFirebaseAuth.signInWithCredential(credential)) + .thenReturn(taskCompletionSource.task) + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val emailProvider = AuthProvider.Email( + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList() + ) + val config = authUIConfiguration { + context = applicationContext + providers { + provider(emailProvider) + } + isCredentialLinkingEnabled = true + } - val currentState = instance.authStateFlow().first { it is AuthState.Error } - assertThat(currentState).isInstanceOf(AuthState.Error::class.java) - val errorState = currentState as AuthState.Error - assertThat(errorState.exception).isInstanceOf(AuthException.AccountLinkingRequiredException::class.java) + val result = instance.signInAndLinkWithCredential( + config = config, + credential = credential + ) + + assertThat(result).isNotNull() + verify(authenticatedUser).linkWithCredential(credential) + verify(mockFirebaseAuth, never()).signInWithCredential(credential) } // ============================================================================================= diff --git a/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/GoogleAuthProviderFirebaseAuthUITest.kt b/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/GoogleAuthProviderFirebaseAuthUITest.kt index 2fd855c37..1135f4691 100644 --- a/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/GoogleAuthProviderFirebaseAuthUITest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/GoogleAuthProviderFirebaseAuthUITest.kt @@ -602,6 +602,69 @@ class GoogleAuthProviderFirebaseAuthUITest { verify(mockFirebaseAuth, never()).signInWithCredential(any()) } + // ============================================================================================= + // signInWithGoogle - Credential Linking for Authenticated (Non-Anonymous) Users + // ============================================================================================= + + @Test + fun `Sign in with Google with authenticated non-anonymous user and isCredentialLinkingEnabled should link credentials`() = runTest { + val mockCredential = mock(AuthCredential::class.java) + val mockAuthenticatedUser = mock(FirebaseUser::class.java) + `when`(mockAuthenticatedUser.isAnonymous).thenReturn(false) + `when`(mockAuthenticatedUser.uid).thenReturn("authenticated-uid") + + val mockAuthResult = mock(AuthResult::class.java) + `when`(mockAuthResult.user).thenReturn(mockAuthenticatedUser) + + val googleSignInResult = AuthProvider.Google.GoogleSignInResult( + credential = mockCredential, + idToken = "test-id-token", + displayName = "Test User", + photoUrl = null + ) + + `when`( + mockCredentialManagerProvider.getGoogleCredential( + context = eq(applicationContext), + credentialManager = any(), + serverClientId = eq("test-client-id"), + filterByAuthorizedAccounts = eq(true), + autoSelectEnabled = eq(false) + ) + ).thenReturn(googleSignInResult) + + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setResult(mockAuthResult) + `when`(mockFirebaseAuth.currentUser).thenReturn(mockAuthenticatedUser) + `when`(mockAuthenticatedUser.linkWithCredential(mockCredential)) + .thenReturn(taskCompletionSource.task) + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val googleProvider = AuthProvider.Google( + serverClientId = "test-client-id", + scopes = emptyList() + ) + val config = authUIConfiguration { + context = applicationContext + isCredentialLinkingEnabled = true + providers { + provider(googleProvider) + } + } + + instance.signInWithGoogle( + context = applicationContext, + config = config, + provider = googleProvider, + authorizationProvider = mockAuthorizationProvider, + credentialManagerProvider = mockCredentialManagerProvider + ) + + // Verify link was called instead of sign-in + verify(mockAuthenticatedUser).linkWithCredential(mockCredential) + verify(mockFirebaseAuth, never()).signInWithCredential(any()) + } + // ============================================================================================= // signInWithGoogle - Configuration Properties // ============================================================================================= diff --git a/e2eTest/src/test/java/com/firebase/ui/auth/testutil/TestHelpers.kt b/e2eTest/src/test/java/com/firebase/ui/auth/testutil/TestHelpers.kt index 958df6f53..98b570008 100644 --- a/e2eTest/src/test/java/com/firebase/ui/auth/testutil/TestHelpers.kt +++ b/e2eTest/src/test/java/com/firebase/ui/auth/testutil/TestHelpers.kt @@ -1,6 +1,7 @@ package com.firebase.ui.auth.testutil import android.os.Looper +import android.util.Base64 import com.firebase.ui.auth.FirebaseAuthUI import com.google.firebase.auth.FirebaseUser import org.robolectric.Shadows.shadowOf @@ -99,3 +100,27 @@ fun verifyEmailInEmulator(authUI: FirebaseAuthUI, emulatorApi: EmulatorAuthApi, println("TEST: Email verified successfully for user ${user.uid}") println("TEST: User isEmailVerified: ${authUI.auth.currentUser?.isEmailVerified}") } + +fun generateMockGoogleIdToken( + email: String, + sub: String = "test-user-id", + name: String? = null, + photoUrl: String? = null, +): String { + val header = """{"alg":"RS256","kid":"test"}""" + val payload = buildString { + append("{") + append("\"iss\":\"https://accounts.google.com\",") + append("\"aud\":\"test-client-id\",") + append("\"sub\":\"$sub\",") + append("\"email\":\"$email\",") + append("\"email_verified\":true") + name?.let { append(",\"name\":\"$it\"") } + photoUrl?.let { append(",\"picture\":\"$it\"") } + append(",\"iat\":1689600000,\"exp\":1689603600") + append("}") + } + val encodedHeader = Base64.encodeToString(header.toByteArray(), Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP) + val encodedPayload = Base64.encodeToString(payload.toByteArray(), Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP) + return "$encodedHeader.$encodedPayload.mock-signature" +} diff --git a/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/CredentialLinkingScreenTest.kt b/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/CredentialLinkingScreenTest.kt new file mode 100644 index 000000000..a862a4e57 --- /dev/null +++ b/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/CredentialLinkingScreenTest.kt @@ -0,0 +1,436 @@ +package com.firebase.ui.auth.ui.screens + +import android.content.Context +import android.net.Uri +import android.os.Bundle +import android.os.Looper +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.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performScrollToNode +import androidx.compose.ui.test.performTextInput +import androidx.credentials.CredentialManager +import androidx.credentials.GetCredentialRequest +import androidx.credentials.GetCredentialResponse +import androidx.test.core.app.ApplicationProvider +import com.firebase.ui.auth.AuthException +import com.firebase.ui.auth.AuthState +import com.firebase.ui.auth.FirebaseAuthUI +import com.firebase.ui.auth.configuration.AuthUIConfiguration +import com.firebase.ui.auth.configuration.authUIConfiguration +import com.firebase.ui.auth.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider +import com.firebase.ui.auth.configuration.string_provider.DefaultAuthUIStringProvider +import com.firebase.ui.auth.testutil.AUTH_STATE_WAIT_TIMEOUT_MS +import com.firebase.ui.auth.testutil.EmulatorAuthApi +import com.firebase.ui.auth.testutil.awaitWithLooper +import com.firebase.ui.auth.testutil.ensureFreshUser +import com.firebase.ui.auth.testutil.generateMockGoogleIdToken +import com.firebase.ui.auth.testutil.verifyEmailInEmulator +import com.firebase.ui.auth.util.CountryUtils +import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential +import com.google.common.truth.Truth.assertThat +import com.google.firebase.FirebaseApp +import com.google.firebase.FirebaseOptions +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assume +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.MockitoAnnotations +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.annotation.LooperMode + +@Config(sdk = [34]) +@RunWith(RobolectricTestRunner::class) +@LooperMode(LooperMode.Mode.PAUSED) +class CredentialLinkingScreenTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Mock + private lateinit var mockCredentialManager: CredentialManager + + private lateinit var applicationContext: Context + private lateinit var stringProvider: AuthUIStringProvider + private lateinit var authUI: FirebaseAuthUI + private lateinit var emulatorApi: EmulatorAuthApi + + @Before + fun setUp() { + MockitoAnnotations.openMocks(this) + + applicationContext = ApplicationProvider.getApplicationContext() + stringProvider = DefaultAuthUIStringProvider(applicationContext) + + FirebaseApp.getApps(applicationContext).forEach { app -> + app.delete() + } + + val firebaseApp = FirebaseApp.initializeApp( + applicationContext, + FirebaseOptions.Builder() + .setApiKey("fake-api-key") + .setApplicationId("fake-app-id") + .setProjectId("fake-project-id") + .build() + ) + + authUI = FirebaseAuthUI.getInstance() + authUI.auth.useEmulator("127.0.0.1", 9099) + + authUI.testCredentialManagerProvider = object : AuthProvider.Google.CredentialManagerProvider { + override suspend fun getGoogleCredential( + context: Context, + credentialManager: CredentialManager, + serverClientId: String, + filterByAuthorizedAccounts: Boolean, + autoSelectEnabled: Boolean, + ): AuthProvider.Google.GoogleSignInResult { + return AuthProvider.Google.DefaultCredentialManagerProvider().getGoogleCredential( + context = context, + credentialManager = mockCredentialManager, + serverClientId = serverClientId, + filterByAuthorizedAccounts = filterByAuthorizedAccounts, + autoSelectEnabled = autoSelectEnabled, + ) + } + + override suspend fun clearCredentialState(context: Context, credentialManager: CredentialManager) {} + } + + emulatorApi = EmulatorAuthApi( + projectId = firebaseApp.options.projectId + ?: throw IllegalStateException("Project ID is required for emulator interactions"), + emulatorHost = "127.0.0.1", + emulatorPort = 9099 + ) + + emulatorApi.clearEmulatorData() + } + + @After + fun tearDown() { + FirebaseAuthUI.clearInstanceCache() + emulatorApi.clearEmulatorData() + } + + @Test + fun `isCredentialLinkingEnabled links phone to existing email user preserving UID`() { + val email = "credentiallink@example.com" + val password = "Test@123" + val phone = "2025550123" + val country = CountryUtils.findByCountryCode("US")!! + + // Step 1: Create an email/password user, verify their email, and sign in + println("TEST: Creating email/password user...") + val createdUser = ensureFreshUser(authUI, email, password) + requireNotNull(createdUser) { "Failed to create user" } + + println("TEST: Verifying email in emulator...") + verifyEmailInEmulator(authUI, emulatorApi, createdUser) + + val signInResult = authUI.auth.signInWithEmailAndPassword(email, password).awaitWithLooper() + val originalUID = signInResult.user!!.uid + println("TEST: Signed in as $email, UID: $originalUID") + + assertThat(authUI.auth.currentUser).isNotNull() + assertThat(authUI.auth.currentUser!!.isAnonymous).isFalse() + assertThat(authUI.auth.currentUser!!.isEmailVerified).isTrue() + + // Step 2: Set up auth screen with isCredentialLinkingEnabled + phone provider + val configuration = authUIConfiguration { + context = applicationContext + providers { + provider( + AuthProvider.Phone( + defaultNumber = null, + defaultCountryCode = country.countryCode, + allowedCountries = null, + timeout = 60L, + ) + ) + } + isCredentialLinkingEnabled = true + isCredentialManagerEnabled = false + } + + var currentAuthState: AuthState = AuthState.Idle + + composeTestRule.setContent { + TestAuthScreen(configuration = configuration) + val authState by authUI.authStateFlow().collectAsState(AuthState.Idle) + currentAuthState = authState + } + + composeTestRule.waitForIdle() + shadowOf(Looper.getMainLooper()).idle() + + // Wait for the authenticated content to render + composeTestRule.waitUntil(timeoutMillis = AUTH_STATE_WAIT_TIMEOUT_MS) { + shadowOf(Looper.getMainLooper()).idle() + currentAuthState is AuthState.Success + } + + // Step 3: Navigate to phone auth from the authenticated content slot + composeTestRule.onNodeWithText("Link Phone") + .assertIsDisplayed() + .performClick() + + composeTestRule.waitForIdle() + shadowOf(Looper.getMainLooper()).idle() + + // Step 4: Enter phone number and request verification code + println("TEST: Entering phone number...") + composeTestRule.onNodeWithText(stringProvider.phoneNumberHint) + .assertIsDisplayed() + .performTextInput(phone) + + composeTestRule.onNodeWithText(stringProvider.sendVerificationCode.uppercase()) + .performScrollTo() + .assertIsEnabled() + .performClick() + + composeTestRule.waitForIdle() + shadowOf(Looper.getMainLooper()).idle() + + // Step 5: Fetch verification code from emulator + println("TEST: Fetching phone verification code...") + var phoneCode: String? = null + var retries = 0 + val maxRetries = 5 + while (phoneCode == null && retries < maxRetries) { + Thread.sleep(if (retries == 0) 200L else 500L * retries) + shadowOf(Looper.getMainLooper()).idle() + try { + phoneCode = emulatorApi.fetchVerifyPhoneCode(phone) + println("TEST: Found phone code after ${retries + 1} attempts") + } catch (e: Exception) { + retries++ + if (retries >= maxRetries) { + Assume.assumeTrue( + "Skipping test: Firebase Auth Emulator not available. Error: ${e.message}", + false + ) + } + println("TEST: Phone code not found yet, retrying... (attempt $retries/$maxRetries)") + } + } + requireNotNull(phoneCode) { "Phone code should not be null at this point" } + + // Step 6: Enter verification code + println("TEST: Entering verification code: $phoneCode") + val textFields = composeTestRule.onAllNodes(hasSetTextAction()) + phoneCode.forEachIndexed { index, digit -> + composeTestRule.waitForIdle() + textFields[index].performTextInput(digit.toString()) + } + + composeTestRule.onNodeWithText(stringProvider.verifyPhoneNumber.uppercase()) + .performScrollTo() + .assertIsEnabled() + .performClick() + + composeTestRule.waitForIdle() + shadowOf(Looper.getMainLooper()).idle() + + // Step 7: Wait for success + println("TEST: Waiting for auth state change after phone verification...") + composeTestRule.waitUntil(timeoutMillis = AUTH_STATE_WAIT_TIMEOUT_MS) { + shadowOf(Looper.getMainLooper()).idle() + println("TEST: Auth state: $currentAuthState") + currentAuthState is AuthState.Success + } + + // Step 8: Verify the UID is preserved (linking happened, not a new account) + val linkedUser = authUI.auth.currentUser!! + println("TEST: Original UID: $originalUID, Linked UID: ${linkedUser.uid}") + assertThat(linkedUser.uid).isEqualTo(originalUID) + assertThat(linkedUser.email).isEqualTo(email) + assertThat(linkedUser.phoneNumber).isEqualTo( + CountryUtils.formatPhoneNumber(country.dialCode, phone) + ) + val providerIds = linkedUser.providerData.map { it.providerId } + assertThat(providerIds).contains("password") + assertThat(providerIds).contains("phone") + } + + @Test + fun `isCredentialLinkingEnabled links Google to existing email user preserving UID`() = runTest { + val email = "googlelinktest@example.com" + val password = "Test@123" + val googleEmail = "googlelinktest@gmail.com" + val googleName = "Google Link Test User" + val googlePhotoUrl = "https://example.com/avatar.jpg" + + // Step 1: Create an email/password user, verify their email, and sign in + println("TEST: Creating email/password user...") + val createdUser = ensureFreshUser(authUI, email, password) + requireNotNull(createdUser) { "Failed to create user" } + + println("TEST: Verifying email in emulator...") + verifyEmailInEmulator(authUI, emulatorApi, createdUser) + + val signInResult = authUI.auth.signInWithEmailAndPassword(email, password).awaitWithLooper() + val originalUID = signInResult.user!!.uid + println("TEST: Signed in as $email, UID: $originalUID") + + assertThat(authUI.auth.currentUser).isNotNull() + assertThat(authUI.auth.currentUser!!.isAnonymous).isFalse() + assertThat(authUI.auth.currentUser!!.isEmailVerified).isTrue() + + // Step 2: Configure mock Google credential + val mockIdToken = generateMockGoogleIdToken( + email = googleEmail, + name = googleName, + photoUrl = googlePhotoUrl, + ) + val mockCredential = mock { + on { type } doReturn GoogleIdTokenCredential.TYPE_GOOGLE_ID_TOKEN_CREDENTIAL + on { data } doReturn Bundle().apply { + putString("com.google.android.libraries.identity.googleid.BUNDLE_KEY_ID_TOKEN", mockIdToken) + putString("com.google.android.libraries.identity.googleid.BUNDLE_KEY_ID", googleEmail) + putString("com.google.android.libraries.identity.googleid.BUNDLE_KEY_DISPLAY_NAME", googleName) + putParcelable("com.google.android.libraries.identity.googleid.BUNDLE_KEY_PROFILE_PICTURE_URI", Uri.parse(googlePhotoUrl)) + } + on { displayName } doReturn googleName + on { profilePictureUri } doReturn Uri.parse(googlePhotoUrl) + } + val mockResult = mock { + on { credential } doReturn mockCredential + } + whenever(mockCredentialManager.getCredential(any(), any())) + .thenReturn(mockResult) + + // Step 3: Set up auth screen with isCredentialLinkingEnabled + Google provider + val configuration = authUIConfiguration { + context = applicationContext + providers { + provider( + AuthProvider.Google( + scopes = listOf("email"), + serverClientId = "test-server-client-id", + ) + ) + } + isCredentialLinkingEnabled = true + isCredentialManagerEnabled = false + } + + var currentAuthState: AuthState = AuthState.Idle + + composeTestRule.setContent { + TestAuthScreen(configuration = configuration) + val authState by authUI.authStateFlow().collectAsState(AuthState.Idle) + currentAuthState = authState + } + + composeTestRule.waitForIdle() + shadowOf(Looper.getMainLooper()).idle() + + // Wait for authenticated content to render + composeTestRule.waitUntil(timeoutMillis = AUTH_STATE_WAIT_TIMEOUT_MS) { + shadowOf(Looper.getMainLooper()).idle() + currentAuthState is AuthState.Success + } + + // Step 4: Click "Link Google" from authenticated content + composeTestRule.onNodeWithText("Link Google") + .assertIsDisplayed() + .performClick() + + composeTestRule.waitForIdle() + shadowOf(Looper.getMainLooper()).idle() + + // Step 5: Click the Google sign-in button on the method picker + println("TEST: Clicking Google sign-in button...") + composeTestRule + .onNodeWithTag("AuthMethodPicker LazyColumn") + .performScrollToNode(hasText(stringProvider.signInWithGoogle)) + composeTestRule + .onNode(hasText(stringProvider.signInWithGoogle)) + .assertIsDisplayed() + .performClick() + + composeTestRule.waitForIdle() + shadowOf(Looper.getMainLooper()).idle() + + // Step 6: Wait for linking to complete + println("TEST: Waiting for Google linking to complete...") + composeTestRule.waitUntil(timeoutMillis = AUTH_STATE_WAIT_TIMEOUT_MS) { + shadowOf(Looper.getMainLooper()).idle() + println("TEST: Auth state: $currentAuthState") + currentAuthState is AuthState.Success + } + + // Step 7: Verify the UID is preserved and Google provider is added + val linkedUser = authUI.auth.currentUser!! + println("TEST: Original UID: $originalUID, Linked UID: ${linkedUser.uid}") + assertThat(linkedUser.uid).isEqualTo(originalUID) + assertThat(linkedUser.email).isEqualTo(email) + val providerIds = linkedUser.providerData.map { it.providerId } + assertThat(providerIds).contains("password") + assertThat(providerIds).contains("google.com") + } + + @Composable + private fun TestAuthScreen(configuration: AuthUIConfiguration) { + composeTestRule.waitForIdle() + shadowOf(Looper.getMainLooper()).idle() + + FirebaseAuthScreen( + configuration = configuration, + authUI = authUI, + onSignInSuccess = {}, + onSignInFailure = { _: AuthException -> }, + onSignInCancelled = {}, + authenticatedContent = { state, uiContext -> + when (state) { + is AuthState.Success -> { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text("UID - ${state.user.uid}") + Text("Email - ${state.user.email}") + Text("Phone - ${state.user.phoneNumber}") + Button(onClick = { uiContext.onNavigate(AuthRoute.Phone) }) { + Text("Link Phone") + } + Button(onClick = { uiContext.onNavigate(AuthRoute.MethodPicker) }) { + Text("Link Google") + } + } + } + } + } + ) + } +} diff --git a/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/GoogleAuthScreenTest.kt b/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/GoogleAuthScreenTest.kt index 64103ec32..840b66f4c 100644 --- a/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/GoogleAuthScreenTest.kt +++ b/e2eTest/src/test/java/com/firebase/ui/auth/ui/screens/GoogleAuthScreenTest.kt @@ -4,7 +4,6 @@ import android.content.Context import android.net.Uri import android.os.Bundle import android.os.Looper -import android.util.Base64 import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -40,6 +39,7 @@ import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider import com.firebase.ui.auth.configuration.string_provider.DefaultAuthUIStringProvider import com.firebase.ui.auth.testutil.AUTH_STATE_WAIT_TIMEOUT_MS import com.firebase.ui.auth.testutil.EmulatorAuthApi +import com.firebase.ui.auth.testutil.generateMockGoogleIdToken import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential import com.google.common.truth.Truth.assertThat import com.google.firebase.FirebaseApp @@ -447,45 +447,4 @@ class GoogleAuthScreenTest { ) } - /** - * Generates a mock Google ID token (JWT) with the specified email. - * This is useful for testing so that the token payload matches the test data. - */ - private fun generateMockGoogleIdToken( - email: String, - sub: String = "test-user-id", - name: String? = null, - photoUrl: String? = null - ): String { - // JWT Header - val header = """{"alg":"RS256","kid":"test"}""" - - // JWT Payload with dynamic email - val payload = buildString { - append("{") - append("\"iss\":\"https://accounts.google.com\",") - append("\"aud\":\"test-client-id\",") - append("\"sub\":\"$sub\",") - append("\"email\":\"$email\",") - append("\"email_verified\":true") - name?.let { append(",\"name\":\"$it\"") } - photoUrl?.let { append(",\"picture\":\"$it\"") } - append(",\"iat\":1689600000,\"exp\":1689603600") - append("}") - } - - // Base64 encode header and payload (URL-safe, no padding, no wrap) - val encodedHeader = Base64.encodeToString( - header.toByteArray(), - Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP - ) - val encodedPayload = Base64.encodeToString( - payload.toByteArray(), - Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP - ) - - // Return JWT format: header.payload.signature - // Signature doesn't need to be valid for testing - return "$encodedHeader.$encodedPayload.mock-signature" - } } \ No newline at end of file