Skip to content

Commit f0b2726

Browse files
authored
[local_auth] Differentiate iOS authentication errors (#9705)
On iOS, the plugin previously returned a generic `NotAvailable` PlatformException for several distinct failure conditions. This made it impossible for developers to handle specific cases e.g user cancellation. This change ensures the plugin returns specific error codes for distinct user actions on iOS: - Returns UserCancelled when a user cancels the authentication prompt. - Returns UserFallback when a user selects the fallback option (e.g., "Enter Password"). - Returns BiometricNotAvailable when the device does not have biometrics enabled or available. Fixes flutter/flutter#148942 ## Pre-Review Checklist - [X] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [X] I read the [Tree Hygiene] page, which explains my responsibilities. - [X] I read and followed the [relevant style guides] and ran [the auto-formatter]. - [X] I signed the [CLA]. - [X] The title of the PR starts with the name of the package surrounded by square brackets, e.g. `[shared_preferences]` - [X] I [linked to at least one issue that this PR fixes] in the description above. - [X] I updated `pubspec.yaml` with an appropriate new version according to the [pub versioning philosophy], or I have commented below to indicate which [version change exemption] this PR falls under[^1]. - [X] I updated `CHANGELOG.md` to add a description of the change, [following repository CHANGELOG style], or I have commented below to indicate which [CHANGELOG exemption] this PR falls under[^1]. - [X] I updated/added any relevant documentation (doc comments with `///`). - [X] I added new tests to check the change I am making, or I have commented below to indicate which [test exemption] this PR falls under[^1]. - [X] All existing and new tests are passing. If you need help, consider asking for advice on the #hackers-new channel on [Discord]. **Note**: The Flutter team is currently trialing the use of [Gemini Code Assist for GitHub](https://developers.google.com/gemini-code-assist/docs/review-github-code). Comments from the `gemini-code-assist` bot should not be taken as authoritative feedback from the Flutter team. If you find its comments useful you can update your code accordingly, but if you are unsure or disagree with the feedback, please feel free to wait for a Flutter team member's review for guidance on which automated comments should be addressed. [^1]: Regular contributors who have demonstrated familiarity with the repository guidelines only need to comment if the PR is not auto-exempted by repo tooling. <!-- Links --> [Contributor Guide]: https://github.com/flutter/packages/blob/main/CONTRIBUTING.md [Tree Hygiene]: https://github.com/flutter/flutter/blob/master/docs/contributing/Tree-hygiene.md [relevant style guides]: https://github.com/flutter/packages/blob/main/CONTRIBUTING.md#style [the auto-formatter]: https://github.com/flutter/packages/blob/main/script/tool/README.md#format-code [CLA]: https://cla.developers.google.com/ [Discord]: https://github.com/flutter/flutter/blob/master/docs/contributing/Chat.md [linked to at least one issue that this PR fixes]: https://github.com/flutter/flutter/blob/master/docs/contributing/Tree-hygiene.md#overview [pub versioning philosophy]: https://dart.dev/tools/pub/versioning [version change exemption]: https://github.com/flutter/flutter/blob/master/docs/ecosystem/contributing/README.md#version [following repository CHANGELOG style]: https://github.com/flutter/flutter/blob/master/docs/ecosystem/contributing/README.md#changelog-style [CHANGELOG exemption]: https://github.com/flutter/flutter/blob/master/docs/ecosystem/contributing/README.md#changelog [test exemption]: https://github.com/flutter/flutter/blob/master/docs/contributing/Tree-hygiene.md#tests
1 parent 32c7212 commit f0b2726

File tree

9 files changed

+214
-4
lines changed

9 files changed

+214
-4
lines changed

packages/local_auth/local_auth_darwin/CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
1+
## 1.6.0
2+
3+
* Provides more specific error codes on iOS for authentication failures.
4+
* `LockedOut` is now returned for biometric lockout.
5+
* `UserCancelled` is now returned when the user cancels the prompt.
6+
* `UserFallback` is now returned when the user selects the fallback option.
7+
18
## 1.5.0
29

310
* Converts implementation to Swift.

packages/local_auth/local_auth_darwin/darwin/Tests/FLALocalAuthPluginTests.swift

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,99 @@ class LocalAuthPluginTests: XCTestCase {
266266
self.waitForExpectations(timeout: timeout)
267267
}
268268

269+
@MainActor
270+
func testFailedAuthWithErrorUserCancelled() {
271+
let stubAuthContext = StubAuthContext()
272+
let alertFactory = StubAlertFactory()
273+
let viewProvider = StubViewProvider()
274+
let plugin = LocalAuthPlugin(
275+
contextFactory: StubAuthContextFactory(contexts: [stubAuthContext]),
276+
alertFactory: alertFactory,
277+
viewProvider: viewProvider)
278+
279+
let strings = createAuthStrings()
280+
stubAuthContext.evaluateError = NSError(
281+
domain: "LocalAuthentication", code: LAError.userCancel.rawValue)
282+
283+
let expectation = expectation(description: "Result is called for user cancel")
284+
plugin.authenticate(
285+
options: AuthOptions(biometricOnly: false, sticky: false, useErrorDialogs: false),
286+
strings: strings
287+
) { resultDetails in
288+
XCTAssertTrue(Thread.isMainThread)
289+
switch resultDetails {
290+
case .success(let successDetails):
291+
XCTAssertEqual(successDetails.result, .errorUserCancelled)
292+
case .failure(let error):
293+
XCTFail("Unexpected error: \(error)")
294+
}
295+
expectation.fulfill()
296+
}
297+
self.waitForExpectations(timeout: timeout)
298+
}
299+
300+
@MainActor
301+
func testFailedAuthWithErrorUserFallback() {
302+
let stubAuthContext = StubAuthContext()
303+
let alertFactory = StubAlertFactory()
304+
let viewProvider = StubViewProvider()
305+
let plugin = LocalAuthPlugin(
306+
contextFactory: StubAuthContextFactory(contexts: [stubAuthContext]),
307+
alertFactory: alertFactory,
308+
viewProvider: viewProvider)
309+
310+
let strings = createAuthStrings()
311+
stubAuthContext.evaluateError = NSError(
312+
domain: "LocalAuthentication", code: LAError.userFallback.rawValue)
313+
314+
let expectation = expectation(description: "Result is called for user fallback")
315+
plugin.authenticate(
316+
options: AuthOptions(biometricOnly: false, sticky: false, useErrorDialogs: false),
317+
strings: strings
318+
) { resultDetails in
319+
XCTAssertTrue(Thread.isMainThread)
320+
switch resultDetails {
321+
case .success(let successDetails):
322+
XCTAssertEqual(successDetails.result, .errorUserFallback)
323+
case .failure(let error):
324+
XCTFail("Unexpected error: \(error)")
325+
}
326+
expectation.fulfill()
327+
}
328+
self.waitForExpectations(timeout: timeout)
329+
}
330+
331+
@MainActor
332+
func testFailedAuthWithErrorBiometricNotAvailable() {
333+
let stubAuthContext = StubAuthContext()
334+
let alertFactory = StubAlertFactory()
335+
let viewProvider = StubViewProvider()
336+
let plugin = LocalAuthPlugin(
337+
contextFactory: StubAuthContextFactory(contexts: [stubAuthContext]),
338+
alertFactory: alertFactory,
339+
viewProvider: viewProvider)
340+
341+
let strings = createAuthStrings()
342+
stubAuthContext.canEvaluateError = NSError(
343+
domain: "LocalAuthentication", code: LAError.biometryNotAvailable.rawValue)
344+
345+
let expectation = expectation(description: "Result is called for biometric not available")
346+
plugin.authenticate(
347+
options: AuthOptions(biometricOnly: false, sticky: false, useErrorDialogs: false),
348+
strings: strings
349+
) { resultDetails in
350+
XCTAssertTrue(Thread.isMainThread)
351+
switch resultDetails {
352+
case .success(let successDetails):
353+
XCTAssertEqual(successDetails.result, .errorBiometricNotAvailable)
354+
case .failure(let error):
355+
XCTFail("Unexpected error: \(error)")
356+
}
357+
expectation.fulfill()
358+
}
359+
self.waitForExpectations(timeout: timeout)
360+
}
361+
269362
@MainActor
270363
func testFailedWithUnknownErrorCode() {
271364
let stubAuthContext = StubAuthContext()

packages/local_auth/local_auth_darwin/darwin/local_auth_darwin/Sources/local_auth_darwin/LocalAuthPlugin.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -381,6 +381,12 @@ public final class LocalAuthPlugin: NSObject, FlutterPlugin, LocalAuthApi, @unch
381381
return
382382
}
383383
result = errorCode == .passcodeNotSet ? .errorPasscodeNotSet : .errorNotEnrolled
384+
case .userCancel:
385+
result = .errorUserCancelled
386+
case .userFallback:
387+
result = .errorUserFallback
388+
case .biometryNotAvailable:
389+
result = .errorBiometricNotAvailable
384390
case .biometryLockout:
385391
DispatchQueue.main.async { [weak self] in
386392
self?.showAlert(

packages/local_auth/local_auth_darwin/darwin/local_auth_darwin/Sources/local_auth_darwin/messages.g.swift

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Copyright 2013 The Flutter Authors. All rights reserved.
22
// Use of this source code is governed by a BSD-style license that can be
33
// found in the LICENSE file.
4-
// Autogenerated from Pigeon (v25.3.2), do not edit directly.
4+
// Autogenerated from Pigeon (v25.5.0), do not edit directly.
55
// See also: https://pub.dev/packages/pigeon
66

77
import Foundation
@@ -141,6 +141,12 @@ enum AuthResult: Int {
141141
case errorNotEnrolled = 3
142142
/// No passcode is set.
143143
case errorPasscodeNotSet = 4
144+
/// The user cancelled the authentication.
145+
case errorUserCancelled = 5
146+
/// The user tapped the "Enter Password" fallback.
147+
case errorUserFallback = 6
148+
/// The user biometrics is disabled.
149+
case errorBiometricNotAvailable = 7
144150
}
145151

146152
/// Pigeon equivalent of the subset of BiometricType used by iOS.

packages/local_auth/local_auth_darwin/lib/local_auth_darwin.dart

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,21 @@ class LocalAuthDarwin extends LocalAuthPlatform {
7575
code: 'PasscodeNotSet',
7676
message: resultDetails.errorMessage,
7777
details: resultDetails.errorDetails);
78+
case AuthResult.errorUserCancelled:
79+
throw PlatformException(
80+
code: 'UserCancelled',
81+
message: resultDetails.errorMessage,
82+
details: resultDetails.errorDetails);
83+
case AuthResult.errorBiometricNotAvailable:
84+
throw PlatformException(
85+
code: 'BiometricNotAvailable',
86+
message: resultDetails.errorMessage,
87+
details: resultDetails.errorDetails);
88+
case AuthResult.errorUserFallback:
89+
throw PlatformException(
90+
code: 'UserFallback',
91+
message: resultDetails.errorMessage,
92+
details: resultDetails.errorDetails);
7893
}
7994
}
8095

packages/local_auth/local_auth_darwin/lib/src/messages.g.dart

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Copyright 2013 The Flutter Authors. All rights reserved.
22
// Use of this source code is governed by a BSD-style license that can be
33
// found in the LICENSE file.
4-
// Autogenerated from Pigeon (v25.3.2), do not edit directly.
4+
// Autogenerated from Pigeon (v25.5.0), do not edit directly.
55
// See also: https://pub.dev/packages/pigeon
66
// ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers
77

@@ -49,6 +49,15 @@ enum AuthResult {
4949

5050
/// No passcode is set.
5151
errorPasscodeNotSet,
52+
53+
/// The user cancelled the authentication.
54+
errorUserCancelled,
55+
56+
/// The user tapped the "Enter Password" fallback.
57+
errorUserFallback,
58+
59+
/// The user biometrics is disabled.
60+
errorBiometricNotAvailable,
5261
}
5362

5463
/// Pigeon equivalent of the subset of BiometricType used by iOS.

packages/local_auth/local_auth_darwin/pigeons/messages.dart

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,15 @@ enum AuthResult {
4949

5050
/// No passcode is set.
5151
errorPasscodeNotSet,
52+
53+
/// The user cancelled the authentication.
54+
errorUserCancelled,
55+
56+
/// The user tapped the "Enter Password" fallback.
57+
errorUserFallback,
58+
59+
/// The user biometrics is disabled.
60+
errorBiometricNotAvailable,
5261
}
5362

5463
class AuthOptions {

packages/local_auth/local_auth_darwin/pubspec.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ name: local_auth_darwin
22
description: iOS implementation of the local_auth plugin.
33
repository: https://github.com/flutter/packages/tree/main/packages/local_auth/local_auth_darwin
44
issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+local_auth%22
5-
version: 1.5.0
5+
version: 1.6.0
66

77
environment:
88
sdk: ^3.6.0
@@ -32,7 +32,7 @@ dev_dependencies:
3232
flutter_test:
3333
sdk: flutter
3434
mockito: ^5.4.4
35-
pigeon: ^25.3.2
35+
pigeon: ^26.0.0
3636

3737
topics:
3838
- authentication

packages/local_auth/local_auth_darwin/test/local_auth_darwin_test.dart

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,71 @@ void main() {
372372
errorDetails)));
373373
});
374374

375+
test('converts errorUserCancelled to PlatformException', () async {
376+
const String errorMessage = 'The user cancelled authentication.';
377+
const String errorDetails = 'com.apple.LocalAuthentication';
378+
when(api.authenticate(any, any)).thenAnswer((_) async =>
379+
AuthResultDetails(
380+
result: AuthResult.errorUserCancelled,
381+
errorMessage: errorMessage,
382+
errorDetails: errorDetails));
383+
384+
expect(
385+
() async => plugin.authenticate(
386+
localizedReason: 'reason', authMessages: <AuthMessages>[]),
387+
throwsA(isA<PlatformException>()
388+
.having(
389+
(PlatformException e) => e.code, 'code', 'UserCancelled')
390+
.having(
391+
(PlatformException e) => e.message, 'message', errorMessage)
392+
.having((PlatformException e) => e.details, 'details',
393+
errorDetails)));
394+
});
395+
396+
test('converts errorUserFallback to PlatformException', () async {
397+
const String errorMessage = 'The user chose to use the fallback.';
398+
const String errorDetails = 'com.apple.LocalAuthentication';
399+
when(api.authenticate(any, any)).thenAnswer((_) async =>
400+
AuthResultDetails(
401+
result: AuthResult.errorUserFallback,
402+
errorMessage: errorMessage,
403+
errorDetails: errorDetails));
404+
405+
expect(
406+
() async => plugin.authenticate(
407+
localizedReason: 'reason', authMessages: <AuthMessages>[]),
408+
throwsA(isA<PlatformException>()
409+
.having((PlatformException e) => e.code, 'code', 'UserFallback')
410+
.having(
411+
(PlatformException e) => e.message, 'message', errorMessage)
412+
.having((PlatformException e) => e.details, 'details',
413+
errorDetails)));
414+
});
415+
416+
test('converts errorBiometricNotAvailable to PlatformException',
417+
() async {
418+
const String errorMessage =
419+
'Biometrics are not available on this device.';
420+
const String errorDetails = 'com.apple.LocalAuthentication';
421+
when(api.authenticate(any, any)).thenAnswer((_) async =>
422+
AuthResultDetails(
423+
result: AuthResult.errorBiometricNotAvailable,
424+
errorMessage: errorMessage,
425+
errorDetails: errorDetails));
426+
427+
expect(
428+
() async => plugin.authenticate(
429+
localizedReason: 'reason', authMessages: <AuthMessages>[]),
430+
throwsA(isA<PlatformException>()
431+
// The code here should match what you defined in your Dart switch statement.
432+
.having((PlatformException e) => e.code, 'code',
433+
'BiometricNotAvailable')
434+
.having(
435+
(PlatformException e) => e.message, 'message', errorMessage)
436+
.having((PlatformException e) => e.details, 'details',
437+
errorDetails)));
438+
});
439+
375440
test('converts errorPasscodeNotSet to legacy PlatformException',
376441
() async {
377442
const String errorMessage = 'a message';

0 commit comments

Comments
 (0)