diff --git a/packages/amplify_core/lib/src/state_machine/event.dart b/packages/amplify_core/lib/src/state_machine/event.dart index 6e37e90588..b06976098d 100644 --- a/packages/amplify_core/lib/src/state_machine/event.dart +++ b/packages/amplify_core/lib/src/state_machine/event.dart @@ -68,10 +68,10 @@ final class EventCompleter completer.completeError(error))` would + /// still throw the error in the Zone where the completer was instantiated, + /// not `_zone`. And due to how Zone's work, a listener for a completer which + /// completes in a different error zone will never finish. /// /// The following example illustrates the problem we're trying to solve /// here: @@ -168,5 +168,8 @@ final class EventCompleter signIn(WidgetTester tester) async { + Future signIn( + WidgetTester tester, { + bool cancel = false, + }) async { stateMachine.addInstance( HostedUiTestPlatform( tester, stateMachine, username: username, password: password, + cancel: cancel, ), ); _logger.debug('Signing in with Web UI'); - final result = await plugin.signInWithWebUI( - provider: AuthProvider.cognito, - ); - _logger.debug('Signed in with Web UI'); - expect(result.isSignedIn, isTrue); + if (cancel) { + final expectation = expectLater( + plugin.signInWithWebUI( + provider: AuthProvider.cognito, + ), + throwsA(isA()), + ); + final hostedUiMachine = + stateMachine.expect(HostedUiStateMachine.type); + expect( + hostedUiMachine.stream, + emitsInOrder([ + isA(), + isA().having( + (s) => s.exception, + 'exception', + isA(), + ), + emitsDone, + ]), + ); + await expectation; + // Ensure queue is flushed and done event is emitted after + // signInWithWebUI completes. + await hostedUiMachine.close(); + } else { + final result = await plugin.signInWithWebUI( + provider: AuthProvider.cognito, + ); + _logger.debug('Signed in with Web UI'); + expect(result.isSignedIn, isTrue); + } } Future signOut({required bool globalSignOut}) async { @@ -112,14 +142,17 @@ void main() { await signOut(globalSignOut: false); }); + testWidgets('cancel sign-in', (tester) async { + await signIn(tester, cancel: true); + }); + testWidgets('global sign out', (tester) async { await signIn(tester); await signOut(globalSignOut: true); }); }, // Add remaining platforms as `webview_flutter` adds support. - // TODO(dnys1): Investigate Android failures in CI on Android 33+ - skip: zIsWeb || !((Platform.isAndroid && !isCI) || Platform.isIOS), + skip: zIsWeb || !(Platform.isAndroid || Platform.isIOS), ); } @@ -129,11 +162,15 @@ class HostedUiTestPlatform extends HostedUiPlatformImpl { DependencyManager manager, { required this.username, required this.password, + required this.cancel, }) : super(manager); final String username; final String password; + /// Whether to cancel the sign-in flow once initiated. + final bool cancel; + final WidgetTester tester; final Completer _controller = Completer(); final Completer _oauthParameters = Completer(); @@ -143,6 +180,9 @@ class HostedUiTestPlatform extends HostedUiPlatformImpl { required CognitoSignInWithWebUIPluginOptions options, AuthProvider? provider, }) async { + if (cancel) { + throw const UserCancelledException('Cancelled'); + } final signInUri = await getSignInUri(provider: provider); await tester.pumpWidget( HostedUiApp( diff --git a/packages/auth/amplify_auth_cognito/example/pubspec.yaml b/packages/auth/amplify_auth_cognito/example/pubspec.yaml index 96bae49ee7..ddc36409fe 100644 --- a/packages/auth/amplify_auth_cognito/example/pubspec.yaml +++ b/packages/auth/amplify_auth_cognito/example/pubspec.yaml @@ -40,6 +40,7 @@ dev_dependencies: io: ^1.0.0 otp: ^3.1.4 stack_trace: ^1.10.0 + stream_transform: ^2.1.0 test: ^1.22.1 webdriver: ^3.0.0 webview_flutter: ^4.0.0 diff --git a/packages/auth/amplify_auth_cognito/lib/src/flows/hosted_ui/hosted_ui_platform_flutter.dart b/packages/auth/amplify_auth_cognito/lib/src/flows/hosted_ui/hosted_ui_platform_flutter.dart index d5a0f90049..f108e5110e 100644 --- a/packages/auth/amplify_auth_cognito/lib/src/flows/hosted_ui/hosted_ui_platform_flutter.dart +++ b/packages/auth/amplify_auth_cognito/lib/src/flows/hosted_ui/hosted_ui_platform_flutter.dart @@ -16,6 +16,7 @@ import 'package:amplify_auth_cognito_dart/src/model/hosted_ui/oauth_parameters.d // ignore: implementation_imports, invalid_use_of_internal_member import 'package:amplify_auth_cognito_dart/src/state/state.dart'; import 'package:amplify_core/amplify_core.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; /// {@template amplify_auth_cognito.hosted_ui_platform_flutter} @@ -27,7 +28,17 @@ class HostedUiPlatformImpl extends io.HostedUiPlatformImpl { /// {@macro amplify_auth_cognito.hosted_ui_platform_flutter} HostedUiPlatformImpl(super.dependencyManager); - static final bool _isMobile = Platform.isAndroid || Platform.isIOS; + static bool get _isMobile { + if (Platform.isAndroid || Platform.isIOS) { + return true; + } + // Allow overrides for tests + if (zAssertsEnabled) { + return debugDefaultTargetPlatformOverride == TargetPlatform.android || + debugDefaultTargetPlatformOverride == TargetPlatform.iOS; + } + return false; + } NativeAuthBridge get _nativeAuthBridge => dependencyManager.expect(); @@ -80,39 +91,37 @@ class HostedUiPlatformImpl extends io.HostedUiPlatformImpl { options.isPreferPrivateSession, options.browserPackageName, ); - unawaited( - dispatcher.dispatchAndComplete( - HostedUiEvent.exchange( - OAuthParameters.fromJson(queryParameters.cast()), - ), - ), - ); + dispatcher + .dispatch( + HostedUiEvent.exchange( + OAuthParameters.fromJson(queryParameters.cast()), + ), + ) + .ignore(); } on Exception catch (e) { - unawaited( - dispatcher.dispatchAndComplete(const HostedUiEvent.cancelSignIn()), - ); - if (e is PlatformException) { - if (e.code == 'CANCELLED') { + switch (e) { + case PlatformException(code: 'CANCELLED'): throw const UserCancelledException( 'The user cancelled the sign-in flow', ); - } - // Generated Android message is `CLASS_NAME: message` - var message = e.message; - if (message != null && message.contains(': ')) { - message = message.split(': ')[1]; - } - String? recoverySuggestion; - if (e.code == 'NOBROWSER') { - recoverySuggestion = "Ensure you've added the tag to your " - 'AndroidManifest.xml as outlined in the docs'; - } - throw UrlLauncherException( - message ?? 'An unknown error occurred', - recoverySuggestion: recoverySuggestion, - ); + case PlatformException(:final code, :var message): + // Generated Android message is `CLASS_NAME: message` + if (message != null && message.contains(': ')) { + message = message.split(': ')[1]; + } + String? recoverySuggestion; + if (code == 'NOBROWSER') { + recoverySuggestion = + "Ensure you've added the tag to your " + 'AndroidManifest.xml as outlined in the docs'; + } + throw UrlLauncherException( + message ?? 'An unknown error occurred', + recoverySuggestion: recoverySuggestion, + ); + default: + rethrow; } - rethrow; } } diff --git a/packages/auth/amplify_auth_cognito/pubspec.yaml b/packages/auth/amplify_auth_cognito/pubspec.yaml index 20790eda9b..8042e86754 100644 --- a/packages/auth/amplify_auth_cognito/pubspec.yaml +++ b/packages/auth/amplify_auth_cognito/pubspec.yaml @@ -33,6 +33,7 @@ dependencies: plugin_platform_interface: ^2.0.0 dev_dependencies: + amplify_auth_cognito_test: any amplify_lints: ">=3.0.0 <3.1.0" flutter_test: sdk: flutter diff --git a/packages/auth/amplify_auth_cognito/test/hosted_ui_platform_flutter_test.dart b/packages/auth/amplify_auth_cognito/test/hosted_ui_platform_flutter_test.dart new file mode 100644 index 0000000000..728f725c04 --- /dev/null +++ b/packages/auth/amplify_auth_cognito/test/hosted_ui_platform_flutter_test.dart @@ -0,0 +1,90 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +// ignore_for_file: invalid_use_of_internal_member, non_constant_identifier_names + +import 'package:amplify_auth_cognito/amplify_auth_cognito.dart'; +import 'package:amplify_auth_cognito/src/flows/hosted_ui/hosted_ui_platform_flutter.dart'; +import 'package:amplify_auth_cognito/src/native_auth_plugin.g.dart'; +import 'package:amplify_auth_cognito_dart/src/flows/hosted_ui/hosted_ui_platform.dart'; +import 'package:amplify_auth_cognito_dart/src/state/cognito_state_machine.dart'; +import 'package:amplify_auth_cognito_dart/src/state/state.dart'; +import 'package:amplify_auth_cognito_test/amplify_auth_cognito_test.dart'; +import 'package:amplify_flutter/amplify_flutter.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + AWSLogger().logLevel = LogLevel.verbose; + + group('HostedUiPlatformFlutter', () { + late AmplifyAuthCognito plugin; + late SecureStorageInterface secureStorage; + late DependencyManager dependencyManager; + + setUp(() async { + secureStorage = MockSecureStorage(); + dependencyManager = DependencyManager() + ..addInstance(hostedUiConfig) + ..addInstance(secureStorage) + ..addInstance(ThrowingNativeBridge()); + plugin = AmplifyAuthCognito() + ..stateMachine = CognitoAuthStateMachine( + dependencyManager: dependencyManager, + ); + plugin.stateMachine.addBuilder( + HostedUiPlatformImpl.new, + ); + await plugin.stateMachine.acceptAndComplete( + ConfigurationEvent.configure(mockConfig), + ); + }); + + tearDown(() => plugin.close()); + + test('can cancel flow', () async { + // Pretend to be iOS so that the `ThrowingNativeBridge` is called, + // mimicking a failure from the platform channel. + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + + final expectation = expectLater( + plugin.signInWithWebUI( + provider: AuthProvider.cognito, + ), + throwsA(isA()), + ); + final hostedUiMachine = + plugin.stateMachine.expect(HostedUiStateMachine.type); + expect( + hostedUiMachine.stream, + emitsInOrder([ + isA(), + isA().having( + (s) => s.exception, + 'exception', + isA(), + ), + emitsDone, + ]), + ); + await expectation; + // Ensure queue is flushed and done event is emitted after + // signInWithWebUI completes. + await hostedUiMachine.close(); + }); + }); +} + +final class ThrowingNativeBridge extends Fake implements NativeAuthBridge { + @override + Future> signInWithUrl( + String arg_url, + String arg_callbackUrlScheme, + bool arg_preferPrivateSession, + String? arg_browserPackageName, + ) async { + throw PlatformException(code: 'CANCELLED'); + } +} diff --git a/packages/auth/amplify_auth_cognito_dart/lib/src/flows/hosted_ui/hosted_ui_platform_io.dart b/packages/auth/amplify_auth_cognito_dart/lib/src/flows/hosted_ui/hosted_ui_platform_io.dart index ebcf0b1426..28791f6aca 100644 --- a/packages/auth/amplify_auth_cognito_dart/lib/src/flows/hosted_ui/hosted_ui_platform_io.dart +++ b/packages/auth/amplify_auth_cognito_dart/lib/src/flows/hosted_ui/hosted_ui_platform_io.dart @@ -250,13 +250,13 @@ class HostedUiPlatformImpl extends HostedUiPlatform { ); continue; } - unawaited( - dispatcher.dispatchAndComplete( - HostedUiEvent.exchange( - OAuthParameters.fromJson(queryParams), - ), - ), - ); + dispatcher + .dispatch( + HostedUiEvent.exchange( + OAuthParameters.fromJson(queryParams), + ), + ) + .ignore(); await _respond( request, HttpStatus.ok, @@ -268,7 +268,7 @@ class HostedUiPlatformImpl extends HostedUiPlatform { break; } } finally { - unawaited(close()); + close().ignore(); } } @@ -324,7 +324,7 @@ class HostedUiPlatformImpl extends HostedUiPlatform { break; } } finally { - unawaited(close()); + close().ignore(); } } diff --git a/packages/auth/amplify_auth_cognito_dart/lib/src/state/event/hosted_ui_event.dart b/packages/auth/amplify_auth_cognito_dart/lib/src/state/event/hosted_ui_event.dart index 1e04fcbbf0..0c6d2a9e4d 100644 --- a/packages/auth/amplify_auth_cognito_dart/lib/src/state/event/hosted_ui_event.dart +++ b/packages/auth/amplify_auth_cognito_dart/lib/src/state/event/hosted_ui_event.dart @@ -49,7 +49,10 @@ sealed class HostedUiEvent }) = HostedUiSignIn; /// {@macro amplify_auth_cognito.hosted_ui_cancel_sign_in} - const factory HostedUiEvent.cancelSignIn() = HostedUiCancelSignIn; + const factory HostedUiEvent.cancelSignIn([ + Object? error, + StackTrace? stackTrace, + ]) = HostedUiCancelSignIn; /// {@macro amplify_auth_cognito.hosted_ui_exchange} const factory HostedUiEvent.exchange(OAuthParameters parameters) = @@ -143,10 +146,23 @@ final class HostedUiSignIn extends HostedUiEvent { /// {@endtemplate} final class HostedUiCancelSignIn extends HostedUiEvent { /// {@macro amplify_auth_cognito.hosted_ui_cancel_sign_in} - const HostedUiCancelSignIn() : super._(); + const HostedUiCancelSignIn([ + Object? error, + this.stackTrace, + ]) : error = error ?? + const UserCancelledException( + 'The user canceled the sign-in flow', + ), + super._(); + + /// The cause of the cancellation. + final Object error; + + /// The stack trace associated with [error]. + final StackTrace? stackTrace; @override - List get props => [type]; + List get props => [type, error, stackTrace]; @override HostedUiEventType get type => HostedUiEventType.cancelSignIn; diff --git a/packages/auth/amplify_auth_cognito_dart/lib/src/state/machines/hosted_ui_state_machine.dart b/packages/auth/amplify_auth_cognito_dart/lib/src/state/machines/hosted_ui_state_machine.dart index 655ca454f6..b9235e84ee 100644 --- a/packages/auth/amplify_auth_cognito_dart/lib/src/state/machines/hosted_ui_state_machine.dart +++ b/packages/auth/amplify_auth_cognito_dart/lib/src/state/machines/hosted_ui_state_machine.dart @@ -36,7 +36,7 @@ final class HostedUiStateMachine SecureStorageInterface get _secureStorage => getOrCreate(); /// The platform-specific behavior. - late final HostedUiPlatform _platform = getOrCreate(); + HostedUiPlatform get _platform => getOrCreate(); /// The configured identity pool. CognitoIdentityCredentialsProvider? get _identityPoolConfig => get(); @@ -128,9 +128,14 @@ final class HostedUiStateMachine } else { await _secureStorage.delete(key: _keys[HostedUiKey.provider]); } - await _platform.signIn( - options: event.options, - provider: provider, + unawaited( + // Unblock the state machine to accept new events but ensure + // that [onCancelSignIn] is called if any error occurs. + _platform.signIn(options: event.options, provider: provider).onError( + (error, stackTrace) => dispatch( + HostedUiEvent.cancelSignIn(error, stackTrace), + ), + ), ); } @@ -138,7 +143,10 @@ final class HostedUiStateMachine Future onCancelSignIn(HostedUiCancelSignIn event) async { await _platform.cancelSignIn(); await manager.clearCredentials(_keys); - throw const UserCancelledException('The user cancelled the sign-in flow'); + Error.throwWithStackTrace( + event.error, + event.stackTrace ?? StackTrace.current, + ); } /// State machine callback for the [HostedUiExchange] event. diff --git a/packages/auth/amplify_auth_cognito_test/test/flows/hostedui/hosted_ui_platform_test.dart b/packages/auth/amplify_auth_cognito_test/test/flows/hostedui/hosted_ui_platform_test.dart index e5301c1d9a..c9a600d928 100644 --- a/packages/auth/amplify_auth_cognito_test/test/flows/hostedui/hosted_ui_platform_test.dart +++ b/packages/auth/amplify_auth_cognito_test/test/flows/hostedui/hosted_ui_platform_test.dart @@ -1,9 +1,13 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 +import 'dart:async'; + +import 'package:amplify_auth_cognito_dart/amplify_auth_cognito_dart.dart'; import 'package:amplify_auth_cognito_dart/src/credentials/cognito_keys.dart'; import 'package:amplify_auth_cognito_dart/src/flows/hosted_ui/hosted_ui_platform.dart'; import 'package:amplify_auth_cognito_dart/src/model/hosted_ui/oauth_parameters.dart'; +import 'package:amplify_auth_cognito_dart/src/state/cognito_state_machine.dart'; import 'package:amplify_auth_cognito_dart/src/state/state.dart'; import 'package:amplify_auth_cognito_test/common/mock_config.dart'; import 'package:amplify_auth_cognito_test/common/mock_dispatcher.dart'; @@ -12,6 +16,7 @@ import 'package:amplify_auth_cognito_test/common/mock_secure_storage.dart'; import 'package:amplify_core/amplify_core.dart'; import 'package:amplify_secure_storage_dart/amplify_secure_storage_dart.dart'; import 'package:http/http.dart' as http; +import 'package:test/fake.dart'; import 'package:test/test.dart'; final throwsInvalidStateException = throwsA(isA()); @@ -23,6 +28,8 @@ void main() { late DependencyManager dependencyManager; const keys = HostedUiKeys(hostedUiConfig); + AWSLogger().logLevel = LogLevel.verbose; + group('HostedUiPlatform', () { setUp(() { server = MockOAuthServer(); @@ -108,5 +115,75 @@ void main() { ); }); }); + + group('signIn', () { + late AmplifyAuthCognitoDart plugin; + + setUp(() async { + dependencyManager.addInstance( + CancelingHostedUiPlatform( + cancelSignIn: expectAsync0(() async {}), + ), + ); + plugin = AmplifyAuthCognitoDart() + ..stateMachine = CognitoAuthStateMachine( + dependencyManager: dependencyManager, + ); + await plugin.stateMachine.acceptAndComplete( + ConfigurationEvent.configure(mockConfig), + ); + }); + + tearDown(() => plugin.close()); + + test('can cancel flow', () async { + final expectation = expectLater( + plugin.signInWithWebUI( + provider: AuthProvider.cognito, + ), + throwsA(isA()), + ); + final hostedUiMachine = + plugin.stateMachine.expect(HostedUiStateMachine.type); + expect( + hostedUiMachine.stream, + emitsInOrder([ + isA(), + isA().having( + (s) => s.exception, + 'exception', + isA(), + ), + emitsDone, + ]), + ); + await expectation; + // Ensure queue is flushed and done event is emitted after + // signInWithWebUI completes. + await hostedUiMachine.close(); + }); + }); }); } + +final class CancelingHostedUiPlatform extends Fake implements HostedUiPlatform { + CancelingHostedUiPlatform({ + required Future Function() cancelSignIn, + }) : _cancelSignIn = cancelSignIn; + + final Future Function() _cancelSignIn; + + @override + Future signIn({ + required CognitoSignInWithWebUIPluginOptions options, + AuthProvider? provider, + }) async { + throw const UserCancelledException('Cancelled'); + } + + @override + Future cancelSignIn() => _cancelSignIn(); + + @override + void close() {} +}