From 0734935bc41fa100e57838906717a18eccc1e7da Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Mon, 2 Feb 2026 06:16:12 -0300 Subject: [PATCH 01/11] feat(auth): add biometric authentication support Implement biometric authentication (Face ID/Touch ID) for session protection following the Auth0 CredentialsManager pattern. Features include: - Configurable policies: .default, .always, .session(timeout:), .appLifecycle - Flat API: enableBiometrics(), disableBiometrics(), biometricsAvailability() - Automatic integration: session() automatically requires biometrics when enabled - Internal withBiometrics() helper for protecting sensitive operations - Full Swift 6/Sendable conformance - Platform support: iOS, macOS, tvOS, watchOS, visionOS Co-Authored-By: Claude Haiku 4.5 --- Sources/Auth/AuthClient.swift | 133 +++++++++++++-- Sources/Auth/BiometricTypes.swift | 135 ++++++++++++++++ .../Internal/BiometricAuthenticator.swift | 118 ++++++++++++++ Sources/Auth/Internal/BiometricSession.swift | 96 +++++++++++ Sources/Auth/Internal/BiometricStorage.swift | 153 ++++++++++++++++++ Sources/Auth/Internal/Biometrics.swift | 42 +++++ Sources/Auth/Internal/Dependencies.swift | 66 ++++++++ Sources/Auth/Internal/SessionManager.swift | 10 ++ Sources/Auth/Types.swift | 2 + 9 files changed, 744 insertions(+), 11 deletions(-) create mode 100644 Sources/Auth/BiometricTypes.swift create mode 100644 Sources/Auth/Internal/BiometricAuthenticator.swift create mode 100644 Sources/Auth/Internal/BiometricSession.swift create mode 100644 Sources/Auth/Internal/BiometricStorage.swift create mode 100644 Sources/Auth/Internal/Biometrics.swift diff --git a/Sources/Auth/AuthClient.swift b/Sources/Auth/AuthClient.swift index 2f4e5290c..c19e2c46a 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() } } @@ -1687,3 +1703,98 @@ extension AuthClient { } } #endif + +// 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() + + // Emit event + Dependencies[clientID].eventEmitter.emit(.biometricsEnabled, session: currentSession) + } + + /// 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() + Dependencies[clientID].eventEmitter.emit(.biometricsDisabled, session: currentSession) + } + + /// 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/BiometricTypes.swift b/Sources/Auth/BiometricTypes.swift new file mode 100644 index 000000000..99722e4aa --- /dev/null +++ b/Sources/Auth/BiometricTypes.swift @@ -0,0 +1,135 @@ +// +// BiometricTypes.swift +// Auth +// +// + +#if canImport(LocalAuthentication) + import Foundation + import LocalAuthentication + + /// Policy determining when biometric authentication is required. + public enum BiometricPolicy: Sendable, Equatable { + /// 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 + + /// Biometrics are not enabled for this client. + case notEnabled + + 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." + case .notEnabled: + return "Biometrics are not enabled for this client." + } + } + } + + /// Reason why biometrics are not available. + public enum BiometricUnavailableReason: Sendable, Equatable { + /// No biometric hardware available on this device. + case noBiometryAvailable + + /// Biometrics are not enrolled (no Face ID / Touch ID set up). + case biometryNotEnrolled + + /// Biometrics are temporarily locked out due to too many failed attempts. + case biometryLockout + + /// Device passcode is not set. + case passcodeNotSet + + /// Other error with a specific code. + case other(code: Int) + + var localizedDescription: String { + switch self { + case .noBiometryAvailable: + return "No biometric hardware available." + case .biometryNotEnrolled: + return "No biometrics enrolled." + case .biometryLockout: + return "Biometrics temporarily unavailable due to lockout." + case .passcodeNotSet: + return "Device passcode is not set." + case .other(let code): + return "Unknown error (code: \(code))." + } + } + } +#endif diff --git a/Sources/Auth/Internal/BiometricAuthenticator.swift b/Sources/Auth/Internal/BiometricAuthenticator.swift new file mode 100644 index 000000000..bddd49b5e --- /dev/null +++ b/Sources/Auth/Internal/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/Internal/BiometricSession.swift b/Sources/Auth/Internal/BiometricSession.swift new file mode 100644 index 000000000..e8ef3f0fb --- /dev/null +++ b/Sources/Auth/Internal/BiometricSession.swift @@ -0,0 +1,96 @@ +// +// BiometricSession.swift +// Auth +// +// + +#if canImport(LocalAuthentication) + import ConcurrencyExtras + import Foundation + + #if canImport(UIKit) + import UIKit + #endif + + #if canImport(AppKit) + import AppKit + #endif + + /// 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(UIKit) && !os(watchOS) + Task { @MainActor in + NotificationCenter.default.addObserver( + forName: UIApplication.didEnterBackgroundNotification, + object: nil, + queue: .main + ) { _ in + isInBackground.setValue(true) + } + + NotificationCenter.default.addObserver( + forName: UIApplication.willEnterForegroundNotification, + object: nil, + queue: .main + ) { _ in + // Keep isInBackground true until authentication completes + } + } + #elseif canImport(AppKit) + Task { @MainActor in + NotificationCenter.default.addObserver( + forName: NSApplication.didResignActiveNotification, + object: nil, + queue: .main + ) { _ in + isInBackground.setValue(true) + } + } + #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/Internal/BiometricStorage.swift b/Sources/Auth/Internal/BiometricStorage.swift new file mode 100644 index 000000000..6cbea284a --- /dev/null +++ b/Sources/Auth/Internal/BiometricStorage.swift @@ -0,0 +1,153 @@ +// +// 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 + let cachedSettings = LockIsolated(loadSettings()) + + return BiometricStorage( + getIsEnabled: { + cachedSettings.value?.isEnabled ?? false + }, + getPolicy: { + cachedSettings.value?.policy + }, + getEvaluationPolicy: { + cachedSettings.value?.evaluationPolicy + }, + getPromptTitle: { + 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/Internal/Biometrics.swift b/Sources/Auth/Internal/Biometrics.swift new file mode 100644 index 000000000..bb6fcc5ee --- /dev/null +++ b/Sources/Auth/Internal/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/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/Sources/Auth/Types.swift b/Sources/Auth/Types.swift index beb2ece83..014805aad 100644 --- a/Sources/Auth/Types.swift +++ b/Sources/Auth/Types.swift @@ -9,6 +9,8 @@ public enum AuthChangeEvent: String, Sendable { case userUpdated = "USER_UPDATED" case userDeleted = "USER_DELETED" case mfaChallengeVerified = "MFA_CHALLENGE_VERIFIED" + case biometricsEnabled = "BIOMETRICS_ENABLED" + case biometricsDisabled = "BIOMETRICS_DISABLED" } @available( From 3abbddedf2424ca7069807eb0f56dee9b0ab4327 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Mon, 2 Feb 2026 06:25:52 -0300 Subject: [PATCH 02/11] test(auth): add biometric mocks to test helpers Add mock implementations for BiometricStorage and BiometricSession to support testing. Update test files to use conditional compilation for biometric dependencies. Co-Authored-By: Claude Haiku 4.5 --- Tests/AuthTests/MockHelpers.swift | 103 ++++++++++++++++++---- Tests/AuthTests/SessionManagerTests.swift | 41 ++++++--- Tests/AuthTests/StoredSessionTests.swift | 44 ++++++--- 3 files changed, 148 insertions(+), 40 deletions(-) diff --git a/Tests/AuthTests/MockHelpers.swift b/Tests/AuthTests/MockHelpers.swift index e5c3210cc..872aef0a3 100644 --- a/Tests/AuthTests/MockHelpers.swift +++ b/Tests/AuthTests/MockHelpers.swift @@ -2,6 +2,10 @@ import ConcurrencyExtras import Foundation import TestHelpers +#if canImport(LocalAuthentication) + import LocalAuthentication +#endif + @testable import Auth func json(named name: String) -> Data { @@ -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,87 @@ extension CodeVerifierStorage { ) } } + +#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 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()), + biometricStorage: BiometricStorage.mock, + biometricSession: BiometricSession.mock + ) + } +#else + 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()) + ) + } +#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..979a84a11 100644 --- a/Tests/AuthTests/StoredSessionTests.swift +++ b/Tests/AuthTests/StoredSessionTests.swift @@ -13,19 +13,37 @@ final class StoredSessionTests: XCTestCase { 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 From 68810ca6992451097908f749853fc9a5174ab53b Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Mon, 2 Feb 2026 06:35:12 -0300 Subject: [PATCH 03/11] style: format changed files Co-Authored-By: Claude Haiku 4.5 --- Tests/AuthTests/MockHelpers.swift | 4 ++-- Tests/AuthTests/StoredSessionTests.swift | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Tests/AuthTests/MockHelpers.swift b/Tests/AuthTests/MockHelpers.swift index 872aef0a3..a4b15d316 100644 --- a/Tests/AuthTests/MockHelpers.swift +++ b/Tests/AuthTests/MockHelpers.swift @@ -2,12 +2,12 @@ import ConcurrencyExtras import Foundation import TestHelpers +@testable import Auth + #if canImport(LocalAuthentication) import LocalAuthentication #endif -@testable import Auth - func json(named name: String) -> Data { let url = Bundle.module.url(forResource: name, withExtension: "json") return try! Data(contentsOf: url!) diff --git a/Tests/AuthTests/StoredSessionTests.swift b/Tests/AuthTests/StoredSessionTests.swift index 979a84a11..6d8032a4c 100644 --- a/Tests/AuthTests/StoredSessionTests.swift +++ b/Tests/AuthTests/StoredSessionTests.swift @@ -10,7 +10,7 @@ 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 #if canImport(LocalAuthentication) From 163c630ca159e930bf841c8b3d8d70b15159ed8c Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Mon, 2 Feb 2026 06:56:07 -0300 Subject: [PATCH 04/11] refactor(auth): reorganize biometric files into dedicated directory Move all biometric-related files into Sources/Auth/Biometrics/ and extract AuthClient biometric extension into its own file for better organization. Co-Authored-By: Claude Haiku 4.5 --- Sources/Auth/AuthClient.swift | 95 ------------------- .../Biometrics/AuthClient+Biometrics.swift | 94 ++++++++++++++++++ .../BiometricAuthenticator.swift | 0 .../BiometricSession.swift | 0 .../BiometricStorage.swift | 0 .../{ => Biometrics}/BiometricTypes.swift | 0 .../{Internal => Biometrics}/Biometrics.swift | 0 7 files changed, 94 insertions(+), 95 deletions(-) create mode 100644 Sources/Auth/Biometrics/AuthClient+Biometrics.swift rename Sources/Auth/{Internal => Biometrics}/BiometricAuthenticator.swift (100%) rename Sources/Auth/{Internal => Biometrics}/BiometricSession.swift (100%) rename Sources/Auth/{Internal => Biometrics}/BiometricStorage.swift (100%) rename Sources/Auth/{ => Biometrics}/BiometricTypes.swift (100%) rename Sources/Auth/{Internal => Biometrics}/Biometrics.swift (100%) diff --git a/Sources/Auth/AuthClient.swift b/Sources/Auth/AuthClient.swift index c19e2c46a..d0f250a0d 100644 --- a/Sources/Auth/AuthClient.swift +++ b/Sources/Auth/AuthClient.swift @@ -1703,98 +1703,3 @@ extension AuthClient { } } #endif - -// 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() - - // Emit event - Dependencies[clientID].eventEmitter.emit(.biometricsEnabled, session: currentSession) - } - - /// 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() - Dependencies[clientID].eventEmitter.emit(.biometricsDisabled, session: currentSession) - } - - /// 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/AuthClient+Biometrics.swift b/Sources/Auth/Biometrics/AuthClient+Biometrics.swift new file mode 100644 index 000000000..990468773 --- /dev/null +++ b/Sources/Auth/Biometrics/AuthClient+Biometrics.swift @@ -0,0 +1,94 @@ +// 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() + + // Emit event + Dependencies[clientID].eventEmitter.emit(.biometricsEnabled, session: currentSession) + } + + /// 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() + Dependencies[clientID].eventEmitter.emit(.biometricsDisabled, session: currentSession) + } + + /// 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/Internal/BiometricAuthenticator.swift b/Sources/Auth/Biometrics/BiometricAuthenticator.swift similarity index 100% rename from Sources/Auth/Internal/BiometricAuthenticator.swift rename to Sources/Auth/Biometrics/BiometricAuthenticator.swift diff --git a/Sources/Auth/Internal/BiometricSession.swift b/Sources/Auth/Biometrics/BiometricSession.swift similarity index 100% rename from Sources/Auth/Internal/BiometricSession.swift rename to Sources/Auth/Biometrics/BiometricSession.swift diff --git a/Sources/Auth/Internal/BiometricStorage.swift b/Sources/Auth/Biometrics/BiometricStorage.swift similarity index 100% rename from Sources/Auth/Internal/BiometricStorage.swift rename to Sources/Auth/Biometrics/BiometricStorage.swift diff --git a/Sources/Auth/BiometricTypes.swift b/Sources/Auth/Biometrics/BiometricTypes.swift similarity index 100% rename from Sources/Auth/BiometricTypes.swift rename to Sources/Auth/Biometrics/BiometricTypes.swift diff --git a/Sources/Auth/Internal/Biometrics.swift b/Sources/Auth/Biometrics/Biometrics.swift similarity index 100% rename from Sources/Auth/Internal/Biometrics.swift rename to Sources/Auth/Biometrics/Biometrics.swift From c08c725573e991a53754e407994c42e10dc4f1a3 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Mon, 2 Feb 2026 07:11:24 -0300 Subject: [PATCH 05/11] fix(auth): lazy load biometric settings to avoid init crash The BiometricStorage.live() was eagerly loading settings during initialization, which accessed Dependencies before they were fully set up. Changed to lazy loading that defers settings retrieval until first access. Also added comprehensive tests for the biometrics feature covering: - BiometricStorage enable/disable with all policy types - BiometricSession recording, reset, and policy evaluation - BiometricAuthenticator mock behavior - withBiometrics helper function - AuthClient biometrics extension methods - BiometricError and BiometricPolicy types Co-Authored-By: Claude Opus 4.5 --- .../Auth/Biometrics/BiometricStorage.swift | 24 +- Tests/AuthTests/BiometricsTests.swift | 663 ++++++++++++++++++ Tests/AuthTests/MockHelpers.swift | 80 ++- 3 files changed, 736 insertions(+), 31 deletions(-) create mode 100644 Tests/AuthTests/BiometricsTests.swift diff --git a/Sources/Auth/Biometrics/BiometricStorage.swift b/Sources/Auth/Biometrics/BiometricStorage.swift index 6cbea284a..e08e7ac47 100644 --- a/Sources/Auth/Biometrics/BiometricStorage.swift +++ b/Sources/Auth/Biometrics/BiometricStorage.swift @@ -60,20 +60,34 @@ } // Use LockIsolated for thread-safe caching - let cachedSettings = LockIsolated(loadSettings()) + // 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: { - cachedSettings.value?.isEnabled ?? false + ensureSettingsLoaded() + return cachedSettings.value?.isEnabled ?? false }, getPolicy: { - cachedSettings.value?.policy + ensureSettingsLoaded() + return cachedSettings.value?.policy }, getEvaluationPolicy: { - cachedSettings.value?.evaluationPolicy + ensureSettingsLoaded() + return cachedSettings.value?.evaluationPolicy }, getPromptTitle: { - cachedSettings.value?.promptTitle + ensureSettingsLoaded() + return cachedSettings.value?.promptTitle }, enable: { evaluationPolicy, policy, promptTitle in let settings = Settings( diff --git a/Tests/AuthTests/BiometricsTests.swift b/Tests/AuthTests/BiometricsTests.swift new file mode 100644 index 000000000..a26603f12 --- /dev/null +++ b/Tests/AuthTests/BiometricsTests.swift @@ -0,0 +1,663 @@ +// +// 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: - BiometricStorage Tests + + func testBiometricStorage_initiallyDisabled() { + let storage = BiometricStorage.mock + + XCTAssertFalse(storage.isEnabled) + XCTAssertNil(storage.promptTitle) + } + + func testBiometricStorage_enableWithDefaultPolicy() { + let storage = BiometricStorage.mock + + storage.enable(.deviceOwnerAuthenticationWithBiometrics, .default, "Test Title") + + XCTAssertTrue(storage.isEnabled) + XCTAssertEqual(storage.policy, .default) + XCTAssertEqual(storage.evaluationPolicy, .deviceOwnerAuthenticationWithBiometrics) + XCTAssertEqual(storage.promptTitle, "Test Title") + } + + func testBiometricStorage_enableWithAlwaysPolicy() { + let storage = BiometricStorage.mock + + storage.enable(.deviceOwnerAuthentication, .always, "Always Auth") + + XCTAssertTrue(storage.isEnabled) + XCTAssertEqual(storage.policy, .always) + XCTAssertEqual(storage.evaluationPolicy, .deviceOwnerAuthentication) + } + + func testBiometricStorage_enableWithSessionPolicy() { + let storage = BiometricStorage.mock + + storage.enable( + .deviceOwnerAuthenticationWithBiometrics, + .session(timeoutInSeconds: 300), + "Session Auth" + ) + + XCTAssertTrue(storage.isEnabled) + XCTAssertEqual(storage.policy, .session(timeoutInSeconds: 300)) + } + + func testBiometricStorage_enableWithAppLifecyclePolicy() { + let storage = BiometricStorage.mock + + storage.enable(.deviceOwnerAuthenticationWithBiometrics, .appLifecycle, "App Lifecycle Auth") + + XCTAssertTrue(storage.isEnabled) + XCTAssertEqual(storage.policy, .appLifecycle) + } + + func testBiometricStorage_disable() { + let storage = BiometricStorage.mock + + storage.enable(.deviceOwnerAuthenticationWithBiometrics, .default, "Test") + XCTAssertTrue(storage.isEnabled) + + storage.disable() + + XCTAssertFalse(storage.isEnabled) + XCTAssertNil(storage.policy) + XCTAssertNil(storage.evaluationPolicy) + XCTAssertNil(storage.promptTitle) + } + + // MARK: - BiometricSession Tests + + func testBiometricSession_initiallyNoAuthTime() { + let session = BiometricSession.mock + + XCTAssertNil(session.lastAuthenticationTime()) + } + + func testBiometricSession_recordAuthentication() { + let lastAuthTime = LockIsolated(nil) + + let session = BiometricSession( + recordAuthentication: { lastAuthTime.setValue(Date()) }, + reset: { lastAuthTime.setValue(nil) }, + lastAuthenticationTime: { lastAuthTime.value }, + isAuthenticationRequired: { _ in lastAuthTime.value == nil } + ) + + XCTAssertNil(session.lastAuthenticationTime()) + + session.recordAuthentication() + + XCTAssertNotNil(session.lastAuthenticationTime()) + } + + func testBiometricSession_reset() { + let lastAuthTime = LockIsolated(Date()) + + let session = BiometricSession( + recordAuthentication: { lastAuthTime.setValue(Date()) }, + reset: { lastAuthTime.setValue(nil) }, + lastAuthenticationTime: { lastAuthTime.value }, + isAuthenticationRequired: { _ in lastAuthTime.value == nil } + ) + + XCTAssertNotNil(session.lastAuthenticationTime()) + + session.reset() + + XCTAssertNil(session.lastAuthenticationTime()) + } + + func testBiometricSession_defaultPolicy_requiresAuthOnFirstAccess() { + let lastAuthTime = LockIsolated(nil) + + let session = BiometricSession( + recordAuthentication: { lastAuthTime.setValue(Date()) }, + reset: { lastAuthTime.setValue(nil) }, + lastAuthenticationTime: { lastAuthTime.value }, + isAuthenticationRequired: { policy in + switch policy { + case .default: + return lastAuthTime.value == nil + default: + return false + } + } + ) + + XCTAssertTrue(session.isAuthenticationRequired(.default)) + + session.recordAuthentication() + + XCTAssertFalse(session.isAuthenticationRequired(.default)) + } + + func testBiometricSession_alwaysPolicy_alwaysRequiresAuth() { + let session = BiometricSession( + recordAuthentication: {}, + reset: {}, + lastAuthenticationTime: { Date() }, + isAuthenticationRequired: { policy in + switch policy { + case .always: + return true + default: + return false + } + } + ) + + XCTAssertTrue(session.isAuthenticationRequired(.always)) + } + + func testBiometricSession_sessionPolicy_requiresAuthAfterTimeout() { + let lastAuthTime = LockIsolated(Date().addingTimeInterval(-400)) // 400 seconds ago + + let session = BiometricSession( + recordAuthentication: { lastAuthTime.setValue(Date()) }, + reset: { lastAuthTime.setValue(nil) }, + lastAuthenticationTime: { lastAuthTime.value }, + isAuthenticationRequired: { policy in + switch policy { + case .session(let timeout): + guard let lastAuth = lastAuthTime.value else { return true } + return Date().timeIntervalSince(lastAuth) > timeout + default: + return false + } + } + ) + + // With 300 second timeout and 400 seconds elapsed, should require auth + XCTAssertTrue(session.isAuthenticationRequired(.session(timeoutInSeconds: 300))) + + // With 500 second timeout and 400 seconds elapsed, should not require auth + XCTAssertFalse(session.isAuthenticationRequired(.session(timeoutInSeconds: 500))) + } + + func testBiometricSession_sessionPolicy_noAuthTimeRequiresAuth() { + let lastAuthTime = LockIsolated(nil) + + let session = BiometricSession( + recordAuthentication: { lastAuthTime.setValue(Date()) }, + reset: { lastAuthTime.setValue(nil) }, + lastAuthenticationTime: { lastAuthTime.value }, + isAuthenticationRequired: { policy in + switch policy { + case .session: + return lastAuthTime.value == nil + default: + return false + } + } + ) + + XCTAssertTrue(session.isAuthenticationRequired(.session(timeoutInSeconds: 300))) + } + + // MARK: - BiometricAuthenticator Tests + + func testBiometricAuthenticator_mockAvailable() { + let authenticator = BiometricAuthenticator.mock(available: true, biometryType: .faceID) + + let availability = authenticator.checkAvailability() + + XCTAssertTrue(availability.isAvailable) + XCTAssertEqual(availability.biometryType, .faceID) + XCTAssertNil(availability.error) + } + + func testBiometricAuthenticator_mockNotAvailable() { + let authenticator = BiometricAuthenticator.mock( + available: false, + biometryType: .none, + error: .notAvailable(reason: .noBiometryAvailable) + ) + + let availability = authenticator.checkAvailability() + + XCTAssertFalse(availability.isAvailable) + XCTAssertEqual(availability.biometryType, .none) + XCTAssertEqual(availability.error, .notAvailable(reason: .noBiometryAvailable)) + } + + func testBiometricAuthenticator_mockAuthenticationSuccess() async throws { + let authenticator = BiometricAuthenticator.mock(shouldSucceed: true) + + // Should not throw + try await authenticator.authenticate("Test", .deviceOwnerAuthenticationWithBiometrics) + } + + func testBiometricAuthenticator_mockAuthenticationFailure() async { + let authenticator = BiometricAuthenticator.mock( + shouldSucceed: false, + error: .userCancelled + ) + + do { + try await authenticator.authenticate("Test", .deviceOwnerAuthenticationWithBiometrics) + XCTFail("Expected authentication to fail") + } catch let error as BiometricError { + XCTAssertEqual(error, .userCancelled) + } catch { + XCTFail("Unexpected error type: \(error)") + } + } + + func testBiometricAuthenticator_touchID() { + let authenticator = BiometricAuthenticator.mock(available: true, biometryType: .touchID) + + let availability = authenticator.checkAvailability() + + XCTAssertEqual(availability.biometryType, .touchID) + } + + // 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.mock(available: true, biometryType: .faceID) + ) + + 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.mock(available: true, shouldSucceed: true), + 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.mock( + available: false, + error: .notAvailable(reason: .noBiometryAvailable) + ) + ) + + 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.mock( + available: true, + shouldSucceed: false, + error: .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.notEnabled.errorDescription) + XCTAssertNotNil(BiometricError.authenticationFailed(message: "test").errorDescription) + XCTAssertNotNil(BiometricError.notAvailable(reason: .noBiometryAvailable).errorDescription) + } + + func testBiometricUnavailableReason_descriptions() { + XCTAssertFalse(BiometricUnavailableReason.noBiometryAvailable.localizedDescription.isEmpty) + XCTAssertFalse(BiometricUnavailableReason.biometryNotEnrolled.localizedDescription.isEmpty) + XCTAssertFalse(BiometricUnavailableReason.biometryLockout.localizedDescription.isEmpty) + XCTAssertFalse(BiometricUnavailableReason.passcodeNotSet.localizedDescription.isEmpty) + XCTAssertFalse(BiometricUnavailableReason.other(code: 123).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.mock(available: true, shouldSucceed: true) + + // 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.mock(available: true, shouldSucceed: true) + + 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 a4b15d316..597691dea 100644 --- a/Tests/AuthTests/MockHelpers.swift +++ b/Tests/AuthTests/MockHelpers.swift @@ -30,6 +30,30 @@ 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 { @@ -82,34 +106,38 @@ extension CodeVerifierStorage { } 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()), - biometricStorage: BiometricStorage.mock, - biometricSession: BiometricSession.mock - ) + 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 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()) - ) + 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 From a9ee5ab82bed7c44a9a7fed752754ae772c05049 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Mon, 2 Feb 2026 07:14:16 -0300 Subject: [PATCH 06/11] refactor(auth): extract app lifecycle notifications to shared utility Created AppLifecycle enum in Internal/ that provides cross-platform notification names for app lifecycle events (active, resign active, background, foreground). This consolidates the platform-specific conditional compilation logic that was duplicated in AuthClient and BiometricSession. - AuthClient now uses AppLifecycle notification names with Combine - BiometricSession uses AppLifecycle.observeBackgroundTransitions() - Removed ~70 lines of duplicated platform-specific code Co-Authored-By: Claude Opus 4.5 --- Sources/Auth/AuthClient.swift | 85 ++++++------- .../Auth/Biometrics/BiometricSession.swift | 43 ++----- Sources/Auth/Internal/AppLifecycle.swift | 118 ++++++++++++++++++ 3 files changed, 162 insertions(+), 84 deletions(-) create mode 100644 Sources/Auth/Internal/AppLifecycle.swift diff --git a/Sources/Auth/AuthClient.swift b/Sources/Auth/AuthClient.swift index d0f250a0d..cb3d2fe5c 100644 --- a/Sources/Auth/AuthClient.swift +++ b/Sources/Auth/AuthClient.swift @@ -167,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/BiometricSession.swift b/Sources/Auth/Biometrics/BiometricSession.swift index e8ef3f0fb..d3d94e950 100644 --- a/Sources/Auth/Biometrics/BiometricSession.swift +++ b/Sources/Auth/Biometrics/BiometricSession.swift @@ -8,14 +8,6 @@ import ConcurrencyExtras import Foundation - #if canImport(UIKit) - import UIKit - #endif - - #if canImport(AppKit) - import AppKit - #endif - /// Tracks biometric authentication state for session-based policies. struct BiometricSession: Sendable { var recordAuthentication: @Sendable () -> Void @@ -30,33 +22,16 @@ let isInBackground = LockIsolated(false) // Subscribe to app lifecycle notifications - #if canImport(UIKit) && !os(watchOS) + #if canImport(ObjectiveC) Task { @MainActor in - NotificationCenter.default.addObserver( - forName: UIApplication.didEnterBackgroundNotification, - object: nil, - queue: .main - ) { _ in - isInBackground.setValue(true) - } - - NotificationCenter.default.addObserver( - forName: UIApplication.willEnterForegroundNotification, - object: nil, - queue: .main - ) { _ in - // Keep isInBackground true until authentication completes - } - } - #elseif canImport(AppKit) - Task { @MainActor in - NotificationCenter.default.addObserver( - forName: NSApplication.didResignActiveNotification, - object: nil, - queue: .main - ) { _ in - isInBackground.setValue(true) - } + AppLifecycle.observeBackgroundTransitions( + onEnterBackground: { + isInBackground.setValue(true) + }, + onEnterForeground: { + // Keep isInBackground true until authentication completes + } + ) } #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 From 3b25bcd09d11686ae3aea5c05c648790d3895d33 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Mon, 2 Feb 2026 07:21:29 -0300 Subject: [PATCH 07/11] test(auth): remove tests that only test mock implementations Removed 18 tests that were just testing mock implementations: - BiometricStorage.mock tests - BiometricSession inline mock tests - BiometricAuthenticator.mock tests Kept 17 tests that test real code: - withBiometrics function tests (with injected dependencies) - AuthClient extension tests (with injected dependencies) - BiometricError, BiometricPolicy, BiometricEvaluationPolicy type tests Co-Authored-By: Claude Opus 4.5 --- Tests/AuthTests/BiometricsTests.swift | 305 ++++---------------------- 1 file changed, 42 insertions(+), 263 deletions(-) diff --git a/Tests/AuthTests/BiometricsTests.swift b/Tests/AuthTests/BiometricsTests.swift index a26603f12..54e90b189 100644 --- a/Tests/AuthTests/BiometricsTests.swift +++ b/Tests/AuthTests/BiometricsTests.swift @@ -23,258 +23,6 @@ localStorage = InMemoryLocalStorage() } - // MARK: - BiometricStorage Tests - - func testBiometricStorage_initiallyDisabled() { - let storage = BiometricStorage.mock - - XCTAssertFalse(storage.isEnabled) - XCTAssertNil(storage.promptTitle) - } - - func testBiometricStorage_enableWithDefaultPolicy() { - let storage = BiometricStorage.mock - - storage.enable(.deviceOwnerAuthenticationWithBiometrics, .default, "Test Title") - - XCTAssertTrue(storage.isEnabled) - XCTAssertEqual(storage.policy, .default) - XCTAssertEqual(storage.evaluationPolicy, .deviceOwnerAuthenticationWithBiometrics) - XCTAssertEqual(storage.promptTitle, "Test Title") - } - - func testBiometricStorage_enableWithAlwaysPolicy() { - let storage = BiometricStorage.mock - - storage.enable(.deviceOwnerAuthentication, .always, "Always Auth") - - XCTAssertTrue(storage.isEnabled) - XCTAssertEqual(storage.policy, .always) - XCTAssertEqual(storage.evaluationPolicy, .deviceOwnerAuthentication) - } - - func testBiometricStorage_enableWithSessionPolicy() { - let storage = BiometricStorage.mock - - storage.enable( - .deviceOwnerAuthenticationWithBiometrics, - .session(timeoutInSeconds: 300), - "Session Auth" - ) - - XCTAssertTrue(storage.isEnabled) - XCTAssertEqual(storage.policy, .session(timeoutInSeconds: 300)) - } - - func testBiometricStorage_enableWithAppLifecyclePolicy() { - let storage = BiometricStorage.mock - - storage.enable(.deviceOwnerAuthenticationWithBiometrics, .appLifecycle, "App Lifecycle Auth") - - XCTAssertTrue(storage.isEnabled) - XCTAssertEqual(storage.policy, .appLifecycle) - } - - func testBiometricStorage_disable() { - let storage = BiometricStorage.mock - - storage.enable(.deviceOwnerAuthenticationWithBiometrics, .default, "Test") - XCTAssertTrue(storage.isEnabled) - - storage.disable() - - XCTAssertFalse(storage.isEnabled) - XCTAssertNil(storage.policy) - XCTAssertNil(storage.evaluationPolicy) - XCTAssertNil(storage.promptTitle) - } - - // MARK: - BiometricSession Tests - - func testBiometricSession_initiallyNoAuthTime() { - let session = BiometricSession.mock - - XCTAssertNil(session.lastAuthenticationTime()) - } - - func testBiometricSession_recordAuthentication() { - let lastAuthTime = LockIsolated(nil) - - let session = BiometricSession( - recordAuthentication: { lastAuthTime.setValue(Date()) }, - reset: { lastAuthTime.setValue(nil) }, - lastAuthenticationTime: { lastAuthTime.value }, - isAuthenticationRequired: { _ in lastAuthTime.value == nil } - ) - - XCTAssertNil(session.lastAuthenticationTime()) - - session.recordAuthentication() - - XCTAssertNotNil(session.lastAuthenticationTime()) - } - - func testBiometricSession_reset() { - let lastAuthTime = LockIsolated(Date()) - - let session = BiometricSession( - recordAuthentication: { lastAuthTime.setValue(Date()) }, - reset: { lastAuthTime.setValue(nil) }, - lastAuthenticationTime: { lastAuthTime.value }, - isAuthenticationRequired: { _ in lastAuthTime.value == nil } - ) - - XCTAssertNotNil(session.lastAuthenticationTime()) - - session.reset() - - XCTAssertNil(session.lastAuthenticationTime()) - } - - func testBiometricSession_defaultPolicy_requiresAuthOnFirstAccess() { - let lastAuthTime = LockIsolated(nil) - - let session = BiometricSession( - recordAuthentication: { lastAuthTime.setValue(Date()) }, - reset: { lastAuthTime.setValue(nil) }, - lastAuthenticationTime: { lastAuthTime.value }, - isAuthenticationRequired: { policy in - switch policy { - case .default: - return lastAuthTime.value == nil - default: - return false - } - } - ) - - XCTAssertTrue(session.isAuthenticationRequired(.default)) - - session.recordAuthentication() - - XCTAssertFalse(session.isAuthenticationRequired(.default)) - } - - func testBiometricSession_alwaysPolicy_alwaysRequiresAuth() { - let session = BiometricSession( - recordAuthentication: {}, - reset: {}, - lastAuthenticationTime: { Date() }, - isAuthenticationRequired: { policy in - switch policy { - case .always: - return true - default: - return false - } - } - ) - - XCTAssertTrue(session.isAuthenticationRequired(.always)) - } - - func testBiometricSession_sessionPolicy_requiresAuthAfterTimeout() { - let lastAuthTime = LockIsolated(Date().addingTimeInterval(-400)) // 400 seconds ago - - let session = BiometricSession( - recordAuthentication: { lastAuthTime.setValue(Date()) }, - reset: { lastAuthTime.setValue(nil) }, - lastAuthenticationTime: { lastAuthTime.value }, - isAuthenticationRequired: { policy in - switch policy { - case .session(let timeout): - guard let lastAuth = lastAuthTime.value else { return true } - return Date().timeIntervalSince(lastAuth) > timeout - default: - return false - } - } - ) - - // With 300 second timeout and 400 seconds elapsed, should require auth - XCTAssertTrue(session.isAuthenticationRequired(.session(timeoutInSeconds: 300))) - - // With 500 second timeout and 400 seconds elapsed, should not require auth - XCTAssertFalse(session.isAuthenticationRequired(.session(timeoutInSeconds: 500))) - } - - func testBiometricSession_sessionPolicy_noAuthTimeRequiresAuth() { - let lastAuthTime = LockIsolated(nil) - - let session = BiometricSession( - recordAuthentication: { lastAuthTime.setValue(Date()) }, - reset: { lastAuthTime.setValue(nil) }, - lastAuthenticationTime: { lastAuthTime.value }, - isAuthenticationRequired: { policy in - switch policy { - case .session: - return lastAuthTime.value == nil - default: - return false - } - } - ) - - XCTAssertTrue(session.isAuthenticationRequired(.session(timeoutInSeconds: 300))) - } - - // MARK: - BiometricAuthenticator Tests - - func testBiometricAuthenticator_mockAvailable() { - let authenticator = BiometricAuthenticator.mock(available: true, biometryType: .faceID) - - let availability = authenticator.checkAvailability() - - XCTAssertTrue(availability.isAvailable) - XCTAssertEqual(availability.biometryType, .faceID) - XCTAssertNil(availability.error) - } - - func testBiometricAuthenticator_mockNotAvailable() { - let authenticator = BiometricAuthenticator.mock( - available: false, - biometryType: .none, - error: .notAvailable(reason: .noBiometryAvailable) - ) - - let availability = authenticator.checkAvailability() - - XCTAssertFalse(availability.isAvailable) - XCTAssertEqual(availability.biometryType, .none) - XCTAssertEqual(availability.error, .notAvailable(reason: .noBiometryAvailable)) - } - - func testBiometricAuthenticator_mockAuthenticationSuccess() async throws { - let authenticator = BiometricAuthenticator.mock(shouldSucceed: true) - - // Should not throw - try await authenticator.authenticate("Test", .deviceOwnerAuthenticationWithBiometrics) - } - - func testBiometricAuthenticator_mockAuthenticationFailure() async { - let authenticator = BiometricAuthenticator.mock( - shouldSucceed: false, - error: .userCancelled - ) - - do { - try await authenticator.authenticate("Test", .deviceOwnerAuthenticationWithBiometrics) - XCTFail("Expected authentication to fail") - } catch let error as BiometricError { - XCTAssertEqual(error, .userCancelled) - } catch { - XCTFail("Unexpected error type: \(error)") - } - } - - func testBiometricAuthenticator_touchID() { - let authenticator = BiometricAuthenticator.mock(available: true, biometryType: .touchID) - - let availability = authenticator.checkAvailability() - - XCTAssertEqual(availability.biometryType, .touchID) - } - // MARK: - withBiometrics Tests func testWithBiometrics_disabled_executesOperationDirectly() async throws { @@ -369,7 +117,12 @@ func testAuthClient_biometricsAvailability() { let client = makeAuthClient( - authenticator: BiometricAuthenticator.mock(available: true, biometryType: .faceID) + authenticator: BiometricAuthenticator( + checkAvailability: { + BiometricAvailability(isAvailable: true, biometryType: .faceID, error: nil) + }, + authenticate: { _, _ in } + ) ) let availability = client.biometricsAvailability() @@ -392,7 +145,12 @@ ) let client = makeAuthClient( - authenticator: BiometricAuthenticator.mock(available: true, shouldSucceed: true), + authenticator: BiometricAuthenticator( + checkAvailability: { + BiometricAvailability(isAvailable: true, biometryType: .faceID, error: nil) + }, + authenticate: { _, _ in } + ), storage: storage ) @@ -407,9 +165,15 @@ func testAuthClient_enableBiometrics_notAvailable_throws() async { let client = makeAuthClient( - authenticator: BiometricAuthenticator.mock( - available: false, - error: .notAvailable(reason: .noBiometryAvailable) + authenticator: BiometricAuthenticator( + checkAvailability: { + BiometricAvailability( + isAvailable: false, + biometryType: .none, + error: .notAvailable(reason: .noBiometryAvailable) + ) + }, + authenticate: { _, _ in } ) ) @@ -429,10 +193,13 @@ func testAuthClient_enableBiometrics_authFails_throws() async { let client = makeAuthClient( - authenticator: BiometricAuthenticator.mock( - available: true, - shouldSucceed: false, - error: .userCancelled + authenticator: BiometricAuthenticator( + checkAvailability: { + BiometricAvailability(isAvailable: true, biometryType: .faceID, error: nil) + }, + authenticate: { _, _ in + throw BiometricError.userCancelled + } ) ) @@ -556,7 +323,13 @@ session ?? makeBiometricSession(authRequired: authRequired, onRecordAuth: sessionRecordAuth) let finalAuthenticator = - authenticator ?? BiometricAuthenticator.mock(available: true, shouldSucceed: true) + authenticator + ?? BiometricAuthenticator( + checkAvailability: { + BiometricAvailability(isAvailable: true, biometryType: .faceID, error: nil) + }, + authenticate: { _, _ in } + ) // Create AuthClient first - this sets up dependencies let configuration = AuthClient.Configuration( @@ -595,7 +368,13 @@ storage ?? makeBiometricStorage(isEnabled: biometricsEnabled, policy: policy) let finalSession = session ?? makeBiometricSession(authRequired: authRequired) let finalAuthenticator = - authenticator ?? BiometricAuthenticator.mock(available: true, shouldSucceed: true) + authenticator + ?? BiometricAuthenticator( + checkAvailability: { + BiometricAvailability(isAvailable: true, biometryType: .faceID, error: nil) + }, + authenticate: { _, _ in } + ) Dependencies[client.clientID].biometricAuthenticator = finalAuthenticator Dependencies[client.clientID].biometricStorage = finalStorage From 1f2a72fa28c8d663786845ac65bc62c079f504be Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Mon, 2 Feb 2026 08:08:03 -0300 Subject: [PATCH 08/11] feat(examples): add biometrics usage examples Add comprehensive biometrics examples to the Examples app: - BiometricsExample.swift: Standalone example demonstrating all biometrics features including availability check, policy configuration, session access testing, and enable/disable flows - ProfileView: Add biometrics configuration to the Security section, allowing users to enable/disable biometrics after login - AuthExamplesView: Add navigation link to biometrics example Also change BiometricPolicy from Equatable to Hashable for SwiftUI Picker compatibility. Co-Authored-By: Claude Opus 4.5 --- Examples/Examples/Auth/AuthExamplesView.swift | 12 + .../Examples/Auth/BiometricsExample.swift | 374 ++++++++++++++++++ Examples/Examples/Profile/ProfileView.swift | 279 +++++++++++++ Sources/Auth/Biometrics/BiometricTypes.swift | 2 +- 4 files changed, 666 insertions(+), 1 deletion(-) create mode 100644 Examples/Examples/Auth/BiometricsExample.swift 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/Biometrics/BiometricTypes.swift b/Sources/Auth/Biometrics/BiometricTypes.swift index 99722e4aa..c86d9fe6d 100644 --- a/Sources/Auth/Biometrics/BiometricTypes.swift +++ b/Sources/Auth/Biometrics/BiometricTypes.swift @@ -9,7 +9,7 @@ import LocalAuthentication /// Policy determining when biometric authentication is required. - public enum BiometricPolicy: Sendable, Equatable { + public enum BiometricPolicy: Sendable, Hashable { /// Biometric authentication is required on first access only. /// After successful authentication, no further prompts until app terminates. case `default` From 00cece9fe75d53342627e736f2b9680c6d25151a Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Tue, 3 Feb 2026 17:02:43 -0300 Subject: [PATCH 09/11] refactor(auth): remove biometrics auth state events Remove BIOMETRICS_ENABLED and BIOMETRICS_DISABLED events from AuthChangeEvent as biometric state changes are local-only and don't need to be part of the auth state change stream. Co-Authored-By: Claude Opus 4.5 --- Sources/Auth/Biometrics/AuthClient+Biometrics.swift | 4 ---- Sources/Auth/Types.swift | 2 -- 2 files changed, 6 deletions(-) diff --git a/Sources/Auth/Biometrics/AuthClient+Biometrics.swift b/Sources/Auth/Biometrics/AuthClient+Biometrics.swift index 990468773..47f2b8af4 100644 --- a/Sources/Auth/Biometrics/AuthClient+Biometrics.swift +++ b/Sources/Auth/Biometrics/AuthClient+Biometrics.swift @@ -53,9 +53,6 @@ // Update session timestamp Dependencies[clientID].biometricSession.recordAuthentication() - - // Emit event - Dependencies[clientID].eventEmitter.emit(.biometricsEnabled, session: currentSession) } /// Disable biometric protection for session retrieval. @@ -64,7 +61,6 @@ nonisolated public func disableBiometrics() { Dependencies[clientID].biometricStorage.disable() Dependencies[clientID].biometricSession.reset() - Dependencies[clientID].eventEmitter.emit(.biometricsDisabled, session: currentSession) } /// Check if biometric authentication would be required on next session access. diff --git a/Sources/Auth/Types.swift b/Sources/Auth/Types.swift index 014805aad..beb2ece83 100644 --- a/Sources/Auth/Types.swift +++ b/Sources/Auth/Types.swift @@ -9,8 +9,6 @@ public enum AuthChangeEvent: String, Sendable { case userUpdated = "USER_UPDATED" case userDeleted = "USER_DELETED" case mfaChallengeVerified = "MFA_CHALLENGE_VERIFIED" - case biometricsEnabled = "BIOMETRICS_ENABLED" - case biometricsDisabled = "BIOMETRICS_DISABLED" } @available( From 59dc67a4fce00b97b0510ff2285859dfff81db06 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Wed, 4 Feb 2026 05:29:59 -0300 Subject: [PATCH 10/11] refactor(auth): simplify biometric error types Remove redundant error cases from BiometricError and BiometricUnavailableReason: - Remove notEnabled from BiometricError (handled differently) - Remove biometryNotEnrolled, biometryLockout, other from BiometricUnavailableReason (these are covered by notAvailable or other error paths) Co-Authored-By: Claude Opus 4.5 --- Sources/Auth/Biometrics/BiometricTypes.swift | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/Sources/Auth/Biometrics/BiometricTypes.swift b/Sources/Auth/Biometrics/BiometricTypes.swift index c86d9fe6d..725c858de 100644 --- a/Sources/Auth/Biometrics/BiometricTypes.swift +++ b/Sources/Auth/Biometrics/BiometricTypes.swift @@ -79,9 +79,6 @@ /// Biometric authentication was locked out due to too many failed attempts. case lockedOut - /// Biometrics are not enabled for this client. - case notEnabled - public var errorDescription: String? { switch self { case .notAvailable(let reason): @@ -94,8 +91,6 @@ return "No biometrics enrolled on this device." case .lockedOut: return "Biometrics locked out due to too many failed attempts." - case .notEnabled: - return "Biometrics are not enabled for this client." } } } @@ -105,30 +100,15 @@ /// No biometric hardware available on this device. case noBiometryAvailable - /// Biometrics are not enrolled (no Face ID / Touch ID set up). - case biometryNotEnrolled - - /// Biometrics are temporarily locked out due to too many failed attempts. - case biometryLockout - /// Device passcode is not set. case passcodeNotSet - /// Other error with a specific code. - case other(code: Int) - var localizedDescription: String { switch self { case .noBiometryAvailable: return "No biometric hardware available." - case .biometryNotEnrolled: - return "No biometrics enrolled." - case .biometryLockout: - return "Biometrics temporarily unavailable due to lockout." case .passcodeNotSet: return "Device passcode is not set." - case .other(let code): - return "Unknown error (code: \(code))." } } } From 92821b39c69ca5bf02b673e8685ec051862299f9 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Wed, 4 Feb 2026 05:31:33 -0300 Subject: [PATCH 11/11] test(auth): update tests for simplified biometric error types Remove tests for removed error cases to match the simplified BiometricError and BiometricUnavailableReason enums. Co-Authored-By: Claude Opus 4.5 --- Tests/AuthTests/BiometricsTests.swift | 4 ---- 1 file changed, 4 deletions(-) diff --git a/Tests/AuthTests/BiometricsTests.swift b/Tests/AuthTests/BiometricsTests.swift index 54e90b189..d3f879937 100644 --- a/Tests/AuthTests/BiometricsTests.swift +++ b/Tests/AuthTests/BiometricsTests.swift @@ -262,17 +262,13 @@ XCTAssertNotNil(BiometricError.userCancelled.errorDescription) XCTAssertNotNil(BiometricError.notEnrolled.errorDescription) XCTAssertNotNil(BiometricError.lockedOut.errorDescription) - XCTAssertNotNil(BiometricError.notEnabled.errorDescription) XCTAssertNotNil(BiometricError.authenticationFailed(message: "test").errorDescription) XCTAssertNotNil(BiometricError.notAvailable(reason: .noBiometryAvailable).errorDescription) } func testBiometricUnavailableReason_descriptions() { XCTAssertFalse(BiometricUnavailableReason.noBiometryAvailable.localizedDescription.isEmpty) - XCTAssertFalse(BiometricUnavailableReason.biometryNotEnrolled.localizedDescription.isEmpty) - XCTAssertFalse(BiometricUnavailableReason.biometryLockout.localizedDescription.isEmpty) XCTAssertFalse(BiometricUnavailableReason.passcodeNotSet.localizedDescription.isEmpty) - XCTAssertFalse(BiometricUnavailableReason.other(code: 123).localizedDescription.isEmpty) } // MARK: - BiometricPolicy Equality Tests