Skip to content

Commit ceedf2d

Browse files
authored
Merge pull request #1917 from dnys1/fix/auth/multiple-hosted-ui-attempts
feat(auth): Allow multiple Hosted UI sign-in attempts
2 parents 1fac842 + 255b2b5 commit ceedf2d

29 files changed

+620
-322
lines changed

packages/amplify_authenticator/lib/amplify_authenticator.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -598,7 +598,7 @@ class _AuthenticatorState extends State<Authenticator> {
598598
_exceptionSub.cancel();
599599
_infoSub.cancel();
600600
_successSub.cancel();
601-
_stateMachineBloc.dispose();
601+
_stateMachineBloc.close();
602602
_hubSubscription?.cancel();
603603
super.dispose();
604604
}

packages/amplify_authenticator/lib/src/blocs/auth/auth_bloc.dart

Lines changed: 102 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -21,18 +21,25 @@ import 'package:amplify_authenticator/src/blocs/auth/auth_data.dart';
2121
import 'package:amplify_authenticator/src/services/amplify_auth_service.dart';
2222
import 'package:amplify_authenticator/src/state/auth_state.dart';
2323
import 'package:amplify_flutter/amplify_flutter.dart';
24+
import 'package:async/async.dart';
25+
import 'package:stream_transform/stream_transform.dart';
2426

2527
part 'auth_event.dart';
2628

2729
/// {@template amplify_authenticator.state_machine_bloc}
2830
/// Internal state machine for the authenticator. Listens to authentication events
2931
/// and maps them to appropriate state transitions.
3032
/// {@endtemplate}
31-
class StateMachineBloc {
33+
class StateMachineBloc
34+
with AWSDebuggable, AmplifyLoggerMixin
35+
implements Closeable {
3236
final AuthService _authService;
3337
final bool preferPrivateSession;
3438
final AuthenticatorStep initialStep;
3539

40+
@override
41+
String get runtimeTypeName => 'StateMachineBloc';
42+
3643
/// State controller.
3744
final StreamController<AuthState> _authStateController =
3845
StreamController<AuthState>.broadcast();
@@ -63,18 +70,28 @@ class StateMachineBloc {
6370
required this.preferPrivateSession,
6471
this.initialStep = AuthenticatorStep.signIn,
6572
}) : _authService = authService {
66-
_subscription =
67-
_authEventStream.asyncExpand(_eventTransformer).listen((state) {
68-
_controllerSink.add(state);
69-
_currentState = state;
70-
});
73+
final blocStream = _authEventStream.asyncExpand(_eventTransformer);
74+
final hubStream =
75+
_authService.hubEvents.map(_mapHubEvent).whereType<AuthState>();
76+
final mergedStream = StreamGroup<AuthState>()
77+
..add(blocStream)
78+
..add(hubStream)
79+
..close();
80+
_subscription = mergedStream.stream.listen(_emit);
7181
}
7282

7383
/// Adds an event to the Bloc.
7484
void add(AuthEvent event) {
7585
_authEventController.add(event);
7686
}
7787

88+
/// Emits a new state to the bloc.
89+
void _emit(AuthState state) {
90+
logger.debug('Emitting next state: $state');
91+
_controllerSink.add(state);
92+
_currentState = state;
93+
}
94+
7895
/// Manages exception events separate from the bloc's state.
7996
final StreamController<AuthenticatorException> _exceptionController =
8097
StreamController<AuthenticatorException>.broadcast();
@@ -119,6 +136,27 @@ class StateMachineBloc {
119136
}
120137
}
121138

139+
/// Listens for asynchronous events which occurred outside the control of the
140+
/// [Authenticator] and [StateMachineBloc].
141+
AuthState? _mapHubEvent(AuthHubEvent event) {
142+
logger.debug('Handling hub event: ${event.type}');
143+
switch (event.type) {
144+
case AuthHubEventType.signedIn:
145+
if (_currentState is! AuthenticatedState) {
146+
return const AuthenticatedState();
147+
}
148+
break;
149+
case AuthHubEventType.signedOut:
150+
case AuthHubEventType.sessionExpired:
151+
case AuthHubEventType.userDeleted:
152+
if (_currentState is AuthenticatedState) {
153+
return UnauthenticatedState(step: initialStep);
154+
}
155+
break;
156+
}
157+
return null;
158+
}
159+
122160
Stream<AuthState> _authLoad() async* {
123161
yield const LoadingState();
124162
await Amplify.asyncConfig;
@@ -246,62 +284,77 @@ class StateMachineBloc {
246284
_infoMessageController.add(MessageResolverKey.codeSent(destination));
247285
}
248286

287+
Future<void> _processSignInResult(
288+
SignInResult result, {
289+
required bool isSocialSignIn,
290+
}) async {
291+
switch (result.nextStep!.signInStep) {
292+
case 'CONFIRM_SIGN_IN_WITH_SMS_MFA_CODE':
293+
_notifyCodeSent(result.nextStep?.codeDeliveryDetails?.destination);
294+
_emit(UnauthenticatedState.confirmSignInMfa);
295+
break;
296+
case 'CONFIRM_SIGN_IN_WITH_CUSTOM_CHALLENGE':
297+
_emit(ConfirmSignInCustom(
298+
publicParameters:
299+
result.nextStep?.additionalInfo ?? <String, String>{},
300+
));
301+
break;
302+
case 'CONFIRM_SIGN_IN_WITH_NEW_PASSWORD':
303+
_emit(UnauthenticatedState.confirmSignInNewPassword);
304+
break;
305+
case 'RESET_PASSWORD':
306+
_emit(UnauthenticatedState.confirmResetPassword);
307+
break;
308+
case 'CONFIRM_SIGN_UP':
309+
_notifyCodeSent(result.nextStep?.codeDeliveryDetails?.destination);
310+
_emit(UnauthenticatedState.confirmSignUp);
311+
break;
312+
case 'DONE':
313+
if (isSocialSignIn) {
314+
_emit(const AuthenticatedState());
315+
} else {
316+
await for (final state in _checkUserVerification()) {
317+
_emit(state);
318+
}
319+
}
320+
break;
321+
default:
322+
break;
323+
}
324+
}
325+
249326
Stream<AuthState> _signIn(AuthSignInData data) async* {
250327
try {
251328
// Make sure no user is signed in before calling the sign in method
252329
if (await _authService.isLoggedIn) {
253330
await _authService.signOut();
254331
}
255332

256-
final SignInResult result;
257-
258-
final bool isSocialSignIn = data is AuthSocialSignInData;
259-
260333
if (data is AuthUsernamePasswordSignInData) {
261-
result = await _authService.signIn(
334+
final result = await _authService.signIn(
262335
data.username,
263336
data.password,
264337
);
338+
await _processSignInResult(result, isSocialSignIn: false);
265339
} else if (data is AuthSocialSignInData) {
266-
result = await _authService.signInWithProvider(
267-
data.provider,
268-
preferPrivateSession: preferPrivateSession,
269-
);
340+
// Do not await a social sign-in since multiple sign-in attempts
341+
// can occur.
342+
_authService
343+
.signInWithProvider(
344+
data.provider,
345+
preferPrivateSession: preferPrivateSession,
346+
)
347+
.then(
348+
(result) => _processSignInResult(result, isSocialSignIn: true),
349+
)
350+
.onError<Exception>((error, stackTrace) {
351+
final log =
352+
error is UserCancelledException ? logger.info : logger.error;
353+
log('Error signing in', error, stackTrace);
354+
});
270355
} else {
271356
throw StateError('Bad sign in data: $data');
272357
}
273-
274-
switch (result.nextStep!.signInStep) {
275-
case 'CONFIRM_SIGN_IN_WITH_SMS_MFA_CODE':
276-
_notifyCodeSent(result.nextStep?.codeDeliveryDetails?.destination);
277-
yield UnauthenticatedState.confirmSignInMfa;
278-
break;
279-
case 'CONFIRM_SIGN_IN_WITH_CUSTOM_CHALLENGE':
280-
yield ConfirmSignInCustom(
281-
publicParameters:
282-
result.nextStep?.additionalInfo ?? <String, String>{},
283-
);
284-
break;
285-
case 'CONFIRM_SIGN_IN_WITH_NEW_PASSWORD':
286-
yield UnauthenticatedState.confirmSignInNewPassword;
287-
break;
288-
case 'RESET_PASSWORD':
289-
yield UnauthenticatedState.confirmResetPassword;
290-
break;
291-
case 'CONFIRM_SIGN_UP':
292-
_notifyCodeSent(result.nextStep?.codeDeliveryDetails?.destination);
293-
yield UnauthenticatedState.confirmSignUp;
294-
break;
295-
case 'DONE':
296-
if (isSocialSignIn) {
297-
yield const AuthenticatedState();
298-
} else {
299-
yield* _checkUserVerification();
300-
}
301-
break;
302-
default:
303-
break;
304-
}
305358
} on UserNotConfirmedException catch (e) {
306359
_exceptionController.add(AuthenticatorException(
307360
e.message ?? 'An unknown error occurred',
@@ -446,7 +499,8 @@ class StateMachineBloc {
446499
yield* const Stream.empty();
447500
}
448501

449-
Future<void> dispose() {
502+
@override
503+
Future<void> close() {
450504
return Future.wait<void>([
451505
_subscription.cancel(),
452506
_authStateController.close(),

packages/amplify_authenticator/lib/src/services/amplify_auth_service.dart

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,8 @@ abstract class AuthService {
7272
Future<AmplifyConfig> waitForConfiguration();
7373

7474
Future<void> rememberDevice();
75+
76+
Stream<AuthHubEvent> get hubEvents;
7577
}
7678

7779
class AmplifyAuthService implements AuthService {
@@ -264,6 +266,10 @@ class AmplifyAuthService implements AuthService {
264266
Future<AmplifyConfig> waitForConfiguration() {
265267
return Amplify.asyncConfig;
266268
}
269+
270+
@override
271+
Stream<AuthHubEvent> get hubEvents =>
272+
Amplify.Hub.availableStreams[HubChannel.Auth]!.cast();
267273
}
268274

269275
class GetAttributeVerificationStatusResult {

packages/amplify_authenticator/lib/src/state/authenticator_state.dart

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -382,11 +382,8 @@ class AuthenticatorState extends ChangeNotifier {
382382

383383
/// Perform sicial sign in with the given provider
384384
Future<void> signInWithProvider(AuthProvider provider) async {
385-
_setIsBusy(true);
386385
final signInData = AuthSocialSignInData(provider: provider);
387386
_authBloc.add(AuthSignIn(signInData));
388-
await nextBlocEvent();
389-
_setIsBusy(false);
390387
}
391388

392389
/// Sign out the currecnt user

packages/amplify_authenticator/pubspec.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ dependencies:
1212
amplify_auth_cognito: ">=0.3.0 <0.6.0"
1313
amplify_core: ">=0.3.0 <0.6.0"
1414
amplify_flutter: ">=0.3.0 <0.6.0"
15+
async: ^2.8.0
1516
aws_common: ^0.1.0
1617
collection: ^1.15.0
1718
flutter:
@@ -21,6 +22,7 @@ dependencies:
2122
intl: ^0.17.0
2223
meta: ^1.7.0
2324
smithy: ^0.1.0
25+
stream_transform: ^2.0.0
2426

2527
dev_dependencies:
2628
amplify_lints:

packages/amplify_authenticator/test/ui/tab_view_test.dart

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -248,15 +248,20 @@ void main() {
248248

249249
class MockAuthViewModel extends Mock implements AuthenticatorState {}
250250

251-
class MockBloc implements StateMachineBloc {
251+
class MockBloc
252+
with AWSDebuggable, AmplifyLoggerMixin
253+
implements StateMachineBloc {
254+
@override
255+
String get runtimeTypeName => 'MockBloc';
256+
252257
@override
253258
void add(AuthEvent event) {}
254259

255260
@override
256261
AuthState get currentState => UnauthenticatedState.signIn;
257262

258263
@override
259-
Future<void> dispose() async {}
264+
Future<void> close() async {}
260265

261266
@override
262267
Stream<AuthenticatorException> get exceptions => const Stream.empty();

packages/amplify_core/lib/src/config/auth/auth_config.dart

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,34 @@ class AuthConfig extends AmplifyPluginConfigMap {
3131
factory AuthConfig.fromJson(Map<String, Object?> json) =>
3232
_$AuthConfigFromJson(json);
3333

34+
/// Creates an [AuthConfig] with the given Cognito configurations.
35+
factory AuthConfig.cognito({
36+
CognitoUserPoolConfig? userPoolConfig,
37+
CognitoIdentityPoolConfig? identityPoolConfig,
38+
CognitoOAuthConfig? hostedUiConfig,
39+
}) =>
40+
AuthConfig(
41+
plugins: {
42+
CognitoPluginConfig.pluginKey: CognitoPluginConfig(
43+
auth: hostedUiConfig == null
44+
? null
45+
: AWSConfigMap.withDefault(
46+
CognitoAuthConfig(oAuth: hostedUiConfig)),
47+
cognitoUserPool: userPoolConfig == null
48+
? null
49+
: AWSConfigMap.withDefault(userPoolConfig),
50+
credentialsProvider: identityPoolConfig == null
51+
? null
52+
: CredentialsProviders(
53+
AWSConfigMap({
54+
CognitoIdentityCredentialsProvider.configKey:
55+
AWSConfigMap.withDefault(identityPoolConfig),
56+
}),
57+
),
58+
),
59+
},
60+
);
61+
3462
/// The AWS Cognito plugin configuration, if available.
3563
@override
3664
CognitoPluginConfig? get awsPlugin =>

packages/amplify_core/lib/src/config/auth/cognito/credentials_provider.dart

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ import 'package:amplify_core/amplify_core.dart';
1717

1818
part 'credentials_provider.g.dart';
1919

20+
typedef CognitoIdentityPoolConfig = CognitoIdentityCredentialsProvider;
21+
2022
class CredentialsProviders extends AWSConfigMap {
2123
const CredentialsProviders(
2224
Map<String, AWSSerializable> providers,
@@ -31,7 +33,7 @@ class CredentialsProviders extends AWSConfigMap {
3133
'${value.runtimeType} is not a Map',
3234
);
3335
}
34-
if (key == 'CognitoIdentity') {
36+
if (key == CognitoIdentityCredentialsProvider.configKey) {
3537
final configs = AWSConfigMap.fromJson(
3638
value,
3739
(json) =>
@@ -56,6 +58,8 @@ class CredentialsProviders extends AWSConfigMap {
5658
@zAwsSerializable
5759
class CognitoIdentityCredentialsProvider
5860
with AWSEquatable<CognitoIdentityCredentialsProvider>, AWSSerializable {
61+
static const configKey = 'CognitoIdentity';
62+
5963
final String poolId;
6064
final String region;
6165

packages/amplify_core/lib/src/config/config_map.dart

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -71,10 +71,6 @@ class AWSConfigMap<T extends AWSSerializable> extends ConfigMap<T> {
7171
/// {@macro amplify_core.aws_config_map}
7272
const AWSConfigMap(this.configs);
7373

74-
/// All configurations.
75-
@JsonKey(name: 'configs')
76-
final Map<String, T> configs;
77-
7874
factory AWSConfigMap.fromJson(
7975
Map<String, Object?> json,
8076
T Function(Object? json) fromJsonT,
@@ -84,8 +80,19 @@ class AWSConfigMap<T extends AWSSerializable> extends ConfigMap<T> {
8480
fromJsonT,
8581
);
8682

83+
/// Creates an [AWSConfigMap] with a single, default, [value].
84+
factory AWSConfigMap.withDefault(T value) => AWSConfigMap({
85+
_defaultKey: value,
86+
});
87+
88+
static const _defaultKey = 'Default';
89+
90+
/// All configurations.
91+
@JsonKey(name: 'configs')
92+
final Map<String, T> configs;
93+
8794
@override
88-
T? get default$ => this['Default'];
95+
T? get default$ => this[_defaultKey];
8996

9097
@override
9198
AWSConfigMap<T> copy() => AWSConfigMap(Map.of(configs));

0 commit comments

Comments
 (0)