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