-
Notifications
You must be signed in to change notification settings - Fork 11
Expand file tree
/
Copy pathStytchClient+Biometrics.swift
More file actions
311 lines (263 loc) · 14.2 KB
/
StytchClient+Biometrics.swift
File metadata and controls
311 lines (263 loc) · 14.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
import Foundation
#if !os(tvOS) && !os(watchOS)
import LocalAuthentication
public extension StytchClient.Biometrics {
enum Availability {
case systemUnavailable(LAError.Code?)
case availableNoRegistration
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 {
/// Biometric authentication enables your users to leverage their devices' built-in biometric authenticators such as FaceID and TouchID for quick and seamless login experiences.
///
/// ## Important Notes
/// - To use Biometric authentication, you must set `NSFaceIDUsageDescription` in your app's `Info.plist`.
struct Biometrics {
let router: NetworkingRouter<BiometricsRoute>
@Dependency(\.cryptoClient) private var cryptoClient
@Dependency(\.keychainClient) private var keychainClient
@Dependency(\.sessionManager.persistedSessionIdentifiersExist) private var activeSessionExists
@Dependency(\.jsonDecoder) private var jsonDecoder
/// Indicates if there is an existing biometric registration on device.
public var registrationAvailable: Bool {
keychainClient.valueExistsForItem(.biometricKeyRegistration)
}
#if !os(tvOS) && !os(watchOS)
/// Indicates if biometrics are available
public var availability: Availability {
let context = LAContext()
var error: NSError?
switch (context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error), registrationAvailable) {
case (false, _):
return .systemUnavailable((error as? LAError)?.code)
case (true, false):
return .availableNoRegistration
case (true, true):
return .availableRegistered
}
}
#endif
// 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 = try? keychainClient.get(.biometricKeyRegistration).first else {
return
}
// Delete registration from backend
if let biometricRegistrationId = queryResult.stringValue {
_ = try await StytchClient.user.deleteFactor(.biometricRegistration(id: User.BiometricRegistration.ID(stringLiteral: biometricRegistrationId)))
}
// Remove local registrations
try clearBiometricRegistrations()
}
// sourcery: AsyncVariants, (NOTE: - must use /// doc comment styling)
/// When a valid/active session exists, this method will add a biometric registration for the current user. The user will later be able to start a new session with biometrics or use biometrics as an additional authentication factor.
///
/// NOTE: - You should ensure the `accessPolicy` parameters match your particular needs, defaults to `deviceOwnerWithBiometrics`.
public func register(parameters: RegisterParameters) async throws -> RegisterCompleteResponse {
// Early out if not authenticated
guard activeSessionExists else {
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(
to: .register(.start),
parameters: RegisterStartParameters(publicKey: publicKey)
)
let finishResponse: Response<RegisterCompleteResponseData> = try await router.post(
to: .register(.complete),
parameters: RegisterFinishParameters(
biometricRegistrationId: startResponse.biometricRegistrationId,
signature: cryptoClient.signChallengeWithPrivateKey(
startResponse.challenge,
privateKey
),
sessionDuration: parameters.sessionDuration
)
)
let registration: KeychainClient.KeyRegistration = .init(
userId: finishResponse.user.id,
userLabel: parameters.identifier,
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 privateKeyRegistrationQueryResult: KeychainClient.QueryResult = try keychainClient.get(.privateKeyRegistration).first else {
throw StytchSDKError.noBiometricRegistration
}
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
let authenticateResponse: AuthenticateResponse = try await router.post(
to: .authenticate(.complete),
parameters: authenticateCompleteParameters,
useDFPPA: true
)
return authenticateResponse
}
// 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)
}
}
}
}
public extension StytchClient.Biometrics {
typealias RegisterCompleteResponse = Response<RegisterCompleteResponseData>
/// A dedicated parameters type for biometrics `authenticate` calls.
struct AuthenticateParameters: Sendable {
let sessionDuration: Minutes
/// Initializes the parameters struct
/// - Parameter sessionDuration: The duration, in minutes, for the requested session. Defaults to 5 minutes.
public init(sessionDuration: Minutes = .defaultSessionDuration) {
self.sessionDuration = sessionDuration
}
}
/// A dedicated parameters type for biometrics `register` calls.
struct RegisterParameters: Sendable {
let identifier: String
let accessPolicy: AccessPolicy
let sessionDuration: Minutes
/// Initializes the parameters struct
/// - Parameters:
/// - identifier: An id used to easily identify the account associated with the biometric registration, generally an email or phone number.
/// - accessPolicy: Defines the policy as to how the user must confirm their ownership.
/// - sessionDuration: The duration, in minutes, for the requested session. Defaults to 5 minutes.
public init(
identifier: String,
accessPolicy: StytchClient.Biometrics.RegisterParameters.AccessPolicy = .deviceOwnerAuthenticationWithBiometrics,
sessionDuration: Minutes = .defaultSessionDuration
) {
self.identifier = identifier
self.accessPolicy = accessPolicy
self.sessionDuration = sessionDuration
}
}
struct RegisterCompleteResponseData: Codable, Sendable, AuthenticateResponseDataType {
public let biometricRegistrationId: User.BiometricRegistration.ID
public let user: User
public let session: Session
public let sessionToken: String
public let sessionJwt: String
}
}
public extension StytchClient.Biometrics.RegisterParameters {
/// Defines the policy as to how the user must confirm their device ownership.
enum AccessPolicy: Sendable {
/// The device will first try to confirm access rights via biometrics and will fall back to device passcode.
case deviceOwnerAuthentication
/// The device will try to confirm access rights via biometrics.
case deviceOwnerAuthenticationWithBiometrics
#if os(macOS)
/// The device will, in parallel, try to confirm access rights via biometrics or an associated Apple Watch.
case deviceOwnerAuthenticationWithBiometricsOrWatch // swiftlint:disable:this identifier_name
#endif
var keychainValue: KeychainClient.Item.AccessPolicy {
switch self {
case .deviceOwnerAuthentication:
return .deviceOwnerAuthentication
case .deviceOwnerAuthenticationWithBiometrics:
return .deviceOwnerAuthenticationWithBiometrics
#if os(macOS)
case .deviceOwnerAuthenticationWithBiometricsOrWatch:
return .deviceOwnerAuthenticationWithBiometricsOrWatch
#endif
}
}
}
}
// Internal/private parameters and keys
extension StytchClient.Biometrics {
struct AuthenticateStartParameters: Encodable, Sendable {
let publicKey: Data
}
struct AuthenticateStartResponse: Codable, Sendable {
let challenge: Data
let biometricRegistrationId: User.BiometricRegistration.ID
}
struct AuthenticateCompleteParameters: Codable, Sendable {
let signature: Data
let biometricRegistrationId: User.BiometricRegistration.ID
let sessionDurationMinutes: Minutes
}
private struct RegisterStartParameters: Encodable, Sendable {
let publicKey: Data
}
struct RegisterStartResponse: Codable, Sendable {
let biometricRegistrationId: User.BiometricRegistration.ID
let challenge: Data
}
private struct RegisterFinishParameters: Encodable, Sendable {
private enum CodingKeys: String, CodingKey {
case biometricRegistrationId, signature, sessionDuration = "sessionDurationMinutes"
}
let biometricRegistrationId: User.BiometricRegistration.ID
let signature: Data
let sessionDuration: Minutes
}
}