Skip to content

Commit 0d3bde9

Browse files
feature: implement reauth for email link in Views and updating method in auth service
1 parent 0de6a54 commit 0d3bde9

File tree

5 files changed

+283
-29
lines changed

5 files changed

+283
-29
lines changed

FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift

Lines changed: 81 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,10 @@ public final class AuthService {
122122
// Needed because provider data sign-in doesn't distinguish between email link and password
123123
// sign-in needed for reauthentication
124124
@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
125129

126130
private var currentMFAResolver: MultiFactorResolver?
127131
private var listenerManager: AuthListenerManager?
@@ -181,6 +185,9 @@ public final class AuthService {
181185
currentUser = nil
182186
// Clear email link sign-in flag
183187
isEmailLinkSignIn = false
188+
// Clear email link reauth state
189+
emailLinkReauth = nil
190+
isReauthenticating = false
184191
updateAuthenticationState()
185192
}
186193

@@ -385,20 +392,44 @@ public extension AuthService {
385392
// MARK: - Email Link Sign In
386393

387394
public extension AuthService {
388-
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 {
389400
let actionCodeSettings = try updateActionCodeSettings()
390401
try await auth.sendSignInLink(
391402
toEmail: email,
392403
actionCodeSettings: actionCodeSettings
393404
)
405+
406+
// Store email based on context
407+
if isReauth {
408+
emailLinkReauth = email
409+
isReauthenticating = true
410+
}
394411
}
395412

396413
func handleSignInLink(url url: URL) async throws {
397414
do {
398-
guard let email = emailLink else {
399-
throw AuthServiceError
400-
.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
401431
}
432+
402433
let urlString = url.absoluteString
403434

404435
guard let originalLink = CommonUtils.getQueryParamValue(from: urlString, paramName: "link")
@@ -412,41 +443,62 @@ public extension AuthService {
412443
.invalidEmailLink("Failed to decode Link URL")
413444
}
414445

415-
guard let continueUrl = CommonUtils.getQueryParamValue(from: link, paramName: "continueUrl")
416-
else {
417-
throw AuthServiceError
418-
.invalidEmailLink("`continueUrl` parameter is missing from the email link URL")
419-
}
420-
421446
if auth.isSignIn(withEmailLink: link) {
422-
let anonymousUserID = CommonUtils.getQueryParamValue(
423-
from: continueUrl,
424-
paramName: "ui_auid"
425-
)
426-
if shouldHandleAnonymousUpgrade, anonymousUserID == currentUser?.uid {
427-
let credential = EmailAuthProvider.credential(withEmail: email, link: link)
428-
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
429455
} else {
430-
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
431479
}
432-
updateAuthenticationState()
433-
// Track that user signed in with email link
434-
isEmailLinkSignIn = true
435-
emailLink = nil
436480
}
437481
} catch {
438-
// Reconstruct credential for conflict handling
482+
// Determine which email to use for error handling
483+
let email = isReauthenticating ? emailLinkReauth : emailLink
439484
let link = url.absoluteString
440-
guard let email = emailLink else {
485+
486+
guard let email = email else {
441487
throw AuthServiceError
442-
.invalidEmailLink("email address is missing from app storage. Is this the same device?")
488+
.invalidEmailLink("email address is missing from app storage")
443489
}
444490
let credential = EmailAuthProvider.credential(withEmail: email, link: link)
445491

446-
// Possible conflicts from auth.signIn(withEmail:link:):
447-
// - accountExistsWithDifferentCredential: account exists with different provider
448-
// - credentialAlreadyInUse: credential is already linked to another account
449-
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+
}
450502
}
451503
}
452504
}
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+
}

FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/ReauthenticationCoordinator.swift

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ public final class ReauthenticationCoordinator {
2424
public var showingPhoneReauth = false
2525
public var showingPhoneReauthAlert = false
2626
public var showingEmailPasswordPrompt = false
27+
public var showingEmailLinkReauth = false
28+
public var showingEmailLinkReauthAlert = false
2729

2830
private var continuation: CheckedContinuation<Void, Error>?
2931

@@ -41,6 +43,8 @@ public final class ReauthenticationCoordinator {
4143
self.showingPhoneReauthAlert = true
4244
case .email:
4345
self.showingEmailPasswordPrompt = true
46+
case .emailLink:
47+
self.showingEmailLinkReauthAlert = true
4448
case .oauth:
4549
// For OAuth providers (Google, Apple, etc.)
4650
self.isReauthenticating = true
@@ -54,6 +58,12 @@ public final class ReauthenticationCoordinator {
5458
showingPhoneReauth = true
5559
}
5660

61+
/// Called when user confirms email link reauth alert
62+
public func confirmEmailLinkReauth() {
63+
showingEmailLinkReauthAlert = false
64+
showingEmailLinkReauth = true
65+
}
66+
5767
/// Called when reauthentication completes successfully
5868
public func reauthCompleted() {
5969
continuation?.resume()
@@ -72,6 +82,8 @@ public final class ReauthenticationCoordinator {
7282
showingPhoneReauth = false
7383
showingPhoneReauthAlert = false
7484
showingEmailPasswordPrompt = false
85+
showingEmailLinkReauth = false
86+
showingEmailLinkReauthAlert = false
7587
reauthContext = nil
7688
}
7789
}

FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/ReauthenticationModifier.swift

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,31 @@ struct ReauthenticationModifier: ViewModifier {
7272
)
7373
}
7474
}
75+
// Alert for email link reauthentication
76+
.alert(
77+
"Email Verification Required",
78+
isPresented: $coordinator.showingEmailLinkReauthAlert
79+
) {
80+
Button("Send Verification Email") {
81+
coordinator.confirmEmailLinkReauth()
82+
}
83+
Button("Cancel", role: .cancel) {
84+
coordinator.reauthCancelled()
85+
}
86+
} message: {
87+
if case let .emailLink(context) = coordinator.reauthContext {
88+
Text("We'll send a verification link to \(context.email). Tap the link to continue.")
89+
}
90+
}
91+
// Sheet for email link reauthentication
92+
.sheet(isPresented: $coordinator.showingEmailLinkReauth) {
93+
if case let .emailLink(context) = coordinator.reauthContext {
94+
EmailLinkReauthView(
95+
email: context.email,
96+
coordinator: coordinator
97+
)
98+
}
99+
}
75100
}
76101

77102
private func performReauth() {

0 commit comments

Comments
 (0)