Skip to content

feat(auth): confirm the email when a sign-in link opens on another device - #1429

Open
demolaf wants to merge 5 commits into
auth/email-signup-optionsfrom
auth/email-link-same-device
Open

demolaf wants to merge 5 commits into
auth/email-signup-optionsfrom
auth/email-link-same-device

Conversation

@demolaf

@demolaf demolaf commented Nov 4, 2026 •

Copy link
Copy Markdown
Member

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. forceSameDevice keeps links on the requesting device. Builds on #1428.

Changes

  • Sign-in links carry ui_sid and ui_sd as in v15, and keep the app's own continue-URL parameters instead of replacing them.
  • EmailLinkContentState gains needsEmailConfirmation, confirmEmail and cancelConfirmation, with init defaults, and the stock screen shows a Confirm Email step. handleSignInLink(url:) still throws for such a link and also keeps it in pendingEmailLink; sign-out, closing the sheet or a used link clears it.
  • AuthPickerView opens 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' own onOpenURL handlers keep working.
  • Reads the continue URL before the link's second decode, which split off every continue-URL parameter after the first.

API Usage

authService.withEmailLinkSignIn(forceSameDevice: false)

EmailLinkScreen { state in
  if state.needsEmailConfirmation {
    Text("Enter the email this link was sent to")
  }
  TextField("Email", text: state.email)
  Button(state.needsEmailConfirmation ? "Confirm" : "Send link") {
    if state.needsEmailConfirmation {
      state.confirmEmail()
    } else {
      state.sendSignInLink()
    }
  }
  .disabled(!state.isValid || state.isLoading)
  if state.needsEmailConfirmation {
    Button("Send a new link", action: state.cancelConfirmation)
  }
}

Preview


Maintainer note: Fixes internal CPRN-640

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +138 to 143
// 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) }
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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()
    }

Comment on lines +1139 to 1140
private func updateActionCodeSettings(sessionID: String?) throws -> ActionCodeSettings {
let actionCodeSettings = try safeActionCodeSettings()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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")
    }

Comment on lines 629 to +636
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 }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
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
}

Comment on lines +659 to +665
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)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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
  }

@demolaf
demolaf force-pushed the auth/email-link-same-device branch from 43994e1 to e74d5a5 Compare November 4, 2026 19:24
@demolaf
demolaf force-pushed the auth/email-link-same-device branch from e74d5a5 to 2fef1b8 Compare November 9, 2026 15:41
@demolaf
demolaf added this pull request to stack #1433 November 9, 2026 15:50
@demolaf
demolaf marked this pull request as ready for review November 10, 2026 14:25
@demolaf
demolaf force-pushed the auth/email-link-same-device branch from 0325fa6 to 3036794 Compare November 10, 2026 14:30
…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.
@demolaf
demolaf force-pushed the auth/email-link-same-device branch from 3036794 to 47eed83 Compare November 10, 2026 15:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant