diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Auth/MultiFactor.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Auth/MultiFactor.swift new file mode 100644 index 0000000000..02eb20200b --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Auth/MultiFactor.swift @@ -0,0 +1,114 @@ +// 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 SwiftUI + +public enum SecondFactorType { + case sms + case totp +} + +public struct TOTPEnrollmentInfo { + public let sharedSecretKey: String + public let qrCodeURL: URL? + public let accountName: String? + public let issuer: String? + public let verificationStatus: VerificationStatus + + public enum VerificationStatus { + case pending + case verified + case failed + } + + public init(sharedSecretKey: String, + qrCodeURL: URL? = nil, + accountName: String? = nil, + issuer: String? = nil, + verificationStatus: VerificationStatus = .pending) { + self.sharedSecretKey = sharedSecretKey + self.qrCodeURL = qrCodeURL + self.accountName = accountName + self.issuer = issuer + self.verificationStatus = verificationStatus + } +} + +public struct EnrollmentSession { + public let id: String + public let type: SecondFactorType + public let session: MultiFactorSession + public let totpInfo: TOTPEnrollmentInfo? + public let phoneNumber: String? + public let verificationId: String? + public let status: EnrollmentStatus + public let createdAt: Date + public let expiresAt: Date + + // Internal handle to finish TOTP + internal let _totpSecret: AnyObject? + + public enum EnrollmentStatus { + case initiated + case verificationSent + case verificationPending + case completed + case failed + case expired + } + + public init(id: String = UUID().uuidString, + type: SecondFactorType, + session: MultiFactorSession, + totpInfo: TOTPEnrollmentInfo? = nil, + phoneNumber: String? = nil, + verificationId: String? = nil, + status: EnrollmentStatus = .initiated, + createdAt: Date = Date(), + expiresAt: Date = Date().addingTimeInterval(600), // 10 minutes default + _totpSecret: AnyObject? = nil) { + self.id = id + self.type = type + self.session = session + self.totpInfo = totpInfo + self.phoneNumber = phoneNumber + self.verificationId = verificationId + self.status = status + self.createdAt = createdAt + self.expiresAt = expiresAt + self._totpSecret = _totpSecret + } + + public var isExpired: Bool { + return Date() > expiresAt + } + + public var canProceed: Bool { + return !isExpired && + (status == .initiated || status == .verificationSent || status == .verificationPending) + } +} + +public enum MFAHint { + case phone(displayName: String?, uid: String, phoneNumber: String?) + case totp(displayName: String?, uid: String) +} + +public struct MFARequired { + public let hints: [MFAHint] + + public init(hints: [MFAHint]) { + self.hints = hints + } +} \ No newline at end of file diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/AuthServiceError.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/AuthServiceError.swift index fbb5570f45..836ee116d4 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/AuthServiceError.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/AuthServiceError.swift @@ -38,6 +38,8 @@ public enum AuthServiceError: LocalizedError { case accountMergeConflict(context: AccountMergeConflictContext) case invalidPhoneAuthenticationArguments(String) case providerNotFound(String) + case multiFactorAuth(String) + public var errorDescription: String? { switch self { @@ -61,6 +63,8 @@ public enum AuthServiceError: LocalizedError { return description case let .invalidPhoneAuthenticationArguments(description): return description + case let .multiFactorAuth(description): + return description } } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthConfiguration.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthConfiguration.swift index c1d4b24e01..918140bf24 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthConfiguration.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthConfiguration.swift @@ -25,6 +25,12 @@ public struct AuthConfiguration { public let emailLinkSignInActionCodeSettings: ActionCodeSettings? public let verifyEmailActionCodeSettings: ActionCodeSettings? + // MARK: - MFA Configuration + + public let mfaEnabled: Bool + public let allowedSecondFactors: Set + public let mfaIssuer: String + public init(shouldHideCancelButton: Bool = false, interactiveDismissEnabled: Bool = true, shouldAutoUpgradeAnonymousUsers: Bool = false, @@ -32,7 +38,10 @@ public struct AuthConfiguration { tosUrl: URL? = nil, privacyPolicyUrl: URL? = nil, emailLinkSignInActionCodeSettings: ActionCodeSettings? = nil, - verifyEmailActionCodeSettings: ActionCodeSettings? = nil) { + verifyEmailActionCodeSettings: ActionCodeSettings? = nil, + mfaEnabled: Bool = false, + allowedSecondFactors: Set = [.sms, .totp], + mfaIssuer: String = "Firebase Auth") { self.shouldHideCancelButton = shouldHideCancelButton self.interactiveDismissEnabled = interactiveDismissEnabled self.shouldAutoUpgradeAnonymousUsers = shouldAutoUpgradeAnonymousUsers @@ -41,5 +50,8 @@ public struct AuthConfiguration { self.privacyPolicyUrl = privacyPolicyUrl self.emailLinkSignInActionCodeSettings = emailLinkSignInActionCodeSettings self.verifyEmailActionCodeSettings = verifyEmailActionCodeSettings + self.mfaEnabled = mfaEnabled + self.allowedSecondFactors = allowedSecondFactors + self.mfaIssuer = mfaIssuer } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift index 82e326164f..7e2d763e6d 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift @@ -49,9 +49,13 @@ public enum AuthView { case passwordRecovery case emailLink case updatePassword + case mfaEnrollment + case mfaManagement + case mfaResolution } public enum SignInOutcome: @unchecked Sendable { + case mfaRequired(MFARequired) case signedIn(AuthDataResult?) } @@ -101,6 +105,8 @@ public final class AuthService { public var authenticationFlow: AuthenticationFlow = .signIn public var errorMessage = "" public let passwordPrompt: PasswordPromptCoordinator = .init() + public var currentMFARequired: MFARequired? + private var currentMFAResolver: MultiFactorResolver? // MARK: - AuthPickerView Modal APIs @@ -228,6 +234,7 @@ public final class AuthService { } do { let result = try await currentUser?.link(with: credentials) + signedInCredential = credentials updateAuthenticationState() return .signedIn(result) } catch let error as NSError { @@ -255,11 +262,18 @@ public final class AuthService { updateAuthenticationState() return .signedIn(result) } - } catch { + } catch let error as NSError { authenticationState = .unauthenticated - errorMessage = string.localizedErrorMessage( - for: error - ) + errorMessage = string.localizedErrorMessage(for: error) + + // Check if this is an MFA required error + if error.code == AuthErrorCode.secondFactorRequired.rawValue { + if let resolver = error + .userInfo[AuthErrorUserInfoMultiFactorResolverKey] as? MultiFactorResolver { + return handleMFARequiredError(resolver: resolver) + } + } + throw error } } @@ -517,4 +531,368 @@ public extension AuthService { throw error } } -} \ No newline at end of file +} + +// MARK: - MFA Methods + +public extension AuthService { + func startMfaEnrollment(type: SecondFactorType, accountName: String? = nil, + issuer: String? = nil) async throws -> EnrollmentSession { + guard let user = auth.currentUser else { + throw AuthServiceError.noCurrentUser + } + + // Check if MFA is enabled in configuration + guard configuration.mfaEnabled else { + throw AuthServiceError.multiFactorAuth("MFA is not enabled in configuration, please enable `AuthConfiguration.mfaEnabled`") + } + + // Check if the requested factor type is allowed + guard configuration.allowedSecondFactors.contains(type) else { + throw AuthServiceError + .multiFactorAuth( + "The requested MFA factor type '\(type)' is not allowed in AuthConfiguration.allowedSecondFactors" + ) + } + + let multiFactorUser = user.multiFactor + + // Get the multi-factor session + let session = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation< + MultiFactorSession, + Error + >) in + multiFactorUser.getSessionWithCompletion { session, error in + if let error = error { + continuation.resume(throwing: error) + } else if let session = session { + continuation.resume(returning: session) + } else { + continuation.resume(throwing: AuthServiceError.multiFactorAuth("Failed to get MFA session for '\(type)'")) + } + } + } + + switch type { + case .sms: + // For SMS, we just return the session - phone number will be provided in + // sendSmsVerificationForEnrollment + return EnrollmentSession( + type: .sms, + session: session, + status: .initiated + ) + + case .totp: + // For TOTP, generate the secret and QR code + let totpSecret = try await TOTPMultiFactorGenerator.generateSecret(with: session) + + // Generate QR code URL + let resolvedAccountName = accountName ?? user.email ?? "User" + let resolvedIssuer = issuer ?? configuration.mfaIssuer + + let qrCodeURL = totpSecret.generateQRCodeURL( + withAccountName: resolvedAccountName, + issuer: resolvedIssuer + ) + + let totpInfo = TOTPEnrollmentInfo( + sharedSecretKey: totpSecret.sharedSecretKey(), + qrCodeURL: URL(string: qrCodeURL), + accountName: resolvedAccountName, + issuer: resolvedIssuer, + verificationStatus: .pending + ) + + return EnrollmentSession( + type: .totp, + session: session, + totpInfo: totpInfo, + status: .initiated, + _totpSecret: totpSecret + ) + } + } + + func sendSmsVerificationForEnrollment(session: EnrollmentSession, + phoneNumber: String) async throws -> String { + // Validate session + guard session.type == .sms else { + throw AuthServiceError.multiFactorAuth("Session is not configured for SMS enrollment") + } + + guard session.canProceed else { + if session.isExpired { + throw AuthServiceError.multiFactorAuth("Enrollment session has expired") + } else { + throw AuthServiceError + .multiFactorAuth("Session is not in a valid state for SMS verification") + } + } + + // Validate phone number format + guard !phoneNumber.isEmpty else { + throw AuthServiceError.multiFactorAuth("Phone number cannot be empty for SMS enrollment") + } + + // Send SMS verification using Firebase Auth PhoneAuthProvider + let verificationID = + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation< + String, + Error + >) in + PhoneAuthProvider.provider().verifyPhoneNumber( + phoneNumber, + uiDelegate: nil, + multiFactorSession: session.session + ) { verificationID, error in + if let error = error { + continuation.resume(throwing: error) + } else if let verificationID = verificationID { + continuation.resume(returning: verificationID) + } else { + continuation + .resume(throwing: AuthServiceError + .multiFactorAuth("Failed to send SMS verification code to verify phone number")) + } + } + } + + return verificationID + } + + func completeEnrollment(session: EnrollmentSession, verificationId: String?, + verificationCode: String, displayName: String) async throws { + // Validate session state + guard session.canProceed else { + if session.isExpired { + throw AuthServiceError.multiFactorAuth("Enrollment session has expired, cannot complete enrollment") + } else { + throw AuthServiceError.multiFactorAuth("Enrollment session is not in a valid state for completion") + } + } + + // Validate verification code + guard !verificationCode.isEmpty else { + throw AuthServiceError.multiFactorAuth("Verification code cannot be empty") + } + + guard let user = auth.currentUser else { + throw AuthServiceError.noCurrentUser + } + + let multiFactorUser = user.multiFactor + + // Create the appropriate assertion based on factor type + let assertion: MultiFactorAssertion + + switch session.type { + case .sms: + // For SMS, we need the verification ID + guard let verificationId = verificationId else { + throw AuthServiceError + .multiFactorAuth("Verification ID is required for SMS enrollment") + } + + // Create phone credential and assertion + let credential = PhoneAuthProvider.provider().credential( + withVerificationID: verificationId, + verificationCode: verificationCode + ) + assertion = PhoneMultiFactorGenerator.assertion(with: credential) + + case .totp: + // For TOTP, we need the secret from the session + guard let totpInfo = session.totpInfo else { + throw AuthServiceError + .multiFactorAuth("TOTP info is missing from enrollment session") + } + + // Use the stored TOTP secret from the enrollment session + guard let secret = session._totpSecret else { + throw AuthServiceError + .multiFactorAuth("TOTP secret is missing from enrollment session") + } + + // The concrete type is FirebaseAuth.TOTPSecret (kept as AnyObject to avoid exposing it) + guard let totpSecret = secret as? TOTPSecret else { + throw AuthServiceError + .multiFactorAuth("Invalid TOTP secret type in enrollment session") + } + + assertion = TOTPMultiFactorGenerator.assertionForEnrollment( + with: totpSecret, + oneTimePassword: verificationCode + ) + } + + // Complete the enrollment + try await user.multiFactor.enroll(with: assertion, displayName: displayName) + currentUser = auth.currentUser + } + + func reauthenticateCurrentUser(on user: User) async throws { + if let providerId = signedInCredential?.provider { + if providerId == EmailAuthProviderID { + guard let email = user.email else { + throw AuthServiceError.invalidCredentials("User does not have an email address") + } + let password = try await passwordPrompt.confirmPassword() + let credential = EmailAuthProvider.credential(withEmail: email, password: password) + try await user.reauthenticate(with: credential) + } else if let matchingProvider = providers.first(where: { $0.id == providerId }) { + let credential = try await matchingProvider.provider.createAuthCredential() + try await user.reauthenticate(with: credential) + } else { + throw AuthServiceError.providerNotFound("No provider found for \(providerId)") + } + } else { + throw AuthServiceError + .reauthenticationRequired("Recent login required to perform this operation.") + } + } + + func unenrollMFA(_ factorUid: String) async throws -> [MultiFactorInfo] { + guard let user = auth.currentUser else { + throw AuthServiceError.noCurrentUser + } + + let multiFactorUser = user.multiFactor + + do { + try await multiFactorUser.unenroll(withFactorUID: factorUid) + } 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 multiFactorUser.unenroll(withFactorUID: factorUid) + } else { + throw AuthServiceError + .multiFactorAuth( + "Invalid second factor: \(error.localizedDescription)" + ) + } + } + + // This is the only we to get the actual latest enrolledFactors + currentUser = Auth.auth().currentUser + let freshFactors = currentUser?.multiFactor.enrolledFactors ?? [] + + return freshFactors + } + + // MARK: - MFA Helper Methods + + private func extractMFAHints(from resolver: MultiFactorResolver) -> [MFAHint] { + return resolver.hints.map { hint -> MFAHint in + if hint.factorID == PhoneMultiFactorID { + let phoneHint = hint as! PhoneMultiFactorInfo + return .phone( + displayName: phoneHint.displayName, + uid: phoneHint.uid, + phoneNumber: phoneHint.phoneNumber + ) + } else if hint.factorID == TOTPMultiFactorID { + return .totp( + displayName: hint.displayName, + uid: hint.uid + ) + } else { + // Fallback for unknown hint types + return .totp(displayName: hint.displayName, uid: hint.uid) + } + } + } + + private func handleMFARequiredError(resolver: MultiFactorResolver) -> SignInOutcome { + let hints = extractMFAHints(from: resolver) + currentMFARequired = MFARequired(hints: hints) + currentMFAResolver = resolver + authView = .mfaResolution + 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) + signedInCredential = result.credential + updateAuthenticationState() + + // Clear MFA resolution state + currentMFARequired = nil + currentMFAResolver = nil + + } catch { + throw AuthServiceError + .multiFactorAuth("Failed to resolve MFA challenge: \(error.localizedDescription)") + } + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift index 805fe7bc6e..57c98f227c 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift @@ -50,13 +50,24 @@ extension AuthPickerView: View { VStack { authPickerTitleView if authService.authenticationState == .authenticated { - SignedInView() + switch authService.authView { + case .mfaEnrollment: + MFAEnrolmentView() + case .mfaManagement: + MFAManagementView() + default: + SignedInView() + } } else { switch authService.authView { case .passwordRecovery: PasswordRecoveryView() case .emailLink: EmailLinkView() + case .mfaEnrollment: + MFAEnrolmentView() + case .mfaResolution: + MFAResolutionView() case .authPicker: if authService.emailSignInEnabled { Text(authService.authenticationFlow == .signIn ? authService.string diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkView.swift index 62c6e923aa..e753bcd7ad 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkView.swift @@ -34,6 +34,24 @@ public struct EmailLinkView { extension EmailLinkView: View { public var body: some View { VStack { + HStack { + Button(action: { + authService.authView = .authPicker + }) { + HStack(spacing: 4) { + Image(systemName: "chevron.left") + .font(.system(size: 17, weight: .medium)) + Text(authService.string.backButtonLabel) + .font(.system(size: 17)) + } + .foregroundColor(.blue) + } + .accessibilityIdentifier("email-link-back-button") + + Spacer() + } + .padding(.horizontal) + .padding(.top, 8) Text(authService.string.signInWithEmailLinkViewTitle) .accessibilityIdentifier("email-link-title-text") LabeledContent { @@ -61,6 +79,7 @@ extension EmailLinkView: View { .frame(maxWidth: .infinity) .buttonStyle(.borderedProminent) Text(authService.errorMessage).foregroundColor(.red) + Spacer() }.sheet(isPresented: $showModal) { VStack { Text(authService.string.signInWithEmailLinkViewMessage) @@ -78,14 +97,6 @@ extension EmailLinkView: View { } catch {} } } - .navigationBarItems(leading: Button(action: { - authService.authView = .authPicker - }) { - Image(systemName: "chevron.left") - .foregroundColor(.blue) - Text(authService.string.backButtonLabel) - .foregroundColor(.blue) - }.accessibilityIdentifier("email-link-back-button")) } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAEnrolmentView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAEnrolmentView.swift new file mode 100644 index 0000000000..680462e780 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAEnrolmentView.swift @@ -0,0 +1,685 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import FirebaseAuth +import FirebaseCore +import SwiftUI + +private enum FocusableField: Hashable { + case phoneNumber + case verificationCode + case totpCode +} + +@MainActor +public struct MFAEnrolmentView { + @Environment(AuthService.self) private var authService + + @State private var selectedFactorType: SecondFactorType = .sms + @State private var phoneNumber = "" + @State private var verificationCode = "" + @State private var totpCode = "" + @State private var currentSession: EnrollmentSession? + @State private var isLoading = false + @State private var errorMessage = "" + @State private var displayName = "" + @State private var showCopiedFeedback = false + + @FocusState private var focus: FocusableField? + + public init() {} + + private var allowedFactorTypes: [SecondFactorType] { + return Array(authService.configuration.allowedSecondFactors).sorted { lhs, rhs in + // Sort SMS first, then TOTP + switch (lhs, rhs) { + case (.sms, .totp): return true + case (.totp, .sms): return false + default: return false + } + } + } + + private var canStartEnrollment: Bool { + !isLoading && currentSession == nil && authService.configuration.mfaEnabled + } + + private var canSendSMSVerification: Bool { + currentSession?.type == .sms && + currentSession?.status == .initiated && + !phoneNumber.isEmpty && + !displayName.isEmpty && + !isLoading + } + + private var canCompleteEnrollment: Bool { + guard let session = currentSession, !isLoading else { return false } + + switch session.type { + case .sms: + return session.status == .verificationSent && !verificationCode.isEmpty && !displayName + .isEmpty + case .totp: + return session.status == .initiated && !totpCode.isEmpty && !displayName.isEmpty + } + } + + private func startEnrollment() { + Task { + isLoading = true + errorMessage = "" + + do { + let session = try await authService.startMfaEnrollment( + type: selectedFactorType, + accountName: authService.currentUser?.email, + issuer: authService.configuration.mfaIssuer + ) + currentSession = session + } catch { + errorMessage = error.localizedDescription + } + + isLoading = false + } + } + + private func sendSMSVerification() { + guard let session = currentSession else { return } + + Task { + isLoading = true + errorMessage = "" + + do { + let verificationId = try await authService.sendSmsVerificationForEnrollment( + session: session, + phoneNumber: phoneNumber + ) + // Update session status + currentSession = EnrollmentSession( + id: session.id, + type: session.type, + session: session.session, + totpInfo: session.totpInfo, + phoneNumber: phoneNumber, + verificationId: verificationId, + status: .verificationSent, + createdAt: session.createdAt, + expiresAt: session.expiresAt + ) + } catch { + errorMessage = error.localizedDescription + } + + isLoading = false + } + } + + private func completeEnrollment() { + guard let session = currentSession else { return } + + Task { + isLoading = true + errorMessage = "" + + do { + let code = session.type == .sms ? verificationCode : totpCode + try await authService.completeEnrollment( + session: session, + verificationId: session.verificationId, + verificationCode: code, + displayName: displayName + ) + + // Reset form state on success + resetForm() + + // Navigate back to signed in view + authService.authView = .authPicker + + } catch { + errorMessage = error.localizedDescription + } + + isLoading = false + } + } + + private func resetForm() { + currentSession = nil + phoneNumber = "" + verificationCode = "" + totpCode = "" + displayName = "" + errorMessage = "" + focus = nil + } + + private func cancelEnrollment() { + resetForm() + authService.authView = .authPicker + } + + private func copyToClipboard(_ text: String) { + UIPasteboard.general.string = text + + + // Show feedback + showCopiedFeedback = true + + // Quickly show it has been copied to the clipboard + Task { + try? await Task.sleep(nanoseconds: 500_000_000) + showCopiedFeedback = false + } + } + + private func generateQRCode(from string: String) -> UIImage? { + let data = Data(string.utf8) + + guard let filter = CIFilter(name: "CIQRCodeGenerator") else { return nil } + filter.setValue(data, forKey: "inputMessage") + filter.setValue("H", forKey: "inputCorrectionLevel") + + guard let ciImage = filter.outputImage else { return nil } + + // Scale up the QR code for better quality + let transform = CGAffineTransform(scaleX: 10, y: 10) + let scaledImage = ciImage.transformed(by: transform) + + let context = CIContext() + guard let cgImage = context.createCGImage(scaledImage, from: scaledImage.extent) else { + return nil + } + + return UIImage(cgImage: cgImage) + } +} + +extension MFAEnrolmentView: View { + public var body: some View { + VStack(spacing: 16) { + // Back button + HStack { + Button(action: { + cancelEnrollment() + }) { + HStack(spacing: 4) { + Image(systemName: "chevron.left") + .font(.system(size: 17, weight: .medium)) + Text("Back") + .font(.system(size: 17)) + } + .foregroundColor(.blue) + } + .accessibilityIdentifier("mfa-back-button") + Spacer() + } + .padding(.horizontal) + + // Header + VStack { + Text("Set Up Two-Factor Authentication") + .font(.largeTitle) + .fontWeight(.bold) + .multilineTextAlignment(.center) + + Text("Add an extra layer of security to your account") + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + .padding() + + // Factor Type Selection (only if no session started) + if currentSession == nil { + if !authService.configuration.mfaEnabled { + VStack(spacing: 12) { + Image(systemName: "lock.slash") + .font(.system(size: 40)) + .foregroundColor(.orange) + + Text("Multi-Factor Authentication Disabled") + .font(.title2) + .fontWeight(.semibold) + + Text( + "MFA is not enabled in the current configuration. Please contact your administrator." + ) + .font(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + .padding(.horizontal) + .accessibilityIdentifier("mfa-disabled-message") + } else if allowedFactorTypes.isEmpty { + VStack(spacing: 12) { + Image(systemName: "exclamationmark.triangle") + .font(.system(size: 40)) + .foregroundColor(.orange) + + Text("No Authentication Methods Available") + .font(.title2) + .fontWeight(.semibold) + + Text("No MFA methods are configured as allowed. Please contact your administrator.") + .font(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + .padding(.horizontal) + .accessibilityIdentifier("no-factors-message") + } else { + VStack(alignment: .leading, spacing: 12) { + Text("Choose Authentication Method") + .font(.headline) + + Picker("Authentication Method", selection: $selectedFactorType) { + ForEach(allowedFactorTypes, id: \.self) { factorType in + switch factorType { + case .sms: + Image(systemName: "message").tag(SecondFactorType.sms) + case .totp: + Image(systemName: "qrcode").tag(SecondFactorType.totp) + } + } + } + .pickerStyle(.segmented) + .accessibilityIdentifier("factor-type-picker") + } + .padding(.horizontal) + } + } + + // Content based on current state + if let session = currentSession { + enrollmentContent(for: session) + } else { + initialContent + } + + // Error message + if !errorMessage.isEmpty { + Text(errorMessage) + .foregroundColor(.red) + .font(.caption) + .padding(.horizontal) + .accessibilityIdentifier("error-message") + } + } + .padding(.horizontal, 16) + .padding(.vertical, 20) + .onAppear { + // Initialize selected factor type to first allowed type + if !allowedFactorTypes.contains(selectedFactorType), + let firstAllowed = allowedFactorTypes.first { + selectedFactorType = firstAllowed + } + } + } + + @ViewBuilder + private var initialContent: some View { + VStack(spacing: 12) { + // Description based on selected type + Group { + if selectedFactorType == .sms { + VStack(spacing: 8) { + Image(systemName: "message.circle") + .font(.system(size: 40)) + .foregroundColor(.blue) + + Text("SMS Authentication") + .font(.title2) + .fontWeight(.semibold) + + Text("We'll send a verification code to your phone number each time you sign in.") + .font(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + } else { + VStack(spacing: 8) { + Image(systemName: "qrcode") + .font(.system(size: 40)) + .foregroundColor(.green) + + Text("Authenticator App") + .font(.title2) + .fontWeight(.semibold) + + Text( + "Use an authenticator app like Google Authenticator or Authy to generate verification codes." + ) + .font(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + } + } + .padding(.horizontal) + + Button(action: startEnrollment) { + HStack { + if isLoading { + ProgressView() + .scaleEffect(0.8) + } + Text("Get Started") + } + .frame(maxWidth: .infinity) + .padding() + .background(canStartEnrollment ? Color.blue : Color.gray) + .foregroundColor(.white) + .cornerRadius(8) + } + .disabled(!canStartEnrollment) + .padding(.horizontal) + .accessibilityIdentifier("start-enrollment-button") + } + } + + @ViewBuilder + private func enrollmentContent(for session: EnrollmentSession) -> some View { + switch session.type { + case .sms: + smsEnrollmentContent(session: session) + case .totp: + totpEnrollmentContent(session: session) + } + } + + @ViewBuilder + private func smsEnrollmentContent(session: EnrollmentSession) -> some View { + VStack(spacing: 20) { + // SMS enrollment steps + if session.status == .initiated { + VStack(spacing: 16) { + Image(systemName: "phone") + .font(.system(size: 48)) + .foregroundColor(.blue) + + Text("Enter Your Phone Number") + .font(.title2) + .fontWeight(.semibold) + + Text("We'll send a verification code to this number") + .font(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + + TextField("Phone Number", text: $phoneNumber) + .textFieldStyle(.roundedBorder) + .keyboardType(.phonePad) + .focused($focus, equals: .phoneNumber) + .accessibilityIdentifier("phone-number-field") + .padding(.horizontal) + + TextField("Display Name", text: $displayName) + .textFieldStyle(.roundedBorder) + .focused($focus, equals: nil) + .accessibilityIdentifier("display-name-field") + .padding(.horizontal) + + Button(action: sendSMSVerification) { + HStack { + if isLoading { + ProgressView() + .scaleEffect(0.8) + } + Text("Send Code") + } + .frame(maxWidth: .infinity) + .padding() + .background(canSendSMSVerification ? Color.blue : Color.gray) + .foregroundColor(.white) + .cornerRadius(8) + } + .disabled(!canSendSMSVerification) + .padding(.horizontal) + .accessibilityIdentifier("send-sms-button") + } + } else if session.status == .verificationSent { + VStack(spacing: 16) { + Image(systemName: "checkmark.message") + .font(.system(size: 48)) + .foregroundColor(.green) + + Text("Enter Verification Code") + .font(.title2) + .fontWeight(.semibold) + + Text("We sent a code to \(session.phoneNumber ?? "your phone")") + .font(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + + TextField("Verification Code", text: $verificationCode) + .textFieldStyle(.roundedBorder) + .keyboardType(.numberPad) + .focused($focus, equals: .verificationCode) + .accessibilityIdentifier("verification-code-field") + .padding(.horizontal) + + Button(action: completeEnrollment) { + HStack { + if isLoading { + ProgressView() + .scaleEffect(0.8) + } + Text("Complete Setup") + } + .frame(maxWidth: .infinity) + .padding() + .background(canCompleteEnrollment ? Color.blue : Color.gray) + .foregroundColor(.white) + .cornerRadius(8) + } + .disabled(!canCompleteEnrollment) + .padding(.horizontal) + .accessibilityIdentifier("complete-enrollment-button") + + Button("Resend Code") { + sendSMSVerification() + } + .foregroundColor(.blue) + .accessibilityIdentifier("resend-code-button") + } + } + } + } + + @ViewBuilder + private func totpEnrollmentContent(session: EnrollmentSession) -> some View { + VStack(spacing: 20) { + if let totpInfo = session.totpInfo { + VStack(spacing: 16) { + Image(systemName: "qrcode") + .font(.system(size: 48)) + .foregroundColor(.green) + + Text("Scan QR Code") + .font(.title2) + .fontWeight(.semibold) + + Text("Scan with your authenticator app or tap to open directly") + .font(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + + // QR Code generated from the otpauth:// URI + if let qrURL = totpInfo.qrCodeURL, + let qrImage = generateQRCode(from: qrURL.absoluteString) { + Button(action: { + UIApplication.shared.open(qrURL) + }) { + VStack(spacing: 12) { + Image(uiImage: qrImage) + .interpolation(.none) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 200, height: 200) + .accessibilityIdentifier("qr-code-image") + + HStack(spacing: 6) { + Image(systemName: "arrow.up.forward.app.fill") + .font(.caption) + Text("Tap to open in authenticator app") + .font(.caption) + .fontWeight(.medium) + } + .foregroundColor(.blue) + } + } + .buttonStyle(.plain) + .accessibilityIdentifier("open-authenticator-button") + } else { + RoundedRectangle(cornerRadius: 8) + .fill(Color.gray.opacity(0.3)) + .frame(width: 200, height: 200) + .overlay( + VStack { + Image(systemName: "exclamationmark.triangle") + .font(.title) + .foregroundColor(.orange) + Text("Unable to generate QR Code") + .font(.caption) + } + ) + } + + Text("Manual Entry Key:") + .font(.headline) + + VStack(spacing: 8) { + Button(action: { + copyToClipboard(totpInfo.sharedSecretKey) + }) { + HStack { + Text(totpInfo.sharedSecretKey) + .font(.system(.body, design: .monospaced)) + .lineLimit(1) + .minimumScaleFactor(0.5) + + Spacer() + + Image(systemName: showCopiedFeedback ? "checkmark" : "doc.on.doc") + .foregroundColor(showCopiedFeedback ? .green : .blue) + } + .padding() + .background(Color.gray.opacity(0.1)) + .cornerRadius(8) + } + .buttonStyle(.plain) + .accessibilityIdentifier("totp-secret-key") + + if showCopiedFeedback { + Text("Copied to clipboard!") + .font(.caption) + .foregroundColor(.green) + .transition(.opacity) + } + } + .animation(.easeInOut(duration: 0.2), value: showCopiedFeedback) + + TextField("Display Name", text: $displayName) + .textFieldStyle(.roundedBorder) + .accessibilityIdentifier("display-name-field") + .padding(.horizontal) + + TextField("Enter Code from App", text: $totpCode) + .textFieldStyle(.roundedBorder) + .keyboardType(.numberPad) + .focused($focus, equals: .totpCode) + .accessibilityIdentifier("totp-code-field") + .padding(.horizontal) + + Button(action: completeEnrollment) { + HStack { + if isLoading { + ProgressView() + .scaleEffect(0.8) + } + Text("Complete Setup") + } + .frame(maxWidth: .infinity) + .padding() + .background(canCompleteEnrollment ? Color.blue : Color.gray) + .foregroundColor(.white) + .cornerRadius(8) + } + .disabled(!canCompleteEnrollment) + .padding(.horizontal) + .accessibilityIdentifier("complete-enrollment-button") + } + } + } + } +} + +#Preview("MFA Enabled - Both Methods") { + FirebaseOptions.dummyConfigurationForPreview() + let config = AuthConfiguration( + mfaEnabled: true, + allowedSecondFactors: [.sms, .totp] + ) + let authService = AuthService(configuration: config) + return MFAEnrolmentView() + .environment(authService) +} + +#Preview("MFA Disabled") { + FirebaseOptions.dummyConfigurationForPreview() + let config = AuthConfiguration( + mfaEnabled: false, + allowedSecondFactors: [] + ) + let authService = AuthService(configuration: config) + return MFAEnrolmentView() + .environment(authService) +} + +#Preview("No Allowed Factors") { + FirebaseOptions.dummyConfigurationForPreview() + let config = AuthConfiguration( + mfaEnabled: true, + allowedSecondFactors: [] + ) + let authService = AuthService(configuration: config) + return MFAEnrolmentView() + .environment(authService) +} + +#Preview("SMS Only") { + FirebaseOptions.dummyConfigurationForPreview() + let config = AuthConfiguration( + mfaEnabled: true, + allowedSecondFactors: [.sms] + ) + let authService = AuthService(configuration: config) + return MFAEnrolmentView() + .environment(authService) +} + +#Preview("TOTP Only") { + FirebaseOptions.dummyConfigurationForPreview() + let config = AuthConfiguration( + mfaEnabled: true, + allowedSecondFactors: [.totp] + ) + let authService = AuthService(configuration: config) + return MFAEnrolmentView() + .environment(authService) +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAManagementView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAManagementView.swift new file mode 100644 index 0000000000..c8e2d1fe03 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAManagementView.swift @@ -0,0 +1,235 @@ +// 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 SwiftUI + +@MainActor +public struct MFAManagementView { + @Environment(AuthService.self) private var authService + + @State private var enrolledFactors: [MultiFactorInfo] = [] + @State private var isLoading = false + @State private var errorMessage = "" + + // Present password prompt when required for reauthentication + private var isShowingPasswordPrompt: Binding { + Binding( + get: { authService.passwordPrompt.isPromptingPassword }, + set: { authService.passwordPrompt.isPromptingPassword = $0 } + ) + } + + public init() {} + + private func loadEnrolledFactors() { + guard let user = authService.currentUser else { return } + enrolledFactors = user.multiFactor.enrolledFactors + } + + private func unenrollFactor(_ factorUid: String) { + Task { + isLoading = true + errorMessage = "" + + do { + let freshFactors = try await authService.unenrollMFA(factorUid) + enrolledFactors = freshFactors + } catch { + errorMessage = error.localizedDescription + } + + isLoading = false + } + } + + private func navigateToEnrollment() { + authService.authView = .mfaEnrollment + } + + private func goBack() { + authService.authView = .authPicker + } +} + +extension MFAManagementView: View { + public var body: some View { + VStack(spacing: 20) { + // Header with manual back button + HStack { + Button(action: { + authService.authView = .authPicker + }) { + HStack(spacing: 4) { + Image(systemName: "chevron.left") + .font(.system(size: 17, weight: .medium)) + Text(authService.string.backButtonLabel) + .font(.system(size: 17)) + } + .foregroundColor(.blue) + } + .accessibilityIdentifier("back-button") + + Spacer() + } + .padding(.horizontal) + + // Title section + VStack { + Text("Two-Factor Authentication") + .font(.largeTitle) + .fontWeight(.bold) + .multilineTextAlignment(.center) + + Text("Manage your authentication methods") + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + .padding(.horizontal) + + if enrolledFactors.isEmpty { + // No factors enrolled + VStack(spacing: 16) { + Image(systemName: "shield.slash") + .font(.system(size: 48)) + .foregroundColor(.orange) + + Text("No Authentication Methods") + .font(.title2) + .fontWeight(.semibold) + + Text( + "Set up two-factor authentication to add an extra layer of security to your account." + ) + .font(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + + Button("Set Up Two-Factor Authentication") { + navigateToEnrollment() + } + .buttonStyle(.borderedProminent) + .accessibilityIdentifier("setup-mfa-button") + } + } else { + // Show enrolled factors + VStack(alignment: .leading, spacing: 16) { + Text("Enrolled Methods") + .font(.headline) + .padding(.horizontal) + + ForEach(enrolledFactors, id: \.uid) { factor in + factorRow(factor: factor) + } + + Divider() + .padding(.horizontal) + + Button("Add Another Method") { + navigateToEnrollment() + } + .buttonStyle(.bordered) + .padding(.horizontal) + .accessibilityIdentifier("add-mfa-method-button") + } + } + + // Error message + if !errorMessage.isEmpty { + Text(errorMessage) + .foregroundColor(.red) + .font(.caption) + .padding(.horizontal) + .accessibilityIdentifier("error-message") + } + + Spacer() + } + .onAppear { + loadEnrolledFactors() + } + .sheet(isPresented: isShowingPasswordPrompt) { + PasswordPromptSheet(coordinator: authService.passwordPrompt) + } + } + + @ViewBuilder + private func factorRow(factor: MultiFactorInfo) -> some View { + HStack { + // Factor type icon + Group { + if factor.factorID == PhoneMultiFactorID { + Image(systemName: "message") + .foregroundColor(.blue) + } else { + Image(systemName: "qrcode") + .foregroundColor(.green) + } + } + .font(.title2) + + VStack(alignment: .leading, spacing: 4) { + Text(factor.displayName ?? "Unnamed Method") + .font(.headline) + + if factor.factorID == PhoneMultiFactorID { + let phoneInfo = factor as! PhoneMultiFactorInfo + Text("SMS: \(phoneInfo.phoneNumber)") + .font(.caption) + .foregroundColor(.secondary) + } else { + Text("Authenticator App") + .font(.caption) + .foregroundColor(.secondary) + } + + Text("Enrolled: \(DateFormatter.shortDate.string(from: factor.enrollmentDate))") + .font(.caption2) + .foregroundColor(.secondary) + } + + Spacer() + + Button("Remove") { + unenrollFactor(factor.uid) + } + .buttonStyle(.bordered) + .foregroundColor(.red) + .disabled(isLoading) + .accessibilityIdentifier("remove-factor-\(factor.uid)") + } + .padding() + .background(Color(.systemGray6)) + .cornerRadius(8) + .padding(.horizontal) + } +} + +// MARK: - Date Formatter Extension + +private extension DateFormatter { + static let shortDate: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .short + formatter.timeStyle = .none + return formatter + }() +} + +#Preview { + MFAManagementView() + .environment(AuthService()) +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAResolutionView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAResolutionView.swift new file mode 100644 index 0000000000..a09ade41d6 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAResolutionView.swift @@ -0,0 +1,412 @@ +// 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 FirebaseAuth +import SwiftUI + +private enum FocusableField: Hashable { + case verificationCode + case totpCode +} + +@MainActor +public struct MFAResolutionView { + @Environment(AuthService.self) private var authService + + @State private var verificationCode = "" + @State private var totpCode = "" + @State private var isLoading = false + @State private var errorMessage = "" + @State private var selectedHintIndex = 0 + @State private var verificationId: String? + + @FocusState private var focus: FocusableField? + + public init() {} + + private var mfaRequired: MFARequired? { + // This would be set by the sign-in flow when MFA is required + authService.currentMFARequired + } + + private var selectedHint: MFAHint? { + guard let mfaRequired = mfaRequired, + selectedHintIndex < mfaRequired.hints.count else { + return nil + } + return mfaRequired.hints[selectedHintIndex] + } + + private var canCompleteResolution: Bool { + guard !isLoading else { return false } + + switch selectedHint { + case .phone: + return !verificationCode.isEmpty + case .totp: + return !totpCode.isEmpty + case .none: + return false + } + } + + private func startSMSChallenge() { + guard selectedHintIndex < (mfaRequired?.hints.count ?? 0) else { return } + + Task { + isLoading = true + errorMessage = "" + + do { + let verificationId = try await authService.resolveSmsChallenge(hintIndex: selectedHintIndex) + self.verificationId = verificationId + } catch { + errorMessage = error.localizedDescription + } + + isLoading = false + } + } + + private func completeResolution() { + Task { + isLoading = true + errorMessage = "" + + do { + let code = selectedHint?.isPhoneHint == true ? verificationCode : totpCode + try await authService.resolveSignIn( + code: code, + hintIndex: selectedHintIndex, + verificationId: verificationId + ) + // On success, the AuthService will update the authentication state + // and we should navigate back to the main app + authService.authView = .authPicker + } catch { + errorMessage = error.localizedDescription + } + + isLoading = false + } + } + + private func cancelResolution() { + authService.authView = .authPicker + } +} + +extension MFAResolutionView: View { + public var body: some View { + VStack(spacing: 24) { + // Header + VStack(spacing: 12) { + Image(systemName: "lock.shield") + .font(.system(size: 50)) + .foregroundColor(.blue) + + Text("Two-Factor Authentication") + .font(.largeTitle) + .fontWeight(.bold) + .accessibilityIdentifier("mfa-resolution-title") + + Text("Complete sign-in with your second factor") + .font(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + .padding(.horizontal) + + // MFA Hints Selection (if multiple available) + if let mfaRequired = mfaRequired, mfaRequired.hints.count > 1 { + mfaHintsSelectionView(mfaRequired: mfaRequired) + } + + // Resolution Content + if let hint = selectedHint { + resolutionContent(for: hint) + } + + // Error message + if !errorMessage.isEmpty { + Text(errorMessage) + .foregroundColor(.red) + .font(.caption) + .padding(.horizontal) + .accessibilityIdentifier("error-message") + } + + // Action buttons + VStack(spacing: 12) { + // Complete Resolution Button + Button(action: completeResolution) { + HStack { + if isLoading { + ProgressView() + .scaleEffect(0.8) + } + Text("Complete Sign-In") + } + .frame(maxWidth: .infinity) + .padding() + .background(canCompleteResolution ? Color.blue : Color.gray) + .foregroundColor(.white) + .cornerRadius(8) + } + .disabled(!canCompleteResolution) + .accessibilityIdentifier("complete-resolution-button") + + // Cancel Button + Button(action: cancelResolution) { + Text("Cancel") + .frame(maxWidth: .infinity) + .padding() + .background(Color.gray.opacity(0.2)) + .foregroundColor(.primary) + .cornerRadius(8) + } + .accessibilityIdentifier("cancel-button") + } + .padding(.horizontal) + } + .padding(.vertical, 20) + } + + @ViewBuilder + private func resolutionContent(for hint: MFAHint) -> some View { + switch hint { + case let .phone(displayName, _, phoneNumber): + phoneResolutionContent(displayName: displayName, phoneNumber: phoneNumber) + case let .totp(displayName, _): + totpResolutionContent(displayName: displayName) + } + } + + @ViewBuilder + private func phoneResolutionContent(displayName _: String?, phoneNumber: String?) -> some View { + VStack(spacing: 16) { + VStack(spacing: 8) { + Image(systemName: "message.circle.fill") + .font(.system(size: 40)) + .foregroundColor(.blue) + + Text("SMS Verification") + .font(.title2) + .fontWeight(.semibold) + + if let phoneNumber = phoneNumber { + Text("We'll send a code to ••••••\(String(phoneNumber.suffix(4)))") + .font(.body) + .foregroundColor(.secondary) + } else { + Text("We'll send a verification code to your phone") + .font(.body) + .foregroundColor(.secondary) + } + } + .padding(.horizontal) + + // Send SMS button (if verification ID not yet obtained) + if verificationId == nil { + Button(action: startSMSChallenge) { + HStack { + if isLoading { + ProgressView() + .scaleEffect(0.8) + } + Text("Send Code") + } + .frame(maxWidth: .infinity) + .padding() + .background(isLoading ? Color.gray : Color.blue) + .foregroundColor(.white) + .cornerRadius(8) + } + .disabled(isLoading) + .padding(.horizontal) + .accessibilityIdentifier("send-sms-button") + } else { + // Verification code input + VStack(alignment: .leading, spacing: 8) { + Text("Verification Code") + .font(.headline) + + TextField("Enter 6-digit code", text: $verificationCode) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .keyboardType(.numberPad) + .focused($focus, equals: .verificationCode) + .accessibilityIdentifier("sms-verification-code-field") + } + .padding(.horizontal) + } + } + } + + @ViewBuilder + private func totpResolutionContent(displayName: String?) -> some View { + VStack(spacing: 16) { + VStack(spacing: 8) { + Image(systemName: "qrcode") + .font(.system(size: 40)) + .foregroundColor(.green) + + Text("Authenticator App") + .font(.title2) + .fontWeight(.semibold) + + Text("Enter the 6-digit code from your authenticator app") + .font(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + + if let displayName = displayName { + Text("Account: \(displayName)") + .font(.caption) + .foregroundColor(.secondary) + } + } + .padding(.horizontal) + + // TOTP code input + VStack(alignment: .leading, spacing: 8) { + Text("Verification Code") + .font(.headline) + + TextField("Enter 6-digit code", text: $totpCode) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .keyboardType(.numberPad) + .focused($focus, equals: .totpCode) + .accessibilityIdentifier("totp-verification-code-field") + } + .padding(.horizontal) + } + } + + @ViewBuilder + private func mfaHintsSelectionView(mfaRequired: MFARequired) -> some View { + VStack(alignment: .leading, spacing: 12) { + Text("Choose verification method:") + .font(.headline) + .padding(.horizontal) + + // More idiomatic approach using indices + ForEach(mfaRequired.hints.indices, id: \.self) { index in + let hint = mfaRequired.hints[index] + hintSelectionButton(hint: hint, index: index) + } + } + } + + @ViewBuilder + private func hintSelectionButton(hint: MFAHint, index: Int) -> some View { + Button(action: { + selectedHintIndex = index + // Clear previous input when switching methods + verificationCode = "" + totpCode = "" + verificationId = nil + }) { + HStack { + Image(systemName: hint.isPhoneHint ? "message.circle" : "qrcode") + .foregroundColor(.blue) + + VStack(alignment: .leading) { + Text(hintDisplayName(for: hint)) + .font(.body) + .foregroundColor(.primary) + + hintSubtitle(for: hint) + } + + Spacer() + + if selectedHintIndex == index { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.blue) + } + } + .padding() + .background(selectedHintIndex == index ? Color.blue.opacity(0.1) : Color.clear) + .cornerRadius(8) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(selectedHintIndex == index ? Color.blue : Color.gray.opacity(0.3), lineWidth: 1) + ) + } + .buttonStyle(PlainButtonStyle()) + .padding(.horizontal) + .accessibilityIdentifier("hint-\(index)") + } + + private func hintDisplayName(for hint: MFAHint) -> String { + hint.isPhoneHint ? "SMS" : "Authenticator App" + } + + @ViewBuilder + private func hintSubtitle(for hint: MFAHint) -> some View { + if case let .phone(_, _, phoneNumber) = hint, let phone = phoneNumber { + Text("••••••\(String(phone.suffix(4)))") + .font(.caption) + .foregroundColor(.secondary) + } + } +} + +// Helper extension for MFAHint +private extension MFAHint { + var isPhoneHint: Bool { + switch self { + case .phone: + return true + case .totp: + return false + } + } +} + +#Preview("Phone SMS Only") { + FirebaseOptions.dummyConfigurationForPreview() + let authService = AuthService() + authService.currentMFARequired = MFARequired(hints: [ + .phone(displayName: "Work Phone", uid: "phone-uid-1", phoneNumber: "+15551234567") + ]) + return MFAResolutionView().environment(authService) +} + +#Preview("TOTP Only") { + FirebaseOptions.dummyConfigurationForPreview() + let authService = AuthService() + authService.currentMFARequired = MFARequired(hints: [ + .totp(displayName: "Authenticator App", uid: "totp-uid-1") + ]) + return MFAResolutionView().environment(authService) +} + +#Preview("Multiple Methods") { + FirebaseOptions.dummyConfigurationForPreview() + let authService = AuthService() + authService.currentMFARequired = MFARequired(hints: [ + .phone(displayName: "Mobile", uid: "phone-uid-1", phoneNumber: "+15551234567"), + .totp(displayName: "Google Authenticator", uid: "totp-uid-1") + ]) + return MFAResolutionView().environment(authService) +} + +#Preview("No MFA Required") { + FirebaseOptions.dummyConfigurationForPreview() + let authService = AuthService() + // currentMFARequired is nil by default + return MFAResolutionView().environment(authService) +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordRecoveryView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordRecoveryView.swift index ee2a77a5ed..e490516678 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordRecoveryView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordRecoveryView.swift @@ -42,6 +42,20 @@ public struct PasswordRecoveryView { extension PasswordRecoveryView: View { public var body: some View { VStack { + HStack { + Button(action: { + authService.authView = .authPicker + }) { + HStack(spacing: 4) { + Image(systemName: "chevron.left") + .font(.system(size: 17, weight: .medium)) + Text(authService.string.backButtonLabel) + .font(.system(size: 17)) + } + .foregroundColor(.blue) + } + .accessibilityIdentifier("password-recovery-back-button") + } Text(authService.string.passwordRecoveryTitle) .font(.largeTitle) .fontWeight(.bold) @@ -79,14 +93,6 @@ extension PasswordRecoveryView: View { .sheet(item: $resultWrapper) { wrapper in resultSheet(wrapper.value) } - .navigationBarItems(leading: Button(action: { - authService.authView = .authPicker - }) { - Image(systemName: "chevron.left") - .foregroundColor(.blue) - Text(authService.string.backButtonLabel) - .foregroundColor(.blue) - }.accessibilityIdentifier("password-recovery-back-button")) } @ViewBuilder diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignedInView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignedInView.swift index a8d3acca9f..50f223a16b 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignedInView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignedInView.swift @@ -49,6 +49,11 @@ extension SignedInView: View { authService.authView = .updatePassword } Divider() + Button("Manage Two-Factor Authentication") { + authService.authView = .mfaManagement + } + .accessibilityIdentifier("mfa-management-button") + Divider() Button(authService.string.signOutButtonLabel) { Task { do { diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Tests/FirebaseAuthSwiftUITests/MFAEnrolmentUnitTests.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Tests/FirebaseAuthSwiftUITests/MFAEnrolmentUnitTests.swift new file mode 100644 index 0000000000..c4564dc308 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Tests/FirebaseAuthSwiftUITests/MFAEnrolmentUnitTests.swift @@ -0,0 +1,93 @@ +// 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. + +// +// MFAEnrollmentUnitTests.swift +// FirebaseAuthSwiftUITests +// +// Unit tests for MFA enrollment data structures +// + +import FirebaseAuth +import FirebaseAuthSwiftUI +import Foundation +import Testing + +// MARK: - TOTPEnrollmentInfo Tests + +@Suite("TOTPEnrollmentInfo Tests") +struct TOTPEnrollmentInfoTests { + @Test("Initialization with shared secret key") + func testInitializationWithSharedSecretKey() { + let validSecrets = [ + "JBSWY3DPEHPK3PXP", + "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ", + "MFRGG43FMZQW4ZY=", + ] + + for secret in validSecrets { + let totpInfo = TOTPEnrollmentInfo(sharedSecretKey: secret) + #expect(totpInfo.sharedSecretKey == secret) + #expect(totpInfo.verificationStatus == .pending) + #expect(totpInfo.qrCodeURL == nil) + #expect(totpInfo.accountName == nil) + #expect(totpInfo.issuer == nil) + } + } + + @Test("Initialization with all parameters") + func testInitializationWithAllParameters() throws { + let totpInfo = TOTPEnrollmentInfo( + sharedSecretKey: "JBSWY3DPEHPK3PXP", + qrCodeURL: URL( + string: "otpauth://totp/Example:alice@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Example" + ), + accountName: "alice@example.com", + issuer: "Example", + verificationStatus: .verified + ) + + #expect(totpInfo.sharedSecretKey == "JBSWY3DPEHPK3PXP") + #expect(totpInfo.accountName == "alice@example.com") + #expect(totpInfo.issuer == "Example") + #expect(totpInfo.verificationStatus == .verified) + + let qrURL = try #require(totpInfo.qrCodeURL) + #expect(qrURL.scheme == "otpauth") + #expect(qrURL.host == "totp") + #expect(qrURL.query?.contains("secret=JBSWY3DPEHPK3PXP") == true) + #expect(qrURL.query?.contains("issuer=Example") == true) + } + + @Test("Verification status transitions") + func testVerificationStatusTransitions() { + // Default status is pending + var totpInfo = TOTPEnrollmentInfo(sharedSecretKey: "JBSWY3DPEHPK3PXP") + #expect(totpInfo.verificationStatus == .pending) + + // Verified status + totpInfo = TOTPEnrollmentInfo( + sharedSecretKey: "JBSWY3DPEHPK3PXP", + verificationStatus: .verified + ) + #expect(totpInfo.verificationStatus == .verified) + + // Failed status + totpInfo = TOTPEnrollmentInfo( + sharedSecretKey: "JBSWY3DPEHPK3PXP", + verificationStatus: .failed + ) + #expect(totpInfo.verificationStatus == .failed) + } +} diff --git a/Package.swift b/Package.swift index ef7348373e..bc11bc3aac 100644 --- a/Package.swift +++ b/Package.swift @@ -119,7 +119,7 @@ let package = Package( .target( name: "FirebaseDatabaseUI", dependencies: [ - .product(name: "FirebaseDatabase", package: "Firebase"), + .product(name: "FirebaseDatabase", package: "firebase-ios-sdk"), ], path: "FirebaseDatabaseUI/Sources", exclude: ["Info.plist"], @@ -132,7 +132,7 @@ let package = Package( .target( name: "FirebaseAuthUI", dependencies: [ - .product(name: "FirebaseAuth", package: "Firebase"), + .product(name: "FirebaseAuth", package: "firebase-ios-sdk"), .product(name: "GULUserDefaults", package: "GoogleUtilities"), ], path: "FirebaseAuthUI/Sources", @@ -163,8 +163,8 @@ let package = Package( name: "FirebaseFacebookAuthUI", dependencies: [ "FirebaseAuthUI", - .product(name: "FacebookLogin", package: "Facebook"), - .product(name: "FacebookCore", package: "Facebook"), + .product(name: "FacebookLogin", package: "facebook-ios-sdk"), + .product(name: "FacebookCore", package: "facebook-ios-sdk"), ], path: "FirebaseFacebookAuthUI/Sources", exclude: ["Info.plist"], @@ -180,7 +180,7 @@ let package = Package( .target( name: "FirebaseFirestoreUI", dependencies: [ - .product(name: "FirebaseFirestore", package: "Firebase"), + .product(name: "FirebaseFirestore", package: "firebase-ios-sdk"), ], path: "FirebaseFirestoreUI/Sources", exclude: ["Info.plist"], @@ -194,7 +194,7 @@ let package = Package( name: "FirebaseGoogleAuthUI", dependencies: [ "FirebaseAuthUI", - "GoogleSignIn", + .product(name: "GoogleSignIn", package: "GoogleSignIn-iOS"), ], path: "FirebaseGoogleAuthUI/Sources", exclude: ["Info.plist"], @@ -241,7 +241,7 @@ let package = Package( .target( name: "FirebaseStorageUI", dependencies: [ - .product(name: "FirebaseStorage", package: "Firebase"), + .product(name: "FirebaseStorage", package: "firebase-ios-sdk"), .product(name: "SDWebImage", package: "SDWebImage"), ], path: "FirebaseStorageUI/Sources", @@ -255,7 +255,7 @@ let package = Package( .target( name: "FirebaseAuthSwiftUI", dependencies: [ - .product(name: "FirebaseAuth", package: "Firebase"), + .product(name: "FirebaseAuth", package: "firebase-ios-sdk"), ], path: "FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources", resources: [ @@ -271,8 +271,8 @@ let package = Package( name: "FirebaseGoogleSwiftUI", dependencies: [ "FirebaseAuthSwiftUI", - "GoogleSignIn", - .product(name: "GoogleSignInSwift", package: "GoogleSignIn"), + .product(name: "GoogleSignIn", package: "GoogleSignIn-iOS"), + .product(name: "GoogleSignInSwift", package: "GoogleSignIn-iOS"), ], path: "FirebaseSwiftUI/FirebaseGoogleSwiftUI/Sources" ), @@ -285,8 +285,8 @@ let package = Package( name: "FirebaseFacebookSwiftUI", dependencies: [ "FirebaseAuthSwiftUI", - .product(name: "FacebookLogin", package: "Facebook"), - .product(name: "FacebookCore", package: "Facebook"), + .product(name: "FacebookLogin", package: "facebook-ios-sdk"), + .product(name: "FacebookCore", package: "facebook-ios-sdk"), ], path: "FirebaseSwiftUI/FirebaseFacebookSwiftUI/Sources" ), diff --git a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/ContentView.swift b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/ContentView.swift index e51ef876cd..adccdcbe4d 100644 --- a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/ContentView.swift +++ b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/ContentView.swift @@ -39,10 +39,10 @@ struct ContentView: View { actionCodeSettings.linkDomain = "flutterfire-e2e-tests.firebaseapp.com" actionCodeSettings.setIOSBundleID(Bundle.main.bundleIdentifier!) let configuration = AuthConfiguration( - shouldAutoUpgradeAnonymousUsers: !uiAuthEmulator, tosUrl: URL(string: "https://example.com/tos"), privacyPolicyUrl: URL(string: "https://example.com/privacy"), - emailLinkSignInActionCodeSettings: actionCodeSettings + emailLinkSignInActionCodeSettings: actionCodeSettings, + mfaEnabled: true ) authService = AuthService( diff --git a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/FirebaseSwiftUIExampleApp.swift b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/FirebaseSwiftUIExampleApp.swift index 630fdb6603..735bbbb0f5 100644 --- a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/FirebaseSwiftUIExampleApp.swift +++ b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/FirebaseSwiftUIExampleApp.swift @@ -30,9 +30,6 @@ class AppDelegate: NSObject, UIApplicationDelegate { UIApplication.LaunchOptionsKey: Any ]?) -> Bool { FirebaseApp.configure() - if uiAuthEmulator { - Auth.auth().useEmulator(withHost: "localhost", port: 9099) - } ApplicationDelegate.shared.application( application, @@ -58,6 +55,8 @@ class AppDelegate: NSObject, UIApplicationDelegate { 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, @@ -76,15 +75,13 @@ class AppDelegate: NSObject, UIApplicationDelegate { struct FirebaseSwiftUIExampleApp: App { @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate - init() { - Task { - try await testCreateUser() - } - } + init() {} var body: some Scene { WindowGroup { - NavigationView { + if testRunner { + TestView() + } else { ContentView() } } diff --git a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/Info.plist b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/Info.plist index 968fe3cff8..554c9f159b 100644 --- a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/Info.plist +++ b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/Info.plist @@ -40,6 +40,6 @@ remote-notification FirebaseAppDelegateProxyEnabled - + diff --git a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/TestView.swift b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/TestView.swift new file mode 100644 index 0000000000..79978406f4 --- /dev/null +++ b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/TestView.swift @@ -0,0 +1,66 @@ +// 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. + +// +// ContentView.swift +// FirebaseSwiftUIExample +// +// Created by Russell Wheatley on 23/04/2025. +// + +import FirebaseAuth +import FirebaseAuthSwiftUI +import FirebaseFacebookSwiftUI +import FirebaseGoogleSwiftUI +import FirebasePhoneAuthSwiftUI +import SwiftUI + +struct TestView: View { + let authService: AuthService + + init() { + Auth.auth().useEmulator(withHost: "localhost", port: 9099) + Auth.auth().settings?.isAppVerificationDisabledForTesting = true + Task { + try await testCreateUser() + } + + let isMfaEnabled = ProcessInfo.processInfo.arguments.contains("--mfa-enabled") + + let actionCodeSettings = ActionCodeSettings() + actionCodeSettings.handleCodeInApp = true + actionCodeSettings + .url = URL(string: "https://flutterfire-e2e-tests.firebaseapp.com") + actionCodeSettings.linkDomain = "flutterfire-e2e-tests.firebaseapp.com" + actionCodeSettings.setIOSBundleID(Bundle.main.bundleIdentifier!) + let configuration = AuthConfiguration( + tosUrl: URL(string: "https://example.com/tos"), + privacyPolicyUrl: URL(string: "https://example.com/privacy"), + emailLinkSignInActionCodeSettings: actionCodeSettings, + mfaEnabled: isMfaEnabled + ) + + authService = AuthService( + configuration: configuration + ) + .withGoogleSignIn() + .withPhoneSignIn() + .withFacebookSignIn() + .withEmailSignIn() + } + + var body: some View { + AuthPickerView().environment(authService) + } +} diff --git a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/UITestUtils.swift b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/UITestUtils.swift index 2116c2f26a..4eee5bb746 100644 --- a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/UITestUtils.swift +++ b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/UITestUtils.swift @@ -8,7 +8,8 @@ import FirebaseAuth import SwiftUI // UI Test Runner keys -public let uiAuthEmulator = CommandLine.arguments.contains("--auth-emulator") +public let testRunner = CommandLine.arguments.contains("--auth-emulator") +let verifyEmail = CommandLine.arguments.contains("--verify-email") public var testEmail: String? { guard let emailIndex = CommandLine.arguments.firstIndex(of: "--create-user"), @@ -21,7 +22,109 @@ func testCreateUser() async throws { if let email = testEmail { let password = "123456" let auth = Auth.auth() - try await auth.createUser(withEmail: email, password: password) + let result = try await auth.createUser(withEmail: email, password: password) + if verifyEmail { + try await setEmailVerifiedInEmulator(for: result.user) + } try auth.signOut() } } + +/// Marks the given Firebase `user` as email-verified **in the Auth emulator**. +/// Works in CI even if the email address doesn't exist. +/// - Parameters: +/// - user: The signed-in Firebase user you want to verify. +/// - projectID: Your emulator project ID (e.g. "demo-project" or whatever you're using locally). +/// - emulatorHost: Host:port for the Auth emulator. Defaults to localhost:9099. +func setEmailVerifiedInEmulator(for user: User, + projectID: String = "flutterfire-e2e-tests", + emulatorHost: String = "localhost:9099") async throws { + + guard let email = user.email else { + throw NSError(domain: "EmulatorError", code: 1, + userInfo: [ + NSLocalizedDescriptionKey: "User has no email; cannot look up OOB code in emulator", + ]) + } + + // 1) Trigger a verification email -> creates an OOB code in the emulator. + try await sendVerificationEmail(user) + + // 2) Read OOB codes from the emulator and find the VERIFY_EMAIL code for this user. + let base = "http://\(emulatorHost)" + let oobURL = URL(string: "\(base)/emulator/v1/projects/\(projectID)/oobCodes")! + + let (oobData, oobResp) = try await URLSession.shared.data(from: oobURL) + guard (oobResp as? HTTPURLResponse)?.statusCode == 200 else { + let body = String(data: oobData, encoding: .utf8) ?? "" + throw NSError(domain: "EmulatorError", code: 2, + userInfo: [ + NSLocalizedDescriptionKey: "Failed to fetch oobCodes. Response: \(body)", + ]) + } + + struct OobEnvelope: Decodable { let oobCodes: [OobItem] } + struct OobItem: Decodable { + let oobCode: String + let email: String + let requestType: String + let creationTime: String? // RFC3339/ISO8601; optional for safety + } + + let envelope = try JSONDecoder().decode(OobEnvelope.self, from: oobData) + + // Pick the most recent VERIFY_EMAIL code for this email (in case there are multiple). + let iso = ISO8601DateFormatter() + let codeItem = envelope.oobCodes + .filter { + $0.email.caseInsensitiveCompare(email) == .orderedSame && $0.requestType == "VERIFY_EMAIL" + } + .sorted { + let d0 = $0.creationTime.flatMap { iso.date(from: $0) } ?? .distantPast + let d1 = $1.creationTime.flatMap { iso.date(from: $0) } ?? .distantPast + return d0 > d1 + } + .first + + guard let oobCode = codeItem?.oobCode else { + throw NSError(domain: "EmulatorError", code: 3, + userInfo: [ + NSLocalizedDescriptionKey: "No VERIFY_EMAIL oobCode found for \(email) in emulator", + ]) + } + + // 3) Apply the OOB code via the emulator's identitytoolkit endpoint. + // Note: API key value does not matter when talking to the emulator. + var applyReq = URLRequest( + url: URL(string: "\(base)/identitytoolkit.googleapis.com/v1/accounts:update?key=anything")! + ) + applyReq.httpMethod = "POST" + applyReq.setValue("application/json", forHTTPHeaderField: "Content-Type") + applyReq.httpBody = try JSONSerialization.data(withJSONObject: ["oobCode": oobCode], options: []) + + let (applyData, applyResp) = try await URLSession.shared.data(for: applyReq) + guard let http = applyResp as? HTTPURLResponse, http.statusCode == 200 else { + let body = String(data: applyData, encoding: .utf8) ?? "" + throw NSError(domain: "EmulatorError", code: 4, + userInfo: [ + NSLocalizedDescriptionKey: "Applying oobCode failed. Status \((applyResp as? HTTPURLResponse)?.statusCode ?? -1). Body: \(body)", + ]) + } + + + // 4) Reload the user to reflect the new verification state. + try await user.reload() +} + +/// Small async helper to call FirebaseAuth's callback-based `sendEmailVerification` on iOS. +private func sendVerificationEmail(_ user: User) async throws { + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + user.sendEmailVerification { error in + if let error = error { + cont.resume(throwing: error) + } else { + cont.resume() + } + } + } +} diff --git a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests/FirebaseSwiftUIExampleTests.swift b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests/FirebaseSwiftUIExampleTests.swift index e43a002927..8f08a8e7e4 100644 --- a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests/FirebaseSwiftUIExampleTests.swift +++ b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests/FirebaseSwiftUIExampleTests.swift @@ -105,14 +105,19 @@ struct FirebaseSwiftUIExampleTests { #expect(service.errorMessage.isEmpty) #expect(service.signedInCredential == nil) #expect(service.currentUser == nil) - try await service.createUser(withEmail: createEmail(), password: kPassword) - try await Task.sleep(nanoseconds: 4_000_000_000) + try await service.createUser(email: createEmail(), password: kPassword) + + try await waitForStateChange { + service.authenticationState == .authenticated + } #expect(service.authenticationState == .authenticated) + + try await waitForStateChange { + service.currentUser != nil + } + #expect(service.currentUser != nil) #expect(service.authView == .authPicker) #expect(service.errorMessage.isEmpty) - #expect(service.currentUser != nil) - // TODO: - reinstate once this PR is merged: https://github.com/firebase/FirebaseUI-iOS/pull/1256 -// #expect(service.signedInCredential is AuthCredential) } @Test @@ -120,22 +125,38 @@ struct FirebaseSwiftUIExampleTests { func testSignInUser() async throws { let service = try await prepareFreshAuthService() let email = createEmail() - try await service.createUser(withEmail: email, password: kPassword) + try await service.createUser(email: email, password: kPassword) try await service.signOut() - try await Task.sleep(nanoseconds: 2_000_000_000) + + try await waitForStateChange { + service.authenticationState == .unauthenticated + } #expect(service.authenticationState == .unauthenticated) + + try await waitForStateChange { + service.currentUser == nil + } + #expect(service.currentUser == nil) #expect(service.authView == .authPicker) #expect(service.errorMessage.isEmpty) #expect(service.signedInCredential == nil) - #expect(service.currentUser == nil) - try await service.signIn(withEmail: email, password: kPassword) + try await service.signIn(email: email, password: kPassword) + try await waitForStateChange { + service.authenticationState == .authenticated + } #expect(service.authenticationState == .authenticated) + + try await waitForStateChange { + service.currentUser != nil + } + #expect(service.currentUser != nil) + try await waitForStateChange { + service.signedInCredential != nil + } + #expect(service.signedInCredential != nil) #expect(service.authView == .authPicker) #expect(service.errorMessage.isEmpty) - #expect(service.currentUser != nil) - // TODO: - reinstate once this PR is merged: https://github.com/firebase/FirebaseUI-iOS/pull/1256 - // #expect(service.signedInCredential is AuthCredential) } } diff --git a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests/TestHarness.swift b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests/TestHarness.swift index 1b474daf80..7c81662457 100644 --- a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests/TestHarness.swift +++ b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests/TestHarness.swift @@ -61,3 +61,20 @@ func createEmail() -> String { let after = UUID().uuidString.prefix(6) return "\(before)@\(after).com" } + +func waitForStateChange(timeout: TimeInterval = 10.0, + condition: @escaping () -> Bool) async throws { + let startTime = Date() + + while !condition() { + if Date().timeIntervalSince(startTime) > timeout { + throw TestError.timeout("Timeout waiting for condition to be met") + } + + try await Task.sleep(nanoseconds: 50_000_000) // 50ms + } +} + +enum TestError: Error { + case timeout(String) +} diff --git a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/FirebaseSwiftUIExampleUITests.swift b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/FirebaseSwiftUIExampleUITests.swift index 1252d35519..2f97542321 100644 --- a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/FirebaseSwiftUIExampleUITests.swift +++ b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/FirebaseSwiftUIExampleUITests.swift @@ -23,12 +23,6 @@ import FirebaseAuth import FirebaseCore import XCTest -func createEmail() -> String { - let before = UUID().uuidString.prefix(8) - let after = UUID().uuidString.prefix(6) - return "\(before)@\(after).com" -} - func dismissAlert(app: XCUIApplication) { if app.scrollViews.otherElements.buttons["Not Now"].waitForExistence(timeout: 2) { app.scrollViews.otherElements.buttons["Not Now"].tap() @@ -58,7 +52,7 @@ final class FirebaseSwiftUIExampleUITests: XCTestCase { } @MainActor - func testSignInDisplaysSignedInView() async throws { + func testSignInDisplaysSignedInView() throws { let app = XCUIApplication() let email = createEmail() app.launchArguments.append("--auth-emulator") @@ -80,9 +74,10 @@ final class FirebaseSwiftUIExampleUITests: XCTestCase { XCTAssertTrue(signInButton.exists, "Sign-In button should exist") signInButton.tap() + // Wait for authentication to complete and signed-in view to appear let signedInText = app.staticTexts["signed-in-text"] XCTAssertTrue( - signedInText.waitForExistence(timeout: 10), + signedInText.waitForExistence(timeout: 30), "SignedInView should be visible after login" ) @@ -149,6 +144,12 @@ final class FirebaseSwiftUIExampleUITests: XCTestCase { app.launchArguments.append("--auth-emulator") app.launch() + // Check the Views are updated + let signOutButton = app.buttons["sign-out-button"] + if signOutButton.exists { + signOutButton.tap() + } + let switchFlowButton = app.buttons["switch-auth-flow"] switchFlowButton.tap() @@ -172,14 +173,17 @@ final class FirebaseSwiftUIExampleUITests: XCTestCase { confirmPasswordField.press(forDuration: 1.2) app.menuItems["Paste"].tap() - let signInButton = app.buttons["sign-in-button"] - XCTAssertTrue(signInButton.exists, "Sign-In button should exist") - signInButton.tap() + // Create the user (sign up) + let signUpButton = app + .buttons["sign-in-button"] // This button changes context after switch-auth-flow + XCTAssertTrue(signUpButton.exists, "Sign-Up button should exist") + signUpButton.tap() + // Wait for user creation and signed-in view to appear let signedInText = app.staticTexts["signed-in-text"] XCTAssertTrue( - signedInText.waitForExistence(timeout: 20), - "SignedInView should be visible after login" + signedInText.waitForExistence(timeout: 30), + "SignedInView should be visible after user creation" ) } } diff --git a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/MFAEnrolmentUITests.swift b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/MFAEnrolmentUITests.swift new file mode 100644 index 0000000000..77da6b7116 --- /dev/null +++ b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/MFAEnrolmentUITests.swift @@ -0,0 +1,521 @@ +// 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. + +// +// MFAEnrollmentUITests.swift +// FirebaseSwiftUIExampleUITests +// +// UI tests for MFA enrollment workflows including SMS and TOTP enrollment +// + +import XCTest + +final class MFAEnrollmentUITests: XCTestCase { + override func setUpWithError() throws { + continueAfterFailure = false + } + + // MARK: - MFA Management Navigation Tests + + @MainActor + func testMFAManagementButtonExistsAndIsTappable() throws { + let app = XCUIApplication() + app.launchArguments.append("--auth-emulator") + app.launchArguments.append("--mfa-enabled") + app.launchArguments.append("--create-user") + let email = createEmail() + app.launchArguments.append("\(email)") + app.launch() + + // Sign in first to access MFA management + try signInToApp(app: app, email: email) + + // Check MFA management button exists + let mfaManagementButton = app.buttons["mfa-management-button"] + XCTAssertTrue( + mfaManagementButton.waitForExistence(timeout: 5), + "MFA management button should exist" + ) + XCTAssertTrue(mfaManagementButton.isEnabled, "MFA management button should be enabled") + + // Tap the button + mfaManagementButton.tap() + + // Verify we navigated to MFA management view + let managementTitle = app.staticTexts["Two-Factor Authentication"] + XCTAssertTrue( + managementTitle.waitForExistence(timeout: 5), + "Should navigate to MFA management view" + ) + } + + @MainActor + func testMFAEnrollmentNavigationFromManagement() throws { + let app = XCUIApplication() + app.launchArguments.append("--auth-emulator") + app.launchArguments.append("--mfa-enabled") + app.launchArguments.append("--create-user") + let email = createEmail() + app.launchArguments.append("\(email)") + app.launch() + + // Sign in and navigate to MFA management + try signInToApp(app: app, email: email) + app.buttons["mfa-management-button"].tap() + + // Tap setup MFA button (for users with no enrolled factors) + let setupButton = app.buttons["setup-mfa-button"] + if setupButton.waitForExistence(timeout: 3) { + setupButton.tap() + } else { + // If factors are already enrolled, tap add another method + let addMethodButton = app.buttons["add-mfa-method-button"] + XCTAssertTrue(addMethodButton.waitForExistence(timeout: 3), "Add method button should exist") + addMethodButton.tap() + } + + // Verify we navigated to MFA enrollment view + let enrollmentTitle = app.staticTexts["Set Up Two-Factor Authentication"] + XCTAssertTrue( + enrollmentTitle.waitForExistence(timeout: 5), + "Should navigate to MFA enrollment view" + ) + } + + // MARK: - MFA Enrollment Factor Selection Tests + + @MainActor + func testFactorTypePickerExistsAndWorks() throws { + let app = XCUIApplication() + app.launchArguments.append("--auth-emulator") + app.launchArguments.append("--mfa-enabled") + app.launchArguments.append("--create-user") + let email = createEmail() + app.launchArguments.append("\(email)") + app.launch() + + // Navigate to MFA enrollment + try signInToApp(app: app, email: email) + try navigateToMFAEnrollment(app: app) + + // Check factor type picker exists + let factorPicker = app.segmentedControls["factor-type-picker"] + XCTAssertTrue(factorPicker.waitForExistence(timeout: 5), "Factor type picker should exist") + + // Test selecting SMS + let smsOption = factorPicker.buttons.element(boundBy: 0) + smsOption.tap() + XCTAssertTrue(smsOption.isSelected, "SMS option should be selected") + + // Test selecting TOTP + let totpOption = factorPicker.buttons.element(boundBy: 1) + totpOption.tap() + XCTAssertTrue(totpOption.isSelected, "TOTP option should be selected") + } + + @MainActor + func testStartEnrollmentButtonExistsAndWorks() throws { + let app = XCUIApplication() + app.launchArguments.append("--auth-emulator") + app.launchArguments.append("--mfa-enabled") + app.launchArguments.append("--create-user") + let email = createEmail() + app.launchArguments.append("\(email)") + app.launch() + + // Navigate to MFA enrollment + try signInToApp(app: app, email: email) + try navigateToMFAEnrollment(app: app) + + // Check start enrollment button exists and is enabled + let startButton = app.buttons["start-enrollment-button"] + XCTAssertTrue(startButton.waitForExistence(timeout: 5), "Start enrollment button should exist") + XCTAssertTrue(startButton.isEnabled, "Start enrollment button should be enabled") + + // Tap the button + startButton.tap() + + // Verify the form changes (either phone input for SMS or QR code for TOTP) + let phoneField = app.textFields["phone-number-field"] + let qrCode = app.images["qr-code-image"] + + // Either phone field or QR code should appear + let phoneFieldExists = phoneField.waitForExistence(timeout: 5) + let qrCodeExists = qrCode.waitForExistence(timeout: 5) + + XCTAssertTrue( + phoneFieldExists || qrCodeExists, + "Either phone field or QR code should appear after starting enrollment" + ) + } + + // MARK: - SMS Enrollment Flow Tests + + @MainActor + func testEndToEndSMSEnrollmentAndRemovalFlow() async throws { + // 1) Launch app with emulator and create a fresh user + let app = XCUIApplication() + app.launchArguments.append("--auth-emulator") + app.launchArguments.append("--mfa-enabled") + app.launchArguments.append("--verify-email") + app.launchArguments.append("--create-user") + let email = createEmail() + app.launchArguments.append("\(email)") + app.launch() + + // 2) Sign in to reach SignedInView + try signInToApp(app: app, email: email) + + // 3) From SignedInView, open MFA Management + let mfaManagementButton = app.buttons["mfa-management-button"] + XCTAssertTrue(mfaManagementButton.waitForExistence(timeout: 10)) + mfaManagementButton.tap() + + // 4) In MFAManagementView, tap "Set Up Two-Factor Authentication" + let setupButton = app.buttons["setup-mfa-button"] + XCTAssertTrue(setupButton.waitForExistence(timeout: 10)) + setupButton.tap() + + // 5) In MFAEnrollmentView, select SMS factor and start the flow + let factorPicker = app.segmentedControls["factor-type-picker"] + XCTAssertTrue(factorPicker.waitForExistence(timeout: 10)) + factorPicker.buttons.element(boundBy: 0).tap() // SMS + + let startButton = app.buttons["start-enrollment-button"] + XCTAssertTrue(startButton.waitForExistence(timeout: 10)) + startButton.tap() + + // 6) Enter phone number and display name, then press "Send Code" + let phoneField = app.textFields["phone-number-field"] + XCTAssertTrue(phoneField.waitForExistence(timeout: 10)) + let phoneNumber = "+447444555666" + UIPasteboard.general.string = phoneNumber + phoneField.tap() + phoneField.press(forDuration: 1.2) + app.menuItems["Paste"].tap() + + let displayNameField = app.textFields["display-name-field"] + XCTAssertTrue(displayNameField.waitForExistence(timeout: 10)) + UIPasteboard.general.string = "test user" + displayNameField.tap() + displayNameField.press(forDuration: 1.2) + app.menuItems["Paste"].tap() + + let sendCodeButton = app.buttons["send-sms-button"] + XCTAssertTrue(sendCodeButton.waitForExistence(timeout: 10)) + XCTAssertTrue(sendCodeButton.isEnabled) + sendCodeButton.tap() + + // 7) Retrieve verification code from the Auth Emulator and complete setup + let verificationCodeField = app.textFields["verification-code-field"] + XCTAssertTrue(verificationCodeField.waitForExistence(timeout: 15)) + + // Fetch the latest SMS verification code generated by the emulator for this phone number + let code = try await getLastSmsCode(specificPhone: phoneNumber) + + UIPasteboard.general.string = code + verificationCodeField.tap() + verificationCodeField.press(forDuration: 1.2) + app.menuItems["Paste"].tap() + + // Test resend code button exists + let resendButton = app.buttons["resend-code-button"] + XCTAssertTrue(resendButton.exists, "Resend code button should exist") + + let completeSetupButton = app.buttons["complete-enrollment-button"] + XCTAssertTrue(completeSetupButton.waitForExistence(timeout: 10)) + XCTAssertTrue(completeSetupButton.isEnabled) + completeSetupButton.tap() + + // 8) Verify we've returned to SignedInView + let signedInText = app.staticTexts["signed-in-text"] + XCTAssertTrue(signedInText.waitForExistence(timeout: 15)) + + // 9) Open MFA Management again and verify SMS factor is enrolled + XCTAssertTrue(mfaManagementButton.waitForExistence(timeout: 10)) + mfaManagementButton.tap() + + let enrolledMethodsHeader = app.staticTexts["Enrolled Methods"] + XCTAssertTrue(enrolledMethodsHeader.waitForExistence(timeout: 10)) + + // Find a "Remove" button for any enrolled factor (identifier starts with "remove-factor-") + let removeButton = app.buttons.matching(NSPredicate(format: "identifier BEGINSWITH %@", "remove-factor-")).firstMatch + XCTAssertTrue(removeButton.waitForExistence(timeout: 10)) + + // 10) Remove the enrolled SMS factor and verify we're back to setup state + removeButton.tap() + + // After removal, the setup button should reappear for an empty list + XCTAssertTrue(setupButton.waitForExistence(timeout: 15)) + } + + + // MARK: - TOTP Enrollment Flow Tests + + @MainActor + func testTOTPEnrollmentFlowUI() throws { + let app = XCUIApplication() + + app.launchArguments.append("--auth-emulator") + app.launchArguments.append("--mfa-enabled") + app.launchArguments.append("--verify-email") + app.launchArguments.append("--create-user") + let email = createEmail() + app.launchArguments.append("\(email)") + app.launch() + + // Navigate to MFA enrollment and select TOTP + try signInToApp(app: app, email: email) + try navigateToMFAEnrollment(app: app) + + // Select TOTP factor type + let factorPicker = app.segmentedControls["factor-type-picker"] + factorPicker.buttons.element(boundBy: 1).tap() // TOTP option + + // Start enrollment + app.buttons["start-enrollment-button"].tap() + + // Test QR code image (might not load in test environment) + let qrCodeImage = app.images["qr-code-image"] + if qrCodeImage.waitForExistence(timeout: 5) { + XCTAssertTrue(qrCodeImage.exists, "QR code image should appear") + } + + // TOTP enrollment isn't testable via emulator, so this is commented out for the moment + // Test TOTP secret key display +// let secretKey = app.staticTexts["totp-secret-key"] + +// XCTAssertTrue(secretKey.waitForExistence(timeout: 5), "TOTP secret key should be displayed") +// +// // Test display name field +// let displayNameField = app.textFields["display-name-field"] +// XCTAssertTrue(displayNameField.exists, "Display name field should exist") +// +// // Test TOTP code input field +// let totpCodeField = app.textFields["totp-code-field"] +// XCTAssertTrue(totpCodeField.exists, "TOTP code field should exist") +// XCTAssertTrue(totpCodeField.isEnabled, "TOTP code field should be enabled") +// +// // Test complete enrollment button +// let completeButton = app.buttons["complete-enrollment-button"] +// XCTAssertTrue(completeButton.exists, "Complete enrollment button should exist") +// +// // Button should be disabled without code +// XCTAssertFalse(completeButton.isEnabled, "Complete button should be disabled without code") +// +// // Enter TOTP code +// totpCodeField.tap() +// totpCodeField.typeText("123456") +// +// // Button should be enabled with code +// XCTAssertTrue(completeButton.isEnabled, "Complete button should be enabled with code") + } + + // MARK: - Error Handling Tests + + @MainActor + func testErrorMessageDisplay() throws { + let app = XCUIApplication() + app.launchArguments.append("--auth-emulator") + app.launchArguments.append("--mfa-enabled") + app.launchArguments.append("--create-user") + let email = createEmail() + app.launchArguments.append("\(email)") + app.launch() + + // Navigate to MFA enrollment + try signInToApp(app: app, email: email) + try navigateToMFAEnrollment(app: app) + + // Start enrollment to trigger potential errors + app.buttons["start-enrollment-button"].tap() + + // Check if error message element exists (it might not be visible initially) + let errorMessage = app.staticTexts["error-message"] + + // The error message element should exist even if not currently displaying an error + // In real scenarios, this would test actual error conditions + if errorMessage.exists { + XCTAssertTrue(true, "Error message element exists for error display") + } + } + + // MARK: - Navigation Tests + + @MainActor + func testBackButtonNavigation() throws { + let app = XCUIApplication() + app.launchArguments.append("--auth-emulator") + app.launchArguments.append("--mfa-enabled") + app.launchArguments.append("--create-user") + let email = createEmail() + app.launchArguments.append("\(email)") + app.launch() + + // Navigate to MFA enrollment + try signInToApp(app: app, email: email) + try navigateToMFAEnrollment(app: app) + + // Test back button exists + let cancelButton = app.buttons["mfa-back-button"] + XCTAssertTrue(cancelButton.exists, "Back button should exist") + XCTAssertTrue(cancelButton.isEnabled, "Back button should be enabled") + + // Tap cancel button + cancelButton.tap() + + // Should navigate back to signed in view + let signedInText = app.staticTexts["signed-in-text"] + XCTAssertTrue( + signedInText.waitForExistence(timeout: 5), + "Should navigate back to signed in view" + ) + } + + @MainActor + func testBackButtonFromMFAManagement() throws { + let app = XCUIApplication() + app.launchArguments.append("--auth-emulator") + app.launchArguments.append("--mfa-enabled") + app.launchArguments.append("--create-user") + let email = createEmail() + app.launchArguments.append("\(email)") + app.launch() + + // Sign in and navigate to MFA management + try signInToApp(app: app, email: email) + app.buttons["mfa-management-button"].tap() + + // Test back button exists + let backButton = app.buttons["back-button"] + XCTAssertTrue(backButton.waitForExistence(timeout: 5), "Back button should exist") + XCTAssertTrue(backButton.isEnabled, "Back button should be enabled") + + // Tap back button + backButton.tap() + + // Should navigate back to signed in view + let signedInText = app.staticTexts["signed-in-text"] + XCTAssertTrue( + signedInText.waitForExistence(timeout: 5), + "Should navigate back to signed in view" + ) + } + + // MARK: - Helper Methods + + private func signInToApp(app: XCUIApplication, email: String) throws { + let password = "123456" + + // Fill email field + let emailField = app.textFields["email-field"] + XCTAssertTrue(emailField.waitForExistence(timeout: 10), "Email field should exist") + // Workaround for updating SecureFields with ConnectHardwareKeyboard enabled + UIPasteboard.general.string = email + emailField.press(forDuration: 1.2) + app.menuItems["Paste"].tap() + + // Fill password field + let passwordField = app.secureTextFields["password-field"] + XCTAssertTrue(passwordField.exists, "Password field should exist") + UIPasteboard.general.string = password + passwordField.press(forDuration: 1.2) + app.menuItems["Paste"].tap() + + // Create the user (sign up) + let signUpButton = app + .buttons["sign-in-button"] // This button changes context after switch-auth-flow + XCTAssertTrue(signUpButton.exists, "Sign-up button should exist") + signUpButton.tap() + + let notNowButton = app.scrollViews.containing(.button, identifier: "Not Now").firstMatch + if notNowButton.waitForExistence(timeout: 5) { + notNowButton.tap() + } + + // Wait for signed-in state + // Wait for signed-in state + let signedInText = app.staticTexts["signed-in-text"] + XCTAssertTrue(signedInText.waitForExistence(timeout: 30), "SignedInView should be visible after login") + XCTAssertTrue(signedInText.exists, "SignedInView should be visible after login") + } + + private func navigateToMFAEnrollment(app: XCUIApplication) throws { + // Navigate to MFA management + app.buttons["mfa-management-button"].tap() + + // Navigate to MFA enrollment + let setupButton = app.buttons["setup-mfa-button"] + if setupButton.waitForExistence(timeout: 3) { + setupButton.tap() + } else { + let addMethodButton = app.buttons["add-mfa-method-button"] + XCTAssertTrue(addMethodButton.waitForExistence(timeout: 3), "Add method button should exist") + addMethodButton.tap() + } + + // Verify we're in MFA enrollment view + let enrollmentTitle = app.staticTexts["Set Up Two-Factor Authentication"] + XCTAssertTrue(enrollmentTitle.waitForExistence(timeout: 5), "Should be in MFA enrollment view") + } +} + +struct VerificationCodesResponse: Codable { + let verificationCodes: [VerificationCode]? +} + +struct VerificationCode: Codable { + let phoneNumber: String + let code: String +} + +/// Retrieves the last SMS verification code from Firebase Auth Emulator +/// - Parameter specificPhone: Optional phone number to filter codes for a specific phone +/// - Returns: The verification code as a String +/// - Throws: Error if unable to retrieve codes +private func getLastSmsCode(specificPhone: String? = nil) async throws -> String { + let getSmsCodesUrl = "http://127.0.0.1:9099/emulator/v1/projects/flutterfire-e2e-tests/verificationCodes" + + guard let url = URL(string: getSmsCodesUrl) else { + throw NSError(domain: "getLastSmsCode", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to create URL for SMS codes endpoint"]) + } + + do { + let (data, _) = try await URLSession.shared.data(from: url) + + let decoder = JSONDecoder() + let codesResponse = try decoder.decode(VerificationCodesResponse.self, from: data) + + guard let codes = codesResponse.verificationCodes, !codes.isEmpty else { + throw NSError(domain: "getLastSmsCode", code: -1, userInfo: [NSLocalizedDescriptionKey: "No SMS verification codes found in emulator"]) + } + + if let specificPhone = specificPhone { + // Search backwards through codes for the specific phone number + for code in codes.reversed() { + if code.phoneNumber == specificPhone { + return code.code + } + } + throw NSError(domain: "getLastSmsCode", code: -1, userInfo: [NSLocalizedDescriptionKey: "No SMS verification code found for phone number: \(specificPhone)"]) + } else { + // Return the last code in the array + return codes.last!.code + } + } catch let error as DecodingError { + throw NSError(domain: "getLastSmsCode", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to parse SMS codes response: \(error.localizedDescription)"]) + } catch { + throw NSError(domain: "getLastSmsCode", code: -1, userInfo: [NSLocalizedDescriptionKey: "Network request failed: \(error.localizedDescription)"]) + } +} diff --git a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/MFAResolutionUITests.swift b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/MFAResolutionUITests.swift new file mode 100644 index 0000000000..2a25bf9276 --- /dev/null +++ b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/MFAResolutionUITests.swift @@ -0,0 +1,509 @@ +// 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. + +// +// MFAResolutionUITests.swift +// FirebaseSwiftUIExampleUITests +// +// UI tests for MFA resolution workflows during sign-in +// + +import XCTest + +final class MFAResolutionUITests: XCTestCase { + override func setUpWithError() throws { + continueAfterFailure = false + } + + // MARK: - MFA Resolution UI Tests + + // MARK: - Complete MFA Resolution Flow + + @MainActor + func testCompleteMFAResolutionFlowWithAPIEnrollment() async throws { + let app = XCUIApplication() + app.launchArguments.append("--auth-emulator") + app.launchArguments.append("--mfa-enabled") + app.launch() + + let email = createEmail() + let password = "12345678" + let phoneNumber = "+15551234567" + + // Sign up the user + try await signUpUser(email: email, password: password) + + // Get ID token and enable MFA via API + guard let idToken = await getIDTokenFromEmulator(email: email, password: password) else { + XCTFail("Failed to get ID token from emulator") + return + } + + try await verifyEmailInEmulator(email: email, idToken: idToken) + + let mfaEnabled = await enableSMSMFAViaEmulator( + idToken: idToken, + phoneNumber: phoneNumber, + displayName: "Test Phone" + ) + + XCTAssertTrue(mfaEnabled, "MFA should be enabled successfully via API") + + + // Wait for sign out to complete + let emailField = app.textFields["email-field"] + XCTAssertTrue(emailField.waitForExistence(timeout: 10), "Should return to auth picker") + + try signInUser(app: app, email: email, password: password) + + + let mfaResolutionTitle = app.staticTexts["mfa-resolution-title"] + XCTAssertTrue( + mfaResolutionTitle.waitForExistence(timeout: 10), + "MFA resolution view should appear" + ) + + let smsButton = app.buttons["sms-method-button"] + if smsButton.exists && smsButton.isEnabled { + smsButton.tap() + } + dismissAlert(app: app) + + + // Wait for SMS to be sent + try await Task.sleep(nanoseconds: 2_000_000_000) + + let sendSMSButton = app.buttons["send-sms-button"] + + sendSMSButton.tap() + + try await Task.sleep(nanoseconds: 3_000_000_000) + + guard let verificationCode = await getSMSVerificationCode(for: phoneNumber, codeType: "verification") else { + XCTFail("Failed to retrieve SMS verification code from emulator") + return + } + + let codeField = app.textFields["sms-verification-code-field"] + XCTAssertTrue(codeField.waitForExistence(timeout: 10), "Code field should exist") + codeField.tap() + codeField.typeText(verificationCode) + + let completeButton = app.buttons["complete-resolution-button"] + XCTAssertTrue(completeButton.exists, "Complete button should exist") + completeButton.tap() + + // Wait for sign-in to complete + // Resolution always fails due to ERROR_MULTI_FACTOR_INFO_NOT_FOUND exception. See below issue for more information. + // TODO(russellwheatley): uncomment below when this firebase-ios-sdk issue has been resolved: https://github.com/firebase/firebase-ios-sdk/issues/11079 + + // let signedInText = app.staticTexts["signed-in-text"] + // XCTAssertTrue( + // signedInText.waitForExistence(timeout: 10), + // "User should be signed in after MFA resolution" + // ) + } + + // MARK: - Helper Methods + + private func createEmail() -> String { + let before = UUID().uuidString.prefix(8) + let after = UUID().uuidString.prefix(6) + return "\(before)@\(after).com" + } + + /// Programmatically enables SMS MFA for a user via the Auth emulator REST API + /// - Parameters: + /// - idToken: The user's Firebase ID token + /// - phoneNumber: The phone number to enroll for SMS MFA (e.g., "+15551234567") + /// - displayName: Optional display name for the MFA factor + /// - Returns: True if MFA was successfully enabled, false otherwise + private func enableSMSMFAViaEmulator( + idToken: String, + phoneNumber: String, + displayName: String = "Test Phone" + ) async -> Bool { + let emulatorUrl = "http://127.0.0.1:9099/identitytoolkit.googleapis.com/v2/accounts/mfaEnrollment:start?key=fake-api-key" + + guard let url = URL(string: emulatorUrl) else { + XCTFail("Invalid emulator URL") + return false + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let requestBody: [String: Any] = [ + "idToken": idToken, + "phoneEnrollmentInfo": [ + "phoneNumber": phoneNumber, + "recaptchaToken": "fake-recaptcha-token" + ] + ] + + guard let httpBody = try? JSONSerialization.data(withJSONObject: requestBody) else { + XCTFail("Failed to serialize request body") + return false + } + + request.httpBody = httpBody + + // Step 1: Start MFA enrollment + do { + let (data, _) = try await URLSession.shared.data(for: request) + + + // Step 1: Parse JSON + guard let jsonObject = try? JSONSerialization.jsonObject(with: data) else { + print("❌ Failed to parse JSON from response data") + return false + } + + guard let json = jsonObject as? [String: Any] else { + print("❌ JSON is not a dictionary. Type: \(type(of: jsonObject))") + return false + } + + // Step 2: Extract phoneSessionInfo + guard let info = json["phoneSessionInfo"] as? [String: Any] else { + print("❌ Failed to extract 'phoneSessionInfo' from JSON") + print("Available keys: \(json.keys.joined(separator: ", "))") + if let phoneSessionInfo = json["phoneSessionInfo"] { + print("phoneSessionInfo exists but wrong type: \(type(of: phoneSessionInfo))") + } + return false + } + + // Step 3: Extract sessionInfo + guard let sessionInfo = info["sessionInfo"] as? String else { + print("❌ Failed to extract 'sessionInfo' from phoneSessionInfo") + print("Available keys in phoneSessionInfo: \(info.keys.joined(separator: ", "))") + if let sessionInfoValue = info["sessionInfo"] { + print("sessionInfo exists but wrong type: \(type(of: sessionInfoValue))") + } + return false + } + + // Step 2: Get verification code from emulator + try await Task.sleep(nanoseconds: 1_000_000_000) // 1 second + guard let verificationCode = await getSMSVerificationCode(for: phoneNumber) else { + XCTFail("Failed to retrieve SMS verification code") + return false + } + + // Step 3: Finalize MFA enrollment + let finalizeUrl = "http://127.0.0.1:9099/identitytoolkit.googleapis.com/v2/accounts/mfaEnrollment:finalize?key=fake-api-key" + guard let finalizeURL = URL(string: finalizeUrl) else { + return false + } + + var finalizeRequest = URLRequest(url: finalizeURL) + finalizeRequest.httpMethod = "POST" + finalizeRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let finalizeBody: [String: Any] = [ + "idToken": idToken, + "phoneVerificationInfo": [ + "sessionInfo": sessionInfo, + "code": verificationCode + ], + "displayName": displayName + ] + + guard let finalizeHttpBody = try? JSONSerialization.data(withJSONObject: finalizeBody) else { + return false + } + + finalizeRequest.httpBody = finalizeHttpBody + + let (finalizeData, finalizeResponse) = try await URLSession.shared.data(for: finalizeRequest) + + // Check HTTP status + if let httpResponse = finalizeResponse as? HTTPURLResponse { + print("📡 Finalize HTTP Status: \(httpResponse.statusCode)") + } + + + guard let json = try? JSONSerialization.jsonObject(with: finalizeData) as? [String: Any] else { + print("❌ Failed to parse finalize response as JSON") + return false + } + + // Check if we have the new idToken and MFA info + guard let newIdToken = json["idToken"] as? String else { + print("❌ Missing 'idToken' in finalize response") + return false + } + + // Check if refreshToken is present + if let refreshToken = json["refreshToken"] as? String { + print("✅ Got refreshToken: \(refreshToken.prefix(20))...") + } + + // Check for MFA info in response + if let mfaInfo = json["mfaInfo"] { + print("✅ MFA info in response: \(mfaInfo)") + } + + return true + + } catch { + print("Failed to enable MFA: \(error.localizedDescription)") + return false + } + } + + /// Retrieves SMS verification codes from the Firebase Auth emulator + /// - Parameters: + /// - phoneNumber: The phone number to retrieve the code for + /// - codeType: The type of code - "enrollment" for MFA enrollment, "verification" for phone verification during resolution + private func getSMSVerificationCode(for phoneNumber: String, codeType: String = "enrollment") async -> String? { + let emulatorUrl = "http://127.0.0.1:9099/emulator/v1/projects/flutterfire-e2e-tests/verificationCodes" + + guard let url = URL(string: emulatorUrl) else { + return nil + } + + do { + let (data, _) = try await URLSession.shared.data(from: url) + + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let codes = json["verificationCodes"] as? [[String: Any]] else { + print("❌ Failed to parse verification codes") + return nil + } + + // Filter codes by phone number and type, then get the most recent one + let matchingCodes = codes.filter { codeInfo in + guard let phone = codeInfo["phoneNumber"] as? String else { + print("❌ Code missing phoneNumber field") + return false + } + + // The key difference between enrollment and verification codes: + // - Enrollment codes have full phone numbers (e.g., "+15551234567") + // - Verification codes have masked phone numbers (e.g., "+*******4567") + let isMasked = phone.contains("*") + + // Match phone number + let phoneMatches: Bool + if isMasked { + // Extract last 4 digits from both numbers + let last4OfResponse = String(phone.suffix(4)) + let last4OfTarget = String(phoneNumber.suffix(4)) + phoneMatches = last4OfResponse == last4OfTarget + } else { + // Full phone number match + phoneMatches = phone == phoneNumber + } + + guard phoneMatches else { + return false + } + + if codeType == "enrollment" { + // Enrollment codes have unmasked phone numbers + return !isMasked + } else { // "verification" + // Verification codes have masked phone numbers + return isMasked + } + } + + // Get the last matching code (most recent) + if let lastCode = matchingCodes.last, + let code = lastCode["code"] as? String { + return code + } + + print("❌ No matching code found") + return nil + + } catch { + print("Failed to fetch verification codes: \(error.localizedDescription)") + return nil + } + } + + /// Gets an ID token for a user from the Auth emulator by signing in with email/password + /// This works independently of the app's current auth state + /// - Parameters: + /// - email: The user's email address + /// - password: The user's password (defaults to "123456") + /// - Returns: The user's ID token, or nil if the sign-in failed + private func getIDTokenFromEmulator(email: String, password: String = "123456") async -> String? { + let signInUrl = "http://127.0.0.1:9099/identitytoolkit.googleapis.com/v1/accounts:signInWithPassword?key=fake-api-key" + + guard let url = URL(string: signInUrl) else { + print("Invalid emulator URL") + return nil + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let requestBody: [String: Any] = [ + "email": email, + "password": password, + "returnSecureToken": true + ] + + guard let httpBody = try? JSONSerialization.data(withJSONObject: requestBody) else { + print("Failed to serialize sign-in request body") + return nil + } + + request.httpBody = httpBody + + do { + let (data, _) = try await URLSession.shared.data(for: request) + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let idToken = json["idToken"] as? String else { + print("Failed to parse sign-in response") + return nil + } + + print("Successfully got ID token from emulator: \(idToken.prefix(20))...") + return idToken + + } catch { + print("Failed to get ID token from emulator: \(error.localizedDescription)") + return nil + } + } + + private func signUpUser(email: String, password: String = "12345678") async throws { + // Create user via Auth Emulator REST API + let url = URL(string: "http://localhost:9099/identitytoolkit.googleapis.com/v1/accounts:signUp?key=fake-api-key")! + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let body: [String: Any] = [ + "email": email, + "password": password, + "returnSecureToken": true + ] + request.httpBody = try JSONSerialization.data(withJSONObject: body) + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + XCTFail("Invalid response") + return + } + + guard (200...299).contains(httpResponse.statusCode) else { + let errorMessage = String(data: data, encoding: .utf8) ?? "Unknown error" + XCTFail("Failed to create user. Status: \(httpResponse.statusCode), Error: \(errorMessage)") + return + } + } + + private func signInUser(app: XCUIApplication, email: String, password: String = "123456") throws { + // Ensure we're in sign in flow + let switchFlowButton = app.buttons["switch-auth-flow"] + if switchFlowButton.exists && switchFlowButton.label.contains("Sign In") { + switchFlowButton.tap() + } + + // Fill email field + let emailField = app.textFields["email-field"] + XCTAssertTrue(emailField.waitForExistence(timeout: 6)) + emailField.tap() + emailField.clearAndEnterText(email) + + // Fill password field + let passwordField = app.secureTextFields["password-field"] + passwordField.tap() + passwordField.clearAndEnterText(password) + + // Tap sign in button + let signInButton = app.buttons["sign-in-button"] + signInButton.tap() + } + + private func enrollSMSMFA(app: XCUIApplication) throws { + // Navigate to MFA management + let mfaManagementButton = app.buttons["mfa-management-button"] + XCTAssertTrue(mfaManagementButton.waitForExistence(timeout: 5)) + mfaManagementButton.tap() + + // Tap add factor button + let addFactorButton = app.buttons["add-factor-button"] + XCTAssertTrue(addFactorButton.waitForExistence(timeout: 5)) + addFactorButton.tap() + + // Select SMS factor + let factorPicker = app.segmentedControls["factor-type-picker"] + XCTAssertTrue(factorPicker.waitForExistence(timeout: 5)) + factorPicker.buttons["SMS"].tap() + + // Start enrollment + let startButton = app.buttons["start-enrollment-button"] + startButton.tap() + + // Enter phone number + let phoneField = app.textFields["phone-number-field"] + XCTAssertTrue(phoneField.waitForExistence(timeout: 5)) + phoneField.tap() + phoneField.typeText("+15551234567") + + // Send SMS + let sendSMSButton = app.buttons["send-sms-button"] + sendSMSButton.tap() + + // Enter verification code + let codeField = app.textFields["sms-verification-code-field"] + XCTAssertTrue(codeField.waitForExistence(timeout: 10)) + codeField.tap() + codeField.typeText("123456") // This will work in emulator + + // Complete enrollment + let completeButton = app.buttons["complete-enrollment-button"] + completeButton.tap() + + // Wait for completion + let successMessage = app.staticTexts + .containing(NSPredicate(format: "label CONTAINS[cd] 'successfully enrolled'")) + XCTAssertTrue(successMessage.firstMatch.waitForExistence(timeout: 10)) + + // Go back to signed in view + let backButton = app.buttons["back-button"] + if backButton.exists { + backButton.tap() + } + } +} + +// MARK: - XCUIElement Extensions + +extension XCUIElement { + func clearAndEnterText(_ text: String) { + guard let stringValue = value as? String else { + XCTFail("Tried to clear and enter text into a non-string value") + return + } + + tap() + + let deleteString = String(repeating: XCUIKeyboardKey.delete.rawValue, count: stringValue.count) + typeText(deleteString) + typeText(text) + } +} diff --git a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/TestUtils.swift b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/TestUtils.swift new file mode 100644 index 0000000000..2ac2bae387 --- /dev/null +++ b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/TestUtils.swift @@ -0,0 +1,84 @@ +import Foundation +import XCTest + +func createEmail() -> String { + let before = UUID().uuidString.prefix(8) + let after = UUID().uuidString.prefix(6) + return "\(before)@\(after).com" +} + +func verifyEmailInEmulator(email: String, + idToken: String, + projectID: String = "flutterfire-e2e-tests", + emulatorHost: String = "localhost:9099") async throws { + let base = "http://\(emulatorHost)" + + + // Step 1: Trigger email verification (creates OOB code in emulator) + var sendReq = URLRequest( + url: URL(string: "\(base)/identitytoolkit.googleapis.com/v1/accounts:sendOobCode?key=fake-api-key")! + ) + sendReq.httpMethod = "POST" + sendReq.setValue("application/json", forHTTPHeaderField: "Content-Type") + sendReq.httpBody = try JSONSerialization.data(withJSONObject: [ + "requestType": "VERIFY_EMAIL", + "idToken": idToken + ]) + + + let (_, sendResp) = try await URLSession.shared.data(for: sendReq) + guard let http = sendResp as? HTTPURLResponse, http.statusCode == 200 else { + throw NSError(domain: "EmulatorError", code: 1, + userInfo: [NSLocalizedDescriptionKey: "Failed to send verification email"]) + } + + + // Step 2: Fetch OOB codes from emulator + let oobURL = URL(string: "\(base)/emulator/v1/projects/\(projectID)/oobCodes")! + let (oobData, oobResp) = try await URLSession.shared.data(from: oobURL) + guard (oobResp as? HTTPURLResponse)?.statusCode == 200 else { + throw NSError(domain: "EmulatorError", code: 2, + userInfo: [NSLocalizedDescriptionKey: "Failed to fetch OOB codes"]) + } + + + struct OobEnvelope: Decodable { let oobCodes: [OobItem] } + struct OobItem: Decodable { + let oobCode: String + let email: String + let requestType: String + let creationTime: String? + } + + + let envelope = try JSONDecoder().decode(OobEnvelope.self, from: oobData) + + + // Step 3: Find most recent VERIFY_EMAIL code for this email + let iso = ISO8601DateFormatter() + let codeItem = envelope.oobCodes + .filter { + $0.email.caseInsensitiveCompare(email) == .orderedSame && $0.requestType == "VERIFY_EMAIL" + } + .sorted { + let d0 = $0.creationTime.flatMap { iso.date(from: $0) } ?? .distantPast + let d1 = $1.creationTime.flatMap { iso.date(from: $0) } ?? .distantPast + return d0 > d1 + } + .first + + + guard let oobCode = codeItem?.oobCode else { + throw NSError(domain: "EmulatorError", code: 3, + userInfo: [NSLocalizedDescriptionKey: "No VERIFY_EMAIL OOB code found for \(email)"]) + } + + + // Step 4: Apply the OOB code (simulate clicking verification link) + let verifyURL = URL(string: "\(base)/emulator/action?mode=verifyEmail&oobCode=\(oobCode)&apiKey=fake-api-key")! + let (_, verifyResp) = try await URLSession.shared.data(from: verifyURL) + guard (verifyResp as? HTTPURLResponse)?.statusCode == 200 else { + throw NSError(domain: "EmulatorError", code: 4, + userInfo: [NSLocalizedDescriptionKey: "Failed to apply OOB code"]) + } +} \ No newline at end of file