Repository navigation
Conversation
There was a problem hiding this comment.
Code Review
This pull request enhances the email link sign-in flow by adding support for cross-device sign-in, allowing users to confirm their email address when opening a link on a different device unless forceSameDevice is enabled. The changes include tracking pending links and session IDs in AuthService, updating the UI in EmailLinkScreen and DefaultEmailLinkContent to support the confirmation state, and adding comprehensive integration tests. The review feedback suggests several robust improvements: copying the shared ActionCodeSettings to prevent concurrent mutation issues, guarding against unrelated deep links early in handleSignInLink, and implementing a mechanism to clear the pending email link state when navigating away to prevent the UI from getting stuck in confirmation mode.
| // A link the app opened before this screen was showing. | ||
| .onChange(of: authService.incomingEmailLink, initial: true) { _, url in | ||
| guard let url else { return } | ||
| authService.incomingEmailLink = nil | ||
| handleLink { try await authService.handleSignInLink(url: url) } | ||
| } |
There was a problem hiding this comment.
When a user receives a sign-in link from another device, pendingEmailLink is set, putting the screen into 'Confirm Email' mode. If the user cancels or navigates back, pendingEmailLink remains set, meaning subsequent visits to the email link screen will be stuck in 'Confirm Email' mode instead of showing the 'Send Link' form. Clearing the pending link on disappear resolves this issue.
// A link the app opened before this screen was showing.
.onChange(of: authService.incomingEmailLink, initial: true) { _, url in
guard let url else { return }
authService.incomingEmailLink = nil
handleLink { try await authService.handleSignInLink(url: url) }
}
.onDisappear {
authService.clearPendingEmailLink()
}| private func updateActionCodeSettings(sessionID: String?) throws -> ActionCodeSettings { | ||
| let actionCodeSettings = try safeActionCodeSettings() |
There was a problem hiding this comment.
Mutating the shared configuration.emailLinkSignInActionCodeSettings object directly can lead to race conditions and unexpected behavior if multiple sign-in links are sent concurrently or if the settings are used elsewhere. Since ActionCodeSettings conforms to NSCopying, it is safer to copy the settings object before modifying its URL.
private func updateActionCodeSettings(sessionID: String?) throws -> ActionCodeSettings {
guard let actionCodeSettings = (try safeActionCodeSettings()).copy() as? ActionCodeSettings else {
throw AuthServiceError.notConfiguredActionCodeSettings("Failed to copy ActionCodeSettings")
}| func handleSignInLink(url: URL) async throws { | ||
| do { | ||
| // Check which flow we're in based on the flag | ||
| let email: String | ||
| let isReauth = isReauthenticating | ||
|
|
||
| if isReauth { | ||
| guard let reauthEmail = emailLinkReauth else { | ||
| throw AuthServiceError | ||
| .invalidEmailLink("Email address is missing for reauthentication") | ||
| } | ||
| email = reauthEmail | ||
| } else { | ||
| guard let signInEmail = emailLink else { | ||
| throw AuthServiceError | ||
| .invalidEmailLink("email address is missing from app storage. Is this the same device?") | ||
| } | ||
| email = signInEmail | ||
| } | ||
| if isReauthenticating { | ||
| try await handleEmailLinkReauthentication(url: url) | ||
| return | ||
| } | ||
|
|
||
| let urlString = url.absoluteString | ||
| let link = try Self.emailSignInLink(from: url) | ||
| guard auth.isSignIn(withEmailLink: link) else { return } |
There was a problem hiding this comment.
The handleSignInLink(url:) method will throw an error if the URL is not a valid sign-in link (e.g., if the 'link' parameter is missing). Since onOpenURL can be triggered by any deep link or universal link within the app, this can cause unrelated links to trigger confusing error messages when the user is on the email link screen. Guarding against non-sign-in links at the start of the method prevents these false positives.
| func handleSignInLink(url: URL) async throws { | |
| do { | |
| // Check which flow we're in based on the flag | |
| let email: String | |
| let isReauth = isReauthenticating | |
| if isReauth { | |
| guard let reauthEmail = emailLinkReauth else { | |
| throw AuthServiceError | |
| .invalidEmailLink("Email address is missing for reauthentication") | |
| } | |
| email = reauthEmail | |
| } else { | |
| guard let signInEmail = emailLink else { | |
| throw AuthServiceError | |
| .invalidEmailLink("email address is missing from app storage. Is this the same device?") | |
| } | |
| email = signInEmail | |
| } | |
| if isReauthenticating { | |
| try await handleEmailLinkReauthentication(url: url) | |
| return | |
| } | |
| let urlString = url.absoluteString | |
| let link = try Self.emailSignInLink(from: url) | |
| guard auth.isSignIn(withEmailLink: link) else { return } | |
| func handleSignInLink(url: URL) async throws { | |
| guard let link = try? Self.emailSignInLink(from: url), | |
| auth.isSignIn(withEmailLink: link) else { return } | |
| if isReauthenticating { | |
| try await handleEmailLinkReauthentication(url: url) | |
| return | |
| } |
| func confirmEmailLinkSignIn(email: String) async throws { | ||
| guard let url = pendingEmailLink else { | ||
| throw AuthServiceError.invalidEmailLink("No sign-in link is waiting for confirmation") | ||
| } | ||
| let link = try Self.emailSignInLink(from: url) | ||
| try await signInWithEmailLink(email: email, link: link, anonymousUserID: nil) | ||
| } |
There was a problem hiding this comment.
Add a helper method to clear the pendingEmailLink state when the user cancels or navigates away from the email confirmation screen. This prevents the UI from getting stuck in the 'Confirm Email' state on subsequent visits.
func confirmEmailLinkSignIn(email: String) async throws {
guard let url = pendingEmailLink else {
throw AuthServiceError.invalidEmailLink("No sign-in link is waiting for confirmation")
}
let link = try Self.emailSignInLink(from: url)
try await signInWithEmailLink(email: email, link: link, anonymousUserID: nil)
}
/// Clears the pending email link when the user cancels or navigates away.
func clearPendingEmailLink() {
pendingEmailLink = nil
}43994e1 to
e74d5a5
Compare
e74d5a5 to
2fef1b8
Compare
0325fa6 to
3036794
Compare
…links The action-code builder replaced the continue URL's query items with ui_auid alone, dropping parameters the app put on it. Keep them and drop only the items the library writes, since the configured settings object is reused for every link.
…vice Restores the FirebaseUI-iOS v15 cross-device email-link flow. Links carry ui_sid and ui_sd; a link opened without this device's session asks the user to confirm their email through EmailLinkContentState.needsEmailConfirmation and confirmEmail, or is rejected when withEmailLinkSignIn(forceSameDevice: true) is set. AuthPickerView now opens the email-link screen for a link received while that screen isn't showing, and the continue URL is read before the link's second decode, which dropped every continue-URL parameter after the first.
A link waiting for email confirmation stayed pending after sign-out, a closed sheet or a used link, so the email-link screen kept asking for an email and never offered to send a new link. Clear it in those cases and add cancelEmailLinkConfirmation with a cancelConfirmation action on EmailLinkContentState. Also leave opened links to an app's own email-link screen, and stop treating a link without ui_sid as this device's once a newer link was sent.
A link opened on another device threw before this change, and apps may rely on that, so keep throwing invalidEmailLink while also setting pendingEmailLink; the stock screen shows the confirm step instead of the error. Ignore a link that is already being handled or already signed in, so AuthPickerView and an app's own onOpenURL handler don't both use the same one-time link.
A second handleSignInLink call with a link already being handled returned at once, so an app's own onOpenURL could treat a failed sign-in as done. It now waits for the first attempt and gets its result. EmailLinkScreen hides the cross-device error only for the link that set pendingEmailLink, and signing out forgets the last used link so tapping it again reports that it expired.
3036794 to
47eed83
Compare
Restores v15's cross-device email-link sign-in: a link opened on a device that didn't request it asks for the email address instead of failing.
forceSameDevicekeeps links on the requesting device. Builds on #1428.Changes
ui_sidandui_sdas in v15, and keep the app's own continue-URL parameters instead of replacing them.EmailLinkContentStategainsneedsEmailConfirmation,confirmEmailandcancelConfirmation, with init defaults, and the stock screen shows a Confirm Email step.handleSignInLink(url:)still throws for such a link and also keeps it inpendingEmailLink; sign-out, closing the sheet or a used link clears it.AuthPickerViewopens the email-link screen when the app receives a link while that screen isn't showing, as after a cold launch or on another device. A call with a link already being handled waits for that attempt and gets its result, so apps' ownonOpenURLhandlers keep working.API Usage
Preview
Maintainer note: Fixes internal CPRN-640