diff --git a/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Services/AppleProviderAuthUI.swift b/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Services/AppleProviderAuthUI.swift index 610c08624c..ccc454db30 100644 --- a/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Services/AppleProviderAuthUI.swift +++ b/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Services/AppleProviderAuthUI.swift @@ -143,6 +143,7 @@ public class AppleProviderAuthUI: AuthProviderUI { private let typedProvider: AppleProviderSwift public var provider: AuthProviderSwift { typedProvider } public let id: String = "apple.com" + public let displayName: String = "Apple" public init(provider: AppleProviderSwift) { typedProvider = provider diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/AuthServiceError.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/AuthServiceError.swift index 74575684cd..af3e7a5e40 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/AuthServiceError.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/AuthServiceError.swift @@ -15,6 +15,65 @@ import FirebaseAuth import SwiftUI +/// Context information for OAuth provider reauthentication (Google, Apple, Facebook, Twitter, etc.) +public struct OAuthReauthContext: Equatable { + public let providerId: String + public let providerName: String + + public init(providerId: String, providerName: String) { + self.providerId = providerId + self.providerName = providerName + } + + public var displayMessage: String { + "Please sign in with \(providerName) to continue" + } +} + +/// Context information for email/password reauthentication +public struct EmailReauthContext: Equatable { + public let email: String + + public init(email: String) { + self.email = email + } + + public var displayMessage: String { + "Please enter your password to continue" + } +} + +/// Context information for phone number reauthentication +public struct PhoneReauthContext: Equatable { + public let phoneNumber: String + + public init(phoneNumber: String) { + self.phoneNumber = phoneNumber + } + + public var displayMessage: String { + "Please verify your phone number to continue" + } +} + +/// Type-safe wrapper for reauthentication contexts +public enum ReauthenticationType: Equatable { + case oauth(OAuthReauthContext) + case email(EmailReauthContext) + case phone(PhoneReauthContext) + + public var displayMessage: String { + switch self { + case let .oauth(context): + return context.displayMessage + case let .email(context): + return context.displayMessage + case let .phone(context): + return context.displayMessage + } + } +} + /// Describes the specific type of account conflict that occurred public enum AccountConflictType: Equatable { /// Account exists with a different provider (e.g., user signed up with Google, trying to use @@ -72,7 +131,17 @@ public enum AuthServiceError: LocalizedError { case invalidEmailLink(String) case clientIdNotFound(String) case notConfiguredActionCodeSettings(String) - case reauthenticationRequired(String) + + /// OAuth reauthentication required (Google, Apple, Facebook, Twitter, etc.) + /// Can be passed directly to `reauthenticate(context:)` method + case oauthReauthenticationRequired(context: OAuthReauthContext) + + /// Email reauthentication required - user must handle password prompt externally + case emailReauthenticationRequired(context: EmailReauthContext) + + /// Phone reauthentication required - user must handle SMS verification flow externally + case phoneReauthenticationRequired(context: PhoneReauthContext) + case invalidCredentials(String) case signInFailed(underlying: Error) case accountConflict(AccountConflictContext) @@ -92,8 +161,12 @@ public enum AuthServiceError: LocalizedError { return description case let .notConfiguredActionCodeSettings(description): return description - case let .reauthenticationRequired(description): - return description + case let .oauthReauthenticationRequired(context): + return "Please sign in again with \(context.providerName) to continue" + case .emailReauthenticationRequired: + return "Please enter your password to continue" + case .phoneReauthenticationRequired: + return "Please verify your phone number to continue" case let .invalidCredentials(description): return description // Use when failed to sign-in with Firebase diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AccountService+Email.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AccountService+Email.swift deleted file mode 100644 index 2bb0b053eb..0000000000 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AccountService+Email.swift +++ /dev/null @@ -1,49 +0,0 @@ -// 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 Observation - -/// Coordinator for prompting users to enter their password during reauthentication flows -@MainActor -@Observable -public final class PasswordPromptCoordinator { - public var isPromptingPassword = false - private var continuation: CheckedContinuation? - - public init() {} - - public func confirmPassword() async throws -> String { - return try await withCheckedThrowingContinuation { continuation in - self.continuation = continuation - self.isPromptingPassword = true - } - } - - public func submit(password: String) { - continuation?.resume(returning: password) - cleanup() - } - - public func cancel() { - continuation? - .resume(throwing: AuthServiceError - .signInCancelled("Password entry cancelled for Email provider")) - cleanup() - } - - private func cleanup() { - continuation = nil - isPromptingPassword = false - } -} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift index 42cccfb5ea..8dc932ffcd 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift @@ -28,6 +28,7 @@ public protocol CredentialAuthProviderSwift: AuthProviderSwift { public protocol AuthProviderUI { var id: String { get } + var displayName: String { get } @MainActor func authButton() -> AnyView var provider: AuthProviderSwift { get } } @@ -118,37 +119,26 @@ public final class AuthService { } @ObservationIgnored @AppStorage("email-link") public var emailLink: String? + + private var currentMFAResolver: MultiFactorResolver? + private var listenerManager: AuthListenerManager? + private var emailSignInCallback: (() -> Void)? + private var providers: [AuthProviderUI] = [] + public let configuration: AuthConfiguration public let auth: Auth public var isPresented: Bool = false - public private(set) var navigator = Navigator() - - public var authView: AuthView? { - navigator.routes.last - } - public let string: StringUtils public var currentUser: User? public var authenticationState: AuthenticationState = .unauthenticated public var authenticationFlow: AuthenticationFlow = .signIn + public var emailSignInEnabled = false + public private(set) var navigator = Navigator() - private var currentMFAResolver: MultiFactorResolver? - - // MARK: - Provider APIs - - private var listenerManager: AuthListenerManager? - - private var emailProvider: EmailProviderSwift? - - public var passwordPrompt: PasswordPromptCoordinator { - emailProvider?.passwordPrompt ?? PasswordPromptCoordinator() + public var authView: AuthView? { + navigator.routes.last } - var emailSignInEnabled = false - private var emailSignInCallback: (() -> Void)? - - private var providers: [AuthProviderUI] = [] - public func registerProvider(providerWithButton: AuthProviderUI) { providers.append(providerWithButton) } @@ -182,31 +172,6 @@ public final class AuthService { return result } - // MARK: - End Provider APIs - - private func safeActionCodeSettings() throws -> ActionCodeSettings { - // email sign-in requires action code settings - guard let actionCodeSettings = configuration - .emailLinkSignInActionCodeSettings else { - throw AuthServiceError - .notConfiguredActionCodeSettings( - "ActionCodeSettings has not been configured for `AuthConfiguration.emailLinkSignInActionCodeSettings`" - ) - } - return actionCodeSettings - } - - public func updateAuthenticationState() { - authenticationState = - (currentUser == nil || currentUser?.isAnonymous == true) - ? .unauthenticated - : .authenticated - } - - private var shouldHandleAnonymousUpgrade: Bool { - currentUser?.isAnonymous == true && configuration.shouldAutoUpgradeAnonymousUsers - } - public func signOut() async throws { try await auth.signOut() // Cannot wait for auth listener to change, feedback needs to be immediate @@ -221,34 +186,23 @@ public final class AuthService { throw AuthServiceError.noCurrentUser } - try await withReauthenticationIfNeeded(on: user) { - try await user.link(with: credentials) - } + try await user.link(with: credentials) updateAuthenticationState() } catch { + authenticationState = .unauthenticated + + // Check for reauthentication errors first + try await handleErrorWithReauthCheck(error: error) + + // If not a reauth error, check for conflicts // Possible conflicts from user.link(): // - credentialAlreadyInUse: credential is already linked to another account // - emailAlreadyInUse: email from credential is already used by another account // - accountExistsWithDifferentCredential: account exists with different sign-in method - authenticationState = .unauthenticated try handleErrorWithConflictCheck(error: error, credential: credentials) } } - private func handleAutoUpgradeAnonymousUser(credentials: AuthCredential) async throws - -> SignInOutcome { - if currentUser == nil { - throw AuthServiceError.noCurrentUser - } - do { - let result = try await currentUser?.link(with: credentials) - updateAuthenticationState() - return .signedIn(result) - } catch { - throw error - } - } - public func signIn(credentials: AuthCredential) async throws -> SignInOutcome { authenticationState = .authenticating do { @@ -291,6 +245,39 @@ public final class AuthService { } } } + + /// Reauthenticates with an OAuth provider (Google, Apple, Facebook, Twitter, etc.) + /// - Parameter context: The reauth context from `oauthReauthenticationRequired` error + /// - Throws: Error if reauthentication fails or provider is not found + /// - Note: This only works for providers that can automatically obtain credentials. + /// For email/phone, handle the flow externally and use `reauthenticate(with:)` + public func reauthenticate(context: OAuthReauthContext) async throws { + guard let user = currentUser else { + throw AuthServiceError.noCurrentUser + } + + // Find the provider and get credential + guard let matchingProvider = providers.first(where: { $0.id == context.providerId }), + let credentialProvider = matchingProvider.provider as? CredentialAuthProviderSwift else { + throw AuthServiceError.providerNotFound("No provider found for \(context.providerId)") + } + + let credential = try await credentialProvider.createAuthCredential() + try await user.reauthenticate(with: credential) + currentUser = auth.currentUser + } + + /// Reauthenticates with a pre-obtained credential + /// Use this when you've handled getting the credential yourself (email/phone) + /// - Parameter credential: The authentication credential to use for reauthentication + /// - Throws: Error if reauthentication fails + public func reauthenticate(with credential: AuthCredential) async throws { + guard let user = currentUser else { + throw AuthServiceError.noCurrentUser + } + try await user.reauthenticate(with: credential) + currentUser = auth.currentUser + } } // MARK: - User API @@ -301,8 +288,11 @@ public extension AuthService { throw AuthServiceError.noCurrentUser } - try await withReauthenticationIfNeeded(on: user) { + do { try await user.delete() + } catch { + try await handleErrorWithReauthCheck(error: error) + throw error // If we reach here, it wasn't a reauth error, so rethrow } } @@ -311,9 +301,32 @@ public extension AuthService { throw AuthServiceError.noCurrentUser } - try await withReauthenticationIfNeeded(on: user) { + do { try await user.updatePassword(to: password) + } catch { + try await handleErrorWithReauthCheck(error: error) + throw error // If we reach here, it wasn't a reauth error, so rethrow + } + } + + func updateUserPhotoURL(url: URL) async throws { + guard let user = currentUser else { + throw AuthServiceError.noCurrentUser + } + + let changeRequest = user.createProfileChangeRequest() + changeRequest.photoURL = url + try await changeRequest.commitChanges() + } + + func updateUserDisplayName(name: String) async throws { + guard let user = currentUser else { + throw AuthServiceError.noCurrentUser } + + let changeRequest = user.createProfileChangeRequest() + changeRequest.displayName = name + try await changeRequest.commitChanges() } } @@ -321,16 +334,14 @@ public extension AuthService { public extension AuthService { /// Enable email sign-in with default behavior (navigates to email link view) - func withEmailSignIn(_ provider: EmailProviderSwift? = nil) -> AuthService { - return withEmailSignIn(provider) { [weak self] in + func withEmailSignIn() -> AuthService { + return withEmailSignIn { [weak self] in self?.navigator.push(.emailLink) } } /// Enable email sign-in with custom callback - func withEmailSignIn(_ provider: EmailProviderSwift? = nil, - onTap: @escaping () -> Void) -> AuthService { - emailProvider = provider ?? EmailProviderSwift() + func withEmailSignIn(onTap: @escaping () -> Void) -> AuthService { emailSignInEnabled = true emailSignInCallback = onTap return self @@ -431,33 +442,6 @@ public extension AuthService { try handleErrorWithConflictCheck(error: error, credential: credential) } } - - private func updateActionCodeSettings() throws -> ActionCodeSettings { - let actionCodeSettings = try safeActionCodeSettings() - guard var urlComponents = URLComponents(string: actionCodeSettings.url!.absoluteString) else { - throw AuthServiceError - .notConfiguredActionCodeSettings( - "ActionCodeSettings.url has not been configured for `AuthConfiguration.emailLinkSignInActionCodeSettings`" - ) - } - - var queryItems: [URLQueryItem] = [] - - if shouldHandleAnonymousUpgrade { - if let currentUser = currentUser { - let anonymousUID = currentUser.uid - let auidItem = URLQueryItem(name: "ui_auid", value: anonymousUID) - queryItems.append(auidItem) - } - } - - urlComponents.queryItems = queryItems - if let finalURL = urlComponents.url { - actionCodeSettings.url = finalURL - } - - return actionCodeSettings - } } // MARK: - Phone Auth Sign In @@ -483,30 +467,6 @@ public extension AuthService { } } -// MARK: - User Profile Management - -public extension AuthService { - func updateUserPhotoURL(url: URL) async throws { - guard let user = currentUser else { - throw AuthServiceError.noCurrentUser - } - - let changeRequest = user.createProfileChangeRequest() - changeRequest.photoURL = url - try await changeRequest.commitChanges() - } - - func updateUserDisplayName(name: String) async throws { - guard let user = currentUser else { - throw AuthServiceError.noCurrentUser - } - - let changeRequest = user.createProfileChangeRequest() - changeRequest.displayName = name - try await changeRequest.commitChanges() - } -} - // MARK: - MFA Methods public extension AuthService { @@ -640,6 +600,53 @@ public extension AuthService { return verificationID } + func resolveSignIn(code: String, hintIndex: Int, verificationId: String? = nil) async throws { + guard let resolver = currentMFAResolver else { + throw AuthServiceError.multiFactorAuth("No MFA resolver available") + } + + guard hintIndex < resolver.hints.count else { + throw AuthServiceError.multiFactorAuth("Invalid hint index") + } + + let hint = resolver.hints[hintIndex] + let assertion: MultiFactorAssertion + + // Create the appropriate assertion based on the hint type + if hint.factorID == PhoneMultiFactorID { + guard let verificationId = verificationId else { + throw AuthServiceError.multiFactorAuth("Verification ID is required for SMS MFA") + } + + let credential = PhoneAuthProvider.provider().credential( + withVerificationID: verificationId, + verificationCode: code + ) + assertion = PhoneMultiFactorGenerator.assertion(with: credential) + + } else if hint.factorID == TOTPMultiFactorID { + assertion = TOTPMultiFactorGenerator.assertionForSignIn( + withEnrollmentID: hint.uid, + oneTimePassword: code + ) + + } else { + throw AuthServiceError.multiFactorAuth("Unsupported MFA hint type") + } + + do { + let result = try await resolver.resolveSignIn(with: assertion) + updateAuthenticationState() + + // Clear MFA resolution state + currentMFAResolver = nil + + } catch { + throw AuthServiceError + .multiFactorAuth("Failed to resolve MFA challenge: \(error.localizedDescription)") + } + } + func completeEnrollment(session: EnrollmentSession, verificationId: String?, verificationCode: String, displayName: String) async throws { // Validate session state @@ -708,107 +715,233 @@ public extension AuthService { } // Complete the enrollment - try await withReauthenticationIfNeeded(on: user) { + do { try await user.multiFactor.enroll(with: assertion, displayName: displayName) + currentUser = auth.currentUser + } catch { + try await handleErrorWithReauthCheck(error: error) + throw error // If we reach here, it wasn't a reauth error, so rethrow } - currentUser = auth.currentUser } - /// Gets the provider ID that was used for the current sign-in session - private func getCurrentSignInProvider() async throws -> String { - guard let user = currentUser else { + func unenrollMFA(_ factorUid: String) async throws -> [MultiFactorInfo] { + guard let user = auth.currentUser else { throw AuthServiceError.noCurrentUser } - // Get the ID token result which contains the signInProvider claim - let tokenResult = try await user.getIDTokenResult(forcingRefresh: false) + let multiFactorUser = user.multiFactor - // The signInProvider property tells us which provider was used for this session - let signInProvider = tokenResult.signInProvider + do { + try await multiFactorUser.unenroll(withFactorUID: factorUid) - // If signInProvider is not empty, use it - if !signInProvider.isEmpty { - return signInProvider + // This is the only we to get the actual latest enrolledFactors + currentUser = Auth.auth().currentUser + let freshFactors = currentUser?.multiFactor.enrolledFactors ?? [] + + return freshFactors + } catch { + try await handleErrorWithReauthCheck(error: error) + throw error // If we reach here, it wasn't a reauth error, so rethrow + } + } + + func resolveSmsChallenge(hintIndex: Int) async throws -> String { + guard let resolver = currentMFAResolver else { + throw AuthServiceError.multiFactorAuth("No MFA resolver available") } - // Fallback: if signInProvider is empty, try to infer from providerData - // Prefer non-password providers as they're more specific - let providerId = user.providerData.first(where: { $0.providerID != "password" })?.providerID - ?? user.providerData.first?.providerID + guard hintIndex < resolver.hints.count else { + throw AuthServiceError.multiFactorAuth("Invalid hint index") + } - guard let providerId = providerId else { - throw AuthServiceError.reauthenticationRequired( - "Unable to determine sign-in provider for reauthentication" - ) + let hint = resolver.hints[hintIndex] + guard hint.factorID == PhoneMultiFactorID else { + throw AuthServiceError.multiFactorAuth("Selected hint is not a phone hint") } + let phoneHint = hint as! PhoneMultiFactorInfo - return providerId + return try await withCheckedThrowingContinuation { continuation in + PhoneAuthProvider.provider().verifyPhoneNumber( + with: phoneHint, + uiDelegate: nil, + multiFactorSession: resolver.session + ) { verificationId, error in + if let error = error { + continuation + .resume(throwing: AuthServiceError.multiFactorAuth(error.localizedDescription)) + } else if let verificationId = verificationId { + continuation.resume(returning: verificationId) + } else { + continuation + .resume(throwing: AuthServiceError.multiFactorAuth("Unknown error occurred")) + } + } + } } +} - func reauthenticateCurrentUser(on user: User) async throws { - // Get the provider from the token instead of stored credential - let providerId = try await getCurrentSignInProvider() +// MARK: - Private Helper Methods - if providerId == EmailAuthProviderID { - guard let email = user.email else { - throw AuthServiceError.invalidCredentials("User does not have an email address") - } +private extension AuthService { + internal func updateAuthenticationState() { + authenticationState = + (currentUser == nil || currentUser?.isAnonymous == true) + ? .unauthenticated + : .authenticated + } + + private var shouldHandleAnonymousUpgrade: Bool { + currentUser?.isAnonymous == true && configuration.shouldAutoUpgradeAnonymousUsers + } + + private func handleAutoUpgradeAnonymousUser(credentials: AuthCredential) async throws + -> SignInOutcome { + if currentUser == nil { + throw AuthServiceError.noCurrentUser + } + do { + let result = try await currentUser?.link(with: credentials) + updateAuthenticationState() + return .signedIn(result) + } catch { + throw error + } + } + + // MARK: - Action Code Settings Helper Methods + + private func safeActionCodeSettings() throws -> ActionCodeSettings { + // email sign-in requires action code settings + guard let actionCodeSettings = configuration + .emailLinkSignInActionCodeSettings else { + throw AuthServiceError + .notConfiguredActionCodeSettings( + "ActionCodeSettings has not been configured for `AuthConfiguration.emailLinkSignInActionCodeSettings`" + ) + } + return actionCodeSettings + } - guard let emailProvider = emailProvider else { - throw AuthServiceError.providerNotFound( - "Email provider not configured. Call withEmailSignIn() first." + private func updateActionCodeSettings() throws -> ActionCodeSettings { + let actionCodeSettings = try safeActionCodeSettings() + guard var urlComponents = URLComponents(string: actionCodeSettings.url!.absoluteString) else { + throw AuthServiceError + .notConfiguredActionCodeSettings( + "ActionCodeSettings.url has not been configured for `AuthConfiguration.emailLinkSignInActionCodeSettings`" ) + } + + var queryItems: [URLQueryItem] = [] + + if shouldHandleAnonymousUpgrade { + if let currentUser = currentUser { + let anonymousUID = currentUser.uid + let auidItem = URLQueryItem(name: "ui_auid", value: anonymousUID) + queryItems.append(auidItem) } + } - let credential = try await emailProvider.createReauthCredential(email: email) - _ = try await user.reauthenticate(with: credential) - } else if providerId == PhoneAuthProviderID { - // Phone auth requires manual reauthentication via sign out and sign in otherwise it will take - // the user out of the existing flow - throw AuthServiceError.reauthenticationRequired( - "Phone authentication requires you to sign out and sign in again to continue" - ) - } else if let matchingProvider = providers.first(where: { $0.id == providerId }), - let credentialProvider = matchingProvider.provider as? CredentialAuthProviderSwift { - let credential = try await credentialProvider.createAuthCredential() - _ = try await user.reauthenticate(with: credential) - } else { - throw AuthServiceError.providerNotFound("No provider found for \(providerId)") + urlComponents.queryItems = queryItems + if let finalURL = urlComponents.url { + actionCodeSettings.url = finalURL + } + + return actionCodeSettings + } + + // MARK: - Reauth Error Helper Methods + + /// Checks if an error requires reauthentication and handles it appropriately + /// - Parameter error: The error to check + /// - Throws: Only if it's a reauthentication error (via requireReauthentication()) + private func handleErrorWithReauthCheck(error: Error) async throws { + if let nsError = error as NSError?, + nsError.domain == AuthErrorDomain, + nsError.code == AuthErrorCode.requiresRecentLogin.rawValue || + nsError.code == AuthErrorCode.userTokenExpired.rawValue { + try await requireReauthentication() } + // If not a reauth error, return normally so caller can handle it } - private func withReauthenticationIfNeeded(on user: User, - operation: () async throws -> Void) async throws { - do { - try await operation() - } catch let error as NSError { - if error.domain == AuthErrorDomain, - error.code == AuthErrorCode.requiresRecentLogin.rawValue || error.code == AuthErrorCode - .userTokenExpired.rawValue { - try await reauthenticateCurrentUser(on: user) - try await operation() - } else { - throw error + /// Internal helper to create reauth context and throw appropriate error + /// - Throws: Appropriate `AuthServiceError` based on the provider type + private func requireReauthentication() async throws -> Never { + let providerId = try await getCurrentSignInProvider() + + // Try to find display name from registered provider + let providerDisplayName: String + if let registeredProvider = providers.first(where: { $0.id == providerId }) { + providerDisplayName = registeredProvider.displayName + } else { + // Fallback for built-in providers (email/password) that don't have AuthProviderUI + providerDisplayName = getProviderDisplayName(providerId) + } + + switch providerId { + case EmailAuthProviderID: + guard let email = currentUser?.email else { + throw AuthServiceError.noCurrentUser + } + let context = EmailReauthContext(email: email) + throw AuthServiceError.emailReauthenticationRequired(context: context) + case PhoneAuthProviderID: + guard let phoneNumber = currentUser?.phoneNumber else { + throw AuthServiceError.noCurrentUser } + let context = PhoneReauthContext(phoneNumber: phoneNumber) + throw AuthServiceError.phoneReauthenticationRequired(context: context) + default: + let context = OAuthReauthContext(providerId: providerId, providerName: providerDisplayName) + throw AuthServiceError.oauthReauthenticationRequired(context: context) } } - func unenrollMFA(_ factorUid: String) async throws -> [MultiFactorInfo] { - guard let user = auth.currentUser else { + /// Gets the provider ID that was used for the current sign-in session + private func getCurrentSignInProvider() async throws -> String { + guard let user = currentUser else { throw AuthServiceError.noCurrentUser } - let multiFactorUser = user.multiFactor + // Get the ID token result which contains the signInProvider claim + let tokenResult = try await user.getIDTokenResult(forcingRefresh: false) - try await withReauthenticationIfNeeded(on: user) { - try await multiFactorUser.unenroll(withFactorUID: factorUid) + // The signInProvider property tells us which provider was used for this session + let signInProvider = tokenResult.signInProvider + + // If signInProvider is not empty, use it + if !signInProvider.isEmpty { + return signInProvider + } + + // Fallback: if signInProvider is empty, try to infer from providerData + // Prefer non-password providers as they're more specific + let providerId = user.providerData.first(where: { $0.providerID != "password" })?.providerID + ?? user.providerData.first?.providerID + + guard let providerId = providerId else { + throw AuthServiceError.invalidCredentials( + "Unable to determine sign-in provider for reauthentication" + ) } - // This is the only we to get the actual latest enrolledFactors - currentUser = Auth.auth().currentUser - let freshFactors = currentUser?.multiFactor.enrolledFactors ?? [] + return providerId + } - return freshFactors + /// Get a user-friendly display name for built-in providers without AuthProviderUI + /// (email/password). Other providers should be registered and provide their own display names. + /// - Parameter providerId: The provider ID from Firebase Auth + /// - Returns: A user-friendly name for the provider + private func getProviderDisplayName(_ providerId: String) -> String { + switch providerId { + case EmailAuthProviderID: + return "Email" + case PhoneAuthProviderID: + return "Phone" + default: + // Shouldn't reach here if provider is registered + return providerId + } } // MARK: - Account Conflict Helper Methods @@ -894,85 +1027,4 @@ public extension AuthService { currentMFAResolver = resolver return .mfaRequired(MFARequired(hints: hints)) } - - func resolveSmsChallenge(hintIndex: Int) async throws -> String { - guard let resolver = currentMFAResolver else { - throw AuthServiceError.multiFactorAuth("No MFA resolver available") - } - - guard hintIndex < resolver.hints.count else { - throw AuthServiceError.multiFactorAuth("Invalid hint index") - } - - let hint = resolver.hints[hintIndex] - guard hint.factorID == PhoneMultiFactorID else { - throw AuthServiceError.multiFactorAuth("Selected hint is not a phone hint") - } - let phoneHint = hint as! PhoneMultiFactorInfo - - return try await withCheckedThrowingContinuation { continuation in - PhoneAuthProvider.provider().verifyPhoneNumber( - with: phoneHint, - uiDelegate: nil, - multiFactorSession: resolver.session - ) { verificationId, error in - if let error = error { - continuation - .resume(throwing: AuthServiceError.multiFactorAuth(error.localizedDescription)) - } else if let verificationId = verificationId { - continuation.resume(returning: verificationId) - } else { - continuation - .resume(throwing: AuthServiceError.multiFactorAuth("Unknown error occurred")) - } - } - } - } - - func resolveSignIn(code: String, hintIndex: Int, verificationId: String? = nil) async throws { - guard let resolver = currentMFAResolver else { - throw AuthServiceError.multiFactorAuth("No MFA resolver available") - } - - guard hintIndex < resolver.hints.count else { - throw AuthServiceError.multiFactorAuth("Invalid hint index") - } - - let hint = resolver.hints[hintIndex] - let assertion: MultiFactorAssertion - - // Create the appropriate assertion based on the hint type - if hint.factorID == PhoneMultiFactorID { - guard let verificationId = verificationId else { - throw AuthServiceError.multiFactorAuth("Verification ID is required for SMS MFA") - } - - let credential = PhoneAuthProvider.provider().credential( - withVerificationID: verificationId, - verificationCode: code - ) - assertion = PhoneMultiFactorGenerator.assertion(with: credential) - - } else if hint.factorID == TOTPMultiFactorID { - assertion = TOTPMultiFactorGenerator.assertionForSignIn( - withEnrollmentID: hint.uid, - oneTimePassword: code - ) - - } else { - throw AuthServiceError.multiFactorAuth("Unsupported MFA hint type") - } - - do { - let result = try await resolver.resolveSignIn(with: assertion) - updateAuthenticationState() - - // Clear MFA resolution state - currentMFAResolver = nil - - } catch { - throw AuthServiceError - .multiFactorAuth("Failed to resolve MFA challenge: \(error.localizedDescription)") - } - } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/EmailProviderAuthUI.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/EmailProviderAuthUI.swift deleted file mode 100644 index 6149b3fdeb..0000000000 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/EmailProviderAuthUI.swift +++ /dev/null @@ -1,33 +0,0 @@ -// 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 - -/// Email/Password authentication provider -/// This provider is special and doesn't render in the button list -@MainActor -public class EmailProviderSwift: AuthProviderSwift { - public let passwordPrompt: PasswordPromptCoordinator - public let providerId = EmailAuthProviderID - - public init(passwordPrompt: PasswordPromptCoordinator = .init()) { - self.passwordPrompt = passwordPrompt - } - - /// Create credential for reauthentication - func createReauthCredential(email: String) async throws -> AuthCredential { - let password = try await passwordPrompt.confirmPassword() - return EmailAuthProvider.credential(withEmail: email, password: password) - } -} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/ReauthenticationCoordinator.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/ReauthenticationCoordinator.swift new file mode 100644 index 0000000000..16127cb924 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/ReauthenticationCoordinator.swift @@ -0,0 +1,77 @@ +// 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 Observation + +/// Coordinator for handling reauthentication flows +@MainActor +@Observable +public final class ReauthenticationCoordinator { + public var isReauthenticating = false + public var reauthContext: ReauthenticationType? + public var showingPhoneReauth = false + public var showingPhoneReauthAlert = false + public var showingEmailPasswordPrompt = false + + private var continuation: CheckedContinuation? + + public init() {} + + /// Request reauthentication from the user + public func requestReauth(context: ReauthenticationType) async throws { + return try await withCheckedThrowingContinuation { continuation in + self.continuation = continuation + self.reauthContext = context + + // Route to appropriate flow based on context type + switch context { + case .phone: + self.showingPhoneReauthAlert = true + case .email: + self.showingEmailPasswordPrompt = true + case .oauth: + // For OAuth providers (Google, Apple, etc.) + self.isReauthenticating = true + } + } + } + + /// Called when user confirms phone reauth alert + public func confirmPhoneReauth() { + showingPhoneReauthAlert = false + showingPhoneReauth = true + } + + /// Called when reauthentication completes successfully + public func reauthCompleted() { + continuation?.resume() + cleanup() + } + + /// Called when reauthentication is cancelled + public func reauthCancelled() { + continuation?.resume(throwing: AuthServiceError.signInCancelled("Reauthentication cancelled")) + cleanup() + } + + private func cleanup() { + continuation = nil + isReauthenticating = false + showingPhoneReauth = false + showingPhoneReauthAlert = false + showingEmailPasswordPrompt = false + reauthContext = nil + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/ReauthenticationHelpers.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/ReauthenticationHelpers.swift new file mode 100644 index 0000000000..b1caec48ea --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/ReauthenticationHelpers.swift @@ -0,0 +1,53 @@ +// 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 + +/// Execute an operation that may require reauthentication +/// Automatically handles reauth errors by presenting UI and retrying +/// - Parameters: +/// - authService: The auth service managing authentication +/// - coordinator: The coordinator managing reauthentication UI +/// - operation: The operation to execute +/// - Throws: Rethrows errors from the operation or reauthentication process +@MainActor +public func withReauthenticationIfNeeded(authService _: AuthService, + coordinator: ReauthenticationCoordinator, + operation: @escaping () async throws + -> Void) async throws { + do { + try await operation() + } catch let error as AuthServiceError { + // Check if this is a reauthentication error and extract the context + let reauthContext: ReauthenticationType + + switch error { + case let .emailReauthenticationRequired(ctx): + reauthContext = .email(ctx) + case let .phoneReauthenticationRequired(ctx): + reauthContext = .phone(ctx) + case let .oauthReauthenticationRequired(ctx): + reauthContext = .oauth(ctx) + default: + // Not a reauth error, rethrow + throw error + } + + // Request reauthentication through coordinator (shows UI) + try await coordinator.requestReauth(context: reauthContext) + + // After successful reauth, retry the operation + try await operation() + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Strings/Localizable.xcstrings b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Strings/Localizable.xcstrings index b32e7658f1..362b8e2ca3 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Strings/Localizable.xcstrings +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Strings/Localizable.xcstrings @@ -5254,6 +5254,10 @@ } } }, + "Authentication Required" : { + "comment" : "A title for an alert that appears when reauthentication is required.", + "isCommentAutoGenerated" : true + }, "Authenticator App" : { "localizations" : { "ar" : { @@ -11623,6 +11627,10 @@ } } }, + "Continue" : { + "comment" : "A button that allows a user to continue after being prompted to reauthenticate.", + "isCommentAutoGenerated" : true + }, "Copied to clipboard!" : { "localizations" : { "ar" : { @@ -21151,6 +21159,14 @@ } } }, + "For security, please verify your phone number" : { + "comment" : "A description under the phone number label, emphasizing the importance of phone number verification.", + "isCommentAutoGenerated" : true + }, + "For security, we need to verify your phone number: %@" : { + "comment" : "An alert message requesting phone number verification. The argument is a placeholder for the user's phone number.", + "isCommentAutoGenerated" : true + }, "ForgotPassword" : { "comment" : "Button text for 'Forgot Password' action.", "extractionState" : "manual", @@ -31187,6 +31203,10 @@ } } }, + "Phone Verification Required" : { + "comment" : "A header for a phone verification alert.", + "isCommentAutoGenerated" : true + }, "PlaceholderChosePassword" : { "comment" : "Placeholder of secret input cell when user changes password.", "extractionState" : "manual", @@ -32457,6 +32477,10 @@ } } }, + "Proceed" : { + "comment" : "A button that allows a user to confirm their phone number for reauthentication.", + "isCommentAutoGenerated" : true + }, "Profile" : { "extractionState" : "stale", "localizations" : { @@ -38085,6 +38109,10 @@ } } }, + "Send Verification Code" : { + "comment" : "The label of a button that sends a verification code to a user's phone number.", + "isCommentAutoGenerated" : true + }, "SendEmailSignInLinkButtonLabel" : { "comment" : "Button label for sending email sign-in link", "extractionState" : "manual", @@ -38121,6 +38149,10 @@ } } }, + "Sending verification code..." : { + "comment" : "A message displayed while Firebase is attempting to send a verification code to the user's phone number.", + "isCommentAutoGenerated" : true + }, "Set Up Two-Factor Authentication" : { "localizations" : { "ar" : { @@ -55486,6 +55518,10 @@ } } }, + "Verify" : { + "comment" : "The label of a button that verifies a user's phone number.", + "isCommentAutoGenerated" : true + }, "Verify email address?" : { "comment" : "Label for sending email verification to user.", "localizations" : { @@ -56198,6 +56234,10 @@ } } }, + "Verify Phone Number" : { + "comment" : "A label displayed above the screen where a user can verify their phone number.", + "isCommentAutoGenerated" : true + }, "VerifyItsYou" : { "comment" : "Alert message title show for re-authorization.", "extractionState" : "manual", @@ -58084,6 +58124,10 @@ } } }, + "We'll send a verification code to:" : { + "comment" : "A description of the action that will be taken to verify a user's phone number.", + "isCommentAutoGenerated" : true + }, "WeakPasswordError" : { "comment" : "Error message displayed when the password is too weak.", "extractionState" : "manual", diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AccountConflictModifier.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AccountConflictModifier.swift index 5bea74ed29..f50205f305 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AccountConflictModifier.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AccountConflictModifier.swift @@ -35,10 +35,12 @@ struct AccountConflictModifier: ViewModifier { @Environment(\.mfaHandler) private var mfaHandler @Environment(\.reportError) private var reportError @State private var pendingCredentialForLinking: AuthCredential? + @State private var reauthCoordinator = ReauthenticationCoordinator() func body(content: Content) -> some View { content .environment(\.accountConflictHandler, handleAccountConflict) + .withReauthentication(coordinator: reauthCoordinator) .onChange(of: authService.authenticationState) { _, newState in // Auto-link pending credential after successful sign-in if newState == .authenticated { @@ -82,7 +84,12 @@ struct AccountConflictModifier: ViewModifier { Task { do { - try await authService.linkAccounts(credentials: credential) + try await withReauthenticationIfNeeded( + authService: authService, + coordinator: reauthCoordinator + ) { + try await authService.linkAccounts(credentials: credential) + } // Successfully linked, clear the pending credential pendingCredentialForLinking = nil } catch { diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift index 26754e6038..e358c52019 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift @@ -36,7 +36,6 @@ extension AuthPickerView: View { content() .sheet(isPresented: $authService.isPresented) { @Bindable var navigator = authService.navigator - @Bindable var passwordPrompt = authService.passwordPrompt NavigationStack(path: $navigator.routes) { authPickerViewInternal .navigationTitle(authService.authenticationState == .unauthenticated ? authService @@ -79,10 +78,6 @@ extension AuthPickerView: View { .accountConflictHandler() // Apply MFA handling at NavigationStack level .mfaHandler() - // Centralized password prompt sheet inside auth flow - .sheet(isPresented: $passwordPrompt.isPromptingPassword) { - PasswordPromptSheet(coordinator: passwordPrompt) - } } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailReauthView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailReauthView.swift new file mode 100644 index 0000000000..6ee1ecf652 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailReauthView.swift @@ -0,0 +1,138 @@ +// 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 FirebaseAuthUIComponents +import FirebaseCore +import SwiftUI + +@MainActor +public struct EmailReauthView { + @Environment(AuthService.self) private var authService + @Environment(\.reportError) private var reportError + + let email: String + let coordinator: ReauthenticationCoordinator + + @State private var password = "" + @State private var isLoading = false + @State private var error: AlertError? + + private func verifyPassword() { + guard !password.isEmpty else { return } + + Task { @MainActor in + isLoading = true + do { + let credential = EmailAuthProvider.credential(withEmail: email, password: password) + try await authService.reauthenticate(with: credential) + coordinator.reauthCompleted() + isLoading = false + } catch { + if let reportError = reportError { + reportError(error) + } else { + self.error = AlertError( + title: "Error", + message: error.localizedDescription, + underlyingError: error + ) + } + isLoading = false + } + } + } +} + +extension EmailReauthView: View { + public var body: some View { + NavigationStack { + VStack(spacing: 24) { + // Header + VStack(spacing: 12) { + Image(systemName: "lock.circle.fill") + .font(.system(size: 60)) + .foregroundColor(.blue) + + Text("Confirm Password") + .font(.title) + .fontWeight(.bold) + + Text("For security, please enter your password") + .font(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + .padding() + + VStack(spacing: 20) { + Text("Email: \(email)") + .font(.caption) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.bottom, 8) + + AuthTextField( + text: $password, + label: authService.string.passwordFieldLabel, + prompt: authService.string.passwordInputLabel, + contentType: .password, + isSecureTextField: true, + onSubmit: { _ in + verifyPassword() + }, + leading: { + Image(systemName: "lock") + } + ) + .submitLabel(.done) + .accessibilityIdentifier("email-reauth-password-field") + + Button(action: verifyPassword) { + if isLoading { + ProgressView() + .frame(height: 32) + .frame(maxWidth: .infinity) + } else { + Text("Confirm") + .frame(height: 32) + .frame(maxWidth: .infinity) + } + } + .buttonStyle(.borderedProminent) + .disabled(password.isEmpty || isLoading) + .accessibilityIdentifier("confirm-password-button") + + Button(authService.string.cancelButtonLabel) { + coordinator.reauthCancelled() + } + } + .padding(.horizontal) + + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .navigationBarTitleDisplayMode(.inline) + } + .errorAlert(error: $error, okButtonLabel: authService.string.okButtonLabel) + } +} + +#Preview { + FirebaseOptions.dummyConfigurationForPreview() + return EmailReauthView( + email: "test@example.com", + coordinator: ReauthenticationCoordinator() + ) + .environment(AuthService()) +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAEnrolmentView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAEnrolmentView.swift index 6f11974503..75b13b4765 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAEnrolmentView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAEnrolmentView.swift @@ -37,6 +37,7 @@ public struct MFAEnrolmentView { @State private var isLoading = false @State private var displayName = "" @State private var showCopiedFeedback = false + @State private var reauthCoordinator = ReauthenticationCoordinator() @FocusState private var focus: FocusableField? @@ -135,12 +136,17 @@ public struct MFAEnrolmentView { do { let code = session.type == .sms ? verificationCode : totpCode - try await authService.completeEnrollment( - session: session, - verificationId: session.verificationId, - verificationCode: code, - displayName: displayName - ) + try await withReauthenticationIfNeeded( + authService: authService, + coordinator: reauthCoordinator + ) { + try await authService.completeEnrollment( + session: session, + verificationId: session.verificationId, + verificationCode: code, + displayName: displayName + ) + } // Reset form state on success resetForm() @@ -281,6 +287,7 @@ extension MFAEnrolmentView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .safeAreaPadding() + .withReauthentication(coordinator: reauthCoordinator) .navigationTitle("Two-Factor Authentication") .onAppear { // Initialize selected factor type to first allowed type diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAManagementView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAManagementView.swift index a79be1d2f3..b17d35858e 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAManagementView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAManagementView.swift @@ -27,6 +27,7 @@ public struct MFAManagementView { @State private var enrolledFactors: [MultiFactorInfo] = [] @State private var isLoading = false + @State private var reauthCoordinator = ReauthenticationCoordinator() public init() {} @@ -40,8 +41,13 @@ public struct MFAManagementView { isLoading = true do { - let freshFactors = try await authService.unenrollMFA(factorUid) - enrolledFactors = freshFactors + try await withReauthenticationIfNeeded( + authService: authService, + coordinator: reauthCoordinator + ) { + let freshFactors = try await authService.unenrollMFA(factorUid) + enrolledFactors = freshFactors + } isLoading = false } catch { reportError?(error) @@ -131,6 +137,7 @@ extension MFAManagementView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .safeAreaPadding() + .withReauthentication(coordinator: reauthCoordinator) .onAppear { loadEnrolledFactors() } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordPromptView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordPromptView.swift deleted file mode 100644 index 0284de7b49..0000000000 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordPromptView.swift +++ /dev/null @@ -1,75 +0,0 @@ -// 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 FirebaseAuthUIComponents -import FirebaseCore -import SwiftUI - -struct PasswordPromptSheet { - @Environment(AuthService.self) private var authService - @Bindable var coordinator: PasswordPromptCoordinator - @State private var password = "" -} - -extension PasswordPromptSheet: View { - var body: some View { - VStack(spacing: 20) { - Text(authService.string.confirmPasswordInputLabel) - .font(.largeTitle) - .fontWeight(.bold) - .padding() - - Divider() - - AuthTextField( - text: $password, - label: authService.string.passwordFieldLabel, - prompt: authService.string.passwordInputLabel, - contentType: .password, - isSecureTextField: true, - onSubmit: { _ in - if !password.isEmpty { - coordinator.submit(password: password) - } - }, - leading: { - Image(systemName: "lock") - } - ) - .submitLabel(.next) - - Button(action: { - coordinator.submit(password: password) - }) { - Text(authService.string.okButtonLabel) - .padding(.vertical, 8) - .frame(maxWidth: .infinity) - } - .disabled(password.isEmpty) - .padding([.top, .bottom, .horizontal], 8) - .frame(maxWidth: .infinity) - .buttonStyle(.borderedProminent) - - Button(authService.string.cancelButtonLabel) { - coordinator.cancel() - } - } - .padding() - } -} - -#Preview { - FirebaseOptions.dummyConfigurationForPreview() - return PasswordPromptSheet(coordinator: PasswordPromptCoordinator()).environment(AuthService()) -} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PhoneReauthView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PhoneReauthView.swift new file mode 100644 index 0000000000..5de8b9089a --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PhoneReauthView.swift @@ -0,0 +1,211 @@ +// 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. + +@preconcurrency import FirebaseAuth +import FirebaseAuthUIComponents +import FirebaseCore +import SwiftUI + +@MainActor +public struct PhoneReauthView { + @Environment(AuthService.self) private var authService + @Environment(\.reportError) private var reportError + + let phoneNumber: String + let coordinator: ReauthenticationCoordinator + + @State private var verificationID: String? + @State private var verificationCode = "" + @State private var isLoading = false + @State private var error: AlertError? + + private func sendSMS() { + Task { @MainActor in + isLoading = true + do { + let vid = try await authService.verifyPhoneNumber(phoneNumber: phoneNumber) + verificationID = vid + isLoading = false + } catch { + if let reportError = reportError { + reportError(error) + } else { + self.error = AlertError( + title: "Error", + message: error.localizedDescription, + underlyingError: error + ) + } + isLoading = false + } + } + } + + private func verifyCode() { + guard let verificationID = verificationID else { return } + + Task { @MainActor in + isLoading = true + do { + let credential = PhoneAuthProvider.provider() + .credential(withVerificationID: verificationID, verificationCode: verificationCode) + + try await authService.reauthenticate(with: credential) + coordinator.reauthCompleted() + isLoading = false + } catch { + if let reportError = reportError { + reportError(error) + } else { + self.error = AlertError( + title: "Error", + message: error.localizedDescription, + underlyingError: error + ) + } + isLoading = false + } + } + } +} + +extension PhoneReauthView: View { + public var body: some View { + NavigationStack { + VStack(spacing: 24) { + // Header + VStack(spacing: 12) { + Image(systemName: "phone.circle.fill") + .font(.system(size: 60)) + .foregroundColor(.blue) + + Text("Verify Phone Number") + .font(.title) + .fontWeight(.bold) + + Text("For security, please verify your phone number") + .font(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + .padding() + + if verificationID == nil { + // Initial state - sending SMS + VStack(spacing: 20) { + Text("We'll send a verification code to:") + .font(.subheadline) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + + Text(phoneNumber) + .font(.headline) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.bottom, 8) + + Button(action: { + sendSMS() + }) { + if isLoading { + ProgressView() + .frame(height: 32) + .frame(maxWidth: .infinity) + } else { + Text("Send Verification Code") + .frame(height: 32) + .frame(maxWidth: .infinity) + } + } + .buttonStyle(.borderedProminent) + .disabled(isLoading) + .accessibilityIdentifier("send-verification-code-button") + Button(authService.string.cancelButtonLabel) { + coordinator.reauthCancelled() + } + } + .padding(.horizontal) + } else { + // Enter verification code + VStack(spacing: 20) { + Text("Enter the 6-digit code sent to:") + .font(.subheadline) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + + Text(phoneNumber) + .font(.caption) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.bottom, 8) + + VerificationCodeInputField( + code: $verificationCode, + validations: [ + FormValidators.verificationCode, + ], + maintainsValidationMessage: true + ) + .accessibilityIdentifier("verification-code-field") + + Button(action: { + verifyCode() + }) { + if isLoading { + ProgressView() + .frame(height: 32) + .frame(maxWidth: .infinity) + } else { + Text("Verify") + .frame(height: 32) + .frame(maxWidth: .infinity) + } + } + .buttonStyle(.borderedProminent) + .disabled(verificationCode.count != 6 || isLoading) + .accessibilityIdentifier("verify-button") + + Button(action: { + sendSMS() + }) { + Text("Resend Code") + .frame(height: 32) + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .disabled(isLoading) + .accessibilityIdentifier("resend-code-button") + + Button(authService.string.cancelButtonLabel) { + coordinator.reauthCancelled() + } + } + .padding(.horizontal) + } + + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .navigationBarTitleDisplayMode(.inline) + } + .errorAlert(error: $error, okButtonLabel: authService.string.okButtonLabel) + } +} + +#Preview { + FirebaseOptions.dummyConfigurationForPreview() + return PhoneReauthView( + phoneNumber: "+1234567890", + coordinator: ReauthenticationCoordinator() + ) + .environment(AuthService()) +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/ReauthenticationModifier.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/ReauthenticationModifier.swift new file mode 100644 index 0000000000..2c976f0683 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/ReauthenticationModifier.swift @@ -0,0 +1,99 @@ +// 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 FirebaseCore +import SwiftUI + +/// View modifier for handling reauthentication flows +struct ReauthenticationModifier: ViewModifier { + @Environment(AuthService.self) private var authService + @Bindable var coordinator: ReauthenticationCoordinator + + func body(content: Content) -> some View { + content + // Alert for OAuth providers only (Google, Apple, etc.) + .alert( + "Authentication Required", + isPresented: $coordinator.isReauthenticating + ) { + Button("Continue") { + performReauth() + } + Button("Cancel", role: .cancel) { + coordinator.reauthCancelled() + } + } message: { + if let context = coordinator.reauthContext { + Text(context.displayMessage) + } + } + // Alert for phone provider + .alert( + "Phone Verification Required", + isPresented: $coordinator.showingPhoneReauthAlert + ) { + Button("Proceed") { + coordinator.confirmPhoneReauth() + } + Button("Cancel", role: .cancel) { + coordinator.reauthCancelled() + } + } message: { + if case let .phone(context) = coordinator.reauthContext { + Text("For security, we need to verify your phone number: \(context.phoneNumber)") + } + } + // Sheet for phone reauthentication + .sheet(isPresented: $coordinator.showingPhoneReauth) { + if case let .phone(context) = coordinator.reauthContext { + PhoneReauthView( + phoneNumber: context.phoneNumber, + coordinator: coordinator + ) + } + } + // Sheet for email reauthentication + .sheet(isPresented: $coordinator.showingEmailPasswordPrompt) { + if case let .email(context) = coordinator.reauthContext { + EmailReauthView( + email: context.email, + coordinator: coordinator + ) + } + } + } + + private func performReauth() { + Task { + do { + guard case let .oauth(context) = coordinator.reauthContext else { return } + + // For OAuth providers (Google, Apple, etc.), call reauthenticate with context + try await authService.reauthenticate(context: context) + coordinator.reauthCompleted() + } catch { + coordinator.reauthCancelled() + } + } + } +} + +public extension View { + /// Adds reauthentication handling to the view + /// - Parameter coordinator: The coordinator managing the reauthentication state + /// - Returns: A view that can handle reauthentication flows + func withReauthentication(coordinator: ReauthenticationCoordinator) -> some View { + modifier(ReauthenticationModifier(coordinator: coordinator)) + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignedInView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignedInView.swift index 960ad1d376..a7b8802345 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignedInView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignedInView.swift @@ -21,6 +21,7 @@ public struct SignedInView { @Environment(\.reportError) private var reportError @State private var showDeleteConfirmation = false @State private var showEmailVerificationSent = false + @State private var reauthCoordinator = ReauthenticationCoordinator() private func sendEmailVerification() async throws { do { @@ -45,7 +46,7 @@ extension SignedInView: View { .padding() .accessibilityIdentifier("signed-in-text") Text( - "\(authService.currentUser?.email ?? authService.currentUser?.displayName ?? "Unknown")" + "\(authService.currentUser?.email ?? authService.currentUser?.displayName ?? authService.currentUser?.phoneNumber ?? "")" ) if authService.currentUser?.isEmailVerified == false { Button { @@ -121,13 +122,19 @@ extension SignedInView: View { .accessibilityIdentifier("sign-out-button") } .safeAreaPadding() + .withReauthentication(coordinator: reauthCoordinator) .sheet(isPresented: $showDeleteConfirmation) { DeleteAccountConfirmationSheet( onConfirm: { showDeleteConfirmation = false Task { do { - try await authService.deleteUser() + try await withReauthenticationIfNeeded( + authService: authService, + coordinator: reauthCoordinator + ) { + try await authService.deleteUser() + } } catch { if let errorHandler = reportError { errorHandler(error) diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/UpdatePasswordView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/UpdatePasswordView.swift index 290f616262..c26dbea580 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/UpdatePasswordView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/UpdatePasswordView.swift @@ -33,6 +33,7 @@ public struct UpdatePasswordView { @State private var password = "" @State private var confirmPassword = "" @State private var showAlert = false + @State private var reauthCoordinator = ReauthenticationCoordinator() @FocusState private var focus: FocusableField? @@ -44,7 +45,12 @@ public struct UpdatePasswordView { private func updatePassword() { Task { do { - try await authService.updatePassword(to: confirmPassword) + try await withReauthenticationIfNeeded( + authService: authService, + coordinator: reauthCoordinator + ) { + try await authService.updatePassword(to: confirmPassword) + } showAlert = true } catch {} } @@ -103,6 +109,7 @@ extension UpdatePasswordView: View { .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .safeAreaPadding() .navigationTitle(authService.string.updatePasswordTitle) + .withReauthentication(coordinator: reauthCoordinator) .alert( "Password Updated", isPresented: $showAlert diff --git a/FirebaseSwiftUI/FirebaseFacebookSwiftUI/Sources/Services/FacebookProviderAuthUI.swift b/FirebaseSwiftUI/FirebaseFacebookSwiftUI/Sources/Services/FacebookProviderAuthUI.swift index 97abaa4c0f..625082b9f5 100644 --- a/FirebaseSwiftUI/FirebaseFacebookSwiftUI/Sources/Services/FacebookProviderAuthUI.swift +++ b/FirebaseSwiftUI/FirebaseFacebookSwiftUI/Sources/Services/FacebookProviderAuthUI.swift @@ -122,6 +122,7 @@ public class FacebookProviderAuthUI: AuthProviderUI { private let typedProvider: FacebookProviderSwift public var provider: AuthProviderSwift { typedProvider } public let id: String = "facebook.com" + public let displayName: String = "Facebook" public init(provider: FacebookProviderSwift) { typedProvider = provider diff --git a/FirebaseSwiftUI/FirebaseGoogleSwiftUI/Sources/Services/GoogleProviderAuthUI.swift b/FirebaseSwiftUI/FirebaseGoogleSwiftUI/Sources/Services/GoogleProviderAuthUI.swift index 5c65e71b51..975d5212c2 100644 --- a/FirebaseSwiftUI/FirebaseGoogleSwiftUI/Sources/Services/GoogleProviderAuthUI.swift +++ b/FirebaseSwiftUI/FirebaseGoogleSwiftUI/Sources/Services/GoogleProviderAuthUI.swift @@ -74,6 +74,7 @@ public class GoogleProviderAuthUI: AuthProviderUI { private let typedProvider: GoogleProviderSwift public var provider: AuthProviderSwift { typedProvider } public let id: String = "google.com" + public let displayName: String = "Google" public init(provider: GoogleProviderSwift) { typedProvider = provider diff --git a/FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Services/OAuthProviderSwift+Presets.swift b/FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Services/OAuthProviderSwift+Presets.swift index f87f3b1a34..204e14aff5 100644 --- a/FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Services/OAuthProviderSwift+Presets.swift +++ b/FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Services/OAuthProviderSwift+Presets.swift @@ -25,7 +25,8 @@ public extension OAuthProviderSwift { return OAuthProviderSwift( providerId: "github.com", scopes: scopes, - displayName: "Sign in with GitHub", + buttonLabel: "Sign in with GitHub", + displayName: "GitHub", buttonIcon: ProviderStyle.github.icon!, buttonBackgroundColor: ProviderStyle.github.backgroundColor, buttonForegroundColor: ProviderStyle.github.contentColor @@ -41,7 +42,8 @@ public extension OAuthProviderSwift { providerId: "microsoft.com", scopes: scopes, customParameters: ["prompt": "consent"], - displayName: "Sign in with Microsoft", + buttonLabel: "Sign in with Microsoft", + displayName: "Microsoft", buttonIcon: ProviderStyle.microsoft.icon!, buttonBackgroundColor: ProviderStyle.microsoft.backgroundColor, buttonForegroundColor: ProviderStyle.microsoft.contentColor @@ -57,7 +59,8 @@ public extension OAuthProviderSwift { providerId: "yahoo.com", scopes: scopes, customParameters: ["prompt": "consent"], - displayName: "Sign in with Yahoo", + buttonLabel: "Sign in with Yahoo", + displayName: "Yahoo", buttonIcon: ProviderStyle.yahoo.icon!, buttonBackgroundColor: ProviderStyle.yahoo.backgroundColor, buttonForegroundColor: ProviderStyle.yahoo.contentColor diff --git a/FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Services/OAuthProviderSwift.swift b/FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Services/OAuthProviderSwift.swift index e20b52913b..a840c4edf3 100644 --- a/FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Services/OAuthProviderSwift.swift +++ b/FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Services/OAuthProviderSwift.swift @@ -23,6 +23,7 @@ public class OAuthProviderSwift: CredentialAuthProviderSwift { public let scopes: [String] public let customParameters: [String: String] // Button appearance + public let buttonLabel: String public let displayName: String public let buttonIcon: Image public let buttonBackgroundColor: Color @@ -32,13 +33,15 @@ public class OAuthProviderSwift: CredentialAuthProviderSwift { /// - providerId: The OAuth provider ID (e.g., "github.com", "microsoft.com") /// - scopes: OAuth scopes to request /// - customParameters: Additional OAuth parameters - /// - displayName: Button label (e.g., "Sign in with GitHub") + /// - buttonLabel: Full button label (e.g., "Sign in with GitHub") + /// - displayName: Short provider name for messages (e.g., "GitHub") /// - buttonIcon: Button icon image /// - buttonBackgroundColor: Button background color /// - buttonForegroundColor: Button text/icon color public init(providerId: String, scopes: [String] = [], customParameters: [String: String] = [:], + buttonLabel: String, displayName: String, buttonIcon: Image, buttonBackgroundColor: Color = .black, @@ -46,6 +49,7 @@ public class OAuthProviderSwift: CredentialAuthProviderSwift { self.providerId = providerId self.scopes = scopes self.customParameters = customParameters + self.buttonLabel = buttonLabel self.displayName = displayName self.buttonIcon = buttonIcon self.buttonBackgroundColor = buttonBackgroundColor @@ -57,13 +61,15 @@ public class OAuthProviderSwift: CredentialAuthProviderSwift { /// - providerId: The OAuth provider ID (e.g., "github.com", "microsoft.com") /// - scopes: OAuth scopes to request /// - customParameters: Additional OAuth parameters - /// - displayName: Button label (e.g., "Sign in with GitHub") + /// - buttonLabel: Full button label (e.g., "Sign in with GitHub") + /// - displayName: Short provider name for messages (e.g., "GitHub") /// - iconSystemName: SF Symbol name /// - buttonBackgroundColor: Button background color /// - buttonForegroundColor: Button text/icon color public convenience init(providerId: String, scopes: [String] = [], customParameters: [String: String] = [:], + buttonLabel: String, displayName: String, iconSystemName: String, buttonBackgroundColor: Color = .black, @@ -72,6 +78,7 @@ public class OAuthProviderSwift: CredentialAuthProviderSwift { providerId: providerId, scopes: scopes, customParameters: customParameters, + buttonLabel: buttonLabel, displayName: displayName, buttonIcon: Image(systemName: iconSystemName), buttonBackgroundColor: buttonBackgroundColor, @@ -127,6 +134,10 @@ public class OAuthProviderAuthUI: AuthProviderUI { return typedProvider.providerId } + public var displayName: String { + return typedProvider.displayName + } + @MainActor public func authButton() -> AnyView { AnyView(GenericOAuthButton(provider: typedProvider)) } diff --git a/FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Views/GenericOAuthButton.swift b/FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Views/GenericOAuthButton.swift index 60e42ab338..3400a4b126 100644 --- a/FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Views/GenericOAuthButton.swift +++ b/FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Views/GenericOAuthButton.swift @@ -42,7 +42,7 @@ extension GenericOAuthButton: View { return AnyView( AuthProviderButton( - label: provider.displayName, + label: provider.buttonLabel, style: resolvedStyle, accessibilityId: "sign-in-with-\(provider.providerId)-button" ) { diff --git a/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Services/PhoneAuthProviderAuthUI.swift b/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Services/PhoneAuthProviderAuthUI.swift index 8ac54b9d16..5d835b959d 100644 --- a/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Services/PhoneAuthProviderAuthUI.swift +++ b/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Services/PhoneAuthProviderAuthUI.swift @@ -24,6 +24,7 @@ public class PhoneAuthProviderAuthUI: AuthProviderUI { private let typedProvider: PhoneProviderSwift public var provider: AuthProviderSwift { typedProvider } public let id: String = "phone" + public let displayName: String = "Phone" // Callback for when the phone auth button is tapped private let onTap: () -> Void diff --git a/FirebaseSwiftUI/FirebaseTwitterSwiftUI/Sources/Services/TwitterProviderAuthUI.swift b/FirebaseSwiftUI/FirebaseTwitterSwiftUI/Sources/Services/TwitterProviderAuthUI.swift index 5025676cd2..9c98f51839 100644 --- a/FirebaseSwiftUI/FirebaseTwitterSwiftUI/Sources/Services/TwitterProviderAuthUI.swift +++ b/FirebaseSwiftUI/FirebaseTwitterSwiftUI/Sources/Services/TwitterProviderAuthUI.swift @@ -48,6 +48,7 @@ public class TwitterProviderAuthUI: AuthProviderUI { private let typedProvider: TwitterProviderSwift public var provider: AuthProviderSwift { typedProvider } public let id: String = "twitter.com" + public let displayName: String = "Twitter" public init(provider: TwitterProviderSwift) { typedProvider = provider diff --git a/FirebaseSwiftUI/README.md b/FirebaseSwiftUI/README.md index 222fafca3a..c0784768ca 100644 --- a/FirebaseSwiftUI/README.md +++ b/FirebaseSwiftUI/README.md @@ -266,6 +266,22 @@ The default views support: - **Sign in with Twitter** - **Generic OAuth Providers** (GitHub, Microsoft, Yahoo, or custom OIDC) +### Reauthentication in Default Views + +Sensitive operations like deleting accounts, updating passwords, or unenrolling MFA factors require recent authentication. When using default views, reauthentication is handled automatically based on the user's sign-in provider. + +#### Automatic Reauthentication Behavior + +When a sensitive operation requires reauthentication, the default views automatically: + +- **OAuth Providers (Google, Apple, Facebook, Twitter, etc.)**: Display an alert asking the user to confirm, then automatically obtain fresh credentials and complete the operation. + +- **Email/Password**: Present a sheet prompting the user to enter their password before continuing. + +- **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.). + --- ## Usage with Custom Views @@ -635,6 +651,76 @@ When building custom views, you need to handle several things yourself that `Aut 3. **Anonymous User Upgrades**: Handle the linking of anonymous accounts if `shouldAutoUpgradeAnonymousUsers` is enabled 4. **Navigation State**: Manage navigation between different auth screens (phone verification, password recovery, etc.) 5. **Loading States**: Show loading indicators during async authentication operations by observing `authService.authenticationState` +6. **Reauthentication**: Handle reauthentication errors for sensitive operations (see [Reauthentication in Custom Views](#reauthentication-in-custom-views) below) + +### 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. + +#### Implementation Patterns + +**OAuth Providers (Google, Apple, Facebook, Twitter, etc.):** + +Catch the error and call `reauthenticate(context:)` which automatically handles the OAuth flow: + +```swift +do { + try await authService.deleteUser() +} catch let error as AuthServiceError { + if case .oauthReauthenticationRequired(let context) = error { + try await authService.reauthenticate(context: context) + try await authService.deleteUser() // Retry operation + } +} +``` + +**Email/Password:** + +Catch the error, prompt for password, create credential, and call `reauthenticate(with:)`: + +```swift +do { + try await authService.updatePassword(to: newPassword) +} catch let error as AuthServiceError { + if case .emailReauthenticationRequired(let context) = error { + // Show your password prompt UI + let password = await promptUserForPassword() + let credential = EmailAuthProvider.credential( + withEmail: context.email, + password: password + ) + try await authService.reauthenticate(with: credential) + try await authService.updatePassword(to: newPassword) // Retry + } +} +``` + +**Phone:** + +Catch the error, verify phone, create credential, and call `reauthenticate(with:)`: + +```swift +do { + try await authService.deleteUser() +} catch let error as AuthServiceError { + if case .phoneReauthenticationRequired(let context) = error { + // Send verification code + let verificationId = try await authService.verifyPhoneNumber( + phoneNumber: context.phoneNumber + ) + // Show your SMS code input UI + let code = await promptUserForSMSCode() + let credential = PhoneAuthProvider.provider().credential( + withVerificationID: verificationId, + verificationCode: code + ) + try await authService.reauthenticate(with: credential) + try await authService.deleteUser() // Retry + } +} +``` + +All reauthentication context objects include a `.displayMessage` property for user-facing text. ### Custom OAuth Providers @@ -767,13 +853,12 @@ Creates a new `AuthService` instance. ##### Email Authentication ```swift -public func withEmailSignIn(_ provider: EmailProviderSwift? = nil, onTap: @escaping () -> Void = {}) -> AuthService +public func withEmailSignIn(onTap: @escaping () -> Void = {}) -> AuthService ``` Enables email authentication and will render email sign-in directly within the AuthPickerView (default Views), email link sign-in is rendered as a button. When calling `AuthService.renderButtons()`, email link sign-in button is rendered. `onTap` custom callback (i.e where to navigate when tapped) allows user to control what happens when tapped. Default behavior in AuthPickerView is to push the user to email link sign-in default View. **Parameters:** -- `provider`: An optional instance of `EmailProviderSwift`. If not provided, a default instance will be created. - `onTap`: A callback that will be executed when the email button is tapped. **Example:** @@ -1213,12 +1298,15 @@ Updates the current user's photo URL. public func updatePassword(to password: String) async throws ``` -Updates the current user's password. May require recent authentication. +Updates the current user's password. This is a sensitive operation that may require recent authentication. **Parameters:** - `password`: New password -**Throws:** `AuthServiceError.noCurrentUser` or Firebase Auth errors +**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) +- Firebase Auth errors --- @@ -1240,7 +1328,42 @@ Sends a verification email to the current user's email address. public func deleteUser() async throws ``` -Deletes the current user's account. May require recent authentication. +Deletes the current user's account. This is a sensitive operation that requires recent authentication. + +**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) +- Firebase Auth errors + +--- + +##### Reauthenticate with OAuth Provider + +```swift +public func reauthenticate(context: OAuthReauthContext) async throws +``` + +Reauthenticates the current user with an OAuth provider (Google, Apple, Facebook, Twitter, etc.). Automatically locates the registered provider, obtains fresh credentials, and completes reauthentication. + +**Parameters:** +- `context`: The reauth context from `oauthReauthenticationRequired` error + +**Throws:** `AuthServiceError.noCurrentUser` or `AuthServiceError.providerNotFound` + +**Note:** Only works for OAuth providers. For email/phone, use `reauthenticate(with:)`. + +--- + +##### Reauthenticate with Credential + +```swift +public func reauthenticate(with credential: AuthCredential) async throws +``` + +Reauthenticates the current user with a pre-obtained authentication credential. Use for email/password or phone authentication. + +**Parameters:** +- `credential`: The authentication credential (from `EmailAuthProvider` or `PhoneAuthProvider`) **Throws:** `AuthServiceError.noCurrentUser` or Firebase Auth errors @@ -1423,22 +1546,6 @@ Navigator for managing navigation routes in default views. --- -```swift -public var passwordPrompt: PasswordPromptCoordinator -``` -A coordinator that manages password prompt dialogs during reauthentication flows for the email provider. - -Users can provide a custom `PasswordPromptCoordinator` instance when initializing `EmailProviderSwift` to customize password prompting behavior: - -```swift -let customPrompt = PasswordPromptCoordinator() -authService.withEmailSignIn(EmailProviderSwift(passwordPrompt: customPrompt)) -``` - -**Default Behavior:** If no custom coordinator is provided, a default `PasswordPromptCoordinator()` instance is created automatically. The default coordinator displays a modal sheet that prompts the user to enter their password when reauthentication is required for sensitive operations (e.g., updating email, deleting account). - ---- - ```swift public var authView: AuthView? ``` @@ -1529,13 +1636,25 @@ public enum AuthServiceError: Error { case providerNotFound(String) case invalidCredentials(String) case multiFactorAuth(String) - case reauthenticationRequired(String) + case oauthReauthenticationRequired(context: OAuthReauthContext) + case emailReauthenticationRequired(context: EmailReauthContext) + case phoneReauthenticationRequired(context: PhoneReauthContext) case accountConflict(AccountConflictContext) } ``` Errors specific to `AuthService` operations. +**Reauthentication Errors:** + +Thrown by sensitive operations when Firebase requires recent authentication. Each includes context information: + +- **`oauthReauthenticationRequired(context: OAuthReauthContext)`**: OAuth providers. Context contains `providerId`, `providerName`, and `displayMessage`. Pass to `reauthenticate(context:)`. + +- **`emailReauthenticationRequired(context: EmailReauthContext)`**: Email/password provider. Context contains `email` and `displayMessage`. Prompt for password, then call `reauthenticate(with:)`. + +- **`phoneReauthenticationRequired(context: PhoneReauthContext)`**: Phone provider. Context contains `phoneNumber` and `displayMessage`. Handle SMS verification, then call `reauthenticate(with:)`. + --- ### Best Practices @@ -1552,6 +1671,8 @@ Errors specific to `AuthService` operations. 6. **Provider-specific setup**: Some providers (Google, Facebook) require additional configuration in AppDelegate or Info.plist. See the [sample app](https://github.com/firebase/FirebaseUI-iOS/tree/main/samples/swiftui) for examples. +7. **Handle reauthentication**: Default views handle reauthentication automatically. For custom views, catch and handle reauthentication errors when performing sensitive operations like `deleteUser()`, `updatePassword()`, and `unenrollMFA()`. See [Reauthentication in Custom Views](#reauthentication-in-custom-views). + --- ## Additional Resources diff --git a/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExample/App/ContentView.swift b/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExample/App/ContentView.swift index 6e5ec14310..5ef18bd61b 100644 --- a/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExample/App/ContentView.swift +++ b/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExample/App/ContentView.swift @@ -62,7 +62,8 @@ struct ContentView: View { .withOAuthSignIn( OAuthProviderSwift( providerId: "oidc.line", - displayName: "Sign in with LINE", + buttonLabel: "Sign in with LINE", + displayName: "Line", buttonIcon: Image(.icLineLogo), buttonBackgroundColor: .lineButton, buttonForegroundColor: .white diff --git a/format-swift.sh b/format-swift.sh index 50777451a5..554f08f4fd 100755 --- a/format-swift.sh +++ b/format-swift.sh @@ -2,7 +2,7 @@ swiftformat ./FirebaseSwiftUI -swiftformat ./samples/swiftui/FirebaseSwiftUIExample +swiftformat ./samples/swiftui/FirebaseSwiftUISample swiftformat ./e2eTest diff --git a/lint-swift.sh b/lint-swift.sh index eecd5d1b8f..b46c2c8348 100755 --- a/lint-swift.sh +++ b/lint-swift.sh @@ -1,6 +1,6 @@ swiftformat --lint ./FirebaseSwiftUI -swiftformat --lint ./samples/swiftui/FirebaseSwiftUIExample +swiftformat --lint ./samples/swiftui/FirebaseSwiftUISample swiftformat --lint ./e2eTest diff --git a/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Application/AppDelegate.swift b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Application/AppDelegate.swift index 8d16d1070c..428cd1b6e3 100644 --- a/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Application/AppDelegate.swift +++ b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Application/AppDelegate.swift @@ -18,45 +18,39 @@ import FirebaseCore import GoogleSignIn class AppDelegate: NSObject, UIApplicationDelegate { - func application( - _ application: UIApplication, - didFinishLaunchingWithOptions launchOptions: [ - UIApplication.LaunchOptionsKey: Any - ]? - ) -> Bool { + func application(_ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [ + UIApplication.LaunchOptionsKey: Any + ]?) -> Bool { FirebaseApp.configure() - + ApplicationDelegate.shared.application( application, didFinishLaunchingWithOptions: launchOptions ) return true } - + func application(_: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { Auth.auth().setAPNSToken(deviceToken, type: .prod) } - - func application( - _: UIApplication, - didReceiveRemoteNotification notification: [AnyHashable: Any], - fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) - -> Void - ) { + + func application(_: UIApplication, + didReceiveRemoteNotification notification: [AnyHashable: Any], + fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) + -> Void) { if Auth.auth().canHandleNotification(notification) { completionHandler(.noData) return } } - - func application( - _ app: UIApplication, - open url: URL, - options: [UIApplication.OpenURLOptionsKey: Any] = [:] - ) -> Bool { + + func application(_ app: UIApplication, + open url: URL, + options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool { if Auth.auth().canHandle(url) { return true } - + if ApplicationDelegate.shared.application( app, open: url, @@ -66,7 +60,7 @@ class AppDelegate: NSObject, UIApplicationDelegate { ) { return true } - + return GIDSignIn.sharedInstance.handle(url) } } diff --git a/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Application/ContentView.swift b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Application/ContentView.swift index 2d629af39e..aad1107f0f 100644 --- a/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Application/ContentView.swift +++ b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Application/ContentView.swift @@ -12,20 +12,19 @@ // See the License for the specific language governing permissions and // limitations under the License. -import SwiftUI -import FirebaseAuth import FirebaseAppleSwiftUI -import FirebasePhoneAuthSwiftUI -import FirebaseGoogleSwiftUI -import FirebaseTwitterSwiftUI +import FirebaseAuth import FirebaseAuthSwiftUI import FirebaseFacebookSwiftUI +import FirebaseGoogleSwiftUI import FirebaseOAuthSwiftUI - +import FirebasePhoneAuthSwiftUI +import FirebaseTwitterSwiftUI +import SwiftUI struct ContentView: View { init() { -// Auth.auth().useEmulator(withHost: "127.0.0.1", port: 9099) + Auth.auth().useEmulator(withHost: "127.0.0.1", port: 9099) let actionCodeSettings = ActionCodeSettings() @@ -40,7 +39,7 @@ struct ContentView: View { emailLinkSignInActionCodeSettings: actionCodeSettings, mfaEnabled: true ) - + authService = AuthService( configuration: configuration ) @@ -56,7 +55,8 @@ struct ContentView: View { OAuthProviderSwift( providerId: "oidc.line", scopes: ["openid", "profile", "email"], - displayName: "Sign in with LINE", + buttonLabel: "Sign in with LINE", + displayName: "Line", buttonIcon: Image(.icLineLogo), buttonBackgroundColor: .lineButton, buttonForegroundColor: .white @@ -64,9 +64,9 @@ struct ContentView: View { ) .withEmailSignIn() } - + let authService: AuthService - + var body: some View { NavigationStack { VStack(spacing: 24) { @@ -102,9 +102,11 @@ struct ContentView: View { .font(.headline) .fontWeight(.bold) Text("How to use with AuthService with a custom view") - Text("• Build custom authentication UI\n• Direct AuthService method calls\n• Full control over user experience") - .font(.caption) - .foregroundColor(.secondary) + Text( + "• Build custom authentication UI\n• Direct AuthService method calls\n• Full control over user experience" + ) + .font(.caption) + .foregroundColor(.secondary) } .multilineTextAlignment(.leading) .padding() diff --git a/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Application/FirebaseSwiftUISampleApp.swift b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Application/FirebaseSwiftUISampleApp.swift index b73ad9e225..a794410402 100644 --- a/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Application/FirebaseSwiftUISampleApp.swift +++ b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Application/FirebaseSwiftUISampleApp.swift @@ -17,7 +17,7 @@ import SwiftUI @main struct FirebaseSwiftUISampleApp: App { @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate - + var body: some Scene { WindowGroup { ContentView() diff --git a/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/AuthPickerViewExample.swift b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/AuthPickerViewExample.swift index d3d4c2159c..eb20b1dcbf 100644 --- a/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/AuthPickerViewExample.swift +++ b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/AuthPickerViewExample.swift @@ -12,19 +12,19 @@ // See the License for the specific language governing permissions and // limitations under the License. -import SwiftUI -import FirebaseAuthSwiftUI import FirebaseAuth +import FirebaseAuthSwiftUI +import SwiftUI struct AuthPickerViewExample: View { @Environment(AuthService.self) private var authService - + var body: some View { AuthPickerView { authenticatedApp } } - + var authenticatedApp: some View { NavigationStack { VStack { diff --git a/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/CustomViewExample.swift b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/CustomViewExample.swift index 370b248525..e31f6ac71c 100644 --- a/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/CustomViewExample.swift +++ b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/CustomViewExample.swift @@ -12,9 +12,9 @@ // See the License for the specific language governing permissions and // limitations under the License. -import SwiftUI -import FirebaseAuthSwiftUI import FirebaseAuth +import FirebaseAuthSwiftUI +import SwiftUI struct CustomViewExample: View { @Environment(AuthService.self) private var authService @@ -23,7 +23,7 @@ struct CustomViewExample: View { @State private var isSignUp: Bool = false @State private var errorMessage: String? @State private var isLoading: Bool = false - + var body: some View { if authService.authenticationState == .authenticated { authenticatedView @@ -31,41 +31,41 @@ struct CustomViewExample: View { landingView } } - + private var landingView: some View { ScrollView { VStack(spacing: 32) { Spacer() .frame(height: 40) - + // Hero section VStack(spacing: 16) { Image(systemName: "flame.fill") .font(.system(size: 80)) .foregroundStyle(.orange) - + Text("Welcome to FirebaseUI") .font(.largeTitle) .fontWeight(.bold) .multilineTextAlignment(.center) - + Text("Sign in to continue and explore all the features") .font(.body) .foregroundColor(.secondary) .multilineTextAlignment(.center) .padding(.horizontal) } - + Spacer() .frame(height: 20) - + // Email/Password form VStack(spacing: 16) { VStack(alignment: .leading, spacing: 8) { Text("Email") .font(.subheadline) .fontWeight(.medium) - + TextField("Enter your email", text: $email) .textInputAutocapitalization(.never) .keyboardType(.emailAddress) @@ -74,26 +74,26 @@ struct CustomViewExample: View { .background(Color(UIColor.secondarySystemBackground)) .cornerRadius(8) } - + VStack(alignment: .leading, spacing: 8) { Text("Password") .font(.subheadline) .fontWeight(.medium) - + SecureField("Enter your password", text: $password) .textContentType(isSignUp ? .newPassword : .password) .padding() .background(Color(UIColor.secondarySystemBackground)) .cornerRadius(8) } - + if let errorMessage = errorMessage { Text(errorMessage) .font(.caption) .foregroundColor(.red) .frame(maxWidth: .infinity, alignment: .leading) } - + Button { Task { await handleAuthentication() @@ -114,7 +114,7 @@ struct CustomViewExample: View { .cornerRadius(8) } .disabled(!isFormValid || isLoading) - + Button { isSignUp.toggle() errorMessage = nil @@ -125,49 +125,49 @@ struct CustomViewExample: View { } } .padding(.horizontal, 24) - + // Divider with text HStack { Rectangle() .fill(Color.secondary.opacity(0.3)) .frame(height: 1) - + Text("or continue with") .font(.caption) .foregroundColor(.secondary) .padding(.horizontal, 8) - + Rectangle() .fill(Color.secondary.opacity(0.3)) .frame(height: 1) } .padding(.horizontal, 24) - + // Auth providers section - using AuthService's renderButtons VStack(spacing: 12) { authService.renderButtons(spacing: 12) } .padding(.horizontal, 24) - + Spacer() .frame(minHeight: 20) - + // Footer VStack(spacing: 8) { HStack(spacing: 4) { Text("By continuing, you agree to our") .font(.caption) .foregroundColor(.secondary) - + if let tosUrl = authService.configuration.tosUrl { Link("Terms", destination: tosUrl) .font(.caption) } - + Text("and") .font(.caption) .foregroundColor(.secondary) - + if let privacyUrl = authService.configuration.privacyPolicyUrl { Link("Privacy Policy", destination: privacyUrl) .font(.caption) @@ -179,19 +179,19 @@ struct CustomViewExample: View { } } } - + private var authenticatedView: some View { VStack(spacing: 24) { Spacer() Image(systemName: "checkmark.circle.fill") .font(.system(size: 80)) .foregroundStyle(.green) - + VStack(spacing: 8) { Text("Signed In Successfully") .font(.title) .fontWeight(.bold) - + if let email = authService.currentUser?.email { Text(email) .font(.body) @@ -202,7 +202,7 @@ struct CustomViewExample: View { .foregroundColor(.secondary) } } - + Button { Task { try? await authService.signOut() @@ -218,19 +218,19 @@ struct CustomViewExample: View { .cornerRadius(8) } .padding(.horizontal, 24) - + Spacer() } } - + private var isFormValid: Bool { !email.isEmpty && !password.isEmpty && password.count >= 6 } - + private func handleAuthentication() async { errorMessage = nil isLoading = true - + do { if isSignUp { _ = try await authService.createUser(email: email, password: password) @@ -240,7 +240,7 @@ struct CustomViewExample: View { } catch { errorMessage = error.localizedDescription } - + isLoading = false } }