Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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)
}
}
Comment on lines +151 to +156
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this needed? The Login view controller should automatically present biometric upon lock if the user is opted in?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Image

This is for the native login path. When useNativeLogin is enabled, SFLoginViewController isn't used — the app's custom VC is presented instead, so the existing auto-present at line 95 of SFLoginViewController.m never fires. This ensures native login apps get the same auto-present behavior when automaticPresentation is enabled.

That said, for the standard (webview) login path, you're right — SFLoginViewController already handles it. Should we gate line 95 behind automaticPresentation too for consistency, or leave it as-is since it's existing behavior?

}

public func biometricOptIn(optIn: Bool) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<SFSDKOAuthProtocol> authClient = self.authClient();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ final class BiometricAuthenticationManagerTests: XCTestCase {
}

override func tearDownWithError() throws {
bioAuthManager.automaticPresentation = true
bioAuthManager.locked = false
_ = KeychainHelper.removeAll()
UserAccountManager.shared.clearAllAccountState()
}
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ class URLSessionTaskRetryPolicyTests: XCTestCase {
var enabled: Bool

var locked: Bool


var automaticPresentation: Bool = true

func lock() {
locked = true
}
Expand Down
Loading