Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add ability to override the biometric prompt text client side #264

Merged
merged 2 commits into from
Mar 7, 2024
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
13 changes: 9 additions & 4 deletions FRCore/FRCore/Authenticator/CryptoKey.swift
Original file line number Diff line number Diff line change
Expand Up @@ -89,24 +89,29 @@ public struct CryptoKey {

/// Get the private key from the Keychain for given key alias
/// - Parameter pin: password for the private key credential if applies
/// - Parameter reason: localized reason for the authentication screen
/// - Returns: private key for the given key alias
public func getSecureKey(pin: String? = nil) -> SecKey? {
public func getSecureKey(pin: String? = nil, reason: String? = nil) -> SecKey? {

var query = [String: Any]()
query[String(kSecClass)] = kSecClassKey
query[String(kSecAttrKeyType)] = String(kSecAttrKeyTypeECSECPrimeRandom)
query[String(kSecReturnRef)] = true
query[String(kSecAttrApplicationTag)] = keyAlias


let context = LAContext()
if let pin = pin {
let context = LAContext()
let credentialIsSet = context.setCredential(pin.data(using: .utf8), type: .applicationPassword)
guard credentialIsSet == true else { return nil }
context.interactionNotAllowed = false
}
if let reason = reason {
context.localizedReason = reason
}
//Add LAContext to the query only if any of it's parameters is set
if pin != nil || reason != nil {
query[kSecUseAuthenticationContext as String] = context
}


if let accessGroup = accessGroup {
query[String(kSecAttrAccessGroup)] = accessGroup
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// ApplicationPinDeviceAuthenticator.swift
// FRDeviceBinding
//
// Copyright (c) 2022-2023 ForgeRock. All rights reserved.
// Copyright (c) 2022-2024 ForgeRock. All rights reserved.
//
// This software may be modified and distributed under the terms
// of the MIT license. See the LICENSE file for details.
Expand All @@ -17,8 +17,6 @@ import JOSESwift

/// DeviceAuthenticator adoption for Application Pin authentication
open class ApplicationPinDeviceAuthenticator: DefaultDeviceAuthenticator, CryptoAware {
/// prompt for authentication promp if applicable
var prompt: Prompt?
/// cryptoKey for key pair generation
var cryptoKey: CryptoKey?
/// AppPinAuthenticator to take care of key generation
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -145,14 +145,16 @@ open class DeviceBindingCallback: MultipleValuesCallback, Binding {

/// Bind the device.
/// - Parameter deviceAuthenticator: method for providing a ``DeviceAuthenticator`` from ``DeviceBindingAuthenticationType`` - defaults value is `deviceAuthenticatorIdentifier`
/// - Parameter prompt: Biometric prompt to override the server values
/// - Parameter completion: Completion block for Device binding result callback
open func bind(deviceAuthenticator: ((DeviceBindingAuthenticationType) -> DeviceAuthenticator)? = nil,
prompt: Prompt? = nil,
completion: @escaping DeviceBindingResultCallback) {
let authInterface = deviceAuthenticator?(deviceBindingAuthenticationType) ?? deviceAuthenticatorIdentifier(deviceBindingAuthenticationType)

let dispatchQueue = DispatchQueue(label: "com.forgerock.serialQueue", qos: .userInitiated)
dispatchQueue.async {
self.execute(authInterface: authInterface, completion)
self.execute(authInterface: authInterface, prompt: prompt, completion)
}
}

Expand All @@ -161,18 +163,20 @@ open class DeviceBindingCallback: MultipleValuesCallback, Binding {
/// - Parameter authInterface: Interface to find the Authentication Type - default value is ``getDeviceAuthenticator(type: deviceBindingAuthenticationType)``
/// - Parameter deviceId: Interface to find the Authentication Type - default value is `FRDevice.currentDevice?.identifier.getIdentifier()`
/// - Parameter deviceRepository: Storage for user keys - default value is ``LocalDeviceBindingRepository()``
/// - Parameter prompt: Biometric prompt to override the server values
/// - Parameter completion: Completion block for Device binding result callback
internal func execute(authInterface: DeviceAuthenticator? = nil,
deviceId: String? = nil,
deviceRepository: DeviceBindingRepository = LocalDeviceBindingRepository(),
prompt: Prompt? = nil,
_ completion: @escaping DeviceBindingResultCallback) {
#if targetEnvironment(simulator)
// DeviceBinding/Signing is not supported on the iOS Simulator
handleException(status: .unsupported(errorMessage: "DeviceBinding/Signing is not supported on the iOS Simulator"), completion: completion)
return
#endif
let authInterface = authInterface ?? getDeviceAuthenticator(type: deviceBindingAuthenticationType)
authInterface.initialize(userId: userId, prompt: Prompt(title: title, subtitle: subtitle, description: promptDescription))
authInterface.initialize(userId: userId, prompt: prompt ?? Prompt(title: title, subtitle: subtitle, description: promptDescription))
let deviceId = deviceId ?? FRDevice.currentDevice?.identifier.getIdentifier()

guard authInterface.isSupported() else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,15 +117,17 @@ open class DeviceSigningVerifierCallback: MultipleValuesCallback, Binding {
/// - Parameter userKeySelector: ``UserKeySelector`` implementation - default value is `DefaultUserKeySelector()`
/// - Parameter deviceAuthenticator: method for providing a ``DeviceAuthenticator`` from ``DeviceBindingAuthenticationType`` -default value is `deviceAuthenticatorIdentifier`
/// - Parameter customClaims: A dictionary of custom claims to be added to the jws payload
/// - Parameter prompt: Biometric prompt to override the server values
/// - Parameter completion: Completion block for Device binding result callback
open func sign(userKeySelector: UserKeySelector = DefaultUserKeySelector(),
deviceAuthenticator: ((DeviceBindingAuthenticationType) -> DeviceAuthenticator)? = nil,
customClaims: [String: Any] = [:],
prompt: Prompt? = nil,
completion: @escaping DeviceSigningResultCallback) {

let deviceAuthenticator = deviceAuthenticator ?? deviceAuthenticatorIdentifier
dispatchQueue.async {
self.execute(userKeySelector: userKeySelector, deviceAuthenticator: deviceAuthenticator, customClaims: customClaims, completion)
self.execute(userKeySelector: userKeySelector, deviceAuthenticator: deviceAuthenticator, customClaims: customClaims, prompt: prompt, completion)
}
}

Expand All @@ -135,24 +137,26 @@ open class DeviceSigningVerifierCallback: MultipleValuesCallback, Binding {
/// - Parameter userKeySelector: ``UserKeySelector`` implementation - default value is `DefaultUserKeySelector()`
/// - Parameter deviceAuthenticator: method for providing a ``DeviceAuthenticator`` from ``DeviceBindingAuthenticationType`` - default value is `deviceAuthenticatorIdentifier`
/// - Parameter customClaims: A dictionary of custom claims to be added to the jws payload
/// - Parameter prompt: Biometric prompt to override the server values
/// - Parameter completion: Completion block for Device signing result callback
internal func execute(userKeyService: UserKeyService = UserDeviceKeyService(),
userKeySelector: UserKeySelector = DefaultUserKeySelector(),
deviceAuthenticator: ((DeviceBindingAuthenticationType) -> DeviceAuthenticator)? = nil,
customClaims: [String: Any] = [:],
prompt: Prompt? = nil,
_ completion: @escaping DeviceSigningResultCallback) {

let deviceAuthenticator = deviceAuthenticator ?? deviceAuthenticatorIdentifier
let status = userKeyService.getKeyStatus(userId: userId)

switch status {
case .singleKeyFound(key: let key):
authenticate(userKey: key, authInterface: deviceAuthenticator(key.authType), customClaims: customClaims, completion)
authenticate(userKey: key, authInterface: deviceAuthenticator(key.authType), customClaims: customClaims, prompt: prompt, completion)
case .multipleKeysFound(keys: _):
userKeySelector.selectUserKey(userKeys: userKeyService.getAll()) { key in
if let key = key {
self.dispatchQueue.async {
self.authenticate(userKey: key, authInterface: deviceAuthenticator(key.authType), customClaims: customClaims, completion)
self.authenticate(userKey: key, authInterface: deviceAuthenticator(key.authType), customClaims: customClaims, prompt: prompt, completion)
}
} else {
self.handleException(status: .abort, completion: completion)
Expand All @@ -169,17 +173,19 @@ open class DeviceSigningVerifierCallback: MultipleValuesCallback, Binding {
/// - Parameter userKey: User Information
/// - Parameter authInterface: Interface to find the Authentication Type
/// - Parameter customClaims: A dictionary of custom claims to be added to the jws payload
/// - Parameter prompt: Biometric prompt to override the server values
/// - Parameter completion: Completion block for Device binding result callback
internal func authenticate(userKey: UserKey,
authInterface: DeviceAuthenticator,
customClaims: [String: Any] = [:],
prompt: Prompt? = nil,
_ completion: @escaping DeviceSigningResultCallback) {
#if targetEnvironment(simulator)
// DeviceBinding/Signing is not supported on the iOS Simulator
handleException(status: .unsupported(errorMessage: "DeviceBinding/Signing is not supported on the iOS Simulator"), completion: completion)
return
#endif
authInterface.initialize(userId: userKey.userId, prompt: Prompt(title: title, subtitle: subtitle, description: promptDescription))
authInterface.initialize(userId: userKey.userId, prompt: prompt ?? Prompt(title: title, subtitle: subtitle, description: promptDescription))
guard authInterface.isSupported() else {
handleException(status: .unsupported(errorMessage: nil), completion: completion)
return
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// DeviceBindingAuthenticators.swift
// FRDeviceBinding
//
// Copyright (c) 2022-2023 ForgeRock. All rights reserved.
// Copyright (c) 2022-2024 ForgeRock. All rights reserved.
//
// This software may be modified and distributed under the terms
// of the MIT license. See the LICENSE file for details.
Expand Down Expand Up @@ -79,6 +79,9 @@ public protocol DeviceAuthenticator {


open class DefaultDeviceAuthenticator: DeviceAuthenticator {
/// prompt for authentication if applicable
var prompt: Prompt?

/// Generate public and private key pair
open func generateKeys() throws -> FRCore.KeyPair {
throw DeviceBindingStatus.unsupported(errorMessage: "Cannot use DefaultDeviceAuthenticator. Must be subclassed")
Expand Down Expand Up @@ -156,7 +159,7 @@ open class DefaultDeviceAuthenticator: DeviceAuthenticator {
open func sign(userKey: UserKey, challenge: String, expiration: Date, customClaims: [String: Any] = [:]) throws -> String {

let cryptoKey = CryptoKey(keyId: userKey.userId, accessGroup: FRAuth.shared?.options?.keychainAccessGroup)
guard let keyStoreKey = cryptoKey.getSecureKey() else {
guard let keyStoreKey = cryptoKey.getSecureKey(reason: prompt?.description) else {
throw DeviceBindingStatus.clientNotRegistered
}
let algorithm = SignatureAlgorithm.ES256
Expand Down Expand Up @@ -242,8 +245,6 @@ open class DefaultDeviceAuthenticator: DeviceAuthenticator {

open class BiometricAuthenticator: DefaultDeviceAuthenticator, CryptoAware {

/// prompt for authentication promp if applicable
var prompt: Prompt?
/// cryptoKey for key pair generation
var cryptoKey: CryptoKey?

Expand Down Expand Up @@ -459,6 +460,17 @@ public struct Prompt {
var title: String
var subtitle: String
var description: String

/// Memberwise initializer
/// - Parameters:
/// - title: title for the prompt
/// - subtitle: subtitle for the promp
/// - description: description for the prompt
public init(title: String, subtitle: String, description: String) {
self.title = title
self.subtitle = subtitle
self.description = description
}
}

// MARK: - Device Binding Constants
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -812,6 +812,50 @@ class DeviceBindingCallbackTests: FRAuthBaseTest {
XCTFail("Failed to construct callback: \(callbackResponse)")
}
}


func test_27_bind_customPrompt() throws {
// Skip the test on iOS 15 Simulator due to the bug when private key generation fails with Access Control Flags set
// https://stackoverflow.com/questions/69279715/ios-15-xcode-13-cannot-generate-private-key-on-simulator-running-ios-15-with-s
try XCTSkipIf(self.isSimulator && isIOS15, "on iOS 15 Simulator private key generation fails with Access Control Flags set")

let jsonStr = getJsonString(authenticationType: .biometricAllowFallback)
let callbackResponse = self.parseStringToDictionary(jsonStr)

do {
let callback = try DeviceBindingCallback(json: callbackResponse)
XCTAssertNotNil(callback)
let authenticator = BiometricAndDeviceCredential()
let customDeviceBindingIdentifier: (DeviceBindingAuthenticationType) -> DeviceAuthenticator = { type in
return authenticator
}

let customPrompt: Prompt = Prompt(title: "Custom Title", subtitle: "Custom Subtitle", description: "Custom Description")

let expectation = self.expectation(description: "Device Binding")
callback.bind(deviceAuthenticator: customDeviceBindingIdentifier, prompt: customPrompt) { result in
switch result {
case .success:
XCTAssertEqual(authenticator.prompt?.title, customPrompt.title)
XCTAssertEqual(authenticator.prompt?.subtitle, customPrompt.subtitle)
XCTAssertEqual(authenticator.prompt?.description, customPrompt.description)
case .failure(let error):
if self.isSimulator {
XCTAssertEqual(error.errorMessage, "DeviceBinding/Signing is not supported on the iOS Simulator")
} else {
XCTFail("Callback Execute failed: \(error.errorMessage)")
}
}
expectation.fulfill()
}
let cryptoKey = CryptoKey(keyId: callback.userId)
cryptoKey.deleteKeys()
waitForExpectations(timeout: 60, handler: nil)
}
catch {
XCTFail("Failed to construct callback: \(callbackResponse)")
}
}
}


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -877,6 +877,60 @@ class DeviceSigningVerifierCallbackTests: FRAuthBaseTest {
}


func test_24_sign_customPrompt() throws {
// Skip the test on iOS 15 Simulator due to the bug when private key generation fails with Access Control Flags set
// https://stackoverflow.com/questions/69279715/ios-15-xcode-13-cannot-generate-private-key-on-simulator-running-ios-15-with-s
try XCTSkipIf(self.isSimulator && isIOS15, "on iOS 15 Simulator private key generation fails with Access Control Flags set")

let jsonStr = getJsonString()
let callbackResponse = self.parseStringToDictionary(jsonStr)

let authenticator = BiometricAndDeviceCredential()
let customDeviceBindingIdentifier: (DeviceBindingAuthenticationType) -> DeviceAuthenticator = { type in
return authenticator
}

let customPrompt: Prompt = Prompt(title: "Custom Title", subtitle: "Custom Subtitle", description: "Custom Description")

do {
let callback = try DeviceSigningVerifierCallback(json: callbackResponse)
XCTAssertNotNil(callback)


let cryptoKey = CryptoKey(keyId: "User Id 1")
let keyPair = try cryptoKey.createKeyPair(builderQuery: cryptoKey.keyBuilderQuery())

let deviceRepository = LocalDeviceBindingRepository()
let _ = deviceRepository.deleteAllKeys()

try? deviceRepository.persist(userKey: UserKey(id: keyPair.keyAlias, userId: "User Id 1", userName: "User Name 1", kid: UUID().uuidString, authType: .none, createdAt: Date().timeIntervalSince1970))

let expectation = self.expectation(description: "Device Signing")
callback.sign(userKeySelector: CustomUserKeySelector(),
deviceAuthenticator: customDeviceBindingIdentifier,
prompt: customPrompt) { result in
switch result {
case .success:
XCTAssertEqual(authenticator.prompt?.title, customPrompt.title)
XCTAssertEqual(authenticator.prompt?.subtitle, customPrompt.subtitle)
XCTAssertEqual(authenticator.prompt?.description, customPrompt.description)
case .failure(let error):
if self.isSimulator {
XCTAssertEqual(error.errorMessage, "DeviceBinding/Signing is not supported on the iOS Simulator")
} else {
XCTFail("Callback Execute failed: \(error.errorMessage)")
}
}
expectation.fulfill()
}
waitForExpectations(timeout: 60, handler: nil)
}
catch {
XCTFail("Failed to construct callback: \(callbackResponse)")
}
}


class CustomUserKeySelector: UserKeySelector {
func selectUserKey(userKeys: [UserKey], selectionCallback: @escaping UserKeySelectorCallback) {
selectionCallback(userKeys.first)
Expand Down
6 changes: 4 additions & 2 deletions SampleApps/FRExample/FRExample/ViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -304,7 +304,8 @@ class ViewController: UIViewController, ErrorAlertShowing {
self.present(alert, animated: true, completion: nil)
return
} else if callback.type == "DeviceBindingCallback", let deviceBindingCallback = callback as? DeviceBindingCallback {
deviceBindingCallback.bind() { result in
let customPrompt: Prompt = Prompt(title: "Custom Title", subtitle: "Custom Subtitle", description: "Custom Description")
deviceBindingCallback.bind(prompt: customPrompt) { result in
DispatchQueue.main.async {
var bindingResult = ""
switch result {
Expand All @@ -324,7 +325,8 @@ class ViewController: UIViewController, ErrorAlertShowing {
}
return
} else if callback.type == "DeviceSigningVerifierCallback", let deviceSigningVerifierCallback = callback as? DeviceSigningVerifierCallback {
deviceSigningVerifierCallback.sign(customClaims: ["isCompanyPhone": true, "lastUpdated": Int(Date().timeIntervalSince1970)]) { result in
let customPrompt: Prompt = Prompt(title: "Custom Title", subtitle: "Custom Subtitle", description: "Custom Description")
deviceSigningVerifierCallback.sign(customClaims: ["isCompanyPhone": true, "lastUpdated": Int(Date().timeIntervalSince1970)], prompt: customPrompt) { result in
DispatchQueue.main.async {
var signingResult = ""
switch result {
Expand Down
Loading