Skip to content

Commit af52b84

Browse files
authored
fix: Email link sign-in (#2263)
* wip: fix email link sign in * fix: email link auth with cross-device support and tests * inject emailLink into exceptions for clarity * doc comments for email link related exceptions * update comments
1 parent 5a9ef80 commit af52b84

23 files changed

+1579
-332
lines changed

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

Lines changed: 63 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -212,38 +212,87 @@ abstract class AuthException(
212212
cause: Throwable? = null
213213
) : AuthException(message, cause)
214214

215+
/**
216+
* The email link used for sign-in is invalid or malformed.
217+
*
218+
* This exception is thrown when the link is not a valid Firebase email link,
219+
* has incorrect format, or is missing required parameters.
220+
*
221+
* @property cause The underlying [Throwable] that caused this exception
222+
*/
215223
class InvalidEmailLinkException(
216224
cause: Throwable? = null
217225
) : AuthException("You are are attempting to sign in with an invalid email link", cause)
218226

227+
/**
228+
* The email link is being used on a different device than where it was requested.
229+
*
230+
* This exception is thrown when `forceSameDevice = true` and the user opens
231+
* the link on a different device than the one used to request it.
232+
*
233+
* @property cause The underlying [Throwable] that caused this exception
234+
*/
219235
class EmailLinkWrongDeviceException(
220236
cause: Throwable? = null
221237
) : AuthException("You must open the email link on the same device.", cause)
222238

239+
/**
240+
* Cross-device account linking is required to complete email link sign-in.
241+
*
242+
* This exception is thrown when the email link matches an existing account with
243+
* a social provider (Google/Facebook), and the user needs to sign in with that
244+
* provider to link accounts.
245+
*
246+
* @property providerName The name of the social provider that needs to be linked
247+
* @property emailLink The email link being processed
248+
* @property cause The underlying [Throwable] that caused this exception
249+
*/
223250
class EmailLinkCrossDeviceLinkingException(
251+
val providerName: String? = null,
252+
val emailLink: String? = null,
224253
cause: Throwable? = null
225-
) : AuthException(
226-
"You must determine if you want to continue linking or " +
227-
"complete the sign in", cause
228-
)
254+
) : AuthException("You must determine if you want to continue linking or " +
255+
"complete the sign in", cause)
229256

257+
/**
258+
* User needs to provide their email address to complete email link sign-in.
259+
*
260+
* This exception is thrown when the email link is opened on a different device
261+
* and the email address cannot be determined from stored session data.
262+
*
263+
* @property emailLink The email link to be used after email is provided
264+
* @property cause The underlying [Throwable] that caused this exception
265+
*/
230266
class EmailLinkPromptForEmailException(
231-
cause: Throwable? = null
267+
cause: Throwable? = null,
268+
val emailLink: String? = null,
232269
) : AuthException("Please enter your email to continue signing in", cause)
233270

271+
/**
272+
* Email link sign-in attempted with a different anonymous user than expected.
273+
*
274+
* This exception is thrown when an email link for anonymous account upgrade is
275+
* opened on a device with a different anonymous user session.
276+
*
277+
* @property cause The underlying [Throwable] that caused this exception
278+
*/
234279
class EmailLinkDifferentAnonymousUserException(
235280
cause: Throwable? = null
236-
) : AuthException(
237-
"The session associated with this sign-in request has either expired or " +
238-
"was cleared", cause
239-
)
281+
) : AuthException("The session associated with this sign-in request has either " +
282+
"expired or was cleared", cause)
240283

284+
/**
285+
* The email address provided does not match the email link.
286+
*
287+
* This exception is thrown when the user enters an email address that doesn't
288+
* match the email to which the sign-in link was sent.
289+
*
290+
* @property cause The underlying [Throwable] that caused this exception
291+
*/
241292
class EmailMismatchException(
242293
cause: Throwable? = null
243-
) : AuthException(
244-
"You are are attempting to sign in a different email than previously " +
245-
"provided", cause
246-
)
294+
) : AuthException("You are are attempting to sign in a different email " +
295+
"than previously provided", cause)
247296

248297
companion object {
249298
/**
@@ -404,4 +453,4 @@ abstract class AuthException(
404453
}
405454
}
406455
}
407-
}
456+
}

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
package com.firebase.ui.auth.compose
1616

1717
import android.content.Context
18+
import android.content.Intent
1819
import androidx.annotation.RestrictTo
1920
import com.firebase.ui.auth.compose.configuration.AuthUIConfiguration
2021
import com.firebase.ui.auth.compose.configuration.auth_provider.AuthProvider
@@ -118,6 +119,17 @@ class FirebaseAuthUI private constructor(
118119
*/
119120
fun getCurrentUser(): FirebaseUser? = auth.currentUser
120121

122+
/**
123+
* Returns true if this instance can handle the provided [Intent].
124+
*
125+
* This mirrors the classic `AuthUI.canHandleIntent` API but uses the [FirebaseAuth] instance
126+
* backing this [FirebaseAuthUI], ensuring custom app/auth configurations are respected.
127+
*/
128+
fun canHandleIntent(intent: Intent?): Boolean {
129+
val link = intent?.data ?: return false
130+
return auth.isSignInWithEmailLink(link.toString())
131+
}
132+
121133
/**
122134
* Creates a new authentication flow controller with the specified configuration.
123135
*

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

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ internal enum class Provider(
9292
APPLE("apple.com", providerName = "Apple", isSocialProvider = true);
9393

9494
companion object {
95-
fun fromId(id: String): Provider? {
95+
fun fromId(id: String?): Provider? {
9696
return entries.find { it.id == id }
9797
}
9898
}
@@ -191,6 +191,7 @@ abstract class AuthProvider(open val providerId: String, open val providerName:
191191
internal fun addSessionInfoToActionCodeSettings(
192192
sessionId: String,
193193
anonymousUserId: String,
194+
credentialForLinking: AuthCredential? = null,
194195
): ActionCodeSettings {
195196
requireNotNull(emailLinkActionCodeSettings) {
196197
"ActionCodeSettings is required for email link sign in"
@@ -200,7 +201,10 @@ abstract class AuthProvider(open val providerId: String, open val providerName:
200201
appendSessionId(sessionId)
201202
appendAnonymousUserId(anonymousUserId)
202203
appendForceSameDeviceBit(isEmailLinkForceSameDeviceEnabled)
203-
appendProviderId(providerId)
204+
// Only append providerId for linking flows (when credentialForLinking is not null)
205+
if (credentialForLinking != null) {
206+
appendProviderId(credentialForLinking.provider)
207+
}
204208
}
205209

206210
return actionCodeSettings {
@@ -540,6 +544,7 @@ abstract class AuthProvider(open val providerId: String, open val providerName:
540544
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
541545
data class GoogleSignInResult(
542546
val credential: AuthCredential,
547+
val idToken: String,
543548
val displayName: String?,
544549
val photoUrl: Uri?
545550
)
@@ -612,8 +617,9 @@ abstract class AuthProvider(open val providerId: String, open val providerName:
612617

613618
return GoogleSignInResult(
614619
credential = credential,
620+
idToken = googleIdTokenCredential.idToken,
615621
displayName = googleIdTokenCredential.displayName,
616-
photoUrl = googleIdTokenCredential.profilePictureUri
622+
photoUrl = googleIdTokenCredential.profilePictureUri,
617623
)
618624
}
619625
}

0 commit comments

Comments
 (0)