From f814a9ab3ca0893995aa8f4029f3cd142cf32dc2 Mon Sep 17 00:00:00 2001 From: Subhankar Maiti Date: Sun, 16 Nov 2025 16:52:15 +0530 Subject: [PATCH 1/4] feat: add BiometricPolicy to manage biometric authentication requirements and update CredentialsManager to support it --- Auth0.xcodeproj/project.pbxproj | 14 +- Auth0/BioAuthentication.swift | 5 +- Auth0/BiometricPolicy.swift | 19 ++ Auth0/CredentialsManager.swift | 95 +++++- Auth0Tests/BiometricPolicySpec.swift | 477 +++++++++++++++++++++++++++ EXAMPLES.md | 36 ++ 6 files changed, 641 insertions(+), 5 deletions(-) create mode 100644 Auth0/BiometricPolicy.swift create mode 100644 Auth0Tests/BiometricPolicySpec.swift diff --git a/Auth0.xcodeproj/project.pbxproj b/Auth0.xcodeproj/project.pbxproj index 3323e8c35..c4917a9b5 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 */; }; @@ -679,6 +682,7 @@ D581CF792757D773007327D1 /* RequestSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = D581CF762757D773007327D1 /* RequestSpec.swift */; }; D5E9E317273ACCA5000CDB0A /* ChallengeGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5E9E316273ACCA5000CDB0A /* ChallengeGenerator.swift */; }; D5E9E318273ACCA5000CDB0A /* ChallengeGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5E9E316273ACCA5000CDB0A /* ChallengeGenerator.swift */; }; + E929B27B2EBDCEC70071DC9F /* Auth0.plist in Resources */ = {isa = PBXBuildFile; fileRef = E929B27A2EBDCEC70071DC9F /* Auth0.plist */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -860,6 +864,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 +1030,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 +1052,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 = ""; }; + E929B27A2EBDCEC70071DC9F /* Auth0.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Auth0.plist; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -1211,6 +1216,7 @@ 5CB41D3B23D0BA0300074024 /* Validators */, 5B1748731EF2D3A40060E653 /* Shared.swift */, 5B9262BF1ECF0CA800F4F6D3 /* BioAuthentication.swift */, + KDG54I4VDN4TZII38JVUS4AP /* BiometricPolicy.swift */, 5BEDE1891EC21B040007300D /* CredentialsManager.swift */, 5C80980A275A7B8600DC0A76 /* CredentialsStorage.swift */, 5B5E93F81EC45C22002A37F9 /* CredentialsManagerError.swift */, @@ -1492,7 +1498,7 @@ 5F3965D01CF67DD800CDE7C0 /* Assets.xcassets */, 5F3965D21CF67DD800CDE7C0 /* LaunchScreen.storyboard */, 5F3965CD1CF67DD800CDE7C0 /* Main.storyboard */, - C177D6C22C2ADDEB0094C657 /* Auth0.plist */, + E929B27A2EBDCEC70071DC9F /* Auth0.plist */, 5F3965D51CF67DD800CDE7C0 /* Info.plist */, ); path = App; @@ -2212,6 +2218,7 @@ 5F3965D41CF67DD800CDE7C0 /* LaunchScreen.storyboard in Resources */, 5F3965D11CF67DD800CDE7C0 /* Assets.xcassets in Resources */, 5F3965CF1CF67DD800CDE7C0 /* Main.storyboard in Resources */, + E929B27B2EBDCEC70071DC9F /* Auth0.plist in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -2481,6 +2488,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 +2587,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 +2976,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..19dd8888c 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,11 @@ 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 3f024ecbe..2db61cdd1 100644 --- a/Auth0/CredentialsManager.swift +++ b/Auth0/CredentialsManager.swift @@ -32,6 +32,10 @@ public struct CredentialsManager { private let dispatchQueue = DispatchQueue(label: "com.auth0.credentialsmanager.serial") #if WEB_AUTH_PLATFORM var bioAuth: BioAuthentication? + // Biometric session management + private static let noSession: TimeInterval = -1 + private static var lastBiometricAuthTime: TimeInterval = noSession + private static let sessionLock = NSLock() #endif /// Creates a new `CredentialsManager` instance. @@ -82,17 +86,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 +139,9 @@ public struct CredentialsManager { /// /// - Returns: If the credentials were removed. public func clear() -> Bool { + #if WEB_AUTH_PLATFORM + Self.clearBiometricSession() + #endif return self.storage.deleteEntry(forKey: self.storeKey) } @@ -142,6 +159,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.sessionLock.lock() + defer { Self.sessionLock.unlock() } + + let lastAuth = Self.lastBiometricAuthTime + if lastAuth == Self.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 static func clearBiometricSession() { + sessionLock.lock() + defer { sessionLock.unlock() } + lastBiometricAuthTime = 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 +374,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 +1579,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 static func updateBiometricSession(for policy: BiometricPolicy) { + // Don't update session for "Always" policy + switch policy { + case .always: + return + case .session, .appLifecycle: + sessionLock.lock() + defer { sessionLock.unlock() } + lastBiometricAuthTime = Date().timeIntervalSince1970 + } + } + #endif + } #endif diff --git a/Auth0Tests/BiometricPolicySpec.swift b/Auth0Tests/BiometricPolicySpec.swift new file mode 100644 index 000000000..00fc71254 --- /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..d0101eade 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. From 7826e86532b14b354f7e59369ce4cedbc5093383 Mon Sep 17 00:00:00 2001 From: Subhankar Maiti Date: Sun, 16 Nov 2025 17:11:20 +0530 Subject: [PATCH 2/4] fix: update Auth0.plist references in project.pbxproj to ensure correct file handling --- Auth0.xcodeproj/project.pbxproj | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/Auth0.xcodeproj/project.pbxproj b/Auth0.xcodeproj/project.pbxproj index c4917a9b5..c64eae9b4 100644 --- a/Auth0.xcodeproj/project.pbxproj +++ b/Auth0.xcodeproj/project.pbxproj @@ -682,7 +682,6 @@ D581CF792757D773007327D1 /* RequestSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = D581CF762757D773007327D1 /* RequestSpec.swift */; }; D5E9E317273ACCA5000CDB0A /* ChallengeGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5E9E316273ACCA5000CDB0A /* ChallengeGenerator.swift */; }; D5E9E318273ACCA5000CDB0A /* ChallengeGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5E9E316273ACCA5000CDB0A /* ChallengeGenerator.swift */; }; - E929B27B2EBDCEC70071DC9F /* Auth0.plist in Resources */ = {isa = PBXBuildFile; fileRef = E929B27A2EBDCEC70071DC9F /* Auth0.plist */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -1052,7 +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 = ""; }; - E929B27A2EBDCEC70071DC9F /* Auth0.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Auth0.plist; 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 */ @@ -1498,7 +1497,7 @@ 5F3965D01CF67DD800CDE7C0 /* Assets.xcassets */, 5F3965D21CF67DD800CDE7C0 /* LaunchScreen.storyboard */, 5F3965CD1CF67DD800CDE7C0 /* Main.storyboard */, - E929B27A2EBDCEC70071DC9F /* Auth0.plist */, + C177D6C22C2ADDEB0094C657 /* Auth0.plist */, 5F3965D51CF67DD800CDE7C0 /* Info.plist */, ); path = App; @@ -2218,7 +2217,6 @@ 5F3965D41CF67DD800CDE7C0 /* LaunchScreen.storyboard in Resources */, 5F3965D11CF67DD800CDE7C0 /* Assets.xcassets in Resources */, 5F3965CF1CF67DD800CDE7C0 /* Main.storyboard in Resources */, - E929B27B2EBDCEC70071DC9F /* Auth0.plist in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; From eff007f2d4f9c86c69d591574839bfda8971b584 Mon Sep 17 00:00:00 2001 From: Subhankar Maiti Date: Wed, 19 Nov 2025 11:18:09 +0530 Subject: [PATCH 3/4] addressed comment --- Auth0/BioAuthentication.swift | 8 +++++- Auth0/CredentialsManager.swift | 47 ++++++++++++++++++++-------------- EXAMPLES.md | 2 +- 3 files changed, 36 insertions(+), 21 deletions(-) diff --git a/Auth0/BioAuthentication.swift b/Auth0/BioAuthentication.swift index 19dd8888c..1a7e023e8 100644 --- a/Auth0/BioAuthentication.swift +++ b/Auth0/BioAuthentication.swift @@ -24,7 +24,13 @@ struct BioAuthentication { return self.authContext.canEvaluatePolicy(evaluationPolicy, error: nil) } - init(authContext: LAContext, evaluationPolicy: LAPolicy, title: String, cancelTitle: String? = nil, fallbackTitle: String? = nil, policy: BiometricPolicy = .always) { + 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 diff --git a/Auth0/CredentialsManager.swift b/Auth0/CredentialsManager.swift index 2db61cdd1..72f3ff641 100644 --- a/Auth0/CredentialsManager.swift +++ b/Auth0/CredentialsManager.swift @@ -32,10 +32,17 @@ public struct CredentialsManager { private let dispatchQueue = DispatchQueue(label: "com.auth0.credentialsmanager.serial") #if WEB_AUTH_PLATFORM var bioAuth: BioAuthentication? - // Biometric session management - private static let noSession: TimeInterval = -1 - private static var lastBiometricAuthTime: TimeInterval = noSession - private static let sessionLock = NSLock() + // 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. @@ -140,7 +147,9 @@ public struct CredentialsManager { /// - Returns: If the credentials were removed. public func clear() -> Bool { #if WEB_AUTH_PLATFORM - Self.clearBiometricSession() + self.biometricSession.lock.lock() + self.biometricSession.lastBiometricAuthTime = self.biometricSession.noSession + self.biometricSession.lock.unlock() #endif return self.storage.deleteEntry(forKey: self.storeKey) } @@ -172,11 +181,11 @@ public struct CredentialsManager { public func isBiometricSessionValid() -> Bool { guard let bioAuth = self.bioAuth else { return false } - Self.sessionLock.lock() - defer { Self.sessionLock.unlock() } + self.biometricSession.lock.lock() + defer { self.biometricSession.lock.unlock() } - let lastAuth = Self.lastBiometricAuthTime - if lastAuth == Self.noSession { return false } + let lastAuth = self.biometricSession.lastBiometricAuthTime + if lastAuth == self.biometricSession.noSession { return false } switch bioAuth.policy { case .session(let timeoutInSeconds), .appLifecycle(let timeoutInSeconds): @@ -193,12 +202,12 @@ public struct CredentialsManager { /// ## Usage /// /// ```swift - /// CredentialsManager.clearBiometricSession() + /// credentialsManager.clearBiometricSession() /// ``` - public static func clearBiometricSession() { - sessionLock.lock() - defer { sessionLock.unlock() } - lastBiometricAuthTime = noSession + public func clearBiometricSession() { + self.biometricSession.lock.lock() + defer { self.biometricSession.lock.unlock() } + self.biometricSession.lastBiometricAuthTime = self.biometricSession.noSession } #endif @@ -392,7 +401,7 @@ public struct CredentialsManager { } // Update biometric session after successful authentication (only for session-based policies) - Self.updateBiometricSession(for: bioAuth.policy) + self.updateBiometricSession(for: bioAuth.policy) self.retrieveCredentials(scope: scope, minTTL: minTTL, @@ -1582,15 +1591,15 @@ 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 static func updateBiometricSession(for policy: BiometricPolicy) { + private func updateBiometricSession(for policy: BiometricPolicy) { // Don't update session for "Always" policy switch policy { case .always: return case .session, .appLifecycle: - sessionLock.lock() - defer { sessionLock.unlock() } - lastBiometricAuthTime = Date().timeIntervalSince1970 + self.biometricSession.lock.lock() + defer { self.biometricSession.lock.unlock() } + self.biometricSession.lastBiometricAuthTime = Date().timeIntervalSince1970 } } #endif diff --git a/EXAMPLES.md b/EXAMPLES.md index d0101eade..d5b37d7a9 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -578,7 +578,7 @@ You can manually clear the biometric session to force re-authentication on the n ```swift // Clear the biometric session -CredentialsManager.clearBiometricSession() +credentialsManager.clearBiometricSession() // Check if the current session is valid let isValid = credentialsManager.isBiometricSessionValid() From d84737e6c1f7dc2d57109e69e622f6018b12a7bb Mon Sep 17 00:00:00 2001 From: Subhankar Maiti Date: Mon, 24 Nov 2025 19:48:24 +0530 Subject: [PATCH 4/4] fix test case --- Auth0Tests/BiometricPolicySpec.swift | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/Auth0Tests/BiometricPolicySpec.swift b/Auth0Tests/BiometricPolicySpec.swift index 00fc71254..fb5303f0f 100644 --- a/Auth0Tests/BiometricPolicySpec.swift +++ b/Auth0Tests/BiometricPolicySpec.swift @@ -36,7 +36,7 @@ class BiometricPolicySpec: QuickSpec { afterEach { _ = credentialsManager.clear() - CredentialsManager.clearBiometricSession() + credentialsManager.clearBiometricSession() } // MARK: - BiometricPolicy Tests @@ -197,7 +197,7 @@ class BiometricPolicySpec: QuickSpec { } it("should clear session") { - CredentialsManager.clearBiometricSession() + credentialsManager.clearBiometricSession() expect(credentialsManager.isBiometricSessionValid()).to(beFalse()) } } @@ -215,7 +215,7 @@ class BiometricPolicySpec: QuickSpec { } it("should clear session") { - CredentialsManager.clearBiometricSession() + credentialsManager.clearBiometricSession() expect(credentialsManager.isBiometricSessionValid()).to(beFalse()) } } @@ -230,7 +230,7 @@ class BiometricPolicySpec: QuickSpec { policy: .session(timeoutInSeconds: 300)) // Clear the session - CredentialsManager.clearBiometricSession() + credentialsManager.clearBiometricSession() // Session should not be valid after clearing expect(credentialsManager.isBiometricSessionValid()).to(beFalse()) @@ -240,7 +240,7 @@ class BiometricPolicySpec: QuickSpec { credentialsManager.enableBiometrics(withTitle: "Touch to unlock", policy: .always) - CredentialsManager.clearBiometricSession() + credentialsManager.clearBiometricSession() // Always policy should never have valid session expect(credentialsManager.isBiometricSessionValid()).to(beFalse()) @@ -250,7 +250,7 @@ class BiometricPolicySpec: QuickSpec { credentialsManager.enableBiometrics(withTitle: "Touch to unlock", policy: .appLifecycle()) - CredentialsManager.clearBiometricSession() + credentialsManager.clearBiometricSession() expect(credentialsManager.isBiometricSessionValid()).to(beFalse()) } @@ -278,7 +278,7 @@ class BiometricPolicySpec: QuickSpec { // Multiple concurrent clears should not crash DispatchQueue.concurrentPerform(iterations: 100) { _ in - CredentialsManager.clearBiometricSession() + credentialsManager.clearBiometricSession() } expect(true).to(beTrue()) // Test passes if no crash @@ -293,7 +293,7 @@ class BiometricPolicySpec: QuickSpec { if index % 2 == 0 { _ = credentialsManager.isBiometricSessionValid() } else { - CredentialsManager.clearBiometricSession() + credentialsManager.clearBiometricSession() } } @@ -318,7 +318,7 @@ class BiometricPolicySpec: QuickSpec { _ = credentialsManager1.clear() _ = credentialsManager2.clear() _ = credentialsManager3.clear() - CredentialsManager.clearBiometricSession() + credentialsManager.clearBiometricSession() } it("should share biometric session across instances") { @@ -345,7 +345,7 @@ class BiometricPolicySpec: QuickSpec { policy: .session(timeoutInSeconds: 300)) // Clear session affects all instances - CredentialsManager.clearBiometricSession() + credentialsManager.clearBiometricSession() expect(credentialsManager1.isBiometricSessionValid()).to(beFalse()) expect(credentialsManager2.isBiometricSessionValid()).to(beFalse())