From 7920513498397588c5071cc4eaa6c786f40df8e6 Mon Sep 17 00:00:00 2001 From: russellwheatley Date: Fri, 21 Nov 2025 10:48:15 +0000 Subject: [PATCH 1/4] feat: email link sign-in --- .../Sources/AuthServiceError.swift | 21 +++++++++++++++++++ .../Sources/Services/AuthService.swift | 17 +++++++++++++-- .../Services/ReauthenticationHelpers.swift | 2 ++ 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/AuthServiceError.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/AuthServiceError.swift index af3e7a5e40..e14af23655 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/AuthServiceError.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/AuthServiceError.swift @@ -43,6 +43,19 @@ public struct EmailReauthContext: Equatable { } } +/// Context information for email link reauthentication +public struct EmailLinkReauthContext: Equatable { + public let email: String + + public init(email: String) { + self.email = email + } + + public var displayMessage: String { + "Please check your email to verify your identity" + } +} + /// Context information for phone number reauthentication public struct PhoneReauthContext: Equatable { public let phoneNumber: String @@ -60,6 +73,7 @@ public struct PhoneReauthContext: Equatable { public enum ReauthenticationType: Equatable { case oauth(OAuthReauthContext) case email(EmailReauthContext) + case emailLink(EmailLinkReauthContext) case phone(PhoneReauthContext) public var displayMessage: String { @@ -68,6 +82,8 @@ public enum ReauthenticationType: Equatable { return context.displayMessage case let .email(context): return context.displayMessage + case let .emailLink(context): + return context.displayMessage case let .phone(context): return context.displayMessage } @@ -139,6 +155,9 @@ public enum AuthServiceError: LocalizedError { /// Email reauthentication required - user must handle password prompt externally case emailReauthenticationRequired(context: EmailReauthContext) + /// Email link reauthentication required - user must handle email link flow externally + case emailLinkReauthenticationRequired(context: EmailLinkReauthContext) + /// Phone reauthentication required - user must handle SMS verification flow externally case phoneReauthenticationRequired(context: PhoneReauthContext) @@ -165,6 +184,8 @@ public enum AuthServiceError: LocalizedError { return "Please sign in again with \(context.providerName) to continue" case .emailReauthenticationRequired: return "Please enter your password to continue" + case .emailLinkReauthenticationRequired: + return "Please check your email to verify your identity" case .phoneReauthenticationRequired: return "Please verify your phone number to continue" case let .invalidCredentials(description): diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift index 8dc932ffcd..5ae849179f 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift @@ -119,6 +119,9 @@ public final class AuthService { } @ObservationIgnored @AppStorage("email-link") public var emailLink: String? + // Needed because provider data sign-in doesn't distinguish between email link and password + // sign-in needed for reauthentication + @ObservationIgnored @AppStorage("is-email-link") private var isEmailLinkSignIn: Bool = false private var currentMFAResolver: MultiFactorResolver? private var listenerManager: AuthListenerManager? @@ -176,6 +179,8 @@ public final class AuthService { try await auth.signOut() // Cannot wait for auth listener to change, feedback needs to be immediate currentUser = nil + // Clear email link sign-in flag + isEmailLinkSignIn = false updateAuthenticationState() } @@ -425,6 +430,8 @@ public extension AuthService { let result = try await auth.signIn(withEmail: email, link: link) } updateAuthenticationState() + // Track that user signed in with email link + isEmailLinkSignIn = true emailLink = nil } } catch { @@ -883,8 +890,14 @@ private extension AuthService { guard let email = currentUser?.email else { throw AuthServiceError.noCurrentUser } - let context = EmailReauthContext(email: email) - throw AuthServiceError.emailReauthenticationRequired(context: context) + // Check if user signed in with email link or password + if isEmailLinkSignIn { + let context = EmailLinkReauthContext(email: email) + throw AuthServiceError.emailLinkReauthenticationRequired(context: context) + } else { + let context = EmailReauthContext(email: email) + throw AuthServiceError.emailReauthenticationRequired(context: context) + } case PhoneAuthProviderID: guard let phoneNumber = currentUser?.phoneNumber else { throw AuthServiceError.noCurrentUser diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/ReauthenticationHelpers.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/ReauthenticationHelpers.swift index b1caec48ea..716075c52f 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/ReauthenticationHelpers.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/ReauthenticationHelpers.swift @@ -35,6 +35,8 @@ public func withReauthenticationIfNeeded(authService _: AuthService, switch error { case let .emailReauthenticationRequired(ctx): reauthContext = .email(ctx) + case let .emailLinkReauthenticationRequired(ctx): + reauthContext = .emailLink(ctx) case let .phoneReauthenticationRequired(ctx): reauthContext = .phone(ctx) case let .oauthReauthenticationRequired(ctx): From 0de6a544b1cb62806c9d12422b947071d8f4233c Mon Sep 17 00:00:00 2001 From: russellwheatley Date: Fri, 21 Nov 2025 10:49:49 +0000 Subject: [PATCH 2/4] chore: move reauth helpers into Views --- .../Sources/{Services => Views}/ReauthenticationCoordinator.swift | 0 .../Sources/{Services => Views}/ReauthenticationHelpers.swift | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/{Services => Views}/ReauthenticationCoordinator.swift (100%) rename FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/{Services => Views}/ReauthenticationHelpers.swift (100%) diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/ReauthenticationCoordinator.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/ReauthenticationCoordinator.swift similarity index 100% rename from FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/ReauthenticationCoordinator.swift rename to FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/ReauthenticationCoordinator.swift diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/ReauthenticationHelpers.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/ReauthenticationHelpers.swift similarity index 100% rename from FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/ReauthenticationHelpers.swift rename to FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/ReauthenticationHelpers.swift From 0d3bde96053c0235c185b91b2e500581400577dd Mon Sep 17 00:00:00 2001 From: russellwheatley Date: Fri, 21 Nov 2025 11:28:14 +0000 Subject: [PATCH 3/4] feature: implement reauth for email link in Views and updating method in auth service --- .../Sources/Services/AuthService.swift | 110 ++++++++---- .../Sources/Views/EmailLinkReauthView.swift | 160 ++++++++++++++++++ .../Views/ReauthenticationCoordinator.swift | 12 ++ .../Views/ReauthenticationModifier.swift | 25 +++ .../FirebaseSwiftUISample.entitlements | 5 + 5 files changed, 283 insertions(+), 29 deletions(-) create mode 100644 FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkReauthView.swift diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift index 5ae849179f..35b8bd66cd 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift @@ -122,6 +122,10 @@ public final class AuthService { // Needed because provider data sign-in doesn't distinguish between email link and password // sign-in needed for reauthentication @ObservationIgnored @AppStorage("is-email-link") private var isEmailLinkSignIn: Bool = false + // Storage for email link reauthentication (separate from sign-in) + @ObservationIgnored @AppStorage("email-link-reauth") private var emailLinkReauth: String? + @ObservationIgnored @AppStorage("is-reauthenticating") private var isReauthenticating: Bool = + false private var currentMFAResolver: MultiFactorResolver? private var listenerManager: AuthListenerManager? @@ -181,6 +185,9 @@ public final class AuthService { currentUser = nil // Clear email link sign-in flag isEmailLinkSignIn = false + // Clear email link reauth state + emailLinkReauth = nil + isReauthenticating = false updateAuthenticationState() } @@ -385,20 +392,44 @@ public extension AuthService { // MARK: - Email Link Sign In public extension AuthService { - func sendEmailSignInLink(email: String) async throws { + /// Send email link for sign-in or reauthentication + /// - Parameters: + /// - email: Email address to send link to + /// - isReauth: Whether this is for reauthentication (default: false) + func sendEmailSignInLink(email: String, isReauth: Bool = false) async throws { let actionCodeSettings = try updateActionCodeSettings() try await auth.sendSignInLink( toEmail: email, actionCodeSettings: actionCodeSettings ) + + // Store email based on context + if isReauth { + emailLinkReauth = email + isReauthenticating = true + } } func handleSignInLink(url url: URL) async throws { do { - guard let email = emailLink else { - throw AuthServiceError - .invalidEmailLink("email address is missing from app storage. Is this the same device?") + // 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 } + let urlString = url.absoluteString guard let originalLink = CommonUtils.getQueryParamValue(from: urlString, paramName: "link") @@ -412,41 +443,62 @@ public extension AuthService { .invalidEmailLink("Failed to decode Link URL") } - guard let continueUrl = CommonUtils.getQueryParamValue(from: link, paramName: "continueUrl") - else { - throw AuthServiceError - .invalidEmailLink("`continueUrl` parameter is missing from the email link URL") - } - if auth.isSignIn(withEmailLink: link) { - let anonymousUserID = CommonUtils.getQueryParamValue( - from: continueUrl, - paramName: "ui_auid" - ) - if shouldHandleAnonymousUpgrade, anonymousUserID == currentUser?.uid { - let credential = EmailAuthProvider.credential(withEmail: email, link: link) - try await handleAutoUpgradeAnonymousUser(credentials: credential) + let credential = EmailAuthProvider.credential(withEmail: email, link: link) + + if isReauth { + // Reauthentication flow + try await reauthenticate(with: credential) + // Clean up reauth state + emailLinkReauth = nil + isReauthenticating = false } else { - let result = try await auth.signIn(withEmail: email, link: link) + // Sign-in flow + guard let continueUrl = CommonUtils.getQueryParamValue( + from: link, + paramName: "continueUrl" + ) + else { + throw AuthServiceError + .invalidEmailLink("`continueUrl` parameter is missing from the email link URL") + } + + let anonymousUserID = CommonUtils.getQueryParamValue( + from: continueUrl, + paramName: "ui_auid" + ) + if shouldHandleAnonymousUpgrade, anonymousUserID == currentUser?.uid { + try await handleAutoUpgradeAnonymousUser(credentials: credential) + } else { + let result = try await auth.signIn(withEmail: email, link: link) + } + updateAuthenticationState() + // Track that user signed in with email link + isEmailLinkSignIn = true + emailLink = nil } - updateAuthenticationState() - // Track that user signed in with email link - isEmailLinkSignIn = true - emailLink = nil } } catch { - // Reconstruct credential for conflict handling + // Determine which email to use for error handling + let email = isReauthenticating ? emailLinkReauth : emailLink let link = url.absoluteString - guard let email = emailLink else { + + guard let email = email else { throw AuthServiceError - .invalidEmailLink("email address is missing from app storage. Is this the same device?") + .invalidEmailLink("email address is missing from app storage") } let credential = EmailAuthProvider.credential(withEmail: email, link: link) - // Possible conflicts from auth.signIn(withEmail:link:): - // - accountExistsWithDifferentCredential: account exists with different provider - // - credentialAlreadyInUse: credential is already linked to another account - try handleErrorWithConflictCheck(error: error, credential: credential) + // Only handle conflicts for sign-in flow, not reauth + if !isReauthenticating { + // Possible conflicts from auth.signIn(withEmail:link:): + // - accountExistsWithDifferentCredential: account exists with different provider + // - credentialAlreadyInUse: credential is already linked to another account + try handleErrorWithConflictCheck(error: error, credential: credential) + } else { + // For reauth, just rethrow + throw error + } } } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkReauthView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkReauthView.swift new file mode 100644 index 0000000000..f151cda431 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkReauthView.swift @@ -0,0 +1,160 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import FirebaseAuth +import FirebaseCore +import SwiftUI + +@MainActor +public struct EmailLinkReauthView { + @Environment(AuthService.self) private var authService + @Environment(\.reportError) private var reportError + + let email: String + let coordinator: ReauthenticationCoordinator + + @State private var emailSent = false + @State private var isLoading = false + @State private var error: AlertError? + + private func sendEmailLink() async { + isLoading = true + do { + try await authService.sendEmailSignInLink(email: email, isReauth: true) + emailSent = true + isLoading = false + } catch { + if let reportError = reportError { + reportError(error) + } else { + self.error = AlertError( + title: "Error", + message: error.localizedDescription, + underlyingError: error + ) + } + isLoading = false + } + } + + private func handleReauthURL(_ url: URL) { + Task { @MainActor in + do { + try await authService.handleSignInLink(url: url) + coordinator.reauthCompleted() + } catch { + if let reportError = reportError { + reportError(error) + } else { + self.error = AlertError( + title: "Error", + message: error.localizedDescription, + underlyingError: error + ) + } + } + } + } +} + +extension EmailLinkReauthView: View { + public var body: some View { + NavigationStack { + VStack(spacing: 24) { + if emailSent { + // "Check your email" state + VStack(spacing: 16) { + Image(systemName: "envelope.open.fill") + .font(.system(size: 60)) + .foregroundColor(.accentColor) + .padding(.top, 32) + + Text("Check Your Email") + .font(.title) + .fontWeight(.bold) + + Text("We've sent a verification link to:") + .font(.body) + .foregroundStyle(.secondary) + + Text(email) + .font(.body) + .fontWeight(.medium) + .padding(.horizontal) + + Text("Tap the link in the email to complete reauthentication.") + .font(.body) + .multilineTextAlignment(.center) + .foregroundStyle(.secondary) + .padding(.horizontal, 32) + .padding(.top, 8) + + Button { + Task { + await sendEmailLink() + } + } label: { + if isLoading { + ProgressView() + .frame(height: 32) + } else { + Text("Resend Email") + .frame(height: 32) + } + } + .buttonStyle(.bordered) + .disabled(isLoading) + .padding(.top, 16) + } + } else { + // Loading/sending state + VStack(spacing: 16) { + ProgressView() + .padding(.top, 32) + Text("Sending verification email...") + .foregroundStyle(.secondary) + } + } + + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .navigationTitle("Verify Your Identity") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + coordinator.reauthCancelled() + } + } + } + .onOpenURL { url in + handleReauthURL(url) + } + .task { + await sendEmailLink() + } + } + .errorAlert(error: $error, okButtonLabel: authService.string.okButtonLabel) + } +} + +#Preview { + FirebaseOptions.dummyConfigurationForPreview() + return EmailLinkReauthView( + email: "test@example.com", + coordinator: ReauthenticationCoordinator() + ) + .environment(AuthService()) +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/ReauthenticationCoordinator.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/ReauthenticationCoordinator.swift index 16127cb924..2e14fa17f4 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/ReauthenticationCoordinator.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/ReauthenticationCoordinator.swift @@ -24,6 +24,8 @@ public final class ReauthenticationCoordinator { public var showingPhoneReauth = false public var showingPhoneReauthAlert = false public var showingEmailPasswordPrompt = false + public var showingEmailLinkReauth = false + public var showingEmailLinkReauthAlert = false private var continuation: CheckedContinuation? @@ -41,6 +43,8 @@ public final class ReauthenticationCoordinator { self.showingPhoneReauthAlert = true case .email: self.showingEmailPasswordPrompt = true + case .emailLink: + self.showingEmailLinkReauthAlert = true case .oauth: // For OAuth providers (Google, Apple, etc.) self.isReauthenticating = true @@ -54,6 +58,12 @@ public final class ReauthenticationCoordinator { showingPhoneReauth = true } + /// Called when user confirms email link reauth alert + public func confirmEmailLinkReauth() { + showingEmailLinkReauthAlert = false + showingEmailLinkReauth = true + } + /// Called when reauthentication completes successfully public func reauthCompleted() { continuation?.resume() @@ -72,6 +82,8 @@ public final class ReauthenticationCoordinator { showingPhoneReauth = false showingPhoneReauthAlert = false showingEmailPasswordPrompt = false + showingEmailLinkReauth = false + showingEmailLinkReauthAlert = false reauthContext = nil } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/ReauthenticationModifier.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/ReauthenticationModifier.swift index 2c976f0683..b20b23a59f 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/ReauthenticationModifier.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/ReauthenticationModifier.swift @@ -72,6 +72,31 @@ struct ReauthenticationModifier: ViewModifier { ) } } + // Alert for email link reauthentication + .alert( + "Email Verification Required", + isPresented: $coordinator.showingEmailLinkReauthAlert + ) { + Button("Send Verification Email") { + coordinator.confirmEmailLinkReauth() + } + Button("Cancel", role: .cancel) { + coordinator.reauthCancelled() + } + } message: { + if case let .emailLink(context) = coordinator.reauthContext { + Text("We'll send a verification link to \(context.email). Tap the link to continue.") + } + } + // Sheet for email link reauthentication + .sheet(isPresented: $coordinator.showingEmailLinkReauth) { + if case let .emailLink(context) = coordinator.reauthContext { + EmailLinkReauthView( + email: context.email, + coordinator: coordinator + ) + } + } } private func performReauth() { diff --git a/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/FirebaseSwiftUISample.entitlements b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/FirebaseSwiftUISample.entitlements index 80b5221de7..58e1cda1f4 100644 --- a/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/FirebaseSwiftUISample.entitlements +++ b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/FirebaseSwiftUISample.entitlements @@ -8,5 +8,10 @@ Default + com.apple.developer.associated-domains + + applinks:flutterfire-e2e-tests.firebaseapp.com + applinks:flutterfiretests.page.link + From 1cb5bec6d80bf6024281c86fb520c56081567dec Mon Sep 17 00:00:00 2001 From: russellwheatley Date: Fri, 21 Nov 2025 11:34:50 +0000 Subject: [PATCH 4/4] docs: update documentation for email link reauth --- FirebaseSwiftUI/README.md | 47 ++++++++++++++++++++++++++++++++++----- 1 file changed, 41 insertions(+), 6 deletions(-) diff --git a/FirebaseSwiftUI/README.md b/FirebaseSwiftUI/README.md index c0784768ca..a017e0c267 100644 --- a/FirebaseSwiftUI/README.md +++ b/FirebaseSwiftUI/README.md @@ -278,6 +278,8 @@ When a sensitive operation requires reauthentication, the default views automati - **Email/Password**: Present a sheet prompting the user to enter their password before continuing. +- **Email Link**: Show an alert asking to send a verification email, then present a sheet with instructions to check email. The user taps the link in their email to complete reauthentication. + - **Phone**: Show an alert explaining verification is needed, then present a sheet for SMS code verification. The operation automatically retries after successful reauthentication. No additional code is required when using `AuthPickerView` or the built-in account management views (`UpdatePasswordView`, `SignedInView`, etc.). @@ -655,7 +657,7 @@ When building custom views, you need to handle several things yourself that `Aut ### Reauthentication in Custom Views -When building custom views, handle reauthentication by catching specific errors and implementing your own flow. Sensitive operations throw three types of reauthentication errors, each containing context information. +When building custom views, handle reauthentication by catching specific errors and implementing your own flow. Sensitive operations throw four types of reauthentication errors, each containing context information. #### Implementation Patterns @@ -720,6 +722,31 @@ do { } ``` +**Email Link:** + +Catch the error, send verification email, and handle the incoming URL: + +```swift +do { + try await authService.updatePassword(to: newPassword) +} catch let error as AuthServiceError { + if case .emailLinkReauthenticationRequired(let context) = error { + // Send verification email + try await authService.sendEmailSignInLink( + email: context.email, + isReauth: true + ) + // Show your "Check your email" UI + await showCheckEmailUI() + // When user taps the link, it opens your app with a URL + // Handle it in your URL handler: + // try await authService.handleSignInLink(url: url) + // The handleSignInLink method automatically completes reauthentication + try await authService.updatePassword(to: newPassword) // Retry + } +} +``` + All reauthentication context objects include a `.displayMessage` property for user-facing text. ### Custom OAuth Providers @@ -1193,18 +1220,21 @@ Links a new authentication method to the current user's account. ##### Send Email Sign-In Link ```swift -public func sendEmailSignInLink(email: String) async throws +public func sendEmailSignInLink(email: String, isReauth: Bool = false) async throws ``` -Sends a sign-in link to the specified email address. +Sends a sign-in link to the specified email address. Can also be used for reauthentication. **Parameters:** - `email`: Email address to send the link to +- `isReauth`: Whether this is for reauthentication (default: `false`) **Throws:** `AuthServiceError` or Firebase Auth errors **Requirements:** `emailLinkSignInActionCodeSettings` must be configured in `AuthConfiguration` +**Note:** When `isReauth` is `true`, the method stores the email for reauthentication flow. The same `handleSignInLink(url:)` method handles both sign-in and reauthentication automatically. + --- ##### Handle Sign-In Link @@ -1213,13 +1243,15 @@ Sends a sign-in link to the specified email address. public func handleSignInLink(url url: URL) async throws ``` -Handles the sign-in flow when the user taps the email link. +Handles the email link flow when the user taps the link. Automatically routes to either sign-in or reauthentication based on the current context. **Parameters:** - `url`: The deep link URL from the email **Throws:** `AuthServiceError` or Firebase Auth errors +**Note:** This method handles both initial sign-in and reauthentication flows automatically. The behavior is determined by whether `sendEmailSignInLink(email:isReauth:)` was called with `isReauth: true`. + --- #### Phone Authentication @@ -1305,7 +1337,7 @@ Updates the current user's password. This is a sensitive operation that may requ **Throws:** - `AuthServiceError.noCurrentUser` if no user is signed in -- Reauthentication errors (`emailReauthenticationRequired`, `phoneReauthenticationRequired`, or `oauthReauthenticationRequired`) if recent authentication is required - see [Reauthentication](#reauthentication-in-default-views) +- Reauthentication errors (`emailReauthenticationRequired`, `emailLinkReauthenticationRequired`, `phoneReauthenticationRequired`, or `oauthReauthenticationRequired`) if recent authentication is required - see [Reauthentication](#reauthentication-in-default-views) - Firebase Auth errors --- @@ -1332,7 +1364,7 @@ Deletes the current user's account. This is a sensitive operation that requires **Throws:** - `AuthServiceError.noCurrentUser` if no user is signed in -- Reauthentication errors (`emailReauthenticationRequired`, `phoneReauthenticationRequired`, or `oauthReauthenticationRequired`) if recent authentication is required - see [Reauthentication](#reauthentication-in-default-views) +- Reauthentication errors (`emailReauthenticationRequired`, `emailLinkReauthenticationRequired`, `phoneReauthenticationRequired`, or `oauthReauthenticationRequired`) if recent authentication is required - see [Reauthentication](#reauthentication-in-default-views) - Firebase Auth errors --- @@ -1638,6 +1670,7 @@ public enum AuthServiceError: Error { case multiFactorAuth(String) case oauthReauthenticationRequired(context: OAuthReauthContext) case emailReauthenticationRequired(context: EmailReauthContext) + case emailLinkReauthenticationRequired(context: EmailLinkReauthContext) case phoneReauthenticationRequired(context: PhoneReauthContext) case accountConflict(AccountConflictContext) } @@ -1653,6 +1686,8 @@ Thrown by sensitive operations when Firebase requires recent authentication. Eac - **`emailReauthenticationRequired(context: EmailReauthContext)`**: Email/password provider. Context contains `email` and `displayMessage`. Prompt for password, then call `reauthenticate(with:)`. +- **`emailLinkReauthenticationRequired(context: EmailLinkReauthContext)`**: Email link (passwordless) provider. Context contains `email` and `displayMessage`. Send verification email with `sendEmailSignInLink(email:isReauth:true)`, then handle the incoming link with `handleSignInLink(url:)`. + - **`phoneReauthenticationRequired(context: PhoneReauthContext)`**: Phone provider. Context contains `phoneNumber` and `displayMessage`. Handle SMS verification, then call `reauthenticate(with:)`. ---