Skip to content

Commit c363c6c

Browse files
authored
TF-4679 Silence transient SSO session recovery errors (#4698)
1 parent a366961 commit c363c6c

9 files changed

Lines changed: 529 additions & 65 deletions

File tree

lib/features/login/data/network/authentication_client/authentication_client_web.dart

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,18 @@ class AuthenticationClientWeb with AuthenticationClientInteractionMixin
3535
scopes,
3636
loginHint: loginHint,
3737
);
38-
final authorizationTokenResponse = await _appAuthWeb.authorizeAndExchangeCode(
38+
// Nullable at runtime: the web plugin returns null when it starts a
39+
// full-page redirect, though the analysis platform interface declares it non-null.
40+
// ignore: unnecessary_nullable_for_final_variable_declarations
41+
final AuthorizationTokenResponse? authorizationTokenResponse =
42+
await _appAuthWeb.authorizeAndExchangeCode(
3943
authorizationTokenRequest,
4044
);
45+
if (authorizationTokenResponse == null) {
46+
// Null means an SSO redirect was just initiated, not a failure: surface a
47+
// silenced exception instead of dereferencing null and flashing an error.
48+
throw AutoRedirectToAppAfterStoreAuthorizeDestinationUrlException();
49+
}
4150
log('$runtimeType::getTokenOIDC():Token: ${authorizationTokenResponse.accessToken}');
4251
final tokenOIDC = authorizationTokenResponse.toTokenOIDC();
4352
if (tokenOIDC.isTokenValid()) {
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import 'package:core/presentation/state/failure.dart';
2+
import 'package:core/utils/platform_info.dart';
3+
import 'package:tmail_ui_user/features/login/domain/exceptions/authentication_exception.dart';
4+
import 'package:tmail_ui_user/features/login/domain/state/authenticate_oidc_on_browser_state.dart';
5+
import 'package:tmail_ui_user/features/login/domain/state/get_authenticated_account_state.dart';
6+
import 'package:tmail_ui_user/features/login/domain/state/get_authentication_info_state.dart';
7+
import 'package:tmail_ui_user/features/login/domain/state/get_token_oidc_state.dart';
8+
import 'package:tmail_ui_user/main/exceptions/remote/network_exception.dart';
9+
import 'package:tmail_ui_user/main/localizations/app_localizations.dart';
10+
import 'package:tmail_ui_user/main/utils/toast_manager.dart';
11+
12+
typedef LoginFailurePredicate = bool Function(Failure failure);
13+
14+
/// Failures emitted while web login silently re-authenticates on app open
15+
/// (recovery chain + confirmed-SSO redirect); silenced unless they have a known message.
16+
final List<LoginFailurePredicate> _silentReAuthenticationPredicates = [
17+
_matchesAuthenticationInfoRecovery,
18+
_matchesAuthenticatedAccountRecovery,
19+
_matchesNonNetworkSSORedirect,
20+
];
21+
22+
extension LoginFailureExtensions on Failure {
23+
Object? get exceptionOrNull =>
24+
this is FeatureFailure ? (this as FeatureFailure).exception : null;
25+
26+
/// Redirect failure on a confirmed-SSO server. On app open it is the silent
27+
/// re-authentication redirecting to SSO, so it must not flash an error.
28+
bool get isConfirmedSSORedirectFailure {
29+
final failure = this;
30+
return (failure is GetTokenOIDCFailure && failure.ssoConfirmed) ||
31+
(failure is AuthenticateOidcOnBrowserFailure && failure.ssoConfirmed);
32+
}
33+
34+
bool get isSilentReAuthenticationFailure =>
35+
_silentReAuthenticationPredicates.any((predicate) => predicate(this));
36+
37+
bool shouldHideRetryDuringSilentReAuthentication({
38+
required ToastManager? toastManager,
39+
required AppLocalizations appLocalizations,
40+
}) {
41+
if (!isSilentReAuthenticationFailure) {
42+
return false;
43+
}
44+
45+
final exception = exceptionOrNull;
46+
if (exception is AutoRedirectToAppAfterStoreAuthorizeDestinationUrlException) {
47+
return true;
48+
}
49+
50+
return PlatformInfo.isWeb &&
51+
!_hasVisibleMessage(
52+
toastManager,
53+
appLocalizations,
54+
exception,
55+
);
56+
}
57+
}
58+
59+
bool _hasVisibleMessage(
60+
ToastManager? toastManager,
61+
AppLocalizations appLocalizations,
62+
Object? exception,
63+
) {
64+
final message = toastManager?.getMessageByException(
65+
appLocalizations,
66+
exception,
67+
);
68+
return message?.isNotEmpty == true;
69+
}
70+
71+
bool _matchesAuthenticationInfoRecovery(Failure failure) =>
72+
failure is GetAuthenticationInfoFailure;
73+
74+
bool _matchesAuthenticatedAccountRecovery(Failure failure) =>
75+
failure is GetAuthenticatedAccountFailure;
76+
77+
bool _matchesNonNetworkSSORedirect(Failure failure) =>
78+
failure.isConfirmedSSORedirectFailure &&
79+
failure.exceptionOrNull is! NetworkException;

lib/features/login/presentation/login_view_web.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ class LoginView extends BaseLoginView {
6464
return TryAgainButton(
6565
onRetry: controller.retryCheckOidc,
6666
responsiveUtils: controller.responsiveUtils,
67+
viewState: controller.viewState.value,
6768
);
6869
default:
6970
return const SizedBox.shrink();
@@ -232,6 +233,7 @@ class LoginView extends BaseLoginView {
232233
return TryAgainButton(
233234
onRetry: controller.retryCheckOidc,
234235
responsiveUtils: controller.responsiveUtils,
236+
viewState: controller.viewState.value,
235237
);
236238
default:
237239
return const SizedBox.shrink();
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import 'package:core/presentation/state/failure.dart';
2+
import 'package:core/utils/platform_info.dart';
3+
import 'package:tmail_ui_user/features/login/domain/exceptions/login_exception.dart';
4+
import 'package:tmail_ui_user/features/login/domain/state/dns_lookup_to_get_jmap_url_state.dart';
5+
import 'package:tmail_ui_user/features/login/domain/state/get_oidc_configuration_state.dart';
6+
import 'package:tmail_ui_user/features/login/domain/state/get_token_oidc_state.dart';
7+
import 'package:tmail_ui_user/features/login/presentation/extensions/login_failure_extensions.dart';
8+
import 'package:tmail_ui_user/main/exceptions/remote/network_exception.dart';
9+
import 'package:tmail_ui_user/main/localizations/app_localizations.dart';
10+
import 'package:tmail_ui_user/main/utils/toast_manager.dart';
11+
12+
typedef LoginFailureMessageRule = String? Function(
13+
LoginFailureMessageContext context,
14+
);
15+
16+
final List<LoginFailureMessageRule> _failureMessageRules = [
17+
_noNetworkMessage,
18+
_oidcConfigurationMessage,
19+
_dnsLookupMessage,
20+
_noSuitableBrowserMessage,
21+
_silentReAuthenticationMessage,
22+
_knownExceptionMessage,
23+
];
24+
25+
class LoginFailureMessageResolver {
26+
const LoginFailureMessageResolver(this.toastManager);
27+
28+
final ToastManager? toastManager;
29+
30+
String resolve(
31+
AppLocalizations appLocalizations,
32+
Failure failure,
33+
) {
34+
final context = LoginFailureMessageContext(
35+
appLocalizations: appLocalizations,
36+
failure: failure,
37+
knownExceptionMessage: _getKnownExceptionMessage(
38+
appLocalizations,
39+
failure,
40+
),
41+
);
42+
final matchingMessages = _failureMessageRules
43+
.map((rule) => rule(context))
44+
.where((message) => message != null);
45+
46+
return matchingMessages.isNotEmpty
47+
? matchingMessages.first!
48+
: _defaultMessage(context);
49+
}
50+
51+
String? _getKnownExceptionMessage(
52+
AppLocalizations appLocalizations,
53+
Failure failure,
54+
) {
55+
return failure is FeatureFailure
56+
? toastManager?.getMessageByException(
57+
appLocalizations,
58+
failure.exception,
59+
)
60+
: null;
61+
}
62+
63+
String _defaultMessage(LoginFailureMessageContext context) {
64+
final failure = context.failure;
65+
66+
return failure is FeatureFailure
67+
? toastManager?.getMessageByException(
68+
context.appLocalizations,
69+
failure.exception,
70+
useDefaultMessage: true,
71+
) ?? context.appLocalizations.unknownError
72+
: context.appLocalizations.unknownError;
73+
}
74+
}
75+
76+
class LoginFailureMessageContext {
77+
const LoginFailureMessageContext({
78+
required this.appLocalizations,
79+
required this.failure,
80+
required this.knownExceptionMessage,
81+
});
82+
83+
final AppLocalizations appLocalizations;
84+
final Failure failure;
85+
final String? knownExceptionMessage;
86+
}
87+
88+
String? _noNetworkMessage(LoginFailureMessageContext context) {
89+
return context.failure.exceptionOrNull is NoNetworkError
90+
? context.appLocalizations.youAreOffline
91+
: null;
92+
}
93+
94+
String? _oidcConfigurationMessage(LoginFailureMessageContext context) {
95+
return context.failure is GetOIDCConfigurationFailure
96+
? context.appLocalizations.canNotVerifySSOConfiguration
97+
: null;
98+
}
99+
100+
String? _dnsLookupMessage(LoginFailureMessageContext context) {
101+
return context.failure is DNSLookupToGetJmapUrlFailure
102+
? context.appLocalizations.dnsLookupLoginMessage
103+
: null;
104+
}
105+
106+
String? _noSuitableBrowserMessage(LoginFailureMessageContext context) {
107+
return context.failure is GetTokenOIDCFailure &&
108+
context.failure.exceptionOrNull is NoSuitableBrowserForOIDCException
109+
? context.appLocalizations.noSuitableBrowserForOIDC
110+
: null;
111+
}
112+
113+
/// Silent re-authentication failures on app open render empty unless they carry
114+
/// a known message; the user is being redirected to SSO, so nothing must flash.
115+
String? _silentReAuthenticationMessage(LoginFailureMessageContext context) {
116+
return PlatformInfo.isWeb &&
117+
context.failure.isSilentReAuthenticationFailure &&
118+
context.knownExceptionMessage == null
119+
? ''
120+
: null;
121+
}
122+
123+
String? _knownExceptionMessage(LoginFailureMessageContext context) =>
124+
context.knownExceptionMessage;

lib/features/login/presentation/widgets/login_message_widget.dart

Lines changed: 9 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,8 @@ import 'package:core/presentation/utils/theme_utils.dart';
77
import 'package:dartz/dartz.dart';
88
import 'package:flutter/material.dart';
99
import 'package:get/get.dart';
10-
import 'package:tmail_ui_user/features/login/domain/exceptions/login_exception.dart';
11-
import 'package:tmail_ui_user/features/login/domain/state/authenticate_oidc_on_browser_state.dart';
12-
import 'package:tmail_ui_user/features/login/domain/state/dns_lookup_to_get_jmap_url_state.dart';
13-
import 'package:tmail_ui_user/features/login/domain/state/get_oidc_configuration_state.dart';
14-
import 'package:tmail_ui_user/features/login/domain/state/get_token_oidc_state.dart';
10+
import 'package:tmail_ui_user/features/login/presentation/model/login_failure_message_resolver.dart';
1511
import 'package:tmail_ui_user/features/login/presentation/login_form_type.dart';
16-
import 'package:tmail_ui_user/main/exceptions/remote/network_exception.dart';
1712
import 'package:tmail_ui_user/main/localizations/app_localizations.dart';
1813
import 'package:tmail_ui_user/main/routes/route_navigation.dart';
1914
import 'package:tmail_ui_user/main/utils/toast_manager.dart';
@@ -26,13 +21,15 @@ class LoginMessageWidget extends StatelessWidget {
2621
final LoginFormType formType;
2722
final Either<Failure, Success> viewState;
2823

29-
final ToastManager? _toastManager = getBinding<ToastManager>();
24+
final LoginFailureMessageResolver _failureMessageResolver;
3025

3126
LoginMessageWidget({
3227
super.key,
3328
required this.formType,
3429
required this.viewState
35-
});
30+
}) : _failureMessageResolver = LoginFailureMessageResolver(
31+
getBinding<ToastManager>(),
32+
);
3633

3734
@override
3835
Widget build(BuildContext context) {
@@ -50,34 +47,10 @@ class LoginMessageWidget extends StatelessWidget {
5047
: _loginTextFieldWidthLargeScreen,
5148
child: Text(
5249
viewState.fold(
53-
(failure) {
54-
if (failure is FeatureFailure && failure.exception is NoNetworkError) {
55-
return appLocalizations.youAreOffline;
56-
}
57-
58-
if (failure is GetOIDCConfigurationFailure) {
59-
return appLocalizations.canNotVerifySSOConfiguration;
60-
} else if (failure is DNSLookupToGetJmapUrlFailure) {
61-
return appLocalizations.dnsLookupLoginMessage;
62-
} else if (failure is GetTokenOIDCFailure && failure.exception is NoSuitableBrowserForOIDCException) {
63-
return appLocalizations.noSuitableBrowserForOIDC;
64-
} else if (((failure is GetTokenOIDCFailure && failure.ssoConfirmed) ||
65-
(failure is AuthenticateOidcOnBrowserFailure && failure.ssoConfirmed)) &&
66-
(failure as FeatureFailure).exception is! NetworkException) {
67-
// Show only for a confirmed-SSO, non-network failure. Guessed
68-
// providers fall back to basic auth; network drops show the
69-
// offline message below.
70-
return appLocalizations.ssoRedirectFailedMessage;
71-
} else if (failure is FeatureFailure) {
72-
return _toastManager?.getMessageByException(
73-
appLocalizations,
74-
failure.exception,
75-
useDefaultMessage: true,
76-
) ?? appLocalizations.unknownError;
77-
} else {
78-
return appLocalizations.unknownError;
79-
}
80-
},
50+
(failure) => _failureMessageResolver.resolve(
51+
appLocalizations,
52+
failure,
53+
),
8154
(success) {
8255
if (formType == LoginFormType.credentialForm) {
8356
return appLocalizations.loginInputCredentialMessage;

lib/features/login/presentation/widgets/try_again_button.dart

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,34 @@
11
import 'package:core/presentation/extensions/color_extension.dart';
2+
import 'package:core/presentation/state/failure.dart';
3+
import 'package:core/presentation/state/success.dart';
24
import 'package:core/presentation/utils/responsive_utils.dart';
35
import 'package:core/presentation/utils/theme_utils.dart';
46
import 'package:core/presentation/views/button/tmail_button_widget.dart';
7+
import 'package:dartz/dartz.dart';
58
import 'package:flutter/material.dart';
9+
import 'package:tmail_ui_user/features/login/presentation/extensions/login_failure_extensions.dart';
610
import 'package:tmail_ui_user/main/localizations/app_localizations.dart';
11+
import 'package:tmail_ui_user/main/routes/route_navigation.dart';
12+
import 'package:tmail_ui_user/main/utils/toast_manager.dart';
713

814
class TryAgainButton extends StatelessWidget {
915
const TryAgainButton({
1016
super.key,
1117
required this.onRetry,
1218
required this.responsiveUtils,
19+
this.viewState,
1320
});
1421

1522
final VoidCallback onRetry;
1623
final ResponsiveUtils responsiveUtils;
24+
final Either<Failure, Success>? viewState;
1725

1826
@override
1927
Widget build(BuildContext context) {
28+
if (_shouldHideRetry(context)) {
29+
return const SizedBox.shrink();
30+
}
31+
2032
return TMailButtonWidget.fromText(
2133
text: AppLocalizations.of(context).tryAgain,
2234
textStyle: ThemeUtils.defaultTextStyleInterFont.copyWith(
@@ -32,4 +44,15 @@ class TryAgainButton extends StatelessWidget {
3244
padding: const EdgeInsets.symmetric(vertical: 12),
3345
);
3446
}
47+
48+
bool _shouldHideRetry(BuildContext context) {
49+
return viewState?.fold(
50+
(failure) => failure.shouldHideRetryDuringSilentReAuthentication(
51+
toastManager: getBinding<ToastManager>(),
52+
appLocalizations: AppLocalizations.of(context),
53+
),
54+
(success) => false,
55+
) ??
56+
false;
57+
}
3558
}

0 commit comments

Comments
 (0)