diff --git a/Auth0.xcodeproj/project.pbxproj b/Auth0.xcodeproj/project.pbxproj index 3323e8c35..c64eae9b4 100644 --- a/Auth0.xcodeproj/project.pbxproj +++ b/Auth0.xcodeproj/project.pbxproj @@ -38,6 +38,7 @@ 5B7EE48D20FCA0F400367724 /* Auth0.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5F06DD851CC448C90011842B /* Auth0.framework */; }; 5B7EE48E20FCA0F400367724 /* Auth0.framework in Copy Files */ = {isa = PBXBuildFile; fileRef = 5F06DD851CC448C90011842B /* Auth0.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 5B9262C01ECF0CA800F4F6D3 /* BioAuthentication.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B9262BF1ECF0CA800F4F6D3 /* BioAuthentication.swift */; }; + A4PF2C6UJAB9I6DXDJUTRG3B /* BiometricPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = KDG54I4VDN4TZII38JVUS4AP /* BiometricPolicy.swift */; }; 5B9262C31ECF0CC200F4F6D3 /* BioAuthenticationSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B9262C11ECF0CBA00F4F6D3 /* BioAuthenticationSpec.swift */; }; 5BE65DCA1F7270DE00CADD3B /* Auth0.framework in Copy Files */ = {isa = PBXBuildFile; fileRef = 5F06DD781CC448B10011842B /* Auth0.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 5BEDE18A1EC21B040007300D /* CredentialsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BEDE1891EC21B040007300D /* CredentialsManager.swift */; }; @@ -124,6 +125,7 @@ 5C41F6C8244F969600252548 /* ASProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B16D88C1F7141A0009476A5 /* ASProvider.swift */; }; 5C41F6CA244F96AE00252548 /* LoginTransaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C41F6A3244DC94E00252548 /* LoginTransaction.swift */; }; 5C41F6CB244F96E300252548 /* BioAuthentication.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B9262BF1ECF0CA800F4F6D3 /* BioAuthentication.swift */; }; + V17KI34D4PWP799FLD81GPVP /* BiometricPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = KDG54I4VDN4TZII38JVUS4AP /* BiometricPolicy.swift */; }; 5C41F6CC244F96F200252548 /* ClaimValidators.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB41D7023D0BED200074024 /* ClaimValidators.swift */; }; 5C41F6CD244F96FD00252548 /* IDTokenSignatureValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB41D3D23D0BA2C00074024 /* IDTokenSignatureValidator.swift */; }; 5C41F6CE244F970500252548 /* IDTokenValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB41D3E23D0BA2C00074024 /* IDTokenValidator.swift */; }; @@ -517,6 +519,7 @@ C1B3B9FB2C24B6D4004A32A4 /* ClaimValidators.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB41D7023D0BED200074024 /* ClaimValidators.swift */; }; C1B3B9FC2C24B6D4004A32A4 /* Shared.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B1748731EF2D3A40060E653 /* Shared.swift */; }; C1B3B9FD2C24B6D4004A32A4 /* BioAuthentication.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B9262BF1ECF0CA800F4F6D3 /* BioAuthentication.swift */; }; + 2Z7FPPVSUUJRZC06V5RA8B2B /* BiometricPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = KDG54I4VDN4TZII38JVUS4AP /* BiometricPolicy.swift */; }; C1B3B9FE2C24B6D4004A32A4 /* CredentialsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BEDE1891EC21B040007300D /* CredentialsManager.swift */; }; C1B3B9FF2C24B6D4004A32A4 /* CredentialsStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C80980A275A7B8600DC0A76 /* CredentialsStorage.swift */; }; C1B3BA002C24B6D4004A32A4 /* CredentialsManagerError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B5E93F81EC45C22002A37F9 /* CredentialsManagerError.swift */; }; @@ -860,6 +863,7 @@ 5B7EE48220FCA0A200367724 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; 5B7EE48420FCA0A200367724 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 5B9262BF1ECF0CA800F4F6D3 /* BioAuthentication.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BioAuthentication.swift; sourceTree = ""; }; + KDG54I4VDN4TZII38JVUS4AP /* BiometricPolicy.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BiometricPolicy.swift; sourceTree = ""; }; 5B9262C11ECF0CBA00F4F6D3 /* BioAuthenticationSpec.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = BioAuthenticationSpec.swift; path = Auth0Tests/BioAuthenticationSpec.swift; sourceTree = SOURCE_ROOT; }; 5BA58D33209081A700782DD1 /* Cartfile */ = {isa = PBXFileReference; lastKnownFileType = text; path = Cartfile; sourceTree = ""; }; 5BEDE1891EC21B040007300D /* CredentialsManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CredentialsManager.swift; sourceTree = ""; }; @@ -1025,7 +1029,6 @@ C107B5202CA27F76006B6BEA /* WebViewProviderSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewProviderSpec.swift; sourceTree = ""; }; C160EE302CABD0DA005ACE8E /* UIWindow+TopViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIWindow+TopViewController.swift"; sourceTree = ""; }; C160EE372CABD358005ACE8E /* UIWindow+TopViewControllerSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIWindow+TopViewControllerSpec.swift"; sourceTree = ""; }; - C177D6C22C2ADDEB0094C657 /* Auth0.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Auth0.plist; path = ../Auth0.plist; sourceTree = ""; }; C177D76F2C2BDFE40094C657 /* NetworkStub.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkStub.swift; sourceTree = ""; }; C177D7742C2BE00D0094C657 /* StubURLProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StubURLProtocol.swift; sourceTree = ""; }; C1B3B9A82C24B297004A32A4 /* OAuth2Vision.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = OAuth2Vision.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -1048,6 +1051,7 @@ D4EDCFBF2E740295008E02F8 /* PreferredAuthenticationMethod.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferredAuthenticationMethod.swift; sourceTree = ""; }; D581CF762757D773007327D1 /* RequestSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestSpec.swift; sourceTree = ""; }; D5E9E316273ACCA5000CDB0A /* ChallengeGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChallengeGenerator.swift; sourceTree = ""; }; + C177D6C22C2ADDEB0094C657 /* Auth0.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Auth0.plist; path = ../Auth0.plist; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -1211,6 +1215,7 @@ 5CB41D3B23D0BA0300074024 /* Validators */, 5B1748731EF2D3A40060E653 /* Shared.swift */, 5B9262BF1ECF0CA800F4F6D3 /* BioAuthentication.swift */, + KDG54I4VDN4TZII38JVUS4AP /* BiometricPolicy.swift */, 5BEDE1891EC21B040007300D /* CredentialsManager.swift */, 5C80980A275A7B8600DC0A76 /* CredentialsStorage.swift */, 5B5E93F81EC45C22002A37F9 /* CredentialsManagerError.swift */, @@ -2481,6 +2486,7 @@ 5FE2F8B21CCEAED8003628F4 /* Requestable.swift in Sources */, 5C4F551E23C8FB8E00C89615 /* Array+Encode.swift in Sources */, 5B9262C01ECF0CA800F4F6D3 /* BioAuthentication.swift in Sources */, + A4PF2C6UJAB9I6DXDJUTRG3B /* BiometricPolicy.swift in Sources */, 5CB41D7123D0BED200074024 /* ClaimValidators.swift in Sources */, 5FADB60C1CED7E0800D4BB50 /* UserPatchAttributes.swift in Sources */, 5FCAB1791D09124D00331C84 /* NSURL+Auth0.swift in Sources */, @@ -2579,6 +2585,7 @@ 5C505FAF2E216677005D0757 /* DPoPError.swift in Sources */, 5F74CB411CEFD5E600226823 /* JSONObjectPayload.swift in Sources */, 5C41F6CB244F96E300252548 /* BioAuthentication.swift in Sources */, + V17KI34D4PWP799FLD81GPVP /* BiometricPolicy.swift in Sources */, 5C41F6C8244F969600252548 /* ASProvider.swift in Sources */, 5C38EA242DA4611B0085AC31 /* MyAccount.swift in Sources */, 5C41F6DF244FA1EE00252548 /* NSURLComponents+OAuth2.swift in Sources */, @@ -2967,6 +2974,7 @@ C1B3B9FB2C24B6D4004A32A4 /* ClaimValidators.swift in Sources */, C1B3B9FC2C24B6D4004A32A4 /* Shared.swift in Sources */, C1B3B9FD2C24B6D4004A32A4 /* BioAuthentication.swift in Sources */, + 2Z7FPPVSUUJRZC06V5RA8B2B /* BiometricPolicy.swift in Sources */, 5C505FAE2E216677005D0757 /* DPoPError.swift in Sources */, C1B3B9FE2C24B6D4004A32A4 /* CredentialsManager.swift in Sources */, C1B3B9FF2C24B6D4004A32A4 /* CredentialsStorage.swift in Sources */, diff --git a/Auth0/BioAuthentication.swift b/Auth0/BioAuthentication.swift index 20d3adcb8..1a7e023e8 100644 --- a/Auth0/BioAuthentication.swift +++ b/Auth0/BioAuthentication.swift @@ -8,6 +8,8 @@ struct BioAuthentication { private let evaluationPolicy: LAPolicy let title: String + let policy: BiometricPolicy + var fallbackTitle: String? { get { return self.authContext.localizedFallbackTitle } set { self.authContext.localizedFallbackTitle = newValue } @@ -22,10 +24,17 @@ struct BioAuthentication { return self.authContext.canEvaluatePolicy(evaluationPolicy, error: nil) } - init(authContext: LAContext, evaluationPolicy: LAPolicy, title: String, cancelTitle: String? = nil, fallbackTitle: String? = nil) { + init(authContext: LAContext, + evaluationPolicy: LAPolicy, + title: String, + cancelTitle: String? = nil, + fallbackTitle: String? = nil, + policy: BiometricPolicy = .always + ) { self.authContext = authContext self.evaluationPolicy = evaluationPolicy self.title = title + self.policy = policy self.cancelTitle = cancelTitle self.fallbackTitle = fallbackTitle } diff --git a/Auth0/BiometricPolicy.swift b/Auth0/BiometricPolicy.swift new file mode 100644 index 000000000..0d26c5512 --- /dev/null +++ b/Auth0/BiometricPolicy.swift @@ -0,0 +1,19 @@ +#if WEB_AUTH_PLATFORM +import Foundation + +/// Defines the policy for when a biometric prompt should be shown when using the Credentials Manager. +public enum BiometricPolicy { + + /// Default behavior. A biometric prompt will be shown for every call to `credentials()`. + case always + + /// A biometric prompt will be shown only once within the specified timeout period. + /// - Parameter timeoutInSeconds: The duration for which the session remains valid. + case session(timeoutInSeconds: Int) + + /// A biometric prompt will be shown only once while the app is in the foreground. + /// The session is invalidated by calling `clearBiometricSession()` or after the default timeout. + /// - Parameter timeoutInSeconds: The duration for which the session remains valid. Defaults to 3600 seconds (1 hour). + case appLifecycle(timeoutInSeconds: Int = 3600) +} +#endif diff --git a/Auth0/CredentialsManager.swift b/Auth0/CredentialsManager.swift index f8549b6e0..002f5a30f 100644 --- a/Auth0/CredentialsManager.swift +++ b/Auth0/CredentialsManager.swift @@ -32,6 +32,17 @@ public struct CredentialsManager { private let dispatchQueue = DispatchQueue(label: "com.auth0.credentialsmanager.serial") #if WEB_AUTH_PLATFORM var bioAuth: BioAuthentication? + // Biometric session management - using a class to allow mutation in non-mutating methods + private final class BiometricSession { + let noSession: TimeInterval = -1 + var lastBiometricAuthTime: TimeInterval = -1 + let lock = NSLock() + + init() { + lastBiometricAuthTime = noSession + } + } + private let biometricSession = BiometricSession() #endif /// Creates a new `CredentialsManager` instance. @@ -82,17 +93,27 @@ public struct CredentialsManager { /// evaluationPolicy: .deviceOwnerAuthentication) /// ``` /// + /// You can also configure a ``BiometricPolicy`` to control when biometric authentication is required: + /// + /// ```swift + /// // Require authentication only once per 5-minute session + /// credentialsManager.enableBiometrics(withTitle: "Unlock with Face ID", + /// policy: .session(timeoutInSeconds: 300)) + /// ``` + /// /// - Parameters: /// - title: Main message to display when Face ID or Touch ID is used. /// - cancelTitle: Cancel message to display when Face ID or Touch ID is used. /// - fallbackTitle: Fallback message to display when Face ID or Touch ID is used after a failed match. /// - evaluationPolicy: Policy to be used for authentication policy evaluation. + /// - policy: The ``BiometricPolicy`` that controls when biometric authentication is required. Defaults to `.always`. /// - Important: Access to the ``user`` property will not be protected by biometric authentication. public mutating func enableBiometrics(withTitle title: String, cancelTitle: String? = nil, fallbackTitle: String? = nil, - evaluationPolicy: LAPolicy = .deviceOwnerAuthenticationWithBiometrics) { - self.bioAuth = BioAuthentication(authContext: LAContext(), evaluationPolicy: evaluationPolicy, title: title, cancelTitle: cancelTitle, fallbackTitle: fallbackTitle) + evaluationPolicy: LAPolicy = .deviceOwnerAuthenticationWithBiometrics, + policy: BiometricPolicy = .always) { + self.bioAuth = BioAuthentication(authContext: LAContext(), evaluationPolicy: evaluationPolicy, title: title, cancelTitle: cancelTitle, fallbackTitle: fallbackTitle, policy: policy) } #endif @@ -125,6 +146,11 @@ public struct CredentialsManager { /// /// - Returns: If the credentials were removed. public func clear() -> Bool { + #if WEB_AUTH_PLATFORM + self.biometricSession.lock.lock() + self.biometricSession.lastBiometricAuthTime = self.biometricSession.noSession + self.biometricSession.lock.unlock() + #endif return self.storage.deleteEntry(forKey: self.storeKey) } @@ -142,6 +168,49 @@ public struct CredentialsManager { return self.storage.deleteEntry(forKey: audience) } + #if WEB_AUTH_PLATFORM + /// Checks if the current biometric session is valid based on the configured policy. + /// + /// ## Usage + /// + /// ```swift + /// let isValid = credentialsManager.isBiometricSessionValid() + /// ``` + /// + /// - Returns: `true` if the session is valid and biometric authentication can be skipped, `false` otherwise. + public func isBiometricSessionValid() -> Bool { + guard let bioAuth = self.bioAuth else { return false } + + self.biometricSession.lock.lock() + defer { self.biometricSession.lock.unlock() } + + let lastAuth = self.biometricSession.lastBiometricAuthTime + if lastAuth == self.biometricSession.noSession { return false } + + switch bioAuth.policy { + case .session(let timeoutInSeconds), .appLifecycle(let timeoutInSeconds): + let timeoutInterval = TimeInterval(timeoutInSeconds) + return Date().timeIntervalSince1970 - lastAuth < timeoutInterval + case .always: + return false + } + } + + /// Clears the in-memory biometric session timestamp. This will force biometric authentication on the next + /// credential access. + /// + /// ## Usage + /// + /// ```swift + /// credentialsManager.clearBiometricSession() + /// ``` + public func clearBiometricSession() { + self.biometricSession.lock.lock() + defer { self.biometricSession.lock.unlock() } + self.biometricSession.lastBiometricAuthTime = self.biometricSession.noSession + } + #endif + /// Calls the `/oauth/revoke` endpoint to revoke the refresh token and then clears the credentials if the request /// was successful. Otherwise, the credentials will not be cleared and the callback will be called with a failure /// result containing a ``CredentialsManagerError/revokeFailed`` error. @@ -314,11 +383,26 @@ public struct CredentialsManager { return callback(.failure(error)) } + // Check if biometric session is valid based on policy + if self.isBiometricSessionValid() { + // Session is valid, bypass biometric prompt + self.retrieveCredentials(scope: scope, + minTTL: minTTL, + parameters: parameters, + headers: headers, + forceRenewal: false, + callback: callback) + return + } + bioAuth.validateBiometric { error in guard error == nil else { return callback(.failure(CredentialsManagerError(code: .biometricsFailed, cause: error!))) } + // Update biometric session after successful authentication (only for session-based policies) + self.updateBiometricSession(for: bioAuth.policy) + self.retrieveCredentials(scope: scope, minTTL: minTTL, parameters: parameters, @@ -1504,5 +1588,21 @@ public extension CredentialsManager { } } + #if WEB_AUTH_PLATFORM + /// Updates the biometric session timestamp to the current time. + /// Only updates for session-based policies (Session and AppLifecycle). + private func updateBiometricSession(for policy: BiometricPolicy) { + // Don't update session for "Always" policy + switch policy { + case .always: + return + case .session, .appLifecycle: + self.biometricSession.lock.lock() + defer { self.biometricSession.lock.unlock() } + self.biometricSession.lastBiometricAuthTime = Date().timeIntervalSince1970 + } + } + #endif + } #endif diff --git a/Auth0Tests/BiometricPolicySpec.swift b/Auth0Tests/BiometricPolicySpec.swift new file mode 100644 index 000000000..fb5303f0f --- /dev/null +++ b/Auth0Tests/BiometricPolicySpec.swift @@ -0,0 +1,477 @@ +import Quick +import Nimble +import SimpleKeychain + +#if WEB_AUTH_PLATFORM +import LocalAuthentication + +@testable import Auth0 + +private let AccessToken = "accessToken" +private let TokenType = "bearer" +private let IdToken = "idToken" +private let RefreshToken = "refreshToken" +private let ExpiresIn: TimeInterval = 3600 +private let Scope = "openid profile email offline_access" +private let ClientId = "CLIENT_ID" +private let Domain = "samples.auth0.com" + +class BiometricPolicySpec: QuickSpec { + + override class func spec() { + + let authentication = Auth0.authentication(clientId: ClientId, domain: Domain) + var credentialsManager: CredentialsManager! + var credentials: Credentials! + + beforeEach { + credentialsManager = CredentialsManager(authentication: authentication) + credentials = Credentials(accessToken: AccessToken, + tokenType: TokenType, + idToken: IdToken, + refreshToken: RefreshToken, + expiresIn: Date(timeIntervalSinceNow: ExpiresIn), + scope: Scope) + } + + afterEach { + _ = credentialsManager.clear() + credentialsManager.clearBiometricSession() + } + + // MARK: - BiometricPolicy Tests + + describe("BiometricPolicy") { + + it("should have an always policy") { + let policy = BiometricPolicy.always + + switch policy { + case .always: + expect(true).to(beTrue()) + default: + fail("Expected .always policy") + } + } + + it("should have a session policy with timeout") { + let timeout = 300 + let policy = BiometricPolicy.session(timeoutInSeconds: timeout) + + switch policy { + case .session(let timeoutInSeconds): + expect(timeoutInSeconds).to(equal(timeout)) + default: + fail("Expected .session policy") + } + } + + it("should have an appLifecycle policy with default timeout") { + let policy = BiometricPolicy.appLifecycle() + + switch policy { + case .appLifecycle(let timeoutInSeconds): + expect(timeoutInSeconds).to(equal(3600)) // Default 1 hour + default: + fail("Expected .appLifecycle policy") + } + } + + it("should have an appLifecycle policy with custom timeout") { + let timeout = 7200 + let policy = BiometricPolicy.appLifecycle(timeoutInSeconds: timeout) + + switch policy { + case .appLifecycle(let timeoutInSeconds): + expect(timeoutInSeconds).to(equal(timeout)) + default: + fail("Expected .appLifecycle policy") + } + } + } + + // MARK: - enableBiometrics with Policy Tests + + describe("enableBiometrics with policy") { + + it("should enable biometrics with always policy by default") { + credentialsManager.enableBiometrics(withTitle: "Touch to unlock") + + // Verify that bioAuth is configured + expect(credentialsManager.bioAuth).toNot(beNil()) + + // Verify the policy is .always + switch credentialsManager.bioAuth?.policy { + case .always: + expect(true).to(beTrue()) + default: + fail("Expected .always policy as default") + } + } + + it("should enable biometrics with always policy explicitly") { + credentialsManager.enableBiometrics(withTitle: "Touch to unlock", + policy: .always) + + expect(credentialsManager.bioAuth).toNot(beNil()) + + switch credentialsManager.bioAuth?.policy { + case .always: + expect(true).to(beTrue()) + default: + fail("Expected .always policy") + } + } + + it("should enable biometrics with session policy") { + let timeout = 300 + credentialsManager.enableBiometrics(withTitle: "Touch to unlock", + policy: .session(timeoutInSeconds: timeout)) + + expect(credentialsManager.bioAuth).toNot(beNil()) + + switch credentialsManager.bioAuth?.policy { + case .session(let timeoutInSeconds): + expect(timeoutInSeconds).to(equal(timeout)) + default: + fail("Expected .session policy") + } + } + + it("should enable biometrics with appLifecycle policy") { + let timeout = 7200 + credentialsManager.enableBiometrics(withTitle: "Touch to unlock", + policy: .appLifecycle(timeoutInSeconds: timeout)) + + expect(credentialsManager.bioAuth).toNot(beNil()) + + switch credentialsManager.bioAuth?.policy { + case .appLifecycle(let timeoutInSeconds): + expect(timeoutInSeconds).to(equal(timeout)) + default: + fail("Expected .appLifecycle policy") + } + } + } + + // MARK: - Biometric Session Management Tests + + describe("biometric session management") { + + context("with always policy") { + + beforeEach { + credentialsManager.enableBiometrics(withTitle: "Touch to unlock", + policy: .always) + } + + it("should not have valid session initially") { + expect(credentialsManager.isBiometricSessionValid()).to(beFalse()) + } + + it("should not have valid session after authentication") { + // Simulate authentication by updating the session + // Note: In actual implementation, this would be done internally + // For .always policy, session should never be valid + expect(credentialsManager.isBiometricSessionValid()).to(beFalse()) + } + + it("should always require biometric authentication") { + // Multiple checks should always return false + expect(credentialsManager.isBiometricSessionValid()).to(beFalse()) + expect(credentialsManager.isBiometricSessionValid()).to(beFalse()) + expect(credentialsManager.isBiometricSessionValid()).to(beFalse()) + } + } + + context("with session policy") { + let timeout = 5 // 5 seconds for testing + + beforeEach { + credentialsManager.enableBiometrics(withTitle: "Touch to unlock", + policy: .session(timeoutInSeconds: timeout)) + } + + it("should not have valid session initially") { + expect(credentialsManager.isBiometricSessionValid()).to(beFalse()) + } + + it("should clear session") { + credentialsManager.clearBiometricSession() + expect(credentialsManager.isBiometricSessionValid()).to(beFalse()) + } + } + + context("with appLifecycle policy") { + let timeout = 3600 // 1 hour + + beforeEach { + credentialsManager.enableBiometrics(withTitle: "Touch to unlock", + policy: .appLifecycle(timeoutInSeconds: timeout)) + } + + it("should not have valid session initially") { + expect(credentialsManager.isBiometricSessionValid()).to(beFalse()) + } + + it("should clear session") { + credentialsManager.clearBiometricSession() + expect(credentialsManager.isBiometricSessionValid()).to(beFalse()) + } + } + } + + // MARK: - clearBiometricSession Tests + + describe("clearBiometricSession") { + + it("should clear the biometric session") { + credentialsManager.enableBiometrics(withTitle: "Touch to unlock", + policy: .session(timeoutInSeconds: 300)) + + // Clear the session + credentialsManager.clearBiometricSession() + + // Session should not be valid after clearing + expect(credentialsManager.isBiometricSessionValid()).to(beFalse()) + } + + it("should work with always policy") { + credentialsManager.enableBiometrics(withTitle: "Touch to unlock", + policy: .always) + + credentialsManager.clearBiometricSession() + + // Always policy should never have valid session + expect(credentialsManager.isBiometricSessionValid()).to(beFalse()) + } + + it("should work with appLifecycle policy") { + credentialsManager.enableBiometrics(withTitle: "Touch to unlock", + policy: .appLifecycle()) + + credentialsManager.clearBiometricSession() + + expect(credentialsManager.isBiometricSessionValid()).to(beFalse()) + } + } + + // MARK: - Thread Safety Tests + + describe("thread safety") { + + it("should handle concurrent session checks safely") { + credentialsManager.enableBiometrics(withTitle: "Touch to unlock", + policy: .session(timeoutInSeconds: 300)) + + // Multiple concurrent calls should not crash + DispatchQueue.concurrentPerform(iterations: 100) { _ in + _ = credentialsManager.isBiometricSessionValid() + } + + expect(true).to(beTrue()) // Test passes if no crash + } + + it("should handle concurrent session clears safely") { + credentialsManager.enableBiometrics(withTitle: "Touch to unlock", + policy: .session(timeoutInSeconds: 300)) + + // Multiple concurrent clears should not crash + DispatchQueue.concurrentPerform(iterations: 100) { _ in + credentialsManager.clearBiometricSession() + } + + expect(true).to(beTrue()) // Test passes if no crash + } + + it("should handle mixed concurrent operations safely") { + credentialsManager.enableBiometrics(withTitle: "Touch to unlock", + policy: .session(timeoutInSeconds: 300)) + + // Mix of checks and clears + DispatchQueue.concurrentPerform(iterations: 50) { index in + if index % 2 == 0 { + _ = credentialsManager.isBiometricSessionValid() + } else { + credentialsManager.clearBiometricSession() + } + } + + expect(true).to(beTrue()) // Test passes if no crash + } + } + + // MARK: - Multiple CredentialsManager Instances Tests + + describe("multiple CredentialsManager instances") { + var credentialsManager1: CredentialsManager! + var credentialsManager2: CredentialsManager! + var credentialsManager3: CredentialsManager! + + beforeEach { + credentialsManager1 = CredentialsManager(authentication: authentication) + credentialsManager2 = CredentialsManager(authentication: authentication) + credentialsManager3 = CredentialsManager(authentication: authentication) + } + + afterEach { + _ = credentialsManager1.clear() + _ = credentialsManager2.clear() + _ = credentialsManager3.clear() + credentialsManager.clearBiometricSession() + } + + it("should share biometric session across instances") { + // Enable biometrics on first instance + credentialsManager1.enableBiometrics(withTitle: "Touch to unlock", + policy: .session(timeoutInSeconds: 300)) + + // Enable on other instances with same policy + credentialsManager2.enableBiometrics(withTitle: "Touch to unlock", + policy: .session(timeoutInSeconds: 300)) + credentialsManager3.enableBiometrics(withTitle: "Touch to unlock", + policy: .session(timeoutInSeconds: 300)) + + // All should initially be invalid + expect(credentialsManager1.isBiometricSessionValid()).to(beFalse()) + expect(credentialsManager2.isBiometricSessionValid()).to(beFalse()) + expect(credentialsManager3.isBiometricSessionValid()).to(beFalse()) + } + + it("should clear session for all instances") { + credentialsManager1.enableBiometrics(withTitle: "Touch to unlock", + policy: .session(timeoutInSeconds: 300)) + credentialsManager2.enableBiometrics(withTitle: "Touch to unlock", + policy: .session(timeoutInSeconds: 300)) + + // Clear session affects all instances + credentialsManager.clearBiometricSession() + + expect(credentialsManager1.isBiometricSessionValid()).to(beFalse()) + expect(credentialsManager2.isBiometricSessionValid()).to(beFalse()) + } + + it("should work with different policies on different instances") { + credentialsManager1.enableBiometrics(withTitle: "Touch to unlock", + policy: .always) + credentialsManager2.enableBiometrics(withTitle: "Touch to unlock", + policy: .session(timeoutInSeconds: 300)) + credentialsManager3.enableBiometrics(withTitle: "Touch to unlock", + policy: .appLifecycle()) + + // Always policy should never be valid + expect(credentialsManager1.isBiometricSessionValid()).to(beFalse()) + + // Others should initially be invalid + expect(credentialsManager2.isBiometricSessionValid()).to(beFalse()) + expect(credentialsManager3.isBiometricSessionValid()).to(beFalse()) + } + } + + // MARK: - Policy Behavior Tests + + describe("policy behavior") { + + context("always policy") { + + it("should require authentication every time") { + credentialsManager.enableBiometrics(withTitle: "Touch to unlock", + policy: .always) + + // Should always require authentication + for _ in 0..<10 { + expect(credentialsManager.isBiometricSessionValid()).to(beFalse()) + } + } + } + + context("session policy with short timeout") { + + it("should respect session timeout") { + let timeout = 1 // 1 second + credentialsManager.enableBiometrics(withTitle: "Touch to unlock", + policy: .session(timeoutInSeconds: timeout)) + + // Initially invalid + expect(credentialsManager.isBiometricSessionValid()).to(beFalse()) + } + } + + context("appLifecycle policy") { + + it("should maintain session throughout app lifecycle") { + let timeout = 3600 // 1 hour + credentialsManager.enableBiometrics(withTitle: "Touch to unlock", + policy: .appLifecycle(timeoutInSeconds: timeout)) + + // Should behave same as session policy with longer timeout + expect(credentialsManager.isBiometricSessionValid()).to(beFalse()) + } + } + } + + // MARK: - Clear Tests + + describe("clear with biometric policy") { + + it("should clear biometric session when clearing credentials") { + credentialsManager.enableBiometrics(withTitle: "Touch to unlock", + policy: .session(timeoutInSeconds: 300)) + + _ = credentialsManager.store(credentials: credentials) + + // Clear credentials should also clear session + _ = credentialsManager.clear() + + expect(credentialsManager.isBiometricSessionValid()).to(beFalse()) + } + } + + // MARK: - Edge Cases + + describe("edge cases") { + + it("should handle zero timeout in session policy") { + credentialsManager.enableBiometrics(withTitle: "Touch to unlock", + policy: .session(timeoutInSeconds: 0)) + + // With zero timeout, session should immediately expire + expect(credentialsManager.isBiometricSessionValid()).to(beFalse()) + } + + it("should handle very large timeout in session policy") { + let largeTimeout = Int.max + credentialsManager.enableBiometrics(withTitle: "Touch to unlock", + policy: .session(timeoutInSeconds: largeTimeout)) + + // Should not crash with large timeout + expect(credentialsManager.isBiometricSessionValid()).to(beFalse()) + } + + it("should handle enabling biometrics multiple times") { + // Enable with always policy + credentialsManager.enableBiometrics(withTitle: "Touch to unlock", + policy: .always) + + // Enable again with session policy + credentialsManager.enableBiometrics(withTitle: "Touch to unlock", + policy: .session(timeoutInSeconds: 300)) + + // Should use the latest policy + switch credentialsManager.bioAuth?.policy { + case .session(let timeoutInSeconds): + expect(timeoutInSeconds).to(equal(300)) + default: + fail("Expected .session policy") + } + } + + it("should handle session validity check without enabling biometrics") { + // Should return false if biometrics not enabled + expect(credentialsManager.isBiometricSessionValid()).to(beFalse()) + } + } + } +} + +#endif diff --git a/EXAMPLES.md b/EXAMPLES.md index afee22122..d5b37d7a9 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -548,6 +548,42 @@ credentialsManager.enableBiometrics(withTitle: "Unlock with Face ID or passcode" evaluationPolicy: .deviceOwnerAuthentication) ``` +#### Biometric Policy + +You can configure a `BiometricPolicy` to control when biometric authentication is required. There are three types of policies available: + +- **`.always`**: Requires biometric authentication every time credentials are accessed. This is the default policy and provides the highest security level. +- **`.session(timeoutInSeconds:)`**: Requires biometric authentication only if the specified time (in seconds) has passed since the last successful authentication. Once authenticated, subsequent access within the timeout period will not require re-authentication. +- **`.appLifecycle(timeoutInSeconds:)`**: Similar to the session policy, but the session persists for the lifetime of the app process. The default timeout is 1 hour (3600 seconds). + +**Examples:** + +```swift +// Always require biometric authentication (default) +credentialsManager.enableBiometrics(withTitle: "Unlock with Face ID", + policy: .always) + +// Require authentication only once per 5-minute session +credentialsManager.enableBiometrics(withTitle: "Unlock with Face ID", + policy: .session(timeoutInSeconds: 300)) + +// Require authentication once per app lifecycle (1 hour default) +credentialsManager.enableBiometrics(withTitle: "Unlock with Face ID", + policy: .appLifecycle()) // Default: 3600 seconds (1 hour) +``` + +**Managing Biometric Sessions:** + +You can manually clear the biometric session to force re-authentication on the next credential access: + +```swift +// Clear the biometric session +credentialsManager.clearBiometricSession() + +// Check if the current session is valid +let isValid = credentialsManager.isBiometricSessionValid() +``` + > [!NOTE] > Retrieving the user information with `credentialsManager.user` will not be protected by biometric authentication.