Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/AuthServiceError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -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
}
Expand Down Expand Up @@ -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)

Expand All @@ -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):
Expand Down
123 changes: 94 additions & 29 deletions FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,13 @@ 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
// 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?
Expand Down Expand Up @@ -176,6 +183,11 @@ 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
// Clear email link reauth state
emailLinkReauth = nil
isReauthenticating = false
updateAuthenticationState()
}

Expand Down Expand Up @@ -380,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")
Expand All @@ -407,39 +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()
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
}
}
}
}
Expand Down Expand Up @@ -883,8 +942,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
Expand Down
Original file line number Diff line number Diff line change
@@ -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())
}
Loading
Loading