Skip to content

Commit 76923bc

Browse files
committed
wip: Email Provider integration
1 parent 24c687c commit 76923bc

File tree

6 files changed

+379
-6
lines changed

6 files changed

+379
-6
lines changed

auth/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ dependencies {
8585
implementation(Config.Libs.Androidx.materialDesign)
8686
implementation(Config.Libs.Androidx.activity)
8787
implementation(libs.androidx.compose.material.icons.extended)
88+
implementation(libs.androidx.datastore.preferences)
8889
// The new activity result APIs force us to include Fragment 1.3.0
8990
// See https://issuetracker.google.com/issues/152554847
9091
implementation(Config.Libs.Androidx.fragment)

auth/src/main/java/com/firebase/ui/auth/compose/FirebaseAuthUI.kt

Lines changed: 261 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,29 @@ import com.google.firebase.auth.FirebaseUser
2222
import com.google.firebase.auth.ktx.auth
2323
import com.google.firebase.ktx.Firebase
2424
import android.content.Context
25+
import androidx.datastore.core.DataStore
26+
import androidx.datastore.preferences.core.Preferences
27+
import androidx.datastore.preferences.core.edit
28+
import androidx.datastore.preferences.core.stringPreferencesKey
29+
import androidx.datastore.preferences.preferencesDataStore
30+
import com.firebase.ui.auth.compose.configuration.AuthProvider
31+
import com.firebase.ui.auth.compose.configuration.AuthUIConfiguration
32+
import com.firebase.ui.auth.util.data.EmailLinkParser
33+
import com.firebase.ui.auth.util.data.SessionUtils
34+
import com.google.firebase.auth.ActionCodeSettings
35+
import com.google.firebase.auth.AuthCredential
36+
import com.google.firebase.auth.EmailAuthProvider
2537
import kotlinx.coroutines.CancellationException
2638
import kotlinx.coroutines.channels.awaitClose
2739
import kotlinx.coroutines.flow.Flow
2840
import kotlinx.coroutines.flow.MutableStateFlow
2941
import kotlinx.coroutines.flow.callbackFlow
42+
import kotlinx.coroutines.flow.first
3043
import kotlinx.coroutines.tasks.await
3144
import java.util.concurrent.ConcurrentHashMap
3245

46+
val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "com.firebase.ui.auth.util.data.EmailLinkPersistenceManager")
47+
3348
/**
3449
* The central class that coordinates all authentication operations for Firebase Auth UI Compose.
3550
* This class manages UI state and provides methods for signing in, signing up, and managing
@@ -168,7 +183,8 @@ class FirebaseAuthUI private constructor(
168183
// Check if email verification is required
169184
if (!currentUser.isEmailVerified &&
170185
currentUser.email != null &&
171-
currentUser.providerData.any { it.providerId == "password" }) {
186+
currentUser.providerData.any { it.providerId == "password" }
187+
) {
172188
AuthState.RequiresEmailVerification(
173189
user = currentUser,
174190
email = currentUser.email!!
@@ -213,6 +229,249 @@ class FirebaseAuthUI private constructor(
213229
_authStateFlow.value = state
214230
}
215231

232+
internal suspend fun createOrLinkUserWithEmailAndPassword(
233+
config: AuthUIConfiguration,
234+
provider: AuthProvider.Email,
235+
email: String,
236+
password: String
237+
) {
238+
try {
239+
updateAuthState(AuthState.Loading("Creating user..."))
240+
if (provider.canUpgradeAnonymous(config, auth)) {
241+
val credential = EmailAuthProvider.getCredential(email, password)
242+
auth.currentUser?.linkWithCredential(credential)?.await()
243+
} else {
244+
auth.createUserWithEmailAndPassword(email, password).await()
245+
}
246+
updateAuthState(AuthState.Idle)
247+
} catch (e: CancellationException) {
248+
val cancelledException = AuthException.AuthCancelledException(
249+
message = "Create or link user with email and password was cancelled",
250+
cause = e
251+
)
252+
updateAuthState(AuthState.Error(cancelledException))
253+
throw cancelledException
254+
} catch (e: AuthException) {
255+
updateAuthState(AuthState.Error(e))
256+
throw e
257+
} catch (e: Exception) {
258+
val authException = AuthException.from(e)
259+
updateAuthState(AuthState.Error(authException))
260+
throw authException
261+
}
262+
}
263+
264+
internal suspend fun signInAndLinkWithCredential(
265+
config: AuthUIConfiguration,
266+
provider: AuthProvider.Email,
267+
credential: AuthCredential
268+
) {
269+
try {
270+
updateAuthState(AuthState.Loading("Signing in user..."))
271+
if (provider.canUpgradeAnonymous(config, auth)) {
272+
auth.currentUser?.linkWithCredential(credential)?.await()
273+
} else {
274+
auth.signInWithCredential(credential).await()
275+
}
276+
updateAuthState(AuthState.Idle)
277+
} catch (e: CancellationException) {
278+
val cancelledException = AuthException.AuthCancelledException(
279+
message = "Sign in and link with credential was cancelled",
280+
cause = e
281+
)
282+
updateAuthState(AuthState.Error(cancelledException))
283+
throw cancelledException
284+
} catch (e: AuthException) {
285+
updateAuthState(AuthState.Error(e))
286+
throw e
287+
} catch (e: Exception) {
288+
val authException = AuthException.from(e)
289+
updateAuthState(AuthState.Error(authException))
290+
throw authException
291+
}
292+
}
293+
294+
internal suspend fun sendSignInLinkToEmail(
295+
context: Context,
296+
config: AuthUIConfiguration,
297+
provider: AuthProvider.Email,
298+
email: String,
299+
) {
300+
try {
301+
updateAuthState(AuthState.Loading("Sending sign in email link..."))
302+
303+
// Get anonymousUserId if can upgrade anonymously else default to empty string.
304+
// NOTE: check for empty string instead of null to validate anonymous user ID matches
305+
// when sign in from email link
306+
val anonymousUserId =
307+
if (provider.canUpgradeAnonymous(config, auth)) (auth.currentUser?.uid
308+
?: "") else ""
309+
310+
// Generate sessionId
311+
val sessionId =
312+
SessionUtils.generateRandomAlphaNumericString(AuthProvider.Email.SESSION_ID_LENGTH)
313+
314+
// Modify actionCodeSettings Url to include sessionId, anonymousUserId, force same
315+
// device flag
316+
val updatedActionCodeSettings =
317+
provider.addSessionInfoToActionCodeSettings(sessionId, anonymousUserId)
318+
319+
auth.sendSignInLinkToEmail(email, updatedActionCodeSettings).await()
320+
321+
// Save Email to dataStore for use in signInWithEmailLink
322+
context.dataStore.edit { prefs ->
323+
prefs[AuthProvider.Email.KEY_EMAIL] = email
324+
prefs[AuthProvider.Email.KEY_ANONYMOUS_USER_ID] = anonymousUserId
325+
prefs[AuthProvider.Email.KEY_SESSION_ID] = sessionId
326+
}
327+
updateAuthState(AuthState.Idle)
328+
} catch (e: CancellationException) {
329+
val cancelledException = AuthException.AuthCancelledException(
330+
message = "Send sign in link to email was cancelled",
331+
cause = e
332+
)
333+
updateAuthState(AuthState.Error(cancelledException))
334+
throw cancelledException
335+
} catch (e: AuthException) {
336+
updateAuthState(AuthState.Error(e))
337+
throw e
338+
} catch (e: Exception) {
339+
val authException = AuthException.from(e)
340+
updateAuthState(AuthState.Error(authException))
341+
throw authException
342+
}
343+
}
344+
345+
/**
346+
* Signs in a user using an email link (passwordless authentication).
347+
*
348+
* This method completes the email link sign-in flow after the user clicks the magic link
349+
* sent to their email. It validates the link, extracts session information, and either
350+
* signs in the user normally or upgrades an anonymous account based on configuration.
351+
*
352+
* **Flow:**
353+
* 1. User receives email with magic link
354+
* 2. User clicks link, app opens via deep link
355+
* 3. Activity extracts emailLink from Intent.data
356+
* 4. This method validates and completes sign-in
357+
*
358+
* @param config The [AuthUIConfiguration] containing authentication settings
359+
* @param provider The [AuthProvider.Email] configuration with email-link settings
360+
* @param email The email address of the user (retrieved from DataStore or user input)
361+
* @param emailLink The complete deep link URL received from the Intent.
362+
*
363+
* This URL contains:
364+
* - Firebase action code (oobCode) for authentication
365+
* - Session ID (ui_sid) for same-device validation
366+
* - Anonymous user ID (ui_auid) if upgrading anonymous account
367+
* - Force same-device flag (ui_sd) for security enforcement
368+
*
369+
* Example:
370+
* `https://yourapp.page.link/emailSignIn?oobCode=ABC123&continueUrl=...`
371+
*
372+
* @throws AuthException.InvalidCredentialsException if the email link is invalid or expired
373+
* @throws AuthException.AuthCancelledException if the operation is cancelled
374+
* @throws AuthException.NetworkException if a network error occurs
375+
* @throws AuthException.UnknownException for other errors
376+
*
377+
* @see sendSignInLinkToEmail for sending the initial email link
378+
*/
379+
internal suspend fun signInWithEmailLink(
380+
context: Context,
381+
config: AuthUIConfiguration,
382+
provider: AuthProvider.Email,
383+
email: String,
384+
emailLink: String,
385+
) {
386+
try {
387+
updateAuthState(AuthState.Loading("Signing in with email link..."))
388+
389+
// Validate link format
390+
if (!auth.isSignInWithEmailLink(emailLink)) {
391+
throw AuthException.InvalidCredentialsException("Invalid email link")
392+
}
393+
394+
// Parses email link for session data and returns sessionId, anonymousUserId,
395+
// force same device flag etc.
396+
val parser = EmailLinkParser(emailLink)
397+
val sessionIdFromLink = parser.sessionId
398+
val anonymousUserIdFromLink = parser.anonymousUserId
399+
400+
// Retrieve stored session id from DataStore
401+
val storedSessionId = context.dataStore.data.first()[AuthProvider.Email.KEY_SESSION_ID]
402+
403+
// Validate same-device
404+
if (provider.isDifferentDevice(
405+
sessionIdFromLocal = storedSessionId,
406+
sessionIdFromLink = sessionIdFromLink
407+
)
408+
) {
409+
if (provider.isEmailLinkForceSameDeviceEnabled
410+
|| !anonymousUserIdFromLink.isNullOrEmpty()
411+
) {
412+
throw AuthException.InvalidCredentialsException(
413+
"Email link must be" +
414+
"opened on the same device"
415+
)
416+
}
417+
418+
// TODO(demolaf): handle different device flow -
419+
// would need to prompt user for email and start flow on new device
420+
// Different device flow - prompt for email
421+
// This is a FUTURE ticket - not part of P2 core implementation
422+
// The UI layer needs to handle this by:
423+
// 1. Detecting that email is null/missing from DataStore
424+
// 2. Showing an EmailPromptScreen composable
425+
// 3. User enters email
426+
// 4. Retrying signInWithEmailLink() with user-provided email
427+
428+
// For now, throw an exception since we don't have the UI
429+
throw AuthException.InvalidCredentialsException(
430+
"Email not found. Please enter your email to complete sign-in."
431+
)
432+
}
433+
434+
// Validate anonymous user ID matches
435+
if (!anonymousUserIdFromLink.isNullOrEmpty()) {
436+
val currentUser = auth.currentUser
437+
if (currentUser == null
438+
|| !currentUser.isAnonymous
439+
|| currentUser.uid != anonymousUserIdFromLink
440+
) {
441+
throw AuthException.InvalidCredentialsException(
442+
"Anonymous " +
443+
"user mismatch"
444+
)
445+
}
446+
}
447+
448+
// Create credential and sign in
449+
val emailLinkCredential = EmailAuthProvider.getCredentialWithLink(email, emailLink)
450+
signInAndLinkWithCredential(config, provider, emailLinkCredential)
451+
452+
// Clear DataStore after success
453+
context.dataStore.edit { prefs ->
454+
prefs.remove(AuthProvider.Email.KEY_SESSION_ID)
455+
prefs.remove(AuthProvider.Email.KEY_EMAIL)
456+
prefs.remove(AuthProvider.Email.KEY_ANONYMOUS_USER_ID)
457+
}
458+
} catch (e: CancellationException) {
459+
val cancelledException = AuthException.AuthCancelledException(
460+
message = "Sign in with email link was cancelled",
461+
cause = e
462+
)
463+
updateAuthState(AuthState.Error(cancelledException))
464+
throw cancelledException
465+
} catch (e: AuthException) {
466+
updateAuthState(AuthState.Error(e))
467+
throw e
468+
} catch (e: Exception) {
469+
val authException = AuthException.from(e)
470+
updateAuthState(AuthState.Error(authException))
471+
throw authException
472+
}
473+
}
474+
216475
/**
217476
* Signs out the current user and clears authentication state.
218477
*
@@ -374,7 +633,7 @@ class FirebaseAuthUI private constructor(
374633
} catch (e: IllegalStateException) {
375634
throw IllegalStateException(
376635
"Default FirebaseApp is not initialized. " +
377-
"Make sure to call FirebaseApp.initializeApp(Context) first.",
636+
"Make sure to call FirebaseApp.initializeApp(Context) first.",
378637
e
379638
)
380639
}

0 commit comments

Comments
 (0)