12
12
* limitations under the License.
13
13
*/
14
14
15
- package com.firebase.ui.auth.compose.configuration
15
+ package com.firebase.ui.auth.compose.configuration.auth_provider
16
16
17
17
import android.content.Context
18
- import android.text.TextUtils
18
+ import android.net.Uri
19
19
import android.util.Log
20
20
import androidx.compose.ui.graphics.Color
21
21
import androidx.datastore.preferences.core.stringPreferencesKey
22
22
import com.firebase.ui.auth.R
23
+ import com.firebase.ui.auth.compose.AuthException
24
+ import com.firebase.ui.auth.compose.configuration.AuthUIConfiguration
25
+ import com.firebase.ui.auth.compose.configuration.AuthUIConfigurationDsl
26
+ import com.firebase.ui.auth.compose.configuration.PasswordRule
23
27
import com.firebase.ui.auth.compose.configuration.theme.AuthUIAsset
24
28
import com.firebase.ui.auth.util.Preconditions
25
29
import com.firebase.ui.auth.util.data.ContinueUrlBuilder
26
- import com.firebase.ui.auth.util.data.EmailLinkPersistenceManager.SessionRecord
27
30
import com.firebase.ui.auth.util.data.PhoneNumberUtils
28
31
import com.firebase.ui.auth.util.data.ProviderAvailability
29
32
import com.google.firebase.auth.ActionCodeSettings
@@ -34,7 +37,9 @@ import com.google.firebase.auth.GithubAuthProvider
34
37
import com.google.firebase.auth.GoogleAuthProvider
35
38
import com.google.firebase.auth.PhoneAuthProvider
36
39
import com.google.firebase.auth.TwitterAuthProvider
40
+ import com.google.firebase.auth.UserProfileChangeRequest
37
41
import com.google.firebase.auth.actionCodeSettings
42
+ import kotlinx.coroutines.tasks.await
38
43
39
44
@AuthUIConfigurationDsl
40
45
class AuthProvidersBuilder {
@@ -50,11 +55,11 @@ class AuthProvidersBuilder {
50
55
/* *
51
56
* Enum class to represent all possible providers.
52
57
*/
53
- internal enum class Provider (val id : String ) {
54
- GOOGLE (GoogleAuthProvider .PROVIDER_ID ),
55
- FACEBOOK (FacebookAuthProvider .PROVIDER_ID ),
56
- TWITTER (TwitterAuthProvider .PROVIDER_ID ),
57
- GITHUB (GithubAuthProvider .PROVIDER_ID ),
58
+ internal enum class Provider (val id : String , val isSocialProvider : Boolean = false ) {
59
+ GOOGLE (GoogleAuthProvider .PROVIDER_ID , isSocialProvider = true ),
60
+ FACEBOOK (FacebookAuthProvider .PROVIDER_ID , isSocialProvider = true ),
61
+ TWITTER (TwitterAuthProvider .PROVIDER_ID , isSocialProvider = true ),
62
+ GITHUB (GithubAuthProvider .PROVIDER_ID , isSocialProvider = true ),
58
63
EMAIL (EmailAuthProvider .PROVIDER_ID ),
59
64
PHONE (PhoneAuthProvider .PROVIDER_ID ),
60
65
ANONYMOUS (" anonymous" ),
@@ -82,6 +87,74 @@ abstract class OAuthProvider(
82
87
* Base abstract class for authentication providers.
83
88
*/
84
89
abstract class AuthProvider (open val providerId : String ) {
90
+
91
+ companion object {
92
+ internal fun canUpgradeAnonymous (config : AuthUIConfiguration , auth : FirebaseAuth ): Boolean {
93
+ val currentUser = auth.currentUser
94
+ return config.isAnonymousUpgradeEnabled
95
+ && currentUser != null
96
+ && currentUser.isAnonymous
97
+ }
98
+
99
+ /* *
100
+ * Merges profile information (display name and photo URL) with the current user's profile.
101
+ *
102
+ * This method updates the user's profile only if the current profile is incomplete
103
+ * (missing display name or photo URL). This prevents overwriting existing profile data.
104
+ *
105
+ * **Use case:**
106
+ * After creating a new user account or linking credentials, update the profile with
107
+ * information from the sign-up form or social provider.
108
+ *
109
+ * @param auth The [FirebaseAuth] instance
110
+ * @param displayName The display name to set (if current is empty)
111
+ * @param photoUri The photo URL to set (if current is null)
112
+ *
113
+ * **Old library reference:**
114
+ * - ProfileMerger.java:34-56 (complete implementation)
115
+ * - ProfileMerger.java:39-43 (only update if profile incomplete)
116
+ * - ProfileMerger.java:49-55 (updateProfile call)
117
+ *
118
+ * **Note:** This operation always succeeds to minimize login interruptions.
119
+ * Failures are logged but don't prevent sign-in completion.
120
+ */
121
+ internal suspend fun mergeProfile (
122
+ auth : FirebaseAuth ,
123
+ displayName : String? ,
124
+ photoUri : Uri ?
125
+ ) {
126
+ try {
127
+ val currentUser = auth.currentUser ? : return
128
+
129
+ // Only update if current profile is incomplete
130
+ val currentDisplayName = currentUser.displayName
131
+ val currentPhotoUrl = currentUser.photoUrl
132
+
133
+ if (! currentDisplayName.isNullOrEmpty() && currentPhotoUrl != null ) {
134
+ // Profile is complete, no need to update
135
+ return
136
+ }
137
+
138
+ // Build profile update with provided values
139
+ val nameToSet = if (currentDisplayName.isNullOrEmpty()) displayName else currentDisplayName
140
+ val photoToSet = currentPhotoUrl ? : photoUri
141
+
142
+ if (nameToSet != null || photoToSet != null ) {
143
+ val profileUpdates = UserProfileChangeRequest .Builder ()
144
+ .setDisplayName(nameToSet)
145
+ .setPhotoUri(photoToSet)
146
+ .build()
147
+
148
+ currentUser.updateProfile(profileUpdates).await()
149
+ }
150
+ } catch (e: Exception ) {
151
+ // Log error but don't throw - profile update failure shouldn't prevent sign-in
152
+ // Old library uses TaskFailureLogger for this
153
+ Log .e(" AuthProvider.Email" , " Error updating profile" , e)
154
+ }
155
+ }
156
+ }
157
+
85
158
/* *
86
159
* Email/Password authentication provider configuration.
87
160
*/
@@ -125,15 +198,17 @@ abstract class AuthProvider(open val providerId: String) {
125
198
val passwordValidationRules : List <PasswordRule >
126
199
) : AuthProvider(providerId = Provider .EMAIL .id) {
127
200
companion object {
128
- val SESSION_ID_LENGTH = 10
201
+ const val SESSION_ID_LENGTH = 10
129
202
val KEY_EMAIL = stringPreferencesKey(" com.firebase.ui.auth.data.client.email" )
130
203
val KEY_PROVIDER = stringPreferencesKey(" com.firebase.ui.auth.data.client.provider" )
131
204
val KEY_ANONYMOUS_USER_ID =
132
205
stringPreferencesKey(" com.firebase.ui.auth.data.client.auid" )
133
206
val KEY_SESSION_ID = stringPreferencesKey(" com.firebase.ui.auth.data.client.sid" )
207
+ val KEY_IDP_TOKEN = stringPreferencesKey(" com.firebase.ui.auth.data.client.idpToken" )
208
+ val KEY_IDP_SECRET = stringPreferencesKey(" com.firebase.ui.auth.data.client.idpSecret" )
134
209
}
135
210
136
- fun validate () {
211
+ internal fun validate () {
137
212
if (isEmailLinkSignInEnabled) {
138
213
val actionCodeSettings = requireNotNull(actionCodeSettings) {
139
214
" ActionCodeSettings cannot be null when using " +
@@ -147,14 +222,133 @@ abstract class AuthProvider(open val providerId: String) {
147
222
}
148
223
}
149
224
150
- fun canUpgradeAnonymous (config : AuthUIConfiguration , auth : FirebaseAuth ): Boolean {
151
- val currentUser = auth.currentUser
152
- return config.isAnonymousUpgradeEnabled
153
- && currentUser != null
154
- && currentUser.isAnonymous
225
+ /* *
226
+ * Handles cross-device email link validation.
227
+ *
228
+ * This method validates email links that are opened on a different device
229
+ * from where they were sent. It performs security checks and throws appropriate
230
+ * exceptions if the link cannot be used.
231
+ *
232
+ * @param auth FirebaseAuth instance for validation
233
+ * @param sessionIdFromLink Session ID extracted from the email link
234
+ * @param anonymousUserIdFromLink Anonymous user ID from the link (if present)
235
+ * @param isEmailLinkForceSameDeviceEnabled Whether same-device is enforced
236
+ * @param oobCode The action code from the email link
237
+ * @param providerIdFromLink Provider ID from the link (for linking flows)
238
+ *
239
+ * @throws com.firebase.ui.auth.compose.AuthException.InvalidEmailLinkException if session ID is missing
240
+ * @throws com.firebase.ui.auth.compose.AuthException.EmailLinkWrongDeviceException if same-device is required
241
+ * @throws com.firebase.ui.auth.compose.AuthException.EmailLinkCrossDeviceLinkingException if provider linking is attempted
242
+ * @throws com.firebase.ui.auth.compose.AuthException.EmailLinkPromptForEmailException if email input is required
243
+ */
244
+ internal suspend fun handleCrossDeviceEmailLink (
245
+ auth : FirebaseAuth ,
246
+ sessionIdFromLink : String? ,
247
+ anonymousUserIdFromLink : String? ,
248
+ isEmailLinkForceSameDeviceEnabled : Boolean ,
249
+ oobCode : String ,
250
+ providerIdFromLink : String?
251
+ ) {
252
+ // Session ID must always be present in the link
253
+ if (sessionIdFromLink.isNullOrEmpty()) {
254
+ throw AuthException .InvalidEmailLinkException ()
255
+ }
256
+
257
+ // These scenarios require same-device flow
258
+ if (isEmailLinkForceSameDeviceEnabled || ! anonymousUserIdFromLink.isNullOrEmpty()) {
259
+ throw AuthException .EmailLinkWrongDeviceException ()
260
+ }
261
+
262
+ // Validate the action code
263
+ auth.checkActionCode(oobCode).await()
264
+
265
+ // If there's a provider ID, this is a linking flow which can't be done cross-device
266
+ if (! providerIdFromLink.isNullOrEmpty()) {
267
+ throw AuthException .EmailLinkCrossDeviceLinkingException ()
268
+ }
269
+
270
+ // Link is valid but we need the user to provide their email
271
+ throw AuthException .EmailLinkPromptForEmailException ()
272
+ }
273
+
274
+ /* *
275
+ * Handles email link sign-in with social credential linking.
276
+ *
277
+ * This method signs in the user with an email link credential and then links
278
+ * a stored social provider credential (e.g., Google, Facebook). It handles both
279
+ * anonymous upgrade flows (with safe link) and normal linking flows.
280
+ *
281
+ * @param context Android context for creating scratch auth instance
282
+ * @param config Auth configuration
283
+ * @param auth FirebaseAuth instance
284
+ * @param emailLinkCredential The email link credential to sign in with
285
+ * @param storedCredentialForLink The social credential to link after sign-in
286
+ * @param updateAuthState Callback to update auth state
287
+ *
288
+ * @return AuthResult from the linking operation
289
+ */
290
+ internal suspend fun handleEmailLinkWithSocialLinking (
291
+ context : Context ,
292
+ config : AuthUIConfiguration ,
293
+ auth : FirebaseAuth ,
294
+ emailLinkCredential : com.google.firebase.auth.AuthCredential ,
295
+ storedCredentialForLink : com.google.firebase.auth.AuthCredential ,
296
+ updateAuthState : (com.firebase.ui.auth.compose.AuthState ) -> Unit
297
+ ): com.google.firebase.auth.AuthResult {
298
+ return if (canUpgradeAnonymous(config, auth)) {
299
+ // Anonymous upgrade: Use safe link pattern with scratch auth
300
+ val appExplicitlyForValidation = com.google.firebase.FirebaseApp .initializeApp(
301
+ context,
302
+ auth.app.options,
303
+ " FUIAuthScratchApp_${System .currentTimeMillis()} "
304
+ )
305
+ val authExplicitlyForValidation = FirebaseAuth
306
+ .getInstance(appExplicitlyForValidation)
307
+
308
+ // Safe link: Validate that both credentials can be linked
309
+ val emailResult = authExplicitlyForValidation
310
+ .signInWithCredential(emailLinkCredential).await()
311
+
312
+ val linkResult = emailResult.user
313
+ ?.linkWithCredential(storedCredentialForLink)?.await()
314
+
315
+ // If safe link succeeds, emit merge conflict for UI to handle
316
+ if (linkResult?.user != null ) {
317
+ updateAuthState(com.firebase.ui.auth.compose.AuthState .MergeConflict (storedCredentialForLink))
318
+ }
319
+
320
+ // Return the link result (will be non-null if successful)
321
+ linkResult!!
322
+ } else {
323
+ // Non-upgrade: Sign in with email link, then link social credential
324
+ val emailLinkResult = auth.signInWithCredential(emailLinkCredential).await()
325
+
326
+ // Link the social credential
327
+ val linkResult = emailLinkResult.user
328
+ ?.linkWithCredential(storedCredentialForLink)?.await()
329
+
330
+ // Merge profile from the linked social credential
331
+ linkResult?.user?.let { user ->
332
+ mergeProfile(auth, user.displayName, user.photoUrl)
333
+ }
334
+
335
+ // Update to success state
336
+ if (linkResult?.user != null ) {
337
+ updateAuthState(
338
+ com.firebase.ui.auth.compose.AuthState .Success (
339
+ result = linkResult,
340
+ user = linkResult.user!! ,
341
+ isNewUser = linkResult.additionalUserInfo?.isNewUser ? : false
342
+ )
343
+ )
344
+ }
345
+
346
+ linkResult!!
347
+ }
155
348
}
156
349
157
- fun addSessionInfoToActionCodeSettings (
350
+ // For Send Email Link
351
+ internal fun addSessionInfoToActionCodeSettings (
158
352
sessionId : String ,
159
353
anonymousUserId : String ,
160
354
): ActionCodeSettings {
@@ -181,7 +375,8 @@ abstract class AuthProvider(open val providerId: String) {
181
375
}
182
376
}
183
377
184
- fun isDifferentDevice (
378
+ // For Sign In With Email Link
379
+ internal fun isDifferentDevice (
185
380
sessionIdFromLocal : String? ,
186
381
sessionIdFromLink : String
187
382
): Boolean {
@@ -233,7 +428,7 @@ abstract class AuthProvider(open val providerId: String) {
233
428
*/
234
429
val isAutoRetrievalEnabled : Boolean = true
235
430
) : AuthProvider(providerId = Provider .PHONE .id) {
236
- fun validate () {
431
+ internal fun validate () {
237
432
defaultNumber?.let {
238
433
check(PhoneNumberUtils .isValid(it)) {
239
434
" Invalid phone number: $it "
@@ -296,7 +491,7 @@ abstract class AuthProvider(open val providerId: String) {
296
491
scopes = scopes,
297
492
customParameters = customParameters
298
493
) {
299
- fun validate (context : Context ) {
494
+ internal fun validate (context : Context ) {
300
495
if (serverClientId == null ) {
301
496
Preconditions .checkConfigured(
302
497
context,
@@ -348,7 +543,7 @@ abstract class AuthProvider(open val providerId: String) {
348
543
scopes = scopes,
349
544
customParameters = customParameters
350
545
) {
351
- fun validate (context : Context ) {
546
+ internal fun validate (context : Context ) {
352
547
if (! ProviderAvailability .IS_FACEBOOK_AVAILABLE ) {
353
548
throw RuntimeException (
354
549
" Facebook provider cannot be configured " +
@@ -475,7 +670,7 @@ abstract class AuthProvider(open val providerId: String) {
475
670
* Anonymous authentication provider. It has no configurable properties.
476
671
*/
477
672
object Anonymous : AuthProvider(providerId = Provider .ANONYMOUS .id) {
478
- fun validate (providers : List <AuthProvider >) {
673
+ internal fun validate (providers : List <AuthProvider >) {
479
674
if (providers.size == 1 && providers.first() is Anonymous ) {
480
675
throw IllegalStateException (
481
676
" Sign in as guest cannot be the only sign in method. " +
@@ -528,7 +723,7 @@ abstract class AuthProvider(open val providerId: String) {
528
723
scopes = scopes,
529
724
customParameters = customParameters
530
725
) {
531
- fun validate () {
726
+ internal fun validate () {
532
727
require(providerId.isNotBlank()) {
533
728
" Provider ID cannot be null or empty"
534
729
}
0 commit comments