Skip to content

Commit 7dbf7a1

Browse files
Merge pull request #1315 from firebase/email-link-sign-in-reauth
2 parents 2233338 + 1cb5bec commit 7dbf7a1

File tree

8 files changed

+360
-35
lines changed

8 files changed

+360
-35
lines changed

FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/AuthServiceError.swift

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,19 @@ public struct EmailReauthContext: Equatable {
4343
}
4444
}
4545

46+
/// Context information for email link reauthentication
47+
public struct EmailLinkReauthContext: Equatable {
48+
public let email: String
49+
50+
public init(email: String) {
51+
self.email = email
52+
}
53+
54+
public var displayMessage: String {
55+
"Please check your email to verify your identity"
56+
}
57+
}
58+
4659
/// Context information for phone number reauthentication
4760
public struct PhoneReauthContext: Equatable {
4861
public let phoneNumber: String
@@ -60,6 +73,7 @@ public struct PhoneReauthContext: Equatable {
6073
public enum ReauthenticationType: Equatable {
6174
case oauth(OAuthReauthContext)
6275
case email(EmailReauthContext)
76+
case emailLink(EmailLinkReauthContext)
6377
case phone(PhoneReauthContext)
6478

6579
public var displayMessage: String {
@@ -68,6 +82,8 @@ public enum ReauthenticationType: Equatable {
6882
return context.displayMessage
6983
case let .email(context):
7084
return context.displayMessage
85+
case let .emailLink(context):
86+
return context.displayMessage
7187
case let .phone(context):
7288
return context.displayMessage
7389
}
@@ -139,6 +155,9 @@ public enum AuthServiceError: LocalizedError {
139155
/// Email reauthentication required - user must handle password prompt externally
140156
case emailReauthenticationRequired(context: EmailReauthContext)
141157

158+
/// Email link reauthentication required - user must handle email link flow externally
159+
case emailLinkReauthenticationRequired(context: EmailLinkReauthContext)
160+
142161
/// Phone reauthentication required - user must handle SMS verification flow externally
143162
case phoneReauthenticationRequired(context: PhoneReauthContext)
144163

@@ -165,6 +184,8 @@ public enum AuthServiceError: LocalizedError {
165184
return "Please sign in again with \(context.providerName) to continue"
166185
case .emailReauthenticationRequired:
167186
return "Please enter your password to continue"
187+
case .emailLinkReauthenticationRequired:
188+
return "Please check your email to verify your identity"
168189
case .phoneReauthenticationRequired:
169190
return "Please verify your phone number to continue"
170191
case let .invalidCredentials(description):

FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift

Lines changed: 94 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,13 @@ public final class AuthService {
119119
}
120120

121121
@ObservationIgnored @AppStorage("email-link") public var emailLink: String?
122+
// Needed because provider data sign-in doesn't distinguish between email link and password
123+
// sign-in needed for reauthentication
124+
@ObservationIgnored @AppStorage("is-email-link") private var isEmailLinkSignIn: Bool = false
125+
// Storage for email link reauthentication (separate from sign-in)
126+
@ObservationIgnored @AppStorage("email-link-reauth") private var emailLinkReauth: String?
127+
@ObservationIgnored @AppStorage("is-reauthenticating") private var isReauthenticating: Bool =
128+
false
122129

123130
private var currentMFAResolver: MultiFactorResolver?
124131
private var listenerManager: AuthListenerManager?
@@ -176,6 +183,11 @@ public final class AuthService {
176183
try await auth.signOut()
177184
// Cannot wait for auth listener to change, feedback needs to be immediate
178185
currentUser = nil
186+
// Clear email link sign-in flag
187+
isEmailLinkSignIn = false
188+
// Clear email link reauth state
189+
emailLinkReauth = nil
190+
isReauthenticating = false
179191
updateAuthenticationState()
180192
}
181193

@@ -380,20 +392,44 @@ public extension AuthService {
380392
// MARK: - Email Link Sign In
381393

382394
public extension AuthService {
383-
func sendEmailSignInLink(email: String) async throws {
395+
/// Send email link for sign-in or reauthentication
396+
/// - Parameters:
397+
/// - email: Email address to send link to
398+
/// - isReauth: Whether this is for reauthentication (default: false)
399+
func sendEmailSignInLink(email: String, isReauth: Bool = false) async throws {
384400
let actionCodeSettings = try updateActionCodeSettings()
385401
try await auth.sendSignInLink(
386402
toEmail: email,
387403
actionCodeSettings: actionCodeSettings
388404
)
405+
406+
// Store email based on context
407+
if isReauth {
408+
emailLinkReauth = email
409+
isReauthenticating = true
410+
}
389411
}
390412

391413
func handleSignInLink(url url: URL) async throws {
392414
do {
393-
guard let email = emailLink else {
394-
throw AuthServiceError
395-
.invalidEmailLink("email address is missing from app storage. Is this the same device?")
415+
// Check which flow we're in based on the flag
416+
let email: String
417+
let isReauth = isReauthenticating
418+
419+
if isReauth {
420+
guard let reauthEmail = emailLinkReauth else {
421+
throw AuthServiceError
422+
.invalidEmailLink("Email address is missing for reauthentication")
423+
}
424+
email = reauthEmail
425+
} else {
426+
guard let signInEmail = emailLink else {
427+
throw AuthServiceError
428+
.invalidEmailLink("email address is missing from app storage. Is this the same device?")
429+
}
430+
email = signInEmail
396431
}
432+
397433
let urlString = url.absoluteString
398434

399435
guard let originalLink = CommonUtils.getQueryParamValue(from: urlString, paramName: "link")
@@ -407,39 +443,62 @@ public extension AuthService {
407443
.invalidEmailLink("Failed to decode Link URL")
408444
}
409445

410-
guard let continueUrl = CommonUtils.getQueryParamValue(from: link, paramName: "continueUrl")
411-
else {
412-
throw AuthServiceError
413-
.invalidEmailLink("`continueUrl` parameter is missing from the email link URL")
414-
}
415-
416446
if auth.isSignIn(withEmailLink: link) {
417-
let anonymousUserID = CommonUtils.getQueryParamValue(
418-
from: continueUrl,
419-
paramName: "ui_auid"
420-
)
421-
if shouldHandleAnonymousUpgrade, anonymousUserID == currentUser?.uid {
422-
let credential = EmailAuthProvider.credential(withEmail: email, link: link)
423-
try await handleAutoUpgradeAnonymousUser(credentials: credential)
447+
let credential = EmailAuthProvider.credential(withEmail: email, link: link)
448+
449+
if isReauth {
450+
// Reauthentication flow
451+
try await reauthenticate(with: credential)
452+
// Clean up reauth state
453+
emailLinkReauth = nil
454+
isReauthenticating = false
424455
} else {
425-
let result = try await auth.signIn(withEmail: email, link: link)
456+
// Sign-in flow
457+
guard let continueUrl = CommonUtils.getQueryParamValue(
458+
from: link,
459+
paramName: "continueUrl"
460+
)
461+
else {
462+
throw AuthServiceError
463+
.invalidEmailLink("`continueUrl` parameter is missing from the email link URL")
464+
}
465+
466+
let anonymousUserID = CommonUtils.getQueryParamValue(
467+
from: continueUrl,
468+
paramName: "ui_auid"
469+
)
470+
if shouldHandleAnonymousUpgrade, anonymousUserID == currentUser?.uid {
471+
try await handleAutoUpgradeAnonymousUser(credentials: credential)
472+
} else {
473+
let result = try await auth.signIn(withEmail: email, link: link)
474+
}
475+
updateAuthenticationState()
476+
// Track that user signed in with email link
477+
isEmailLinkSignIn = true
478+
emailLink = nil
426479
}
427-
updateAuthenticationState()
428-
emailLink = nil
429480
}
430481
} catch {
431-
// Reconstruct credential for conflict handling
482+
// Determine which email to use for error handling
483+
let email = isReauthenticating ? emailLinkReauth : emailLink
432484
let link = url.absoluteString
433-
guard let email = emailLink else {
485+
486+
guard let email = email else {
434487
throw AuthServiceError
435-
.invalidEmailLink("email address is missing from app storage. Is this the same device?")
488+
.invalidEmailLink("email address is missing from app storage")
436489
}
437490
let credential = EmailAuthProvider.credential(withEmail: email, link: link)
438491

439-
// Possible conflicts from auth.signIn(withEmail:link:):
440-
// - accountExistsWithDifferentCredential: account exists with different provider
441-
// - credentialAlreadyInUse: credential is already linked to another account
442-
try handleErrorWithConflictCheck(error: error, credential: credential)
492+
// Only handle conflicts for sign-in flow, not reauth
493+
if !isReauthenticating {
494+
// Possible conflicts from auth.signIn(withEmail:link:):
495+
// - accountExistsWithDifferentCredential: account exists with different provider
496+
// - credentialAlreadyInUse: credential is already linked to another account
497+
try handleErrorWithConflictCheck(error: error, credential: credential)
498+
} else {
499+
// For reauth, just rethrow
500+
throw error
501+
}
443502
}
444503
}
445504
}
@@ -883,8 +942,14 @@ private extension AuthService {
883942
guard let email = currentUser?.email else {
884943
throw AuthServiceError.noCurrentUser
885944
}
886-
let context = EmailReauthContext(email: email)
887-
throw AuthServiceError.emailReauthenticationRequired(context: context)
945+
// Check if user signed in with email link or password
946+
if isEmailLinkSignIn {
947+
let context = EmailLinkReauthContext(email: email)
948+
throw AuthServiceError.emailLinkReauthenticationRequired(context: context)
949+
} else {
950+
let context = EmailReauthContext(email: email)
951+
throw AuthServiceError.emailReauthenticationRequired(context: context)
952+
}
888953
case PhoneAuthProviderID:
889954
guard let phoneNumber = currentUser?.phoneNumber else {
890955
throw AuthServiceError.noCurrentUser
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
// Copyright 2025 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
import FirebaseAuth
16+
import FirebaseCore
17+
import SwiftUI
18+
19+
@MainActor
20+
public struct EmailLinkReauthView {
21+
@Environment(AuthService.self) private var authService
22+
@Environment(\.reportError) private var reportError
23+
24+
let email: String
25+
let coordinator: ReauthenticationCoordinator
26+
27+
@State private var emailSent = false
28+
@State private var isLoading = false
29+
@State private var error: AlertError?
30+
31+
private func sendEmailLink() async {
32+
isLoading = true
33+
do {
34+
try await authService.sendEmailSignInLink(email: email, isReauth: true)
35+
emailSent = true
36+
isLoading = false
37+
} catch {
38+
if let reportError = reportError {
39+
reportError(error)
40+
} else {
41+
self.error = AlertError(
42+
title: "Error",
43+
message: error.localizedDescription,
44+
underlyingError: error
45+
)
46+
}
47+
isLoading = false
48+
}
49+
}
50+
51+
private func handleReauthURL(_ url: URL) {
52+
Task { @MainActor in
53+
do {
54+
try await authService.handleSignInLink(url: url)
55+
coordinator.reauthCompleted()
56+
} catch {
57+
if let reportError = reportError {
58+
reportError(error)
59+
} else {
60+
self.error = AlertError(
61+
title: "Error",
62+
message: error.localizedDescription,
63+
underlyingError: error
64+
)
65+
}
66+
}
67+
}
68+
}
69+
}
70+
71+
extension EmailLinkReauthView: View {
72+
public var body: some View {
73+
NavigationStack {
74+
VStack(spacing: 24) {
75+
if emailSent {
76+
// "Check your email" state
77+
VStack(spacing: 16) {
78+
Image(systemName: "envelope.open.fill")
79+
.font(.system(size: 60))
80+
.foregroundColor(.accentColor)
81+
.padding(.top, 32)
82+
83+
Text("Check Your Email")
84+
.font(.title)
85+
.fontWeight(.bold)
86+
87+
Text("We've sent a verification link to:")
88+
.font(.body)
89+
.foregroundStyle(.secondary)
90+
91+
Text(email)
92+
.font(.body)
93+
.fontWeight(.medium)
94+
.padding(.horizontal)
95+
96+
Text("Tap the link in the email to complete reauthentication.")
97+
.font(.body)
98+
.multilineTextAlignment(.center)
99+
.foregroundStyle(.secondary)
100+
.padding(.horizontal, 32)
101+
.padding(.top, 8)
102+
103+
Button {
104+
Task {
105+
await sendEmailLink()
106+
}
107+
} label: {
108+
if isLoading {
109+
ProgressView()
110+
.frame(height: 32)
111+
} else {
112+
Text("Resend Email")
113+
.frame(height: 32)
114+
}
115+
}
116+
.buttonStyle(.bordered)
117+
.disabled(isLoading)
118+
.padding(.top, 16)
119+
}
120+
} else {
121+
// Loading/sending state
122+
VStack(spacing: 16) {
123+
ProgressView()
124+
.padding(.top, 32)
125+
Text("Sending verification email...")
126+
.foregroundStyle(.secondary)
127+
}
128+
}
129+
130+
Spacer()
131+
}
132+
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
133+
.navigationTitle("Verify Your Identity")
134+
.navigationBarTitleDisplayMode(.inline)
135+
.toolbar {
136+
ToolbarItem(placement: .cancellationAction) {
137+
Button("Cancel") {
138+
coordinator.reauthCancelled()
139+
}
140+
}
141+
}
142+
.onOpenURL { url in
143+
handleReauthURL(url)
144+
}
145+
.task {
146+
await sendEmailLink()
147+
}
148+
}
149+
.errorAlert(error: $error, okButtonLabel: authService.string.okButtonLabel)
150+
}
151+
}
152+
153+
#Preview {
154+
FirebaseOptions.dummyConfigurationForPreview()
155+
return EmailLinkReauthView(
156+
157+
coordinator: ReauthenticationCoordinator()
158+
)
159+
.environment(AuthService())
160+
}

0 commit comments

Comments
 (0)