Skip to content

Commit 28bcccb

Browse files
committed
feat: Email provider integration
1 parent f607bf8 commit 28bcccb

File tree

3 files changed

+1885
-22
lines changed

3 files changed

+1885
-22
lines changed

auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthProvider.kt

Lines changed: 217 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -12,18 +12,21 @@
1212
* limitations under the License.
1313
*/
1414

15-
package com.firebase.ui.auth.compose.configuration
15+
package com.firebase.ui.auth.compose.configuration.auth_provider
1616

1717
import android.content.Context
18-
import android.text.TextUtils
18+
import android.net.Uri
1919
import android.util.Log
2020
import androidx.compose.ui.graphics.Color
2121
import androidx.datastore.preferences.core.stringPreferencesKey
2222
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
2327
import com.firebase.ui.auth.compose.configuration.theme.AuthUIAsset
2428
import com.firebase.ui.auth.util.Preconditions
2529
import com.firebase.ui.auth.util.data.ContinueUrlBuilder
26-
import com.firebase.ui.auth.util.data.EmailLinkPersistenceManager.SessionRecord
2730
import com.firebase.ui.auth.util.data.PhoneNumberUtils
2831
import com.firebase.ui.auth.util.data.ProviderAvailability
2932
import com.google.firebase.auth.ActionCodeSettings
@@ -34,7 +37,9 @@ import com.google.firebase.auth.GithubAuthProvider
3437
import com.google.firebase.auth.GoogleAuthProvider
3538
import com.google.firebase.auth.PhoneAuthProvider
3639
import com.google.firebase.auth.TwitterAuthProvider
40+
import com.google.firebase.auth.UserProfileChangeRequest
3741
import com.google.firebase.auth.actionCodeSettings
42+
import kotlinx.coroutines.tasks.await
3843

3944
@AuthUIConfigurationDsl
4045
class AuthProvidersBuilder {
@@ -50,11 +55,11 @@ class AuthProvidersBuilder {
5055
/**
5156
* Enum class to represent all possible providers.
5257
*/
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),
5863
EMAIL(EmailAuthProvider.PROVIDER_ID),
5964
PHONE(PhoneAuthProvider.PROVIDER_ID),
6065
ANONYMOUS("anonymous"),
@@ -82,6 +87,74 @@ abstract class OAuthProvider(
8287
* Base abstract class for authentication providers.
8388
*/
8489
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+
85158
/**
86159
* Email/Password authentication provider configuration.
87160
*/
@@ -125,15 +198,17 @@ abstract class AuthProvider(open val providerId: String) {
125198
val passwordValidationRules: List<PasswordRule>
126199
) : AuthProvider(providerId = Provider.EMAIL.id) {
127200
companion object {
128-
val SESSION_ID_LENGTH = 10
201+
const val SESSION_ID_LENGTH = 10
129202
val KEY_EMAIL = stringPreferencesKey("com.firebase.ui.auth.data.client.email")
130203
val KEY_PROVIDER = stringPreferencesKey("com.firebase.ui.auth.data.client.provider")
131204
val KEY_ANONYMOUS_USER_ID =
132205
stringPreferencesKey("com.firebase.ui.auth.data.client.auid")
133206
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")
134209
}
135210

136-
fun validate() {
211+
internal fun validate() {
137212
if (isEmailLinkSignInEnabled) {
138213
val actionCodeSettings = requireNotNull(actionCodeSettings) {
139214
"ActionCodeSettings cannot be null when using " +
@@ -147,14 +222,133 @@ abstract class AuthProvider(open val providerId: String) {
147222
}
148223
}
149224

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+
}
155348
}
156349

157-
fun addSessionInfoToActionCodeSettings(
350+
// For Send Email Link
351+
internal fun addSessionInfoToActionCodeSettings(
158352
sessionId: String,
159353
anonymousUserId: String,
160354
): ActionCodeSettings {
@@ -181,7 +375,8 @@ abstract class AuthProvider(open val providerId: String) {
181375
}
182376
}
183377

184-
fun isDifferentDevice(
378+
// For Sign In With Email Link
379+
internal fun isDifferentDevice(
185380
sessionIdFromLocal: String?,
186381
sessionIdFromLink: String
187382
): Boolean {
@@ -233,7 +428,7 @@ abstract class AuthProvider(open val providerId: String) {
233428
*/
234429
val isAutoRetrievalEnabled: Boolean = true
235430
) : AuthProvider(providerId = Provider.PHONE.id) {
236-
fun validate() {
431+
internal fun validate() {
237432
defaultNumber?.let {
238433
check(PhoneNumberUtils.isValid(it)) {
239434
"Invalid phone number: $it"
@@ -296,7 +491,7 @@ abstract class AuthProvider(open val providerId: String) {
296491
scopes = scopes,
297492
customParameters = customParameters
298493
) {
299-
fun validate(context: Context) {
494+
internal fun validate(context: Context) {
300495
if (serverClientId == null) {
301496
Preconditions.checkConfigured(
302497
context,
@@ -348,7 +543,7 @@ abstract class AuthProvider(open val providerId: String) {
348543
scopes = scopes,
349544
customParameters = customParameters
350545
) {
351-
fun validate(context: Context) {
546+
internal fun validate(context: Context) {
352547
if (!ProviderAvailability.IS_FACEBOOK_AVAILABLE) {
353548
throw RuntimeException(
354549
"Facebook provider cannot be configured " +
@@ -475,7 +670,7 @@ abstract class AuthProvider(open val providerId: String) {
475670
* Anonymous authentication provider. It has no configurable properties.
476671
*/
477672
object Anonymous : AuthProvider(providerId = Provider.ANONYMOUS.id) {
478-
fun validate(providers: List<AuthProvider>) {
673+
internal fun validate(providers: List<AuthProvider>) {
479674
if (providers.size == 1 && providers.first() is Anonymous) {
480675
throw IllegalStateException(
481676
"Sign in as guest cannot be the only sign in method. " +
@@ -528,7 +723,7 @@ abstract class AuthProvider(open val providerId: String) {
528723
scopes = scopes,
529724
customParameters = customParameters
530725
) {
531-
fun validate() {
726+
internal fun validate() {
532727
require(providerId.isNotBlank()) {
533728
"Provider ID cannot be null or empty"
534729
}

0 commit comments

Comments
 (0)