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
3 changes: 3 additions & 0 deletions Sources/StytchCore/KeychainClient/KeychainClient+Item.swift
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,10 @@ extension KeychainClient {
}

extension KeychainClient.Item {
// The private key registration is central to biometric authentication, and this item should be protected by biometrics unless explicitly specified otherwise by the caller.
static let privateKeyRegistration: Self = .init(kind: .privateKey, name: "stytch_private_key_registration")
// This was introduced in version 0.54.0 to store the biometric registration ID in a keychain item that is not protected by biometrics.
static let biometricKeyRegistration: Self = .init(kind: .object, name: "stytch_biometric_key_registration")

static let sessionToken: Self = .init(kind: .token, name: SessionToken.Kind.opaque.name)
static let sessionJwt: Self = .init(kind: .token, name: SessionToken.Kind.jwt.name)
Expand Down
11 changes: 3 additions & 8 deletions Sources/StytchCore/Networking/NetworkingRouter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -117,13 +117,6 @@ public extension NetworkingRouter {
}
}

private func cleanupPotentiallyOrphanedBiometricRegistrations(_ user: User) {
// if we have a local biometric registration that doesn't exist on the user object, delete the local
if let queryResult: KeychainClient.QueryResult = try? keychainClient.get(.privateKeyRegistration).first, let biometricRegistrationId = try? queryResult.generic.map({ try jsonDecoder.decode(KeychainClient.KeyRegistration.self, from: $0) }), !user.biometricRegistrations.map(\.id).contains(biometricRegistrationId.registrationId) {
try? keychainClient.removeItem(.privateKeyRegistration)
}
}

// swiftlint:disable:next function_body_length
private func performRequest<Response: Decodable>(
_ method: NetworkingClient.Method,
Expand All @@ -146,7 +139,9 @@ public extension NetworkingRouter {
hostUrl: configuration.hostUrl
)
userStorage.update(sessionResponse.user)
cleanupPotentiallyOrphanedBiometricRegistrations(sessionResponse.user)
#if !os(tvOS) && !os(watchOS)
StytchClient.biometrics.cleanupPotentiallyOrphanedBiometricRegistrations()
#endif
} else if let sessionResponse = dataContainer.data as? B2BAuthenticateResponseType {
sessionManager.updateSession(
sessionType: .member(sessionResponse.memberSession),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ public extension StytchClient.Biometrics {
case availableRegistered
}
}

public extension StytchClient {
/// The interface for interacting with biometrics products.
static var biometrics: Biometrics { .init(router: router.scopedRouter { $0.biometrics }) }
}
#endif

public extension StytchClient {
Expand All @@ -29,7 +34,7 @@ public extension StytchClient {

/// Indicates if there is an existing biometric registration on device.
public var registrationAvailable: Bool {
keychainClient.valueExistsForItem(.privateKeyRegistration)
keychainClient.valueExistsForItem(.biometricKeyRegistration)
}

#if !os(tvOS) && !os(watchOS)
Expand All @@ -51,17 +56,17 @@ public extension StytchClient {
// sourcery: AsyncVariants, (NOTE: - must use /// doc comment styling)
/// Removes the current device's existing biometric registration from both the device itself and from the server.
public func removeRegistration() async throws {
guard let queryResult: KeychainClient.QueryResult = try? keychainClient.get(.privateKeyRegistration).first else {
guard let queryResult = try? keychainClient.get(.biometricKeyRegistration).first else {
return
}

// Delete registration from backend
if let registration = try queryResult.generic.map({ try jsonDecoder.decode(KeychainClient.KeyRegistration.self, from: $0) }) {
_ = try await StytchClient.user.deleteFactor(.biometricRegistration(id: registration.registrationId))
if let biometricRegistrationId = queryResult.stringValue {
_ = try await StytchClient.user.deleteFactor(.biometricRegistration(id: User.BiometricRegistration.ID(stringLiteral: biometricRegistrationId)))
}

// Remove local registration
try keychainClient.removeItem(.privateKeyRegistration)
// Remove local registrations
try clearBiometricRegistrations()
}

// sourcery: AsyncVariants, (NOTE: - must use /// doc comment styling)
Expand All @@ -74,6 +79,11 @@ public extension StytchClient {
throw StytchSDKError.noCurrentSession
}

// Attempt to remove the current registration if it exists.
// If we don't remove the current one before creating a new one, the user record will retain an unused biometric registration indefinitely.
// The error thrown here can be safely ignored. If it fails, we don't want to prevent the creation of the new biometric registration.
try? await removeRegistration()

let (privateKey, publicKey) = cryptoClient.generateKeyPair()

let startResponse: RegisterStartResponse = try await router.post(
Expand All @@ -99,50 +109,97 @@ public extension StytchClient {
registrationId: finishResponse.biometricRegistrationId
)

// Set the .privateKeyRegistration
try keychainClient.set(
key: privateKey,
registration: registration,
accessPolicy: parameters.accessPolicy.keychainValue
)

// Set the .biometricKeyRegistration
try keychainClient.set(registration.registrationId.rawValue, for: .biometricKeyRegistration)

return finishResponse
}

// sourcery: AsyncVariants, (NOTE: - must use /// doc comment styling)
/// If a valid biometric registration exists, this method confirms the current device owner via the device's built-in biometric reader and returns an updated session object by either starting a new session or adding a the biometric factor to an existing session.
public func authenticate(parameters: AuthenticateParameters) async throws -> AuthenticateResponse {
guard let queryResult: KeychainClient.QueryResult = try keychainClient.get(.privateKeyRegistration).first else {
guard let privateKeyRegistrationQueryResult: KeychainClient.QueryResult = try keychainClient.get(.privateKeyRegistration).first else {
throw StytchSDKError.noBiometricRegistration
}

let privateKey = queryResult.data
try copyBiometricRegistrationIDToKeychainIfNeeded(privateKeyRegistrationQueryResult)

let privateKey = privateKeyRegistrationQueryResult.data
let publicKey = try cryptoClient.publicKeyForPrivateKey(privateKey)

let startResponse: AuthenticateStartResponse = try await router.post(
to: .authenticate(.start),
parameters: AuthenticateStartParameters(publicKey: publicKey)
)

let authenticateCompleteParameters = AuthenticateCompleteParameters(
signature: try cryptoClient.signChallengeWithPrivateKey(startResponse.challenge, privateKey),
biometricRegistrationId: startResponse.biometricRegistrationId,
sessionDurationMinutes: parameters.sessionDuration
)

// NOTE: - We could return separate concrete type which deserializes/contains biometric_registration_id, but this doesn't currently add much value
return try await router.post(
let authenticateResponse: AuthenticateResponse = try await router.post(
to: .authenticate(.complete),
parameters: AuthenticateCompleteParameters(
signature: cryptoClient.signChallengeWithPrivateKey(startResponse.challenge, privateKey),
biometricRegistrationId: startResponse.biometricRegistrationId,
sessionDurationMinutes: parameters.sessionDuration
),
parameters: authenticateCompleteParameters,
useDFPPA: true
)
return authenticateResponse
}
}
}

#if !os(tvOS) && !os(watchOS)
public extension StytchClient {
/// The interface for interacting with biometrics products.
static var biometrics: Biometrics { .init(router: router.scopedRouter { $0.biometrics }) }
// Clear both the .privateKeyRegistration and the .biometricKeyRegistration
func clearBiometricRegistrations() throws {
try keychainClient.removeItem(.privateKeyRegistration)
try keychainClient.removeItem(.biometricKeyRegistration)
}

// if we have a local biometric registration that doesn't exist on the user object, delete the local
func cleanupPotentiallyOrphanedBiometricRegistrations() {
guard let user = StytchClient.user.getSync() else {
return
}

if user.biometricRegistrations.isEmpty {
try? clearBiometricRegistrations()
} else {
let queryResult = try? keychainClient.get(.biometricKeyRegistration).first

// Check if the user's biometric registrations contain the ID
var userBiometricRegistrationIds = [String]()
for biometricRegistration in user.biometricRegistrations {
userBiometricRegistrationIds.append(biometricRegistration.biometricRegistrationId.rawValue)
}

if let biometricRegistrationId = queryResult?.stringValue, userBiometricRegistrationIds.contains(biometricRegistrationId) == false {
// Remove the orphaned biometric registration
try? clearBiometricRegistrations()
}
}
}

/*
After introducing the .biometricKeyRegistration keychain item in version 0.54.0, we needed a way for versions prior to 0.54.0
to copy the value stored in the biometrically protected .privateKeyRegistration keychain item into the non-biometric
.biometricKeyRegistration keychain item without triggering unnecessary Face ID prompts. Since a Face ID prompt is already
being shown here for authentication, we decided to use this as an opportunity to perform the migration by copying the
registration ID into the .biometricKeyRegistration keychain item. For versions after 0.54.0, this action occurs during
registration, and it should only happen here if the .biometricKeyRegistration keychain item is empty.
*/
func copyBiometricRegistrationIDToKeychainIfNeeded(_ queryResult: KeychainClient.QueryResult) throws {
let biometricKeyRegistration = try? keychainClient.get(.biometricKeyRegistration).first
if biometricKeyRegistration == nil, let registration = try queryResult.generic.map({ try jsonDecoder.decode(KeychainClient.KeyRegistration.self, from: $0) }) {
try keychainClient.set(registration.registrationId.rawValue, for: .biometricKeyRegistration)
}
}
}
}
#endif

public extension StytchClient.Biometrics {
typealias RegisterCompleteResponse = Response<RegisterCompleteResponseData>
Expand Down
103 changes: 0 additions & 103 deletions Stytch/DemoApps/B2BWorkbench/Extensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,6 @@ import Foundation
import StytchCore
import UIKit

enum TextFieldAlertError: Error {
case emptyString
}

extension UIViewController {
var organizationId: String? {
UserDefaults.standard.string(forKey: Constants.orgIdDefaultsKey)
Expand All @@ -14,105 +10,6 @@ extension UIViewController {
var memberId: String? {
StytchB2BClient.member.getSync()?.id.rawValue
}

func presentAlertWithTitle(
alertTitle: String,
buttonTitle: String = "OK",
completion: (() -> Void)? = nil
) {
let alertController = UIAlertController(title: alertTitle, message: nil, preferredStyle: .alert)
let okAction = UIAlertAction(title: buttonTitle, style: .cancel) { _ in
completion?()
}
alertController.addAction(okAction)
present(alertController, animated: true)
}

func presentTextFieldAlertWithTitle(
alertTitle: String,
buttonTitle: String = "Submit",
cancelButtonTitle: String = "Cancel",
completion: ((String?) -> Void)? = nil
) {
let alertController = UIAlertController(title: alertTitle, message: nil, preferredStyle: .alert)
alertController.addTextField()

let submitAction = UIAlertAction(title: buttonTitle, style: .default) { [unowned alertController] _ in
if let text = alertController.textFields?[0].text {
completion?(text)
}
}
alertController.addAction(.init(title: cancelButtonTitle, style: .cancel, handler: { _ in
completion?(nil)
}))

alertController.addAction(submitAction)
present(alertController, animated: true)
}

@MainActor
func presentTextFieldAlertWithTitle(
alertTitle: String,
buttonTitle: String = "Submit",
cancelButtonTitle: String = "Cancel"
) async throws -> String? {
try await withCheckedThrowingContinuation { continuation in
let alertController = UIAlertController(title: alertTitle, message: nil, preferredStyle: .alert)
alertController.addTextField()

let submitAction = UIAlertAction(title: buttonTitle, style: .default) { [unowned alertController] _ in
if let text = alertController.textFields?[0].text, text.isEmpty == false {
continuation.resume(returning: text)
} else {
continuation.resume(returning: nil)
}
}

alertController.addAction(submitAction)

alertController.addAction(.init(title: cancelButtonTitle, style: .cancel, handler: { _ in
continuation.resume(returning: nil)
}))

present(alertController, animated: true)
}
}

func presentErrorWithDescription(error: Error, description: String) {
presentAlertWithTitle(alertTitle: "\(description) - \(error.errorInfo)")
}

func presentAlertAndLogMessage(description: String, object: Any) {
if let error = object as? Error {
presentAlertWithTitle(alertTitle: "\(description)\n\ncheck logs for more info")
print("\(description)\n\(error.errorInfo)\n\(error)\n")
} else {
presentAlertWithTitle(alertTitle: "\(description)\n\ncheck logs for more info")
print("\(description)\n\(object)\n")
}
}
}

extension UIButton {
convenience init(title: String, primaryAction: UIAction) {
var configuration: UIButton.Configuration = .borderedProminent()
configuration.title = title
self.init(configuration: configuration, primaryAction: primaryAction)
}
}

extension UITextField {
convenience init(title: String, primaryAction: UIAction, keyboardType: UIKeyboardType = .default, password: Bool = false) {
self.init(frame: .zero, primaryAction: primaryAction)
borderStyle = .roundedRect
placeholder = title
autocorrectionType = .no
autocapitalizationType = .none
self.keyboardType = keyboardType
if password == true {
textContentType = .password
}
}
}

extension UIStackView {
Expand Down
Loading
Loading