Skip to content

Commit 7eaec64

Browse files
committed
Add StytchBiometrics Demo App
1 parent 1f9c87c commit 7eaec64

File tree

19 files changed

+760
-123
lines changed

19 files changed

+760
-123
lines changed

Sources/StytchCore/Networking/NetworkingRouter.swift

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -117,13 +117,6 @@ public extension NetworkingRouter {
117117
}
118118
}
119119

120-
private func cleanupPotentiallyOrphanedBiometricRegistrations(_ user: User) {
121-
// if we have a local biometric registration that doesn't exist on the user object, delete the local
122-
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) {
123-
try? keychainClient.removeItem(.privateKeyRegistration)
124-
}
125-
}
126-
127120
// swiftlint:disable:next function_body_length
128121
private func performRequest<Response: Decodable>(
129122
_ method: NetworkingClient.Method,
@@ -146,7 +139,7 @@ public extension NetworkingRouter {
146139
hostUrl: configuration.hostUrl
147140
)
148141
userStorage.update(sessionResponse.user)
149-
cleanupPotentiallyOrphanedBiometricRegistrations(sessionResponse.user)
142+
StytchClient.biometrics.cleanupPotentiallyOrphanedBiometricRegistrations()
150143
} else if let sessionResponse = dataContainer.data as? B2BAuthenticateResponseType {
151144
sessionManager.updateSession(
152145
sessionType: .member(sessionResponse.memberSession),

Sources/StytchCore/StytchClient/Biometrics/StytchClient+Biometrics.swift

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,11 @@ public extension StytchClient {
7474
throw StytchSDKError.noCurrentSession
7575
}
7676

77+
// Attempt to remove the current registration if it exists.
78+
// If we don't remove the current one before creating a new one, the user record will retain an unused biometric registration indefinitely.
79+
// The error thrown here can be safely ignored. If it fails, we don't want to prevent the creation of the new biometric registration.
80+
try? await removeRegistration()
81+
7782
let (privateKey, publicKey) = cryptoClient.generateKeyPair()
7883

7984
let startResponse: RegisterStartResponse = try await router.post(
@@ -123,16 +128,49 @@ public extension StytchClient {
123128
parameters: AuthenticateStartParameters(publicKey: publicKey)
124129
)
125130

131+
let authenticateCompleteParameters = AuthenticateCompleteParameters(
132+
signature: try cryptoClient.signChallengeWithPrivateKey(startResponse.challenge, privateKey),
133+
biometricRegistrationId: startResponse.biometricRegistrationId,
134+
sessionDurationMinutes: parameters.sessionDuration
135+
)
136+
126137
// NOTE: - We could return separate concrete type which deserializes/contains biometric_registration_id, but this doesn't currently add much value
127-
return try await router.post(
138+
let authenticateResponse: AuthenticateResponse = try await router.post(
128139
to: .authenticate(.complete),
129-
parameters: AuthenticateCompleteParameters(
130-
signature: cryptoClient.signChallengeWithPrivateKey(startResponse.challenge, privateKey),
131-
biometricRegistrationId: startResponse.biometricRegistrationId,
132-
sessionDurationMinutes: parameters.sessionDuration
133-
),
140+
parameters: authenticateCompleteParameters,
134141
useDFPPA: true
135142
)
143+
return authenticateResponse
144+
}
145+
146+
func cleanupPotentiallyOrphanedBiometricRegistrations() {
147+
guard let user = StytchClient.user.getSync() else {
148+
return
149+
}
150+
151+
// if we have a local biometric registration that doesn't exist on the user object, delete the local
152+
if user.biometricRegistrations.isEmpty {
153+
try? keychainClient.removeItem(.privateKeyRegistration)
154+
} else if !user.biometricRegistrations.isEmpty {
155+
let queryResult = try? keychainClient.get(.privateKeyRegistration).first
156+
cleanupBiometricRegistrationIfOrphaned(queryResult: queryResult, user: user)
157+
}
158+
}
159+
160+
private func cleanupBiometricRegistrationIfOrphaned(
161+
queryResult: KeychainClient.QueryResult?,
162+
user: User
163+
) {
164+
// Decode the biometric registration ID from the query result
165+
let biometricRegistrationId = try? queryResult?.generic.map {
166+
try jsonDecoder.decode(KeychainClient.KeyRegistration.self, from: $0)
167+
}
168+
169+
// Check if the user's biometric registrations contain the ID
170+
if user.biometricRegistrations.map(\.id).contains(biometricRegistrationId?.registrationId) == false {
171+
// Remove the orphaned biometric registration
172+
try? keychainClient.removeItem(.privateKeyRegistration)
173+
}
136174
}
137175
}
138176
}

Stytch/DemoApps/B2BWorkbench/Extensions.swift

Lines changed: 0 additions & 103 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,6 @@ import Foundation
22
import StytchCore
33
import UIKit
44

5-
enum TextFieldAlertError: Error {
6-
case emptyString
7-
}
8-
95
extension UIViewController {
106
var organizationId: String? {
117
UserDefaults.standard.string(forKey: Constants.orgIdDefaultsKey)
@@ -14,105 +10,6 @@ extension UIViewController {
1410
var memberId: String? {
1511
StytchB2BClient.member.getSync()?.id.rawValue
1612
}
17-
18-
func presentAlertWithTitle(
19-
alertTitle: String,
20-
buttonTitle: String = "OK",
21-
completion: (() -> Void)? = nil
22-
) {
23-
let alertController = UIAlertController(title: alertTitle, message: nil, preferredStyle: .alert)
24-
let okAction = UIAlertAction(title: buttonTitle, style: .cancel) { _ in
25-
completion?()
26-
}
27-
alertController.addAction(okAction)
28-
present(alertController, animated: true)
29-
}
30-
31-
func presentTextFieldAlertWithTitle(
32-
alertTitle: String,
33-
buttonTitle: String = "Submit",
34-
cancelButtonTitle: String = "Cancel",
35-
completion: ((String?) -> Void)? = nil
36-
) {
37-
let alertController = UIAlertController(title: alertTitle, message: nil, preferredStyle: .alert)
38-
alertController.addTextField()
39-
40-
let submitAction = UIAlertAction(title: buttonTitle, style: .default) { [unowned alertController] _ in
41-
if let text = alertController.textFields?[0].text {
42-
completion?(text)
43-
}
44-
}
45-
alertController.addAction(.init(title: cancelButtonTitle, style: .cancel, handler: { _ in
46-
completion?(nil)
47-
}))
48-
49-
alertController.addAction(submitAction)
50-
present(alertController, animated: true)
51-
}
52-
53-
@MainActor
54-
func presentTextFieldAlertWithTitle(
55-
alertTitle: String,
56-
buttonTitle: String = "Submit",
57-
cancelButtonTitle: String = "Cancel"
58-
) async throws -> String? {
59-
try await withCheckedThrowingContinuation { continuation in
60-
let alertController = UIAlertController(title: alertTitle, message: nil, preferredStyle: .alert)
61-
alertController.addTextField()
62-
63-
let submitAction = UIAlertAction(title: buttonTitle, style: .default) { [unowned alertController] _ in
64-
if let text = alertController.textFields?[0].text, text.isEmpty == false {
65-
continuation.resume(returning: text)
66-
} else {
67-
continuation.resume(returning: nil)
68-
}
69-
}
70-
71-
alertController.addAction(submitAction)
72-
73-
alertController.addAction(.init(title: cancelButtonTitle, style: .cancel, handler: { _ in
74-
continuation.resume(returning: nil)
75-
}))
76-
77-
present(alertController, animated: true)
78-
}
79-
}
80-
81-
func presentErrorWithDescription(error: Error, description: String) {
82-
presentAlertWithTitle(alertTitle: "\(description) - \(error.errorInfo)")
83-
}
84-
85-
func presentAlertAndLogMessage(description: String, object: Any) {
86-
if let error = object as? Error {
87-
presentAlertWithTitle(alertTitle: "\(description)\n\ncheck logs for more info")
88-
print("\(description)\n\(error.errorInfo)\n\(error)\n")
89-
} else {
90-
presentAlertWithTitle(alertTitle: "\(description)\n\ncheck logs for more info")
91-
print("\(description)\n\(object)\n")
92-
}
93-
}
94-
}
95-
96-
extension UIButton {
97-
convenience init(title: String, primaryAction: UIAction) {
98-
var configuration: UIButton.Configuration = .borderedProminent()
99-
configuration.title = title
100-
self.init(configuration: configuration, primaryAction: primaryAction)
101-
}
102-
}
103-
104-
extension UITextField {
105-
convenience init(title: String, primaryAction: UIAction, keyboardType: UIKeyboardType = .default, password: Bool = false) {
106-
self.init(frame: .zero, primaryAction: primaryAction)
107-
borderStyle = .roundedRect
108-
placeholder = title
109-
autocorrectionType = .no
110-
autocapitalizationType = .none
111-
self.keyboardType = keyboardType
112-
if password == true {
113-
textContentType = .password
114-
}
115-
}
11613
}
11714

11815
extension UIStackView {
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import Foundation
2+
import StytchCore
3+
import UIKit
4+
5+
enum TextFieldAlertError: Error {
6+
case emptyString
7+
}
8+
9+
extension UIViewController {
10+
func presentAlertWithTitle(
11+
alertTitle: String,
12+
buttonTitle: String = "OK",
13+
completion: (() -> Void)? = nil
14+
) {
15+
let alertController = UIAlertController(title: alertTitle, message: nil, preferredStyle: .alert)
16+
let okAction = UIAlertAction(title: buttonTitle, style: .cancel) { _ in
17+
completion?()
18+
}
19+
alertController.addAction(okAction)
20+
present(alertController, animated: true)
21+
}
22+
23+
func presentTextFieldAlertWithTitle(
24+
alertTitle: String,
25+
buttonTitle: String = "Submit",
26+
cancelButtonTitle: String = "Cancel",
27+
completion: ((String?) -> Void)? = nil
28+
) {
29+
let alertController = UIAlertController(title: alertTitle, message: nil, preferredStyle: .alert)
30+
alertController.addTextField()
31+
32+
let submitAction = UIAlertAction(title: buttonTitle, style: .default) { [unowned alertController] _ in
33+
if let text = alertController.textFields?[0].text {
34+
completion?(text)
35+
}
36+
}
37+
alertController.addAction(.init(title: cancelButtonTitle, style: .cancel, handler: { _ in
38+
completion?(nil)
39+
}))
40+
41+
alertController.addAction(submitAction)
42+
present(alertController, animated: true)
43+
}
44+
45+
@MainActor
46+
func presentTextFieldAlertWithTitle(
47+
alertTitle: String,
48+
buttonTitle: String = "Submit",
49+
cancelButtonTitle: String = "Cancel",
50+
keyboardType: UIKeyboardType = .default
51+
) async throws -> String? {
52+
try await withCheckedThrowingContinuation { continuation in
53+
let alertController = UIAlertController(title: alertTitle, message: nil, preferredStyle: .alert)
54+
alertController.addTextField()
55+
alertController.textFields?[0].keyboardType = keyboardType
56+
57+
let submitAction = UIAlertAction(title: buttonTitle, style: .default) { [unowned alertController] _ in
58+
if let text = alertController.textFields?[0].text, text.isEmpty == false {
59+
continuation.resume(returning: text)
60+
} else {
61+
continuation.resume(returning: nil)
62+
}
63+
}
64+
65+
alertController.addAction(submitAction)
66+
67+
alertController.addAction(.init(title: cancelButtonTitle, style: .cancel, handler: { _ in
68+
continuation.resume(returning: nil)
69+
}))
70+
71+
present(alertController, animated: true)
72+
}
73+
}
74+
75+
func presentErrorWithDescription(error: Error, description: String) {
76+
presentAlertWithTitle(alertTitle: "\(description) - \(error.errorInfo)")
77+
}
78+
79+
func presentAlertAndLogMessage(description: String, object: Any) {
80+
if let error = object as? Error {
81+
presentAlertWithTitle(alertTitle: "\(description)\n\ncheck logs for more info")
82+
print("\(description)\n\(error.errorInfo)\n\(error)\n")
83+
} else {
84+
presentAlertWithTitle(alertTitle: "\(description)\n\ncheck logs for more info")
85+
print("\(description)\n\(object)\n")
86+
}
87+
}
88+
}
89+
90+
extension UIButton {
91+
convenience init(title: String, primaryAction: UIAction) {
92+
var configuration: UIButton.Configuration = .borderedProminent()
93+
configuration.title = title
94+
self.init(configuration: configuration, primaryAction: primaryAction)
95+
}
96+
}
97+
98+
extension UITextField {
99+
convenience init(title: String, primaryAction: UIAction, keyboardType: UIKeyboardType = .default, password: Bool = false) {
100+
self.init(frame: .zero, primaryAction: primaryAction)
101+
borderStyle = .roundedRect
102+
placeholder = title
103+
autocorrectionType = .no
104+
autocapitalizationType = .none
105+
self.keyboardType = keyboardType
106+
if password == true {
107+
textContentType = .password
108+
}
109+
}
110+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import UIKit
2+
3+
@main
4+
class AppDelegate: UIResponder, UIApplicationDelegate {
5+
func application(_: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
6+
// Override point for customization after application launch.
7+
true
8+
}
9+
10+
// MARK: UISceneSession Lifecycle
11+
12+
func application(_: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options _: UIScene.ConnectionOptions) -> UISceneConfiguration {
13+
// Called when a new scene session is being created.
14+
// Use this method to select a configuration to create the new scene with.
15+
UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
16+
}
17+
18+
func application(_: UIApplication, didDiscardSceneSessions _: Set<UISceneSession>) {
19+
// Called when the user discards a scene session.
20+
// If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions.
21+
// Use this method to release any resources that were specific to the discarded scenes, as they will not return.
22+
}
23+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"colors" : [
3+
{
4+
"idiom" : "universal"
5+
}
6+
],
7+
"info" : {
8+
"author" : "xcode",
9+
"version" : 1
10+
}
11+
}
13.5 KB
Loading
13.5 KB
Loading
13.5 KB
Loading
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
{
2+
"images" : [
3+
{
4+
"filename" : "App icon1024x1024.png",
5+
"idiom" : "universal",
6+
"platform" : "ios",
7+
"size" : "1024x1024"
8+
},
9+
{
10+
"appearances" : [
11+
{
12+
"appearance" : "luminosity",
13+
"value" : "dark"
14+
}
15+
],
16+
"filename" : "App icon1024x1024 1.png",
17+
"idiom" : "universal",
18+
"platform" : "ios",
19+
"size" : "1024x1024"
20+
},
21+
{
22+
"appearances" : [
23+
{
24+
"appearance" : "luminosity",
25+
"value" : "tinted"
26+
}
27+
],
28+
"filename" : "App icon1024x1024 2.png",
29+
"idiom" : "universal",
30+
"platform" : "ios",
31+
"size" : "1024x1024"
32+
}
33+
],
34+
"info" : {
35+
"author" : "xcode",
36+
"version" : 1
37+
}
38+
}

0 commit comments

Comments
 (0)