Skip to content

Commit e78f95b

Browse files
authored
feat: add mapLAErrorToLocal update setBiometricAuthentication (#4)
1 parent e3144b4 commit e78f95b

File tree

8 files changed

+506
-87
lines changed

8 files changed

+506
-87
lines changed

.github/workflows/codeql.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ concurrency:
1414
jobs:
1515
analyze:
1616
name: Analyze
17-
runs-on: macos-13
17+
runs-on: macos-14
1818
permissions:
1919
security-events: write
2020

@@ -28,12 +28,12 @@ jobs:
2828
uses: actions/checkout@v3
2929

3030
- name: Initialize CodeQL
31-
uses: github/codeql-action/init@v2
31+
uses: github/codeql-action/init@v3
3232
with:
3333
languages: ${{ matrix.language }}
3434

3535
- name: Build
3636
run: swift build
3737

3838
- name: Perform CodeQL Analysis
39-
uses: github/codeql-action/analyze@v2
39+
uses: github/codeql-action/analyze@v3

.github/workflows/lint.yml

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,10 @@ concurrency:
99

1010
jobs:
1111
SwiftLint:
12-
runs-on: macOS-13
12+
runs-on: macOS-14
1313
steps:
1414
- uses: actions/checkout@v4
15-
- name: Switch Xcode 🔄
16-
run: sudo xcode-select --switch /Applications/Xcode_15.0.app
17-
- name: Swift Lint
18-
run: swiftlint --strict
15+
- name: Install & Run SwiftLint
16+
run: |
17+
brew install swiftlint
18+
swiftlint --strict

.github/workflows/test.yml

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,11 @@ concurrency:
1313

1414
jobs:
1515
build:
16-
runs-on: macOS-13
16+
runs-on: macOS-14
1717
steps:
1818
- uses: actions/checkout@v4
1919
- name: Switch Xcode 🔄
20-
run: sudo xcode-select --switch /Applications/Xcode_15.0.app
21-
- name: Swift Lint
22-
run: swiftlint --strict
20+
run: sudo xcode-select --switch /Applications/Xcode_15.4.app
2321
- name: Test iOS
2422
run: xcodebuild test -scheme fs-local-authentication-provider -destination "platform=iOS Simulator,name=iPhone 15" -enableCodeCoverage YES -skipPackagePluginValidation
2523
- name: Fetch Coverage

Sources/LocalAuthenticationProvider/LocalAuthenticationError.swift

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,6 @@ public enum LocalAuthenticationError: Error {
4646
/// A general biometric error occurred.
4747
case biometricError
4848

49-
/// Access to local authentication was denied.
50-
case deniedAccess
51-
5249
/// No Face ID is enrolled on the device.
5350
case noFaceIdEnrolled
5451

@@ -58,6 +55,42 @@ public enum LocalAuthenticationError: Error {
5855
/// A passcode isn’t set on the device.
5956
case noPasscodeSet
6057

58+
/// The app canceled authentication.
59+
case appCancel
60+
61+
/// The system canceled authentication.
62+
case systemCancel
63+
64+
/// The device supports biometry only using a removable accessory, but the paired accessory isn’t connected.
65+
case biometryDisconnected
66+
67+
/// Biometry is locked because there were too many failed attempts.
68+
case biometryLockout
69+
70+
/// Biometry is not available on the device.
71+
case biometryNotAvailable
72+
73+
/// The user has no enrolled biometric identities.
74+
case biometryNotEnrolled
75+
76+
/// The device supports biometry only using a removable accessory, but no accessory is paired.
77+
case biometryNotPaired
78+
79+
/// The user failed to provide valid credentials.
80+
case authenticationFailed
81+
82+
/// The context was previously invalidated.
83+
case invalidContext
84+
85+
/// Data from the Touch ID or Face ID sensor is transmitted with incorrect "sizes".
86+
case invalidDimensions
87+
88+
/// Displaying the required authentication user interface is forbidden.
89+
case notInteractive
90+
91+
/// The user tapped the fallback button in the authentication dialog, but no fallback is available for the authentication policy.
92+
case userFallback
93+
6194
/// An underlying error occurred.
6295
///
6396
/// - Parameter error: The underlying error.

Sources/LocalAuthenticationProvider/LocalAuthenticationProvider.swift

Lines changed: 131 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -54,30 +54,17 @@ public final class LocalAuthenticationProvider: LocalAuthenticationProviderProto
5454
return true
5555
} else {
5656
if let error {
57-
switch error.code {
58-
case LocalAuthenticationError.denied:
59-
logger.error("\(#function) Denied access on local authentication with: \(error.localizedDescription)")
60-
throw LocalAuthenticationError.deniedAccess
61-
case LocalAuthenticationError.noBiometricsEnrolled:
62-
if context.biometryType == .faceID {
63-
logger.error("\(#function) Denied access on face id with: \(error.localizedDescription)")
64-
throw LocalAuthenticationError.noFaceIdEnrolled
65-
} else if context.biometryType == .touchID {
66-
logger.error("\(#function) Denied access on touch id with: \(error.localizedDescription)")
67-
throw LocalAuthenticationError.noFingerprintEnrolled
68-
} else {
69-
logger.error("\(#function) Local Authentication Error: \(error.localizedDescription)")
70-
throw LocalAuthenticationError.biometricError
71-
}
72-
case LocalAuthenticationError.passcodeNotSet:
73-
logger.error("\(#function) Check biometric auth available: \(error.localizedDescription)")
74-
throw LocalAuthenticationError.noPasscodeSet
75-
default:
76-
logger.error("\(#function) Local Authentication Error: \(error.localizedDescription)")
77-
throw LocalAuthenticationError.error(error)
78-
}
57+
throw mapToLocalAuthenticationError(error, context: context)
58+
} else {
59+
throw mapToLocalAuthenticationError(
60+
NSError(
61+
domain: LAError.errorDomain,
62+
code: LocalAuthenticationError.unknownError,
63+
userInfo: nil
64+
),
65+
context: context
66+
)
7967
}
80-
return false
8168
}
8269
}
8370

@@ -86,10 +73,13 @@ public final class LocalAuthenticationProvider: LocalAuthenticationProviderProto
8673
/// - Returns: `true` if biometric authentication was successfully set up, `false` otherwise.
8774
/// - Throws: An appropriate `LocalAuthenticationError` if an error occurs during setup.
8875
public func setBiometricAuthentication(localizedReason: String) async throws -> Bool {
89-
if try await checkBiometricAvailable(with: .biometrics) {
90-
return try await context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: localizedReason)
91-
} else {
92-
return false
76+
_ = try await checkBiometricAvailable(with: .biometrics)
77+
do {
78+
return try await context.evaluatePolicy(
79+
.deviceOwnerAuthenticationWithBiometrics, localizedReason: localizedReason
80+
)
81+
} catch {
82+
throw mapToLocalAuthenticationError(error, context: context)
9383
}
9484
}
9585

@@ -98,27 +88,19 @@ public final class LocalAuthenticationProvider: LocalAuthenticationProviderProto
9888
/// - Returns: `true` if authentication was successful, `false` otherwise.
9989
/// - Throws: An appropriate `LocalAuthenticationError` if an error occurs during authentication.
10090
public func authenticate(localizedReason: String) async throws -> Bool {
101-
if try await checkBiometricAvailable(with: .biometrics) {
102-
guard context.biometryType != .none else {
103-
logger.error("\(#function) User face or fingerprint were not recognized")
104-
throw LocalAuthenticationError.biometricError
105-
}
106-
107-
do {
108-
if try await context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: localizedReason) {
109-
return true
110-
}
111-
} catch LAError.userCancel {
112-
throw LocalAuthenticationError.userCanceled
113-
} catch {
114-
throw error
115-
}
116-
91+
_ = try await checkBiometricAvailable(with: .biometrics)
92+
guard context.biometryType != .none else {
11793
logger.error("\(#function) User face or fingerprint were not recognized")
118-
return false
119-
} else {
120-
return false
94+
throw LocalAuthenticationError.biometricError
12195
}
96+
do {
97+
if try await context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: localizedReason) {
98+
return true
99+
}
100+
} catch {
101+
throw mapToLocalAuthenticationError(error, context: context)
102+
}
103+
return false
122104
}
123105

124106
/// Retrieves the type of biometric authentication available on the device.
@@ -142,4 +124,107 @@ public final class LocalAuthenticationProvider: LocalAuthenticationProviderProto
142124
}
143125
return .none
144126
}
127+
128+
/// Maps an `Error` to a corresponding `LocalAuthenticationError` for consistent error handling.
129+
/// - Parameters:
130+
/// - error: The original error thrown by the Local Authentication framework.
131+
/// - context: The `LAContext` used during authentication.
132+
/// - Returns: A `LocalAuthenticationError` that represents the mapped error condition.
133+
func mapToLocalAuthenticationError(
134+
_ error: Error,
135+
context: LAContext
136+
) -> LocalAuthenticationError {
137+
if let laError = error as? LAError {
138+
return handleLAError(laError, context: context)
139+
}
140+
/// Converts NSError from the LAError domain into a type-safe LAError and maps it to custom LocalAuthenticationError
141+
if let nsError = error as NSError?, nsError.domain == LAError.errorDomain {
142+
let laError = LAError(_nsError: nsError)
143+
logger.error("\(#function) Caught NSError with LAError domain: \(nsError.localizedDescription)")
144+
return handleLAError(laError, context: context)
145+
}
146+
logger.error("\(#function) Unknown error: \(error.localizedDescription)")
147+
return .error(error)
148+
}
149+
150+
/// Maps an `LAError` to a corresponding `LocalAuthenticationError` using a direct switch statement.
151+
/// - Parameters:
152+
/// - laError: The `LAError` received from the Local Authentication framework.
153+
/// - context: The `LAContext` used during authentication.
154+
/// - Returns: `LocalAuthenticationError` that represents the equivalent error condition.
155+
// swiftlint:disable:next cyclomatic_complexity
156+
func handleLAError(_ laError: LAError, context: LAContext) -> LocalAuthenticationError {
157+
let localizedDescription = laError.localizedDescription
158+
switch laError.code {
159+
case .authenticationFailed:
160+
logger.error("User failed to provide valid credentials: \(localizedDescription)")
161+
return .authenticationFailed
162+
case .userCancel:
163+
logger.error("User canceled the authentication process: \(localizedDescription)")
164+
return .userCanceled
165+
case .userFallback:
166+
logger.error("User tapped fallback button: \(localizedDescription)")
167+
return .userFallback
168+
case .systemCancel:
169+
logger.error("System canceled authentication: \(localizedDescription)")
170+
return .systemCancel
171+
case .appCancel:
172+
logger.error("App canceled authentication: \(localizedDescription)")
173+
return .appCancel
174+
case .notInteractive:
175+
logger.error("Displaying UI forbidden: \(localizedDescription)")
176+
return .notInteractive
177+
case .biometryLockout:
178+
logger.error("Biometry locked due to too many failed attempts: \(localizedDescription)")
179+
return .biometryLockout
180+
case .passcodeNotSet:
181+
logger.error("Passcode not set: \(localizedDescription)")
182+
return .noPasscodeSet
183+
case .biometryNotAvailable:
184+
logger.error("Biometry not available: \(localizedDescription)")
185+
return .biometryNotAvailable
186+
case .invalidContext:
187+
logger.error("Authentication context is invalid: \(localizedDescription)")
188+
return .invalidContext
189+
case .biometryNotEnrolled:
190+
return handleBiometryNotEnrolledError(context: context, laError: laError)
191+
#if os(macOS)
192+
case .biometryDisconnected:
193+
logger.error("Biometric accessory not connected: \(localizedDescription)")
194+
return .biometryDisconnected
195+
case .biometryNotPaired:
196+
logger.error("No paired biometric accessory: \(localizedDescription)")
197+
return .biometryNotPaired
198+
case .invalidDimensions:
199+
logger.error("Biometric sensor data has invalid dimensions: \(localizedDescription)")
200+
return .invalidDimensions
201+
#endif
202+
default:
203+
logger.error("Unknown LAError: \(localizedDescription)")
204+
return .error(laError)
205+
}
206+
}
207+
208+
/// Handles `.biometryNotEnrolled` errors by mapping them to specific biometric enrollment issues.
209+
/// - Parameters:
210+
/// - context: The `LAContext` containing information about the current biometry type.
211+
/// - laError: The `LAError` instance with the `.biometryNotEnrolled` code.
212+
/// - Returns: A `LocalAuthenticationError` indicating the missing biometric enrollment type.
213+
func handleBiometryNotEnrolledError(
214+
context: LAContext,
215+
laError: LAError
216+
) -> LocalAuthenticationError {
217+
let localizedDescription = laError.localizedDescription
218+
switch context.biometryType {
219+
case .faceID:
220+
logger.error("No Face ID enrolled: \(localizedDescription)")
221+
return .noFaceIdEnrolled
222+
case .touchID:
223+
logger.error("No Touch ID enrolled: \(localizedDescription)")
224+
return .noFingerprintEnrolled
225+
default:
226+
logger.error("No biometrics enrolled: \(localizedDescription)")
227+
return .biometricError
228+
}
229+
}
145230
}

0 commit comments

Comments
 (0)