diff --git a/Examples/Examples/Auth/AuthExamplesView.swift b/Examples/Examples/Auth/AuthExamplesView.swift index 63a051ca5..adb949d70 100644 --- a/Examples/Examples/Auth/AuthExamplesView.swift +++ b/Examples/Examples/Auth/AuthExamplesView.swift @@ -100,6 +100,18 @@ struct AuthExamplesView: View { ) } } + + #if canImport(LocalAuthentication) + Section("Security") { + NavigationLink(destination: BiometricsExample()) { + ExampleRow( + title: "Biometrics", + description: "Protect session access with Face ID / Touch ID", + icon: "faceid" + ) + } + } + #endif } .navigationTitle("Authentication") } diff --git a/Examples/Examples/Auth/BiometricsExample.swift b/Examples/Examples/Auth/BiometricsExample.swift new file mode 100644 index 000000000..3dd082704 --- /dev/null +++ b/Examples/Examples/Auth/BiometricsExample.swift @@ -0,0 +1,374 @@ +// +// BiometricsExample.swift +// Examples +// +// Demonstrates biometric authentication for protecting session access +// + +#if canImport(LocalAuthentication) + import LocalAuthentication + import Supabase + import SwiftUI + + struct BiometricsExample: View { + @Environment(AuthController.self) var auth + + @State private var availability: BiometricAvailability? + @State private var selectedPolicy: BiometricPolicy = .default + @State private var selectedEvaluationPolicy: BiometricEvaluationPolicy = + .deviceOwnerAuthenticationWithBiometrics + @State private var customTimeout: TimeInterval = 300 + @State private var error: Error? + @State private var isLoading = false + @State private var lastSessionAccess: Date? + @State private var sessionInfo: String? + + private var isBiometricsEnabled: Bool { + supabase.auth.isBiometricsEnabled + } + + private var biometryTypeName: String { + guard let availability else { return "Unknown" } + switch availability.biometryType { + case .none: + return "None" + case .touchID: + return "Touch ID" + case .faceID: + return "Face ID" + case .opticID: + return "Optic ID" + @unknown default: + return "Unknown" + } + } + + var body: some View { + List { + Section { + Text( + "Protect session access with Face ID, Touch ID, or Optic ID. When enabled, accessing the session requires biometric authentication." + ) + .font(.caption) + .foregroundColor(.secondary) + } + + // Device Capabilities + Section("Device Capabilities") { + if let availability { + HStack { + Label("Biometry Type", systemImage: biometryIcon) + Spacer() + Text(biometryTypeName) + .foregroundColor(.secondary) + } + + HStack { + Label("Available", systemImage: "checkmark.shield") + Spacer() + Image( + systemName: availability.isAvailable + ? "checkmark.circle.fill" : "xmark.circle.fill" + ) + .foregroundColor(availability.isAvailable ? .green : .red) + } + + if let error = availability.error { + HStack(alignment: .top) { + Label("Error", systemImage: "exclamationmark.triangle") + Spacer() + Text(error.localizedDescription) + .font(.caption) + .foregroundColor(.red) + .multilineTextAlignment(.trailing) + } + } + } else { + HStack { + ProgressView() + Text("Checking availability...") + .foregroundColor(.secondary) + } + } + + Button("Refresh Availability") { + checkAvailability() + } + } + + // Current Status + Section("Current Status") { + HStack { + Label("Biometrics Enabled", systemImage: "faceid") + Spacer() + Image(systemName: isBiometricsEnabled ? "checkmark.circle.fill" : "xmark.circle.fill") + .foregroundColor(isBiometricsEnabled ? .green : .secondary) + } + + HStack { + Label("Auth Required", systemImage: "lock") + Spacer() + Image( + systemName: supabase.auth.isBiometricAuthenticationRequired() + ? "lock.fill" : "lock.open" + ) + .foregroundColor( + supabase.auth.isBiometricAuthenticationRequired() ? .orange : .secondary + ) + } + } + + // Configuration (only show when not enabled) + if !isBiometricsEnabled { + Section("Configuration") { + Picker("Policy", selection: $selectedPolicy) { + Text("Default (First Access)").tag(BiometricPolicy.default) + Text("Always").tag(BiometricPolicy.always) + Text("Session Timeout").tag(BiometricPolicy.session(timeoutInSeconds: customTimeout)) + Text("App Lifecycle").tag(BiometricPolicy.appLifecycle) + } + + if case .session = selectedPolicy { + HStack { + Text("Timeout") + Spacer() + TextField("Seconds", value: $customTimeout, format: .number) + .keyboardType(.numberPad) + .multilineTextAlignment(.trailing) + .frame(width: 80) + Text("sec") + .foregroundColor(.secondary) + } + } + + Picker("Evaluation Policy", selection: $selectedEvaluationPolicy) { + Text("Biometrics Only").tag( + BiometricEvaluationPolicy.deviceOwnerAuthenticationWithBiometrics + ) + Text("Biometrics + Passcode").tag(BiometricEvaluationPolicy.deviceOwnerAuthentication) + } + + VStack(alignment: .leading, spacing: 8) { + Text("Policy Descriptions:") + .font(.caption) + .foregroundColor(.secondary) + + Group { + Text("Default: Auth on first access only") + Text("Always: Auth every time session is accessed") + Text("Session: Auth after timeout elapses") + Text("App Lifecycle: Auth when returning from background") + } + .font(.caption2) + .foregroundColor(.secondary) + } + .padding(.vertical, 4) + } + } + + // Actions + Section("Actions") { + if isBiometricsEnabled { + Button(role: .destructive) { + disableBiometrics() + } label: { + Label("Disable Biometrics", systemImage: "faceid") + } + + Button { + invalidateSession() + } label: { + Label("Invalidate Session", systemImage: "arrow.clockwise") + } + } else { + Button { + Task { + await enableBiometrics() + } + } label: { + Label("Enable Biometrics", systemImage: "faceid") + } + .disabled(availability?.isAvailable != true) + } + } + + // Test Session Access + Section("Test Session Access") { + Button { + Task { + await testSessionAccess() + } + } label: { + Label("Access Session", systemImage: "key") + } + + if let lastSessionAccess { + HStack { + Text("Last Access") + .foregroundColor(.secondary) + Spacer() + Text(lastSessionAccess, style: .time) + .font(.caption) + } + } + + if let sessionInfo { + VStack(alignment: .leading, spacing: 4) { + Text("Session Info:") + .font(.caption) + .foregroundColor(.secondary) + Text(sessionInfo) + .font(.caption2) + .foregroundColor(.secondary) + } + } + } + + // Error Display + if let error { + Section("Error") { + ErrorText(error) + } + } + + // Loading Indicator + if isLoading { + Section { + HStack { + ProgressView() + Text("Processing...") + .foregroundColor(.secondary) + } + } + } + + // About Section + Section("About") { + VStack(alignment: .leading, spacing: 8) { + Text("Biometric Authentication") + .font(.headline) + + Text( + "Biometric authentication adds an extra layer of security by requiring Face ID, Touch ID, or Optic ID before accessing the user's session. This protects sensitive user data even if the device is unlocked." + ) + .font(.caption) + .foregroundColor(.secondary) + + Text("Features:") + .font(.subheadline) + .padding(.top, 4) + + VStack(alignment: .leading, spacing: 4) { + Label("Configurable authentication policies", systemImage: "checkmark.circle") + Label("Session timeout support", systemImage: "checkmark.circle") + Label("App lifecycle integration", systemImage: "checkmark.circle") + Label("Passcode fallback option", systemImage: "checkmark.circle") + } + .font(.caption) + .foregroundColor(.secondary) + + Text("Note:") + .font(.subheadline) + .padding(.top, 4) + + Text( + "Biometrics must be tested on a real device. Simulators do not support biometric authentication." + ) + .font(.caption) + .foregroundColor(.orange) + } + } + } + .navigationTitle("Biometrics") + .gitHubSourceLink() + .task { + checkAvailability() + } + .animation(.default, value: isBiometricsEnabled) + } + + private var biometryIcon: String { + guard let availability else { return "questionmark.circle" } + switch availability.biometryType { + case .faceID: + return "faceid" + case .touchID: + return "touchid" + case .opticID: + return "opticid" + default: + return "person.badge.shield.checkmark" + } + } + + private func checkAvailability() { + availability = supabase.auth.biometricsAvailability() + } + + @MainActor + private func enableBiometrics() async { + do { + error = nil + isLoading = true + defer { isLoading = false } + + let policy: BiometricPolicy + if case .session = selectedPolicy { + policy = .session(timeoutInSeconds: customTimeout) + } else { + policy = selectedPolicy + } + + try await supabase.auth.enableBiometrics( + title: "Authenticate to enable biometrics", + evaluationPolicy: selectedEvaluationPolicy, + policy: policy + ) + } catch { + self.error = error + } + } + + private func disableBiometrics() { + error = nil + supabase.auth.disableBiometrics() + } + + private func invalidateSession() { + error = nil + supabase.auth.invalidateBiometricSession() + } + + @MainActor + private func testSessionAccess() async { + do { + error = nil + isLoading = true + defer { isLoading = false } + + let session = try await supabase.auth.session + lastSessionAccess = Date() + + let expiresAt = Date(timeIntervalSince1970: session.expiresAt) + let expiresIn = expiresAt.timeIntervalSinceNow + + var info = "User: \(session.user.email ?? session.user.id.uuidString)" + if expiresIn > 0 { + let minutes = Int(expiresIn / 60) + info += "\nExpires in: \(minutes) minutes" + } + sessionInfo = info + } catch { + self.error = error + sessionInfo = nil + } + } + } + + #Preview { + NavigationStack { + BiometricsExample() + .environment(AuthController()) + } + } +#endif diff --git a/Examples/Examples/Profile/ProfileView.swift b/Examples/Examples/Profile/ProfileView.swift index 4459756a7..dc8e44c09 100644 --- a/Examples/Examples/Profile/ProfileView.swift +++ b/Examples/Examples/Profile/ProfileView.swift @@ -8,11 +8,16 @@ import Supabase import SwiftUI +#if canImport(LocalAuthentication) + import LocalAuthentication +#endif + struct ProfileView: View { @State var user: User? @State var error: Error? @State var isLoading = false @State var showingMFA = false + @State var showingBiometrics = false var identities: [UserIdentity] { user?.identities ?? [] @@ -123,6 +128,10 @@ struct ProfileView: View { // Security Section Section("Security") { + #if canImport(LocalAuthentication) + BiometricsRow(showingBiometrics: $showingBiometrics, error: $error) + #endif + HStack { Label("Multi-Factor Auth", systemImage: "lock.shield.fill") Spacer() @@ -224,6 +233,7 @@ struct ProfileView: View { Label("Change password", systemImage: "checkmark.circle") Label("Link/unlink OAuth identities", systemImage: "checkmark.circle") Label("Enable multi-factor authentication", systemImage: "checkmark.circle") + Label("Enable biometric protection", systemImage: "checkmark.circle") Label("Reauthenticate for sensitive operations", systemImage: "checkmark.circle") } .font(.caption) @@ -247,6 +257,11 @@ struct ProfileView: View { MFAFlow(status: status) } } + #if canImport(LocalAuthentication) + .sheet(isPresented: $showingBiometrics) { + BiometricsConfigurationSheet() + } + #endif } @MainActor @@ -304,3 +319,267 @@ struct ProfileView: View { #Preview { ProfileView() } + +// MARK: - Biometrics Views + +#if canImport(LocalAuthentication) + struct BiometricsRow: View { + @Binding var showingBiometrics: Bool + @Binding var error: Error? + + private var isBiometricsEnabled: Bool { + supabase.auth.isBiometricsEnabled + } + + private var biometryTypeName: String { + let availability = supabase.auth.biometricsAvailability() + switch availability.biometryType { + case .none: + return "None" + case .touchID: + return "Touch ID" + case .faceID: + return "Face ID" + case .opticID: + return "Optic ID" + @unknown default: + return "Biometrics" + } + } + + private var biometryIcon: String { + let availability = supabase.auth.biometricsAvailability() + switch availability.biometryType { + case .faceID: + return "faceid" + case .touchID: + return "touchid" + case .opticID: + return "opticid" + default: + return "lock.shield" + } + } + + var body: some View { + HStack { + Label(biometryTypeName, systemImage: biometryIcon) + Spacer() + if isBiometricsEnabled { + Text("Enabled") + .font(.caption) + .foregroundColor(.green) + } else { + Text("Not Enabled") + .font(.caption) + .foregroundColor(.secondary) + } + } + .contentShape(Rectangle()) + .onTapGesture { + showingBiometrics = true + } + } + } + + struct BiometricsConfigurationSheet: View { + @Environment(\.dismiss) var dismiss + + @State private var selectedPolicy: BiometricPolicy = .default + @State private var selectedEvaluationPolicy: BiometricEvaluationPolicy = + .deviceOwnerAuthenticationWithBiometrics + @State private var customTimeout: TimeInterval = 300 + @State private var isLoading = false + @State private var error: Error? + + private var isBiometricsEnabled: Bool { + supabase.auth.isBiometricsEnabled + } + + private var availability: BiometricAvailability { + supabase.auth.biometricsAvailability() + } + + private var biometryTypeName: String { + switch availability.biometryType { + case .none: + return "Biometrics" + case .touchID: + return "Touch ID" + case .faceID: + return "Face ID" + case .opticID: + return "Optic ID" + @unknown default: + return "Biometrics" + } + } + + var body: some View { + NavigationStack { + List { + Section { + Text( + "Protect your session with \(biometryTypeName). When enabled, you'll need to authenticate before accessing your account." + ) + .font(.caption) + .foregroundColor(.secondary) + } + + if !availability.isAvailable { + Section { + HStack { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.orange) + Text( + availability.error?.localizedDescription + ?? "Biometrics not available on this device" + ) + .font(.caption) + } + } + } + + Section("Status") { + HStack { + Text("Currently") + Spacer() + Text(isBiometricsEnabled ? "Enabled" : "Disabled") + .foregroundColor(isBiometricsEnabled ? .green : .secondary) + } + } + + if !isBiometricsEnabled && availability.isAvailable { + Section("Configuration") { + Picker("Policy", selection: $selectedPolicy) { + Text("Default (First Access)").tag(BiometricPolicy.default) + Text("Always").tag(BiometricPolicy.always) + Text("Session Timeout").tag( + BiometricPolicy.session(timeoutInSeconds: customTimeout)) + Text("App Lifecycle").tag(BiometricPolicy.appLifecycle) + } + + if case .session = selectedPolicy { + HStack { + Text("Timeout") + Spacer() + TextField("Seconds", value: $customTimeout, format: .number) + .keyboardType(.numberPad) + .multilineTextAlignment(.trailing) + .frame(width: 80) + Text("sec") + .foregroundColor(.secondary) + } + } + + Picker("Fallback", selection: $selectedEvaluationPolicy) { + Text("Biometrics Only").tag( + BiometricEvaluationPolicy.deviceOwnerAuthenticationWithBiometrics) + Text("Biometrics + Passcode").tag( + BiometricEvaluationPolicy.deviceOwnerAuthentication) + } + } + + Section { + VStack(alignment: .leading, spacing: 4) { + Text("Policy Options:") + .font(.caption) + .fontWeight(.medium) + Group { + Text("Default: Authenticate once per app launch") + Text("Always: Authenticate every session access") + Text("Session Timeout: Authenticate after inactivity") + Text("App Lifecycle: Authenticate when returning from background") + } + .font(.caption2) + .foregroundColor(.secondary) + } + } + } + + if let error { + Section { + ErrorText(error) + } + } + + Section { + if isBiometricsEnabled { + Button(role: .destructive) { + disableBiometrics() + } label: { + HStack { + Spacer() + if isLoading { + ProgressView() + } else { + Text("Disable \(biometryTypeName)") + } + Spacer() + } + } + } else { + Button { + Task { + await enableBiometrics() + } + } label: { + HStack { + Spacer() + if isLoading { + ProgressView() + } else { + Text("Enable \(biometryTypeName)") + } + Spacer() + } + } + .disabled(!availability.isAvailable) + } + } + } + .navigationTitle(biometryTypeName) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { + dismiss() + } + } + } + } + } + + @MainActor + private func enableBiometrics() async { + do { + error = nil + isLoading = true + defer { isLoading = false } + + let policy: BiometricPolicy + if case .session = selectedPolicy { + policy = .session(timeoutInSeconds: customTimeout) + } else { + policy = selectedPolicy + } + + try await supabase.auth.enableBiometrics( + title: "Enable \(biometryTypeName)", + evaluationPolicy: selectedEvaluationPolicy, + policy: policy + ) + + dismiss() + } catch { + self.error = error + } + } + + private func disableBiometrics() { + error = nil + supabase.auth.disableBiometrics() + dismiss() + } + } +#endif diff --git a/Sources/Auth/AuthClient.swift b/Sources/Auth/AuthClient.swift index 2f4e5290c..cb3d2fe5c 100644 --- a/Sources/Auth/AuthClient.swift +++ b/Sources/Auth/AuthClient.swift @@ -133,17 +133,33 @@ public actor AuthClient { public init(configuration: Configuration) { clientID = AuthClient.nextClientID() - Dependencies[clientID] = Dependencies( - configuration: configuration, - http: HTTPClient(configuration: configuration), - api: APIClient(clientID: clientID), - codeVerifierStorage: .live(clientID: clientID), - sessionStorage: .live(clientID: clientID), - sessionManager: .live(clientID: clientID), - logger: configuration.logger.map { - AuthClientLoggerDecorator(clientID: clientID, decoratee: $0) - } - ) + #if canImport(LocalAuthentication) + Dependencies[clientID] = Dependencies( + configuration: configuration, + http: HTTPClient(configuration: configuration), + api: APIClient(clientID: clientID), + codeVerifierStorage: CodeVerifierStorage.live(clientID: clientID), + sessionStorage: SessionStorage.live(clientID: clientID), + sessionManager: SessionManager.live(clientID: clientID), + logger: configuration.logger.map { + AuthClientLoggerDecorator(clientID: clientID, decoratee: $0) + }, + biometricStorage: BiometricStorage.live(clientID: clientID), + biometricSession: BiometricSession.live(clientID: clientID) + ) + #else + Dependencies[clientID] = Dependencies( + configuration: configuration, + http: HTTPClient(configuration: configuration), + api: APIClient(clientID: clientID), + codeVerifierStorage: CodeVerifierStorage.live(clientID: clientID), + sessionStorage: SessionStorage.live(clientID: clientID), + sessionManager: SessionManager.live(clientID: clientID), + logger: configuration.logger.map { + AuthClientLoggerDecorator(clientID: clientID, decoratee: $0) + } + ) + #endif Task { @MainActor in observeAppLifecycleChanges() } } @@ -151,58 +167,43 @@ public actor AuthClient { #if canImport(ObjectiveC) && canImport(Combine) @MainActor private func observeAppLifecycleChanges() { - var didBecomeActiveNotification: NSNotification.Name? - var willResignActiveNotification: NSNotification.Name? - - #if canImport(UIKit) - #if canImport(WatchKit) - if #available(watchOS 7.0, *) { - didBecomeActiveNotification = WKExtension.applicationDidBecomeActiveNotification - willResignActiveNotification = WKExtension.applicationWillResignActiveNotification - } - #else - didBecomeActiveNotification = UIApplication.didBecomeActiveNotification - willResignActiveNotification = UIApplication.willResignActiveNotification - #endif - #elseif canImport(AppKit) - didBecomeActiveNotification = NSApplication.didBecomeActiveNotification - willResignActiveNotification = NSApplication.willResignActiveNotification - #endif - - if let didBecomeActiveNotification, let willResignActiveNotification { - var cancellables = Set() - - NotificationCenter.default - .publisher(for: didBecomeActiveNotification) - .sink( - receiveCompletion: { _ in - // hold ref to cancellable until it completes - _ = cancellables - }, - receiveValue: { [weak self] _ in - Task { - await self?.handleDidBecomeActive() - } - } - ) - .store(in: &cancellables) - - NotificationCenter.default - .publisher(for: willResignActiveNotification) - .sink( - receiveCompletion: { _ in - // hold ref to cancellable until it completes - _ = cancellables - }, - receiveValue: { [weak self] _ in - Task { - await self?.handleWillResignActive() - } - } - ) - .store(in: &cancellables) + guard let didBecomeActive = AppLifecycle.didBecomeActiveNotification, + let willResignActive = AppLifecycle.willResignActiveNotification + else { + return } + var cancellables = Set() + + NotificationCenter.default + .publisher(for: didBecomeActive) + .sink( + receiveCompletion: { _ in + // hold ref to cancellable until it completes + _ = cancellables + }, + receiveValue: { [weak self] _ in + Task { + await self?.handleDidBecomeActive() + } + } + ) + .store(in: &cancellables) + + NotificationCenter.default + .publisher(for: willResignActive) + .sink( + receiveCompletion: { _ in + // hold ref to cancellable until it completes + _ = cancellables + }, + receiveValue: { [weak self] _ in + Task { + await self?.handleWillResignActive() + } + } + ) + .store(in: &cancellables) } private func handleDidBecomeActive() { diff --git a/Sources/Auth/Biometrics/AuthClient+Biometrics.swift b/Sources/Auth/Biometrics/AuthClient+Biometrics.swift new file mode 100644 index 000000000..47f2b8af4 --- /dev/null +++ b/Sources/Auth/Biometrics/AuthClient+Biometrics.swift @@ -0,0 +1,90 @@ +// MARK: - Biometrics + +#if canImport(LocalAuthentication) + import LocalAuthentication + + extension AuthClient { + /// Check if biometrics are available on this device. + /// + /// Returns information about the device's biometric capabilities including + /// the type of biometry available (Face ID, Touch ID, or Optic ID) and any + /// errors preventing biometric authentication. + /// + /// - Returns: A ``BiometricAvailability`` instance describing the device's biometric capabilities. + nonisolated public func biometricsAvailability() -> BiometricAvailability { + Dependencies[clientID].biometricAuthenticator.checkAvailability() + } + + /// Whether biometrics are currently enabled for session protection. + /// + /// When biometrics are enabled, accessing the ``session`` property will + /// require biometric authentication based on the configured policy. + nonisolated public var isBiometricsEnabled: Bool { + Dependencies[clientID].biometricStorage.isEnabled + } + + /// Enable biometric protection for session retrieval. + /// + /// After enabling, calls to ``session`` will require biometric authentication + /// based on the configured policy. The user will be prompted for biometric + /// authentication immediately to verify that biometrics are working. + /// + /// - Parameters: + /// - title: The message displayed to the user during the biometric prompt. + /// - evaluationPolicy: The evaluation policy to use for authentication. + /// - policy: The biometric policy determining when authentication is required. + /// + /// - Throws: ``BiometricError`` if biometrics are not available or authentication fails. + public func enableBiometrics( + title: String = "Authenticate to access your account", + evaluationPolicy: BiometricEvaluationPolicy = .deviceOwnerAuthenticationWithBiometrics, + policy: BiometricPolicy = .default + ) async throws { + let availability = biometricsAvailability() + guard availability.isAvailable else { + throw availability.error ?? BiometricError.notAvailable(reason: .noBiometryAvailable) + } + + // Verify biometrics work before enabling + try await Dependencies[clientID].biometricAuthenticator.authenticate(title, evaluationPolicy) + + // Store biometric settings + Dependencies[clientID].biometricStorage.enable(evaluationPolicy, policy, title) + + // Update session timestamp + Dependencies[clientID].biometricSession.recordAuthentication() + } + + /// Disable biometric protection for session retrieval. + /// + /// After disabling, sessions can be retrieved without biometric authentication. + nonisolated public func disableBiometrics() { + Dependencies[clientID].biometricStorage.disable() + Dependencies[clientID].biometricSession.reset() + } + + /// Check if biometric authentication would be required on next session access. + /// + /// This is useful for UI purposes, for example to show a lock icon or + /// prepare the user for a biometric prompt. + /// + /// - Returns: `true` if biometric authentication would be required to access the session. + nonisolated public func isBiometricAuthenticationRequired() -> Bool { + guard isBiometricsEnabled, + let policy = Dependencies[clientID].biometricStorage.policy + else { + return false + } + return Dependencies[clientID].biometricSession.isAuthenticationRequired(policy) + } + + /// Invalidate the biometric session, forcing re-authentication on next access. + /// + /// Use this when you want to ensure the user must re-authenticate with biometrics + /// on the next session access, for example when the app enters a sensitive area + /// or after a period of inactivity. + nonisolated public func invalidateBiometricSession() { + Dependencies[clientID].biometricSession.reset() + } + } +#endif diff --git a/Sources/Auth/Biometrics/BiometricAuthenticator.swift b/Sources/Auth/Biometrics/BiometricAuthenticator.swift new file mode 100644 index 000000000..bddd49b5e --- /dev/null +++ b/Sources/Auth/Biometrics/BiometricAuthenticator.swift @@ -0,0 +1,118 @@ +// +// BiometricAuthenticator.swift +// Auth +// +// + +#if canImport(LocalAuthentication) + import Foundation + import LocalAuthentication + + /// Wrapper around LAContext for biometric authentication with testability. + struct BiometricAuthenticator: Sendable { + var checkAvailability: @Sendable () -> BiometricAvailability + var authenticate: + @Sendable (_ reason: String, _ policy: BiometricEvaluationPolicy) async throws + -> Void + } + + extension BiometricAuthenticator { + static var live: BiometricAuthenticator { + BiometricAuthenticator( + checkAvailability: { + let context = LAContext() + var error: NSError? + let canEvaluate = context.canEvaluatePolicy( + .deviceOwnerAuthenticationWithBiometrics, + error: &error + ) + + if canEvaluate { + return BiometricAvailability( + isAvailable: true, + biometryType: context.biometryType, + error: nil + ) + } + + let biometricError: BiometricError + if let error = error { + biometricError = mapLAError(error) + } else { + biometricError = .notAvailable(reason: .noBiometryAvailable) + } + + return BiometricAvailability( + isAvailable: false, + biometryType: context.biometryType, + error: biometricError + ) + }, + authenticate: { reason, policy in + let context = LAContext() + + do { + let success = try await context.evaluatePolicy( + policy.laPolicy, + localizedReason: reason + ) + + guard success else { + throw BiometricError.authenticationFailed(message: "Authentication returned false") + } + } catch let error as LAError { + throw mapLAError(error as NSError) + } catch let error as BiometricError { + throw error + } catch { + throw BiometricError.authenticationFailed(message: error.localizedDescription) + } + } + ) + } + + /// Creates a mock authenticator for testing. + static func mock( + available: Bool = true, + biometryType: LABiometryType = .faceID, + shouldSucceed: Bool = true, + error: BiometricError? = nil + ) -> BiometricAuthenticator { + BiometricAuthenticator( + checkAvailability: { + BiometricAvailability( + isAvailable: available, + biometryType: biometryType, + error: available ? nil : (error ?? .notAvailable(reason: .noBiometryAvailable)) + ) + }, + authenticate: { _, _ in + if !shouldSucceed { + throw error ?? .authenticationFailed(message: "Mock failure") + } + } + ) + } + } + + private func mapLAError(_ error: NSError) -> BiometricError { + let laError = LAError.Code(rawValue: error.code) ?? .systemCancel + + switch laError { + case .userCancel, .appCancel: + return .userCancelled + case .biometryNotAvailable: + return .notAvailable(reason: .noBiometryAvailable) + case .biometryNotEnrolled: + return .notEnrolled + case .biometryLockout: + return .lockedOut + case .passcodeNotSet: + return .notAvailable(reason: .passcodeNotSet) + case .authenticationFailed: + return .authenticationFailed(message: "Biometric authentication failed") + default: + return .authenticationFailed(message: error.localizedDescription) + } + } +#endif diff --git a/Sources/Auth/Biometrics/BiometricSession.swift b/Sources/Auth/Biometrics/BiometricSession.swift new file mode 100644 index 000000000..d3d94e950 --- /dev/null +++ b/Sources/Auth/Biometrics/BiometricSession.swift @@ -0,0 +1,71 @@ +// +// BiometricSession.swift +// Auth +// +// + +#if canImport(LocalAuthentication) + import ConcurrencyExtras + import Foundation + + /// Tracks biometric authentication state for session-based policies. + struct BiometricSession: Sendable { + var recordAuthentication: @Sendable () -> Void + var reset: @Sendable () -> Void + var lastAuthenticationTime: @Sendable () -> Date? + var isAuthenticationRequired: @Sendable (_ policy: BiometricPolicy) -> Bool + } + + extension BiometricSession { + static func live(clientID: AuthClientID) -> BiometricSession { + let lastAuthTime = LockIsolated(nil) + let isInBackground = LockIsolated(false) + + // Subscribe to app lifecycle notifications + #if canImport(ObjectiveC) + Task { @MainActor in + AppLifecycle.observeBackgroundTransitions( + onEnterBackground: { + isInBackground.setValue(true) + }, + onEnterForeground: { + // Keep isInBackground true until authentication completes + } + ) + } + #endif + + return BiometricSession( + recordAuthentication: { + lastAuthTime.setValue(Date()) + isInBackground.setValue(false) + }, + reset: { + lastAuthTime.setValue(nil) + }, + lastAuthenticationTime: { + lastAuthTime.value + }, + isAuthenticationRequired: { policy in + switch policy { + case .default: + // Required only on first access (no previous authentication) + return lastAuthTime.value == nil + + case .always: + return true + + case .session(let timeout): + guard let lastAuth = lastAuthTime.value else { + return true + } + return Date().timeIntervalSince(lastAuth) > timeout + + case .appLifecycle: + return isInBackground.value || lastAuthTime.value == nil + } + } + ) + } + } +#endif diff --git a/Sources/Auth/Biometrics/BiometricStorage.swift b/Sources/Auth/Biometrics/BiometricStorage.swift new file mode 100644 index 000000000..e08e7ac47 --- /dev/null +++ b/Sources/Auth/Biometrics/BiometricStorage.swift @@ -0,0 +1,167 @@ +// +// BiometricStorage.swift +// Auth +// +// + +#if canImport(LocalAuthentication) + import ConcurrencyExtras + import Foundation + + /// Storage for biometric settings using the existing AuthLocalStorage. + struct BiometricStorage: Sendable { + var getIsEnabled: @Sendable () -> Bool + var getPolicy: @Sendable () -> BiometricPolicy? + var getEvaluationPolicy: @Sendable () -> BiometricEvaluationPolicy? + var getPromptTitle: @Sendable () -> String? + + var enable: + @Sendable ( + _ evaluationPolicy: BiometricEvaluationPolicy, + _ policy: BiometricPolicy, + _ promptTitle: String + ) -> Void + var disable: @Sendable () -> Void + } + + extension BiometricStorage { + var isEnabled: Bool { getIsEnabled() } + var policy: BiometricPolicy? { getPolicy() } + var evaluationPolicy: BiometricEvaluationPolicy? { getEvaluationPolicy() } + var promptTitle: String? { getPromptTitle() } + } + + extension BiometricStorage { + static func live(clientID: AuthClientID) -> BiometricStorage { + var storage: any AuthLocalStorage { + Dependencies[clientID].configuration.localStorage + } + + var settingsKey: String { + let baseKey = Dependencies[clientID].configuration.storageKey ?? "supabase.auth.token" + return "\(baseKey).biometrics" + } + + func loadSettings() -> Settings? { + guard let data = try? storage.retrieve(key: settingsKey) else { + return nil + } + return try? JSONDecoder().decode(Settings.self, from: data) + } + + func saveSettings(_ settings: Settings?) { + if let settings { + if let data = try? JSONEncoder().encode(settings) { + try? storage.store(key: settingsKey, value: data) + } + } else { + try? storage.remove(key: settingsKey) + } + } + + // Use LockIsolated for thread-safe caching + // Use a flag to track if settings have been loaded (lazy loading to avoid + // accessing Dependencies before they're fully set up) + let settingsLoaded = LockIsolated(false) + let cachedSettings = LockIsolated(nil) + + func ensureSettingsLoaded() { + if !settingsLoaded.value { + settingsLoaded.setValue(true) + cachedSettings.setValue(loadSettings()) + } + } + + return BiometricStorage( + getIsEnabled: { + ensureSettingsLoaded() + return cachedSettings.value?.isEnabled ?? false + }, + getPolicy: { + ensureSettingsLoaded() + return cachedSettings.value?.policy + }, + getEvaluationPolicy: { + ensureSettingsLoaded() + return cachedSettings.value?.evaluationPolicy + }, + getPromptTitle: { + ensureSettingsLoaded() + return cachedSettings.value?.promptTitle + }, + enable: { evaluationPolicy, policy, promptTitle in + let settings = Settings( + isEnabled: true, + policy: policy, + evaluationPolicy: evaluationPolicy, + promptTitle: promptTitle + ) + saveSettings(settings) + cachedSettings.setValue(settings) + }, + disable: { + saveSettings(nil) + cachedSettings.setValue(nil) + } + ) + } + } + + // MARK: - Settings + + private struct Settings: Codable, Sendable { + var isEnabled: Bool + var policyType: String + var policyTimeout: TimeInterval? + var evaluationPolicyRaw: Int + var promptTitle: String + + var policy: BiometricPolicy { + switch policyType { + case "default": return .default + case "always": return .always + case "session": return .session(timeoutInSeconds: policyTimeout ?? 300) + case "appLifecycle": return .appLifecycle + default: return .default + } + } + + var evaluationPolicy: BiometricEvaluationPolicy { + evaluationPolicyRaw == 1 + ? .deviceOwnerAuthentication + : .deviceOwnerAuthenticationWithBiometrics + } + + init( + isEnabled: Bool, + policy: BiometricPolicy, + evaluationPolicy: BiometricEvaluationPolicy, + promptTitle: String + ) { + self.isEnabled = isEnabled + self.promptTitle = promptTitle + + switch policy { + case .default: + self.policyType = "default" + self.policyTimeout = nil + case .always: + self.policyType = "always" + self.policyTimeout = nil + case .session(let timeout): + self.policyType = "session" + self.policyTimeout = timeout + case .appLifecycle: + self.policyType = "appLifecycle" + self.policyTimeout = nil + } + + switch evaluationPolicy { + case .deviceOwnerAuthenticationWithBiometrics: + self.evaluationPolicyRaw = 0 + case .deviceOwnerAuthentication: + self.evaluationPolicyRaw = 1 + } + } + } +#endif diff --git a/Sources/Auth/Biometrics/BiometricTypes.swift b/Sources/Auth/Biometrics/BiometricTypes.swift new file mode 100644 index 000000000..725c858de --- /dev/null +++ b/Sources/Auth/Biometrics/BiometricTypes.swift @@ -0,0 +1,115 @@ +// +// BiometricTypes.swift +// Auth +// +// + +#if canImport(LocalAuthentication) + import Foundation + import LocalAuthentication + + /// Policy determining when biometric authentication is required. + public enum BiometricPolicy: Sendable, Hashable { + /// Biometric authentication is required on first access only. + /// After successful authentication, no further prompts until app terminates. + case `default` + + /// Biometric authentication is always required before retrieving credentials. + case always + + /// Biometric authentication is required if the specified timeout has elapsed + /// since the last successful authentication. + case session(timeoutInSeconds: TimeInterval) + + /// Biometric authentication is required when app returns from background. + case appLifecycle + } + + /// Evaluation policy for LocalAuthentication. + public enum BiometricEvaluationPolicy: Sendable { + /// Device owner authentication with biometrics only (Face ID / Touch ID). + /// Falls back to nothing if biometrics unavailable. + case deviceOwnerAuthenticationWithBiometrics + + /// Device owner authentication with biometrics or device passcode fallback. + case deviceOwnerAuthentication + + var laPolicy: LAPolicy { + switch self { + case .deviceOwnerAuthenticationWithBiometrics: + return .deviceOwnerAuthenticationWithBiometrics + case .deviceOwnerAuthentication: + return .deviceOwnerAuthentication + } + } + } + + /// Result of biometric availability check. + public struct BiometricAvailability: Sendable { + /// Whether biometrics are available on the device. + public let isAvailable: Bool + + /// The type of biometry available (Face ID, Touch ID, Optic ID, or none). + public let biometryType: LABiometryType + + /// Error if biometrics are not available. + public let error: BiometricError? + + public init(isAvailable: Bool, biometryType: LABiometryType, error: BiometricError?) { + self.isAvailable = isAvailable + self.biometryType = biometryType + self.error = error + } + } + + /// Errors specific to biometric authentication operations. + public enum BiometricError: LocalizedError, Sendable, Equatable { + /// Biometrics are not available on this device. + case notAvailable(reason: BiometricUnavailableReason) + + /// User cancelled the biometric authentication. + case userCancelled + + /// Biometric authentication failed. + case authenticationFailed(message: String) + + /// Biometrics are not enrolled on this device. + case notEnrolled + + /// Biometric authentication was locked out due to too many failed attempts. + case lockedOut + + public var errorDescription: String? { + switch self { + case .notAvailable(let reason): + return "Biometrics not available: \(reason.localizedDescription)" + case .userCancelled: + return "Biometric authentication was cancelled by the user." + case .authenticationFailed(let message): + return "Biometric authentication failed: \(message)" + case .notEnrolled: + return "No biometrics enrolled on this device." + case .lockedOut: + return "Biometrics locked out due to too many failed attempts." + } + } + } + + /// Reason why biometrics are not available. + public enum BiometricUnavailableReason: Sendable, Equatable { + /// No biometric hardware available on this device. + case noBiometryAvailable + + /// Device passcode is not set. + case passcodeNotSet + + var localizedDescription: String { + switch self { + case .noBiometryAvailable: + return "No biometric hardware available." + case .passcodeNotSet: + return "Device passcode is not set." + } + } + } +#endif diff --git a/Sources/Auth/Biometrics/Biometrics.swift b/Sources/Auth/Biometrics/Biometrics.swift new file mode 100644 index 000000000..bb6fcc5ee --- /dev/null +++ b/Sources/Auth/Biometrics/Biometrics.swift @@ -0,0 +1,42 @@ +// +// Biometrics.swift +// Auth +// +// + +#if canImport(LocalAuthentication) + import Foundation + + /// Executes the given operation after successful biometric authentication. + /// + /// If biometrics are enabled and authentication is required based on the current policy, + /// prompts the user for biometric authentication before executing the operation. + /// + /// - Parameters: + /// - clientID: The auth client ID for accessing dependencies. + /// - operation: The operation to execute after authentication. + /// - Returns: The result of the operation. + /// - Throws: ``BiometricError`` if authentication fails, or any error from the operation. + func withBiometrics( + clientID: AuthClientID, + operation: @Sendable () async throws -> T + ) async throws -> T { + let biometricStorage = Dependencies[clientID].biometricStorage + let biometricSession = Dependencies[clientID].biometricSession + + if biometricStorage.isEnabled, + let policy = biometricStorage.policy, + biometricSession.isAuthenticationRequired(policy) + { + let authenticator = Dependencies[clientID].biometricAuthenticator + let title = biometricStorage.promptTitle ?? "Authenticate to continue" + let evaluationPolicy = + biometricStorage.evaluationPolicy ?? .deviceOwnerAuthenticationWithBiometrics + + try await authenticator.authenticate(title, evaluationPolicy) + biometricSession.recordAuthentication() + } + + return try await operation() + } +#endif diff --git a/Sources/Auth/Internal/AppLifecycle.swift b/Sources/Auth/Internal/AppLifecycle.swift new file mode 100644 index 000000000..2acbc8524 --- /dev/null +++ b/Sources/Auth/Internal/AppLifecycle.swift @@ -0,0 +1,118 @@ +// +// AppLifecycle.swift +// Auth +// +// + +import Foundation + +#if canImport(UIKit) + import UIKit +#endif + +#if canImport(WatchKit) + import WatchKit +#endif + +#if canImport(AppKit) + import AppKit +#endif + +#if canImport(ObjectiveC) + /// Provides cross-platform app lifecycle notification names. + enum AppLifecycle { + /// Notification posted when the app becomes active (foreground). + static var didBecomeActiveNotification: NSNotification.Name? { + #if canImport(UIKit) + #if canImport(WatchKit) + if #available(watchOS 7.0, *) { + return WKExtension.applicationDidBecomeActiveNotification + } + return nil + #else + return UIApplication.didBecomeActiveNotification + #endif + #elseif canImport(AppKit) + return NSApplication.didBecomeActiveNotification + #else + return nil + #endif + } + + /// Notification posted when the app is about to resign active state. + static var willResignActiveNotification: NSNotification.Name? { + #if canImport(UIKit) + #if canImport(WatchKit) + if #available(watchOS 7.0, *) { + return WKExtension.applicationWillResignActiveNotification + } + return nil + #else + return UIApplication.willResignActiveNotification + #endif + #elseif canImport(AppKit) + return NSApplication.willResignActiveNotification + #else + return nil + #endif + } + + /// Notification posted when the app enters the background. + static var didEnterBackgroundNotification: NSNotification.Name? { + #if canImport(UIKit) && !os(watchOS) + return UIApplication.didEnterBackgroundNotification + #elseif canImport(AppKit) + // macOS doesn't have a direct equivalent, use willResignActive instead + return NSApplication.willResignActiveNotification + #else + return nil + #endif + } + + /// Notification posted when the app is about to enter the foreground. + static var willEnterForegroundNotification: NSNotification.Name? { + #if canImport(UIKit) && !os(watchOS) + return UIApplication.willEnterForegroundNotification + #elseif canImport(AppKit) + // macOS doesn't have a direct equivalent, use didBecomeActive instead + return NSApplication.didBecomeActiveNotification + #else + return nil + #endif + } + + /// Observes app background/foreground transitions using NotificationCenter. + /// + /// This method uses `addObserver(forName:object:queue:using:)` which is appropriate + /// for long-lived subscriptions that don't need cancellation. + /// + /// - Parameters: + /// - onEnterBackground: Called when the app enters the background. + /// - onEnterForeground: Called when the app enters the foreground. + @MainActor + static func observeBackgroundTransitions( + onEnterBackground: (@Sendable () -> Void)? = nil, + onEnterForeground: (@Sendable () -> Void)? = nil + ) { + if let notification = didEnterBackgroundNotification, let handler = onEnterBackground { + NotificationCenter.default.addObserver( + forName: notification, + object: nil, + queue: .main + ) { _ in + handler() + } + } + + if let notification = willEnterForegroundNotification, let handler = onEnterForeground { + NotificationCenter.default.addObserver( + forName: notification, + object: nil, + queue: .main + ) { _ in + handler() + } + } + } + } +#endif diff --git a/Sources/Auth/Internal/Dependencies.swift b/Sources/Auth/Internal/Dependencies.swift index 24488727d..a01aac0fc 100644 --- a/Sources/Auth/Internal/Dependencies.swift +++ b/Sources/Auth/Internal/Dependencies.swift @@ -16,8 +16,74 @@ struct Dependencies: Sendable { var pkce: PKCE = .live var logger: (any SupabaseLogger)? + #if canImport(LocalAuthentication) + var biometricAuthenticator: BiometricAuthenticator = .live + var biometricStorage: BiometricStorage + var biometricSession: BiometricSession + #endif + var encoder: JSONEncoder { configuration.encoder } var decoder: JSONDecoder { configuration.decoder } + + #if canImport(LocalAuthentication) + init( + configuration: AuthClient.Configuration, + http: any HTTPClientType, + api: APIClient, + codeVerifierStorage: CodeVerifierStorage, + sessionStorage: SessionStorage, + sessionManager: SessionManager, + eventEmitter: AuthStateChangeEventEmitter = AuthStateChangeEventEmitter(), + date: @escaping @Sendable () -> Date = { Date() }, + urlOpener: URLOpener = .live, + pkce: PKCE = .live, + logger: (any SupabaseLogger)? = nil, + biometricAuthenticator: BiometricAuthenticator = .live, + biometricStorage: BiometricStorage, + biometricSession: BiometricSession + ) { + self.configuration = configuration + self.http = http + self.api = api + self.codeVerifierStorage = codeVerifierStorage + self.sessionStorage = sessionStorage + self.sessionManager = sessionManager + self.eventEmitter = eventEmitter + self.date = date + self.urlOpener = urlOpener + self.pkce = pkce + self.logger = logger + self.biometricAuthenticator = biometricAuthenticator + self.biometricStorage = biometricStorage + self.biometricSession = biometricSession + } + #else + init( + configuration: AuthClient.Configuration, + http: any HTTPClientType, + api: APIClient, + codeVerifierStorage: CodeVerifierStorage, + sessionStorage: SessionStorage, + sessionManager: SessionManager, + eventEmitter: AuthStateChangeEventEmitter = AuthStateChangeEventEmitter(), + date: @escaping @Sendable () -> Date = { Date() }, + urlOpener: URLOpener = .live, + pkce: PKCE = .live, + logger: (any SupabaseLogger)? = nil + ) { + self.configuration = configuration + self.http = http + self.api = api + self.codeVerifierStorage = codeVerifierStorage + self.sessionStorage = sessionStorage + self.sessionManager = sessionManager + self.eventEmitter = eventEmitter + self.date = date + self.urlOpener = urlOpener + self.pkce = pkce + self.logger = logger + } + #endif } extension Dependencies { diff --git a/Sources/Auth/Internal/SessionManager.swift b/Sources/Auth/Internal/SessionManager.swift index 1979f297a..b8d797b54 100644 --- a/Sources/Auth/Internal/SessionManager.swift +++ b/Sources/Auth/Internal/SessionManager.swift @@ -41,6 +41,16 @@ private actor LiveSessionManager { } func session() async throws -> Session { + #if canImport(LocalAuthentication) + return try await withBiometrics(clientID: clientID) { + try await getOrRefreshSession() + } + #else + return try await getOrRefreshSession() + #endif + } + + private func getOrRefreshSession() async throws -> Session { try await trace(using: logger) { guard let currentSession = sessionStorage.get() else { logger?.debug("session missing") diff --git a/Tests/AuthTests/BiometricsTests.swift b/Tests/AuthTests/BiometricsTests.swift new file mode 100644 index 000000000..d3f879937 --- /dev/null +++ b/Tests/AuthTests/BiometricsTests.swift @@ -0,0 +1,438 @@ +// +// BiometricsTests.swift +// Auth +// +// + +#if canImport(LocalAuthentication) + import ConcurrencyExtras + import CustomDump + import LocalAuthentication + import TestHelpers + import XCTest + + @testable import Auth + + final class BiometricsTests: XCTestCase { + var http: HTTPClientMock! + var localStorage: InMemoryLocalStorage! + + override func setUp() { + super.setUp() + http = HTTPClientMock() + localStorage = InMemoryLocalStorage() + } + + // MARK: - withBiometrics Tests + + func testWithBiometrics_disabled_executesOperationDirectly() async throws { + let clientID = setupDependencies(biometricsEnabled: false) + + let operationCalled = LockIsolated(false) + let result = try await withBiometrics(clientID: clientID) { + operationCalled.setValue(true) + return "success" + } + + XCTAssertTrue(operationCalled.value) + XCTAssertEqual(result, "success") + } + + func testWithBiometrics_enabled_authNotRequired_executesOperation() async throws { + let clientID = setupDependencies( + biometricsEnabled: true, + policy: .default, + authRequired: false + ) + + let operationCalled = LockIsolated(false) + let result = try await withBiometrics(clientID: clientID) { + operationCalled.setValue(true) + return 42 + } + + XCTAssertTrue(operationCalled.value) + XCTAssertEqual(result, 42) + } + + func testWithBiometrics_enabled_authRequired_authenticatesAndExecutes() async throws { + let authCalled = LockIsolated(false) + let authRecorded = LockIsolated(false) + + let clientID = setupDependencies( + biometricsEnabled: true, + policy: .always, + authRequired: true, + authenticator: BiometricAuthenticator( + checkAvailability: { + BiometricAvailability(isAvailable: true, biometryType: .faceID, error: nil) + }, + authenticate: { _, _ in + authCalled.setValue(true) + } + ), + sessionRecordAuth: { authRecorded.setValue(true) } + ) + + let operationCalled = LockIsolated(false) + _ = try await withBiometrics(clientID: clientID) { + operationCalled.setValue(true) + return "done" + } + + XCTAssertTrue(authCalled.value, "Authentication should be called") + XCTAssertTrue(authRecorded.value, "Authentication should be recorded") + XCTAssertTrue(operationCalled.value, "Operation should be called") + } + + func testWithBiometrics_authenticationFails_throwsError() async { + let clientID = setupDependencies( + biometricsEnabled: true, + policy: .always, + authRequired: true, + authenticator: BiometricAuthenticator( + checkAvailability: { + BiometricAvailability(isAvailable: true, biometryType: .faceID, error: nil) + }, + authenticate: { _, _ in + throw BiometricError.userCancelled + } + ) + ) + + do { + _ = try await withBiometrics(clientID: clientID) { + XCTFail("Operation should not be called when authentication fails") + return "should not reach" + } + XCTFail("Expected error to be thrown") + } catch let error as BiometricError { + XCTAssertEqual(error, .userCancelled) + } catch { + XCTFail("Unexpected error: \(error)") + } + } + + // MARK: - AuthClient Biometrics Extension Tests + + func testAuthClient_biometricsAvailability() { + let client = makeAuthClient( + authenticator: BiometricAuthenticator( + checkAvailability: { + BiometricAvailability(isAvailable: true, biometryType: .faceID, error: nil) + }, + authenticate: { _, _ in } + ) + ) + + let availability = client.biometricsAvailability() + + XCTAssertTrue(availability.isAvailable) + XCTAssertEqual(availability.biometryType, .faceID) + } + + func testAuthClient_isBiometricsEnabled_initiallyFalse() { + let client = makeAuthClient(biometricsEnabled: false) + + XCTAssertFalse(client.isBiometricsEnabled) + } + + func testAuthClient_enableBiometrics_success() async throws { + let enableCalled = LockIsolated(false) + let storage = makeBiometricStorage( + isEnabled: false, + onEnable: { _, _, _ in enableCalled.setValue(true) } + ) + + let client = makeAuthClient( + authenticator: BiometricAuthenticator( + checkAvailability: { + BiometricAvailability(isAvailable: true, biometryType: .faceID, error: nil) + }, + authenticate: { _, _ in } + ), + storage: storage + ) + + try await client.enableBiometrics( + title: "Test Enable", + evaluationPolicy: .deviceOwnerAuthenticationWithBiometrics, + policy: .default + ) + + XCTAssertTrue(enableCalled.value) + } + + func testAuthClient_enableBiometrics_notAvailable_throws() async { + let client = makeAuthClient( + authenticator: BiometricAuthenticator( + checkAvailability: { + BiometricAvailability( + isAvailable: false, + biometryType: .none, + error: .notAvailable(reason: .noBiometryAvailable) + ) + }, + authenticate: { _, _ in } + ) + ) + + do { + try await client.enableBiometrics() + XCTFail("Expected error when biometrics not available") + } catch let error as BiometricError { + if case .notAvailable = error { + // Expected + } else { + XCTFail("Expected notAvailable error, got: \(error)") + } + } catch { + XCTFail("Unexpected error: \(error)") + } + } + + func testAuthClient_enableBiometrics_authFails_throws() async { + let client = makeAuthClient( + authenticator: BiometricAuthenticator( + checkAvailability: { + BiometricAvailability(isAvailable: true, biometryType: .faceID, error: nil) + }, + authenticate: { _, _ in + throw BiometricError.userCancelled + } + ) + ) + + do { + try await client.enableBiometrics() + XCTFail("Expected error when authentication fails") + } catch let error as BiometricError { + XCTAssertEqual(error, .userCancelled) + } catch { + XCTFail("Unexpected error: \(error)") + } + } + + func testAuthClient_disableBiometrics() { + let disableCalled = LockIsolated(false) + let resetCalled = LockIsolated(false) + + let storage = makeBiometricStorage( + isEnabled: true, + onDisable: { disableCalled.setValue(true) } + ) + let session = makeBiometricSession(onReset: { resetCalled.setValue(true) }) + + let client = makeAuthClient(storage: storage, session: session) + client.disableBiometrics() + + XCTAssertTrue(disableCalled.value) + XCTAssertTrue(resetCalled.value) + } + + func testAuthClient_isBiometricAuthenticationRequired_disabled() { + let client = makeAuthClient(biometricsEnabled: false) + + XCTAssertFalse(client.isBiometricAuthenticationRequired()) + } + + func testAuthClient_isBiometricAuthenticationRequired_enabled() { + let client = makeAuthClient( + biometricsEnabled: true, + policy: .always, + authRequired: true + ) + + XCTAssertTrue(client.isBiometricAuthenticationRequired()) + } + + func testAuthClient_invalidateBiometricSession() { + let resetCalled = LockIsolated(false) + let session = makeBiometricSession(onReset: { resetCalled.setValue(true) }) + + let client = makeAuthClient(session: session) + client.invalidateBiometricSession() + + XCTAssertTrue(resetCalled.value) + } + + // MARK: - BiometricError Tests + + func testBiometricError_errorDescriptions() { + XCTAssertNotNil(BiometricError.userCancelled.errorDescription) + XCTAssertNotNil(BiometricError.notEnrolled.errorDescription) + XCTAssertNotNil(BiometricError.lockedOut.errorDescription) + XCTAssertNotNil(BiometricError.authenticationFailed(message: "test").errorDescription) + XCTAssertNotNil(BiometricError.notAvailable(reason: .noBiometryAvailable).errorDescription) + } + + func testBiometricUnavailableReason_descriptions() { + XCTAssertFalse(BiometricUnavailableReason.noBiometryAvailable.localizedDescription.isEmpty) + XCTAssertFalse(BiometricUnavailableReason.passcodeNotSet.localizedDescription.isEmpty) + } + + // MARK: - BiometricPolicy Equality Tests + + func testBiometricPolicy_equality() { + XCTAssertEqual(BiometricPolicy.default, BiometricPolicy.default) + XCTAssertEqual(BiometricPolicy.always, BiometricPolicy.always) + XCTAssertEqual(BiometricPolicy.appLifecycle, BiometricPolicy.appLifecycle) + XCTAssertEqual( + BiometricPolicy.session(timeoutInSeconds: 300), + BiometricPolicy.session(timeoutInSeconds: 300) + ) + XCTAssertNotEqual( + BiometricPolicy.session(timeoutInSeconds: 300), + BiometricPolicy.session(timeoutInSeconds: 600) + ) + XCTAssertNotEqual(BiometricPolicy.default, BiometricPolicy.always) + } + + // MARK: - BiometricEvaluationPolicy Tests + + func testBiometricEvaluationPolicy_laPolicy() { + XCTAssertEqual( + BiometricEvaluationPolicy.deviceOwnerAuthenticationWithBiometrics.laPolicy, + .deviceOwnerAuthenticationWithBiometrics + ) + XCTAssertEqual( + BiometricEvaluationPolicy.deviceOwnerAuthentication.laPolicy, + .deviceOwnerAuthentication + ) + } + + // MARK: - Helpers + + @discardableResult + private func setupDependencies( + biometricsEnabled: Bool = false, + policy: BiometricPolicy? = nil, + authRequired: Bool = false, + authenticator: BiometricAuthenticator? = nil, + storage: BiometricStorage? = nil, + session: BiometricSession? = nil, + sessionRecordAuth: (() -> Void)? = nil + ) -> AuthClientID { + let finalStorage = + storage ?? makeBiometricStorage(isEnabled: biometricsEnabled, policy: policy) + let finalSession = + session + ?? makeBiometricSession(authRequired: authRequired, onRecordAuth: sessionRecordAuth) + let finalAuthenticator = + authenticator + ?? BiometricAuthenticator( + checkAvailability: { + BiometricAvailability(isAvailable: true, biometryType: .faceID, error: nil) + }, + authenticate: { _, _ in } + ) + + // Create AuthClient first - this sets up dependencies + let configuration = AuthClient.Configuration( + url: URL(string: "https://test.supabase.co")!, + localStorage: localStorage, + logger: nil + ) + let client = AuthClient(configuration: configuration) + let clientID = client.clientID + + // Then modify the biometric dependencies + Dependencies[clientID].biometricAuthenticator = finalAuthenticator + Dependencies[clientID].biometricStorage = finalStorage + Dependencies[clientID].biometricSession = finalSession + + return clientID + } + + private func makeAuthClient( + biometricsEnabled: Bool = false, + policy: BiometricPolicy? = nil, + authRequired: Bool = false, + authenticator: BiometricAuthenticator? = nil, + storage: BiometricStorage? = nil, + session: BiometricSession? = nil + ) -> AuthClient { + let configuration = AuthClient.Configuration( + url: URL(string: "https://test.supabase.co")!, + localStorage: localStorage, + logger: nil + ) + + let client = AuthClient(configuration: configuration) + + let finalStorage = + storage ?? makeBiometricStorage(isEnabled: biometricsEnabled, policy: policy) + let finalSession = session ?? makeBiometricSession(authRequired: authRequired) + let finalAuthenticator = + authenticator + ?? BiometricAuthenticator( + checkAvailability: { + BiometricAvailability(isAvailable: true, biometryType: .faceID, error: nil) + }, + authenticate: { _, _ in } + ) + + Dependencies[client.clientID].biometricAuthenticator = finalAuthenticator + Dependencies[client.clientID].biometricStorage = finalStorage + Dependencies[client.clientID].biometricSession = finalSession + + return client + } + + private func makeBiometricStorage( + isEnabled: Bool = false, + policy: BiometricPolicy? = nil, + evaluationPolicy: BiometricEvaluationPolicy? = nil, + promptTitle: String? = nil, + onEnable: ((BiometricEvaluationPolicy, BiometricPolicy, String) -> Void)? = nil, + onDisable: (() -> Void)? = nil + ) -> BiometricStorage { + let _isEnabled = LockIsolated(isEnabled) + let _policy = LockIsolated(policy) + let _evalPolicy = LockIsolated(evaluationPolicy) + let _title = LockIsolated(promptTitle) + + return BiometricStorage( + getIsEnabled: { _isEnabled.value }, + getPolicy: { _policy.value }, + getEvaluationPolicy: { _evalPolicy.value }, + getPromptTitle: { _title.value }, + enable: { evalPolicy, biometricPolicy, title in + _isEnabled.setValue(true) + _policy.setValue(biometricPolicy) + _evalPolicy.setValue(evalPolicy) + _title.setValue(title) + onEnable?(evalPolicy, biometricPolicy, title) + }, + disable: { + _isEnabled.setValue(false) + _policy.setValue(nil) + _evalPolicy.setValue(nil) + _title.setValue(nil) + onDisable?() + } + ) + } + + private func makeBiometricSession( + authRequired: Bool = false, + onRecordAuth: (() -> Void)? = nil, + onReset: (() -> Void)? = nil + ) -> BiometricSession { + let lastAuthTime = LockIsolated(nil) + + return BiometricSession( + recordAuthentication: { + lastAuthTime.setValue(Date()) + onRecordAuth?() + }, + reset: { + lastAuthTime.setValue(nil) + onReset?() + }, + lastAuthenticationTime: { lastAuthTime.value }, + isAuthenticationRequired: { _ in authRequired } + ) + } + } +#endif diff --git a/Tests/AuthTests/MockHelpers.swift b/Tests/AuthTests/MockHelpers.swift index e5c3210cc..597691dea 100644 --- a/Tests/AuthTests/MockHelpers.swift +++ b/Tests/AuthTests/MockHelpers.swift @@ -4,6 +4,10 @@ import TestHelpers @testable import Auth +#if canImport(LocalAuthentication) + import LocalAuthentication +#endif + func json(named name: String) -> Data { let url = Bundle.module.url(forResource: name, withExtension: "json") return try! Data(contentsOf: url!) @@ -15,21 +19,6 @@ extension Decodable { } } -extension Dependencies { - static var mock = Dependencies( - configuration: AuthClient.Configuration( - url: URL(string: "https://project-id.supabase.com")!, - localStorage: InMemoryLocalStorage(), - logger: nil - ), - http: HTTPClientMock(), - api: APIClient(clientID: AuthClientID()), - codeVerifierStorage: CodeVerifierStorage.mock, - sessionStorage: SessionStorage.live(clientID: AuthClientID()), - sessionManager: SessionManager.live(clientID: AuthClientID()) - ) -} - extension CodeVerifierStorage { static var mock: CodeVerifierStorage { let code = LockIsolated(nil) @@ -40,3 +29,115 @@ extension CodeVerifierStorage { ) } } + +extension SessionStorage { + static var mock: SessionStorage { + let session = LockIsolated(nil) + return SessionStorage( + get: { session.value }, + store: { session.setValue($0) }, + delete: { session.setValue(nil) } + ) + } +} + +extension SessionManager { + static var mock: SessionManager { + SessionManager( + session: { throw AuthError.sessionMissing }, + refreshSession: { _ in throw AuthError.sessionMissing }, + update: { _ in }, + remove: {}, + startAutoRefresh: {}, + stopAutoRefresh: {} + ) + } +} + +#if canImport(LocalAuthentication) + extension BiometricStorage { + static var mock: BiometricStorage { + let isEnabled = LockIsolated(false) + let policy = LockIsolated(.default) + let evaluationPolicy = LockIsolated( + .deviceOwnerAuthenticationWithBiometrics) + let promptTitle = LockIsolated(nil) + + return BiometricStorage( + getIsEnabled: { isEnabled.value }, + getPolicy: { policy.value }, + getEvaluationPolicy: { evaluationPolicy.value }, + getPromptTitle: { promptTitle.value }, + enable: { evalPolicy, biometricPolicy, title in + isEnabled.setValue(true) + policy.setValue(biometricPolicy) + evaluationPolicy.setValue(evalPolicy) + promptTitle.setValue(title) + }, + disable: { + isEnabled.setValue(false) + policy.setValue(nil) + evaluationPolicy.setValue(nil) + promptTitle.setValue(nil) + } + ) + } + } + + extension BiometricSession { + static var mock: BiometricSession { + let lastAuthTime = LockIsolated(nil) + + return BiometricSession( + recordAuthentication: { + lastAuthTime.setValue(Date()) + }, + reset: { + lastAuthTime.setValue(nil) + }, + lastAuthenticationTime: { + lastAuthTime.value + }, + isAuthenticationRequired: { _ in + false + } + ) + } + } + + extension Dependencies { + static func makeMock() -> Dependencies { + Dependencies( + configuration: AuthClient.Configuration( + url: URL(string: "https://project-id.supabase.com")!, + localStorage: InMemoryLocalStorage(), + logger: nil + ), + http: HTTPClientMock(), + api: APIClient(clientID: AuthClientID()), + codeVerifierStorage: CodeVerifierStorage.mock, + sessionStorage: SessionStorage.mock, + sessionManager: SessionManager.mock, + biometricStorage: BiometricStorage.mock, + biometricSession: BiometricSession.mock + ) + } + } +#else + extension Dependencies { + static func makeMock() -> Dependencies { + Dependencies( + configuration: AuthClient.Configuration( + url: URL(string: "https://project-id.supabase.com")!, + localStorage: InMemoryLocalStorage(), + logger: nil + ), + http: HTTPClientMock(), + api: APIClient(clientID: AuthClientID()), + codeVerifierStorage: CodeVerifierStorage.mock, + sessionStorage: SessionStorage.mock, + sessionManager: SessionManager.mock + ) + } + } +#endif diff --git a/Tests/AuthTests/SessionManagerTests.swift b/Tests/AuthTests/SessionManagerTests.swift index 3042419e4..ed4e20d2f 100644 --- a/Tests/AuthTests/SessionManagerTests.swift +++ b/Tests/AuthTests/SessionManagerTests.swift @@ -28,18 +28,35 @@ final class SessionManagerTests: XCTestCase { http = HTTPClientMock() - Dependencies[clientID] = .init( - configuration: .init( - url: clientURL, - localStorage: InMemoryLocalStorage(), - autoRefreshToken: false - ), - http: http, - api: APIClient(clientID: clientID), - codeVerifierStorage: .mock, - sessionStorage: SessionStorage.live(clientID: clientID), - sessionManager: SessionManager.live(clientID: clientID) - ) + #if canImport(LocalAuthentication) + Dependencies[clientID] = .init( + configuration: .init( + url: clientURL, + localStorage: InMemoryLocalStorage(), + autoRefreshToken: false + ), + http: http, + api: APIClient(clientID: clientID), + codeVerifierStorage: .mock, + sessionStorage: SessionStorage.live(clientID: clientID), + sessionManager: SessionManager.live(clientID: clientID), + biometricStorage: BiometricStorage.mock, + biometricSession: BiometricSession.mock + ) + #else + Dependencies[clientID] = .init( + configuration: .init( + url: clientURL, + localStorage: InMemoryLocalStorage(), + autoRefreshToken: false + ), + http: http, + api: APIClient(clientID: clientID), + codeVerifierStorage: .mock, + sessionStorage: SessionStorage.live(clientID: clientID), + sessionManager: SessionManager.live(clientID: clientID) + ) + #endif } #if !os(Windows) && !os(Linux) && !os(Android) diff --git a/Tests/AuthTests/StoredSessionTests.swift b/Tests/AuthTests/StoredSessionTests.swift index 5053e083d..6d8032a4c 100644 --- a/Tests/AuthTests/StoredSessionTests.swift +++ b/Tests/AuthTests/StoredSessionTests.swift @@ -10,22 +10,40 @@ final class StoredSessionTests: XCTestCase { func testStoredSession() throws { #if os(Android) - throw XCTSkip("Disabled for android due to #filePath not existing on emulator") + throw XCTSkip("Disabled for android due to #filePath not existing on emulator") #endif - Dependencies[clientID] = Dependencies( - configuration: AuthClient.Configuration( - url: URL(string: "http://localhost")!, - storageKey: "supabase.auth.token", - localStorage: try! DiskTestStorage(), - logger: nil - ), - http: HTTPClientMock(), - api: .init(clientID: clientID), - codeVerifierStorage: .mock, - sessionStorage: .live(clientID: clientID), - sessionManager: .live(clientID: clientID) - ) + #if canImport(LocalAuthentication) + Dependencies[clientID] = Dependencies( + configuration: AuthClient.Configuration( + url: URL(string: "http://localhost")!, + storageKey: "supabase.auth.token", + localStorage: try! DiskTestStorage(), + logger: nil + ), + http: HTTPClientMock(), + api: .init(clientID: clientID), + codeVerifierStorage: .mock, + sessionStorage: .live(clientID: clientID), + sessionManager: .live(clientID: clientID), + biometricStorage: BiometricStorage.mock, + biometricSession: BiometricSession.mock + ) + #else + Dependencies[clientID] = Dependencies( + configuration: AuthClient.Configuration( + url: URL(string: "http://localhost")!, + storageKey: "supabase.auth.token", + localStorage: try! DiskTestStorage(), + logger: nil + ), + http: HTTPClientMock(), + api: .init(clientID: clientID), + codeVerifierStorage: .mock, + sessionStorage: .live(clientID: clientID), + sessionManager: .live(clientID: clientID) + ) + #endif let sut = Dependencies[clientID].sessionStorage