diff --git a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Security/BiometricAuthentication/BiometricAuthenticationManager.swift b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Security/BiometricAuthentication/BiometricAuthenticationManager.swift index 853c5eda7e..94adb24268 100644 --- a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Security/BiometricAuthentication/BiometricAuthenticationManager.swift +++ b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Security/BiometricAuthentication/BiometricAuthenticationManager.swift @@ -36,6 +36,11 @@ public protocol BiometricAuthenticationManager { /// If the device is currently locked. Authenticated rest requests will fail if true. var locked: Bool { get } + /// If enabled, the SDK will automatically present the biometric opt-in dialog after login + /// (if the user has not yet opted in) and automatically trigger biometric unlock when the + /// app is locked (if the user has opted in). Defaults to true. + var automaticPresentation: Bool { get set } + /// Locks the device immediately. Authenticated rest requests will fail until the user unlocks the app. func lock() diff --git a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Security/BiometricAuthentication/BiometricAuthenticationManagerInternal.swift b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Security/BiometricAuthentication/BiometricAuthenticationManagerInternal.swift index 85c06cd02d..8506c1b0f6 100644 --- a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Security/BiometricAuthentication/BiometricAuthenticationManagerInternal.swift +++ b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Security/BiometricAuthentication/BiometricAuthenticationManagerInternal.swift @@ -45,7 +45,9 @@ public class BiometricAuthenticationManagerInternal: NSObject, BiometricAuthenti } public var locked = false - + + public var automaticPresentation = true + internal var backgroundTimestamp: Double = 0 // This is a local var so it can be stubbed for tests internal var laContext = LAContext() @@ -146,6 +148,12 @@ public class BiometricAuthenticationManagerInternal: NSObject, BiometricAuthenti SFSDKCoreLogger.e(BiometricAuthenticationManagerInternal.self, message: "Biometric authentication failed: \(error)") } } + + if hasBiometricOptedIn() && automaticPresentation { + SFApplicationHelper.sharedApplication()?.connectedScenes.forEach() { scene in + presentBiometric(scene: scene) + } + } } public func biometricOptIn(optIn: Bool) { diff --git a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/UserAccount/SFUserAccountManager.m b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/UserAccount/SFUserAccountManager.m index 8cb0d9b7e3..6ad6b3601a 100644 --- a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/UserAccount/SFUserAccountManager.m +++ b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/UserAccount/SFUserAccountManager.m @@ -1971,7 +1971,10 @@ - (void)retrievedIdentityData:(SFSDKAuthSession *)authSession { [SFSDKAppFeatureMarkers registerAppFeature:kSFAppFeatureBioAuth]; [bioAuthManager storePolicyWithUserAccount:self.currentUser hasMobilePolicy:hasBioAuthPolicy sessionTimeout:sessionTimeout]; - + if (![bioAuthManager hasBiometricOptedIn] && bioAuthManager.automaticPresentation) { + [bioAuthManager presentOptInDialogWithViewController:[[SFSDKWindowManager sharedManager] mainWindow:authSession.oauthRequest.scene].topViewController]; + } + if (preLoginCredentials != nil && ![preLoginCredentials.refreshToken isEqualToString:self.currentUser.credentials.refreshToken]) { id authClient = self.authClient(); diff --git a/libs/SalesforceSDKCore/SalesforceSDKCoreTests/BiometricAuthenticationManagerTests.swift b/libs/SalesforceSDKCore/SalesforceSDKCoreTests/BiometricAuthenticationManagerTests.swift index a89ecebaba..7dd3a7c5e0 100644 --- a/libs/SalesforceSDKCore/SalesforceSDKCoreTests/BiometricAuthenticationManagerTests.swift +++ b/libs/SalesforceSDKCore/SalesforceSDKCoreTests/BiometricAuthenticationManagerTests.swift @@ -39,6 +39,8 @@ final class BiometricAuthenticationManagerTests: XCTestCase { } override func tearDownWithError() throws { + bioAuthManager.automaticPresentation = true + bioAuthManager.locked = false _ = KeychainHelper.removeAll() UserAccountManager.shared.clearAllAccountState() } @@ -165,6 +167,8 @@ final class BiometricAuthenticationManagerTests: XCTestCase { XCTAssertFalse(bioAuthManager.showNativeLoginButton(), "Button should show until user opts in.") bioAuthManager.biometricOptIn(optIn: true) + // showNativeLoginButton() requires hasPolicy && locked && hasBiometricOptedIn + bioAuthManager.locked = true XCTAssertTrue(bioAuthManager.showNativeLoginButton()) bioAuthManager.enableNativeBiometricLoginButton(enabled: false) @@ -181,8 +185,128 @@ final class BiometricAuthenticationManagerTests: XCTestCase { XCTAssertFalse(bioAuthManager.checkForPolicy(userId: user.idData.userId)) XCTAssertFalse(bioAuthManager.locked, "Locked status should be reset.") } - - + + // MARK: - automaticPresentation Tests + + func testAutomaticPresentationDefaultsToTrue() { + XCTAssertTrue(bioAuthManager.automaticPresentation, "automaticPresentation should default to true.") + } + + func testAutomaticPresentationLockAutoPresentsWhenOptedIn() { + // Scenario B1: automaticPresentation=true, hasBiometricOptedIn=true, lock triggered + // Expected: lock() enters the auto-present branch (presentBiometric called for each scene) + let user = createUser(index: 0) + bioAuthManager.storePolicy(userAccount: user, hasMobilePolicy: true, sessionTimeout: 1) + bioAuthManager.biometricOptIn(optIn: true) + bioAuthManager.automaticPresentation = true + bioAuthManager.laContext = StubbedLAContext(canEvaluate: true) + + // Set timestamp past timeout to trigger lock + bioAuthManager.backgroundTimestamp = Date().timeIntervalSince1970 - 120 + + // Verify preconditions + XCTAssertTrue(bioAuthManager.hasBiometricOptedIn()) + XCTAssertTrue(bioAuthManager.automaticPresentation) + XCTAssertTrue(bioAuthManager.shouldLock()) + + // Trigger the full lock flow via handleAppForeground(). + // This calls lock() which exercises the automaticPresentation branch. + // presentBiometric(scene:) is a no-op in test (no connected scenes / LAContext + // cannot evaluate in unit test sandbox), but the code path is exercised. + bioAuthManager.handleAppForeground() + XCTAssertTrue(bioAuthManager.locked, "App should be locked after timeout.") + + // Cleanup + bioAuthManager.automaticPresentation = false + bioAuthManager.locked = false + } + + func testAutomaticPresentationLockDoesNotPresentWhenNotOptedIn() { + // Scenario B2: automaticPresentation=true, hasBiometricOptedIn=false, lock triggered + // Expected: no auto-present + let user = createUser(index: 0) + bioAuthManager.storePolicy(userAccount: user, hasMobilePolicy: true, sessionTimeout: 1) + bioAuthManager.automaticPresentation = true + + XCTAssertFalse(bioAuthManager.hasBiometricOptedIn()) + let shouldAutoPresent = bioAuthManager.hasBiometricOptedIn() && bioAuthManager.automaticPresentation + XCTAssertFalse(shouldAutoPresent, "Should not auto-present biometric when not opted in.") + + // Cleanup + bioAuthManager.automaticPresentation = false + } + + func testAutomaticPresentationDisabledDoesNotPresent() { + // Scenario B3: automaticPresentation=false, hasBiometricOptedIn=true, lock triggered + // Expected: no auto-present + let user = createUser(index: 0) + bioAuthManager.storePolicy(userAccount: user, hasMobilePolicy: true, sessionTimeout: 1) + bioAuthManager.biometricOptIn(optIn: true) + bioAuthManager.automaticPresentation = false + + XCTAssertTrue(bioAuthManager.hasBiometricOptedIn()) + let shouldAutoPresent = bioAuthManager.hasBiometricOptedIn() && bioAuthManager.automaticPresentation + XCTAssertFalse(shouldAutoPresent, "Should not auto-present biometric when automaticPresentation is disabled.") + } + + func testAutomaticPresentationBothDisabled() { + // Scenario B4: automaticPresentation=false, hasBiometricOptedIn=false + // Expected: no auto-present + let user = createUser(index: 0) + bioAuthManager.storePolicy(userAccount: user, hasMobilePolicy: true, sessionTimeout: 1) + bioAuthManager.automaticPresentation = false + + XCTAssertFalse(bioAuthManager.hasBiometricOptedIn()) + XCTAssertFalse(bioAuthManager.automaticPresentation) + let shouldAutoPresent = bioAuthManager.hasBiometricOptedIn() && bioAuthManager.automaticPresentation + XCTAssertFalse(shouldAutoPresent, "Should not auto-present when both conditions are false.") + } + + func testAutomaticPresentationOptInDialogConditions() { + // Scenario A1/A2/A3: Tests the condition for showing opt-in dialog after login + // The condition is: !hasBiometricOptedIn && automaticPresentation + let user = createUser(index: 0) + bioAuthManager.storePolicy(userAccount: user, hasMobilePolicy: true, sessionTimeout: 1) + + // A3: automaticPresentation=false, not opted in -> no dialog + bioAuthManager.automaticPresentation = false + var shouldShowOptIn = !bioAuthManager.hasBiometricOptedIn() && bioAuthManager.automaticPresentation + XCTAssertFalse(shouldShowOptIn, "Should not show opt-in dialog when automaticPresentation is disabled.") + + // A1: automaticPresentation=true, not opted in -> show dialog + bioAuthManager.automaticPresentation = true + shouldShowOptIn = !bioAuthManager.hasBiometricOptedIn() && bioAuthManager.automaticPresentation + XCTAssertTrue(shouldShowOptIn, "Should show opt-in dialog when automaticPresentation is enabled and user has not opted in.") + + // A2: automaticPresentation=true, already opted in -> no dialog + bioAuthManager.biometricOptIn(optIn: true) + shouldShowOptIn = !bioAuthManager.hasBiometricOptedIn() && bioAuthManager.automaticPresentation + XCTAssertFalse(shouldShowOptIn, "Should not show opt-in dialog when user has already opted in.") + + // Cleanup + bioAuthManager.automaticPresentation = false + } + + func testAutomaticPresentationDoesNotAffectExistingLockBehavior() { + // Scenario D1: automaticPresentation=false (default) should not change existing behavior + let user = createUser(index: 0) + bioAuthManager.storePolicy(userAccount: user, hasMobilePolicy: true, sessionTimeout: 1) + bioAuthManager.automaticPresentation = false + + // Set timestamp past timeout + bioAuthManager.backgroundTimestamp = Date().timeIntervalSince1970 - 120 + + // Lock should still trigger normally + XCTAssertTrue(bioAuthManager.shouldLock(), "shouldLock should still work when automaticPresentation is disabled.") + XCTAssertFalse(bioAuthManager.automaticPresentation) + + // After lock, auto-present should NOT fire + let shouldAutoPresent = bioAuthManager.hasBiometricOptedIn() && bioAuthManager.automaticPresentation + XCTAssertFalse(shouldAutoPresent, "Auto-present should not fire when automaticPresentation is off.") + } + + // MARK: - Helpers + private func createUser(index: Int) -> UserAccount { let credentials = OAuthCredentials(identifier: "identifier-\(index)", clientId: "fakeClientIdForTesting", encrypted: true)! let user = UserAccount(credentials: credentials) diff --git a/libs/SalesforceSDKCore/SalesforceSDKCoreTests/URLSessionTask+RetryPolicyTests.swift b/libs/SalesforceSDKCore/SalesforceSDKCoreTests/URLSessionTask+RetryPolicyTests.swift index 0a57da4a2e..7c9d5054bd 100644 --- a/libs/SalesforceSDKCore/SalesforceSDKCoreTests/URLSessionTask+RetryPolicyTests.swift +++ b/libs/SalesforceSDKCore/SalesforceSDKCoreTests/URLSessionTask+RetryPolicyTests.swift @@ -7,7 +7,9 @@ class URLSessionTaskRetryPolicyTests: XCTestCase { var enabled: Bool var locked: Bool - + + var automaticPresentation: Bool = true + func lock() { locked = true }