TF-3833 Fix failed to keep mobile app connect after network issue

Signed-off-by: dab246 <tdvu@linagora.com>
This commit is contained in:
dab246
2025-07-18 09:55:24 +07:00
committed by Dat H. Pham
parent c435529ce4
commit d4a56afaa1
12 changed files with 352 additions and 189 deletions
@@ -1,4 +1,3 @@
import 'package:model/oidc/oidc_configuration.dart';
import 'package:model/oidc/response/oidc_discovery_response.dart';
import 'package:model/oidc/token_id.dart';
@@ -0,0 +1,88 @@
import 'package:core/utils/app_logger.dart';
import 'package:core/utils/platform_info.dart';
import 'package:flutter_appauth_platform_interface/flutter_appauth_platform_interface.dart';
import 'package:model/oidc/oidc_configuration.dart';
import 'package:model/oidc/response/oidc_discovery_response.dart';
import 'package:model/oidc/token_id.dart';
import 'package:tmail_ui_user/features/login/domain/exceptions/oauth_authorization_error.dart';
import 'package:tmail_ui_user/features/login/domain/extensions/oidc_configuration_extensions.dart';
mixin AuthenticationClientInteractionMixin {
EndSessionRequest getEndSessionRequest(
TokenId tokenId,
OIDCConfiguration config,
OIDCDiscoveryResponse discoveryResponse,
) {
final authorizationEndpoint = discoveryResponse.authorizationEndpoint;
final tokenEndpoint = discoveryResponse.tokenEndpoint;
AuthorizationServiceConfiguration? serviceConfiguration;
if (authorizationEndpoint != null && tokenEndpoint != null) {
serviceConfiguration = AuthorizationServiceConfiguration(
authorizationEndpoint: authorizationEndpoint,
tokenEndpoint: tokenEndpoint,
endSessionEndpoint: discoveryResponse.endSessionEndpoint,
);
}
return EndSessionRequest(
idTokenHint: tokenId.uuid,
postLogoutRedirectUrl: config.logoutRedirectUrl,
discoveryUrl: config.discoveryUrl,
serviceConfiguration: serviceConfiguration,
externalUserAgent: getExternalUserAgent(),
);
}
ExternalUserAgent getExternalUserAgent() => PlatformInfo.isIOS
? ExternalUserAgent.ephemeralAsWebAuthenticationSession
: ExternalUserAgent.asWebAuthenticationSession;
TokenRequest getRefreshTokenRequest(
String clientId,
String redirectUrl,
String discoveryUrl,
String refreshToken,
List<String> scopes,
) {
return TokenRequest(
clientId,
redirectUrl,
discoveryUrl: discoveryUrl,
refreshToken: refreshToken,
grantType: GrantType.refreshToken,
scopes: scopes,
);
}
AuthorizationTokenRequest getAuthorizationTokenRequest(
String clientId,
String redirectUrl,
String discoveryUrl,
List<String> scopes,
) {
return AuthorizationTokenRequest(
clientId,
redirectUrl,
discoveryUrl: discoveryUrl,
scopes: scopes,
externalUserAgent: getExternalUserAgent(),
);
}
dynamic handleException(dynamic exception) {
if (exception is FlutterAppAuthPlatformException) {
logError('$runtimeType::handleException: ErrorDetails = ${exception.platformErrorDetails.toString()}');
final errorCode = exception.platformErrorDetails.error;
if (errorCode != null) {
final oauthErrorCode = OAuthAuthorizationError.fromErrorCode(
errorCode,
errorDescription: exception.platformErrorDetails.errorDescription,
);
return oauthErrorCode;
}
}
return exception;
}
}
@@ -10,82 +10,88 @@ import 'package:model/oidc/token_oidc.dart';
import 'package:tmail_ui_user/features/login/data/extensions/authentication_token_extension.dart';
import 'package:tmail_ui_user/features/login/data/extensions/token_response_extension.dart';
import 'package:tmail_ui_user/features/login/data/network/authentication_client/authentication_client_base.dart';
import 'package:tmail_ui_user/features/login/data/network/authentication_client/authentication_client_interaction_mixin.dart';
import 'package:tmail_ui_user/features/login/data/network/config/oidc_constant.dart';
import 'package:tmail_ui_user/features/login/domain/exceptions/authentication_exception.dart';
import 'package:tmail_ui_user/features/login/domain/extensions/oidc_configuration_extensions.dart';
class AuthenticationClientMobile implements AuthenticationClientBase {
class AuthenticationClientMobile with AuthenticationClientInteractionMixin
implements AuthenticationClientBase {
final FlutterAppAuth _appAuth;
AuthenticationClientMobile(this._appAuth);
@override
Future<TokenOIDC> getTokenOIDC(String clientId, String redirectUrl,
String discoveryUrl, List<String> scopes) async {
Future<TokenOIDC> getTokenOIDC(
String clientId,
String redirectUrl,
String discoveryUrl,
List<String> scopes,
) async {
final authorizationTokenRequest = getAuthorizationTokenRequest(
clientId,
redirectUrl,
discoveryUrl,
scopes,
);
final authorizationTokenResponse = await _appAuth.authorizeAndExchangeCode(
AuthorizationTokenRequest(
clientId,
redirectUrl,
discoveryUrl: discoveryUrl,
scopes: scopes,
preferEphemeralSession: true));
log('AuthenticationClientMobile::getTokenOIDC(): token: ${authorizationTokenResponse?.accessToken}');
if (authorizationTokenResponse != null) {
final tokenOIDC = authorizationTokenResponse.toTokenOIDC();
if (tokenOIDC.isTokenValid()) {
return tokenOIDC;
} else {
throw AccessTokenInvalidException();
}
authorizationTokenRequest,
);
log('$runtimeType::getTokenOIDC(): token: ${authorizationTokenResponse.accessToken}');
final tokenOIDC = authorizationTokenResponse.toTokenOIDC();
if (tokenOIDC.isTokenValid()) {
return tokenOIDC;
} else {
throw NotFoundAccessTokenException();
throw AccessTokenInvalidException();
}
}
@override
Future<bool> logoutOidc(TokenId tokenId, OIDCConfiguration config, OIDCDiscoveryResponse oidcRescovery) async {
final authorizationServiceConfiguration = oidcRescovery.authorizationEndpoint == null || oidcRescovery.tokenEndpoint == null
? null
: AuthorizationServiceConfiguration(
authorizationEndpoint: oidcRescovery.authorizationEndpoint!,
tokenEndpoint: oidcRescovery.tokenEndpoint!,
endSessionEndpoint: oidcRescovery.endSessionEndpoint);
final endSession = await _appAuth.endSession(EndSessionRequest(
idTokenHint: tokenId.uuid,
postLogoutRedirectUrl: config.logoutRedirectUrl,
discoveryUrl: config.discoveryUrl,
serviceConfiguration: authorizationServiceConfiguration,
preferEphemeralSession: true,
));
log('AuthenticationClientMobile::logoutOidc(): ${endSession?.state}');
return endSession?.state?.isNotEmpty == true;
Future<bool> logoutOidc(
TokenId tokenId,
OIDCConfiguration config,
OIDCDiscoveryResponse discoveryResponse,
) async {
final endSessionRequest = getEndSessionRequest(
tokenId,
config,
discoveryResponse,
);
final endSession = await _appAuth.endSession(endSessionRequest);
log('$runtimeType::logoutOidc(): ${endSession.state}');
return endSession.state?.isNotEmpty == true;
}
@override
Future<TokenOIDC> refreshingTokensOIDC(String clientId, String redirectUrl,
String discoveryUrl, List<String> scopes, String refreshToken) async {
final tokenResponse = await _appAuth.token(TokenRequest(
Future<TokenOIDC> refreshingTokensOIDC(
String clientId,
String redirectUrl,
String discoveryUrl,
List<String> scopes,
String refreshToken,
) async {
try {
final tokenRequest = getRefreshTokenRequest(
clientId,
redirectUrl,
discoveryUrl: discoveryUrl,
refreshToken: refreshToken,
scopes: scopes));
log('AuthenticationClientMobile::refreshingTokensOIDC(): refreshToken: ${tokenResponse?.accessToken}');
if (tokenResponse != null) {
final tokenOIDC = tokenResponse.toTokenOIDC(maybeAvailableRefreshToken: refreshToken);
discoveryUrl,
refreshToken,
scopes,
);
final tokenResponse = await _appAuth.token(tokenRequest);
log('$runtimeType::refreshingTokensOIDC():Token: ${tokenResponse.accessToken}');
final tokenOIDC = tokenResponse.toTokenOIDC(
maybeAvailableRefreshToken: refreshToken,
);
if (tokenOIDC.isTokenValid()) {
return tokenOIDC;
} else {
throw AccessTokenInvalidException();
}
} else {
throw NotFoundAccessTokenException();
} catch (e) {
logError('$runtimeType::refreshingTokensOIDC(): $e');
throw handleException(e);
}
}
@@ -104,7 +110,7 @@ class AuthenticationClientMobile implements AuthenticationClientBase {
intentFlags: ephemeralIntentFlags,
),
);
log('AuthenticationClientMobile::signInTwakeWorkplace():Uri = $uri');
log('$runtimeType::signInTwakeWorkplace():Uri = $uri');
return TokenOIDC.fromUri(uri);
}
@@ -117,7 +123,7 @@ class AuthenticationClientMobile implements AuthenticationClientBase {
intentFlags: ephemeralIntentFlags,
),
);
log('AuthenticationClientMobile::signUpTwakeWorkplace():Uri = $uri');
log('$runtimeType::signUpTwakeWorkplace():Uri = $uri');
return TokenOIDC.fromUri(uri);
}
}
@@ -9,89 +9,104 @@ import 'package:model/oidc/token_oidc.dart';
import 'package:tmail_ui_user/features/login/data/extensions/authentication_token_extension.dart';
import 'package:tmail_ui_user/features/login/data/extensions/token_response_extension.dart';
import 'package:tmail_ui_user/features/login/data/network/authentication_client/authentication_client_base.dart';
import 'package:tmail_ui_user/features/login/data/network/authentication_client/authentication_client_interaction_mixin.dart';
import 'package:tmail_ui_user/features/login/data/utils/library_platform/app_auth_plugin/app_auth_plugin.dart';
import 'package:tmail_ui_user/features/login/domain/exceptions/authentication_exception.dart';
import 'package:tmail_ui_user/features/login/domain/extensions/oidc_configuration_extensions.dart';
class AuthenticationClientWeb implements AuthenticationClientBase {
class AuthenticationClientWeb with AuthenticationClientInteractionMixin
implements AuthenticationClientBase {
final AppAuthWebPlugin _appAuthWeb;
AuthenticationClientWeb(this._appAuthWeb);
@override
Future<TokenOIDC> getTokenOIDC(String clientId, String redirectUrl,
String discoveryUrl, List<String> scopes) async {
final authorizationTokenResponse = await _appAuthWeb.authorizeAndExchangeCode(AuthorizationTokenRequest(
Future<TokenOIDC> getTokenOIDC(
String clientId,
String redirectUrl,
String discoveryUrl,
List<String> scopes,
) async {
final authorizationTokenRequest = getAuthorizationTokenRequest(
clientId,
redirectUrl,
discoveryUrl,
scopes,
);
final authorizationTokenResponse = await _appAuthWeb.authorizeAndExchangeCode(
authorizationTokenRequest,
);
log('$runtimeType::getTokenOIDC():Token: ${authorizationTokenResponse.accessToken}');
final tokenOIDC = authorizationTokenResponse.toTokenOIDC();
if (tokenOIDC.isTokenValid()) {
return tokenOIDC;
} else {
throw AccessTokenInvalidException();
}
}
@override
Future<bool> logoutOidc(
TokenId tokenId,
OIDCConfiguration config,
OIDCDiscoveryResponse discoveryResponse,
) async {
final endSessionRequest = getEndSessionRequest(
tokenId,
config,
discoveryResponse,
);
final endSession = await _appAuthWeb.endSession(endSessionRequest);
log('$runtimeType::logoutOidc(): ${endSession.state}');
return endSession.state?.isNotEmpty == true;
}
@override
Future<TokenOIDC> refreshingTokensOIDC(
String clientId,
String redirectUrl,
String discoveryUrl,
List<String> scopes,
String refreshToken,
) async {
try {
final tokenRequest = getRefreshTokenRequest(
clientId,
redirectUrl,
discoveryUrl,
refreshToken,
scopes,
);
final tokenResponse = await _appAuthWeb.token(tokenRequest);
final tokenOIDC = tokenResponse.toTokenOIDC(
maybeAvailableRefreshToken: refreshToken,
);
if (tokenOIDC.isTokenValid()) {
return tokenOIDC;
} else {
throw AccessTokenInvalidException();
}
} catch (e) {
logError('$runtimeType::refreshingTokensOIDC(): $e');
throw handleException(e);
}
}
@override
Future<void> authenticateOidcOnBrowser(
String clientId,
String redirectUrl,
String discoveryUrl,
List<String> scopes,
) async {
await _appAuthWeb.authorizeAndExchangeCode(
AuthorizationTokenRequest(
clientId,
redirectUrl,
discoveryUrl: discoveryUrl,
scopes: scopes,
preferEphemeralSession: true));
log('AuthClientMobile::getTokenOIDC(): token: ${authorizationTokenResponse?.accessToken}');
if (authorizationTokenResponse != null) {
final tokenOIDC = authorizationTokenResponse.toTokenOIDC();
if (tokenOIDC.isTokenValid()) {
return tokenOIDC;
} else {
throw AccessTokenInvalidException();
}
} else {
throw NotFoundAccessTokenException();
}
}
@override
Future<bool> logoutOidc(TokenId tokenId, OIDCConfiguration config, OIDCDiscoveryResponse oidcRescovery) async {
final authorizationServiceConfiguration = oidcRescovery.authorizationEndpoint == null || oidcRescovery.tokenEndpoint == null
? null
: AuthorizationServiceConfiguration(
authorizationEndpoint: oidcRescovery.authorizationEndpoint!,
tokenEndpoint: oidcRescovery.tokenEndpoint!,
endSessionEndpoint: oidcRescovery.endSessionEndpoint);
final endSession = await _appAuthWeb.endSession(EndSessionRequest(
idTokenHint: tokenId.uuid,
postLogoutRedirectUrl: config.logoutRedirectUrl,
discoveryUrl: config.discoveryUrl,
serviceConfiguration: authorizationServiceConfiguration
));
return endSession != null;
}
@override
Future<TokenOIDC> refreshingTokensOIDC(String clientId, String redirectUrl,
String discoveryUrl, List<String> scopes, String refreshToken) async {
final tokenResponse = await _appAuthWeb.token(TokenRequest(
clientId,
redirectUrl,
discoveryUrl: discoveryUrl,
refreshToken: refreshToken,
grantType: 'refresh_token',
scopes: scopes));
if (tokenResponse != null) {
final tokenOIDC = tokenResponse.toTokenOIDC(maybeAvailableRefreshToken: refreshToken);
if (tokenOIDC.isTokenValid()) {
return tokenOIDC;
} else {
throw AccessTokenInvalidException();
}
} else {
throw NotFoundAccessTokenException();
}
}
@override
Future<void> authenticateOidcOnBrowser(String clientId, String redirectUrl,
String discoveryUrl, List<String> scopes) async {
await _appAuthWeb.authorizeAndExchangeCode(
AuthorizationTokenRequest(
clientId,
redirectUrl,
discoveryUrl: discoveryUrl,
scopes: scopes));
),
);
}
@override
@@ -14,6 +14,7 @@ import 'package:model/oidc/token_oidc.dart';
import 'package:tmail_ui_user/features/login/data/local/account_cache_manager.dart';
import 'package:tmail_ui_user/features/login/data/local/token_oidc_cache_manager.dart';
import 'package:tmail_ui_user/features/login/data/network/authentication_client/authentication_client_base.dart';
import 'package:tmail_ui_user/features/login/domain/exceptions/oauth_authorization_error.dart';
import 'package:tmail_ui_user/features/login/domain/extensions/oidc_configuration_extensions.dart';
import 'package:tmail_ui_user/features/upload/data/network/file_uploader.dart';
import 'package:tmail_ui_user/main/utils/ios_sharing_manager.dart';
@@ -152,7 +153,14 @@ class AuthorizationInterceptors extends QueuedInterceptorsWrapper {
}
} catch (e) {
logError('AuthorizationInterceptors::onError:Exception: $e');
return super.onError(err.copyWith(error: e), handler);
if (e is ServerError || e is TemporarilyUnavailable) {
return super.onError(
DioError(requestOptions: err.requestOptions, error: e),
handler,
);
} else {
return super.onError(err.copyWith(error: e), handler);
}
}
}
@@ -25,8 +25,6 @@ class InvalidBaseUrl extends AuthenticationException {
InvalidBaseUrl() : super(AuthenticationException.invalidBaseUrl);
}
class NotFoundAccessTokenException implements Exception {}
class AccessTokenInvalidException implements Exception {}
class DownloadAttachmentHasTokenExpiredException implements Exception {
@@ -0,0 +1,40 @@
class OAuthAuthorizationError {
static const String serverError = 'server_error'; // HTTP 500 error code
static const String temporarilyUnavailable =
'temporarily_unavailable'; // HTTP 503 error code
final String error;
final String? errorDescription;
const OAuthAuthorizationError({
required this.error,
this.errorDescription,
});
static OAuthAuthorizationError fromErrorCode(
String error, {
String? errorDescription,
}) {
switch (error) {
case serverError:
return ServerError(errorDescription: errorDescription);
case temporarilyUnavailable:
return TemporarilyUnavailable(errorDescription: errorDescription);
default:
return OAuthAuthorizationError(
error: error,
errorDescription: errorDescription,
);
}
}
}
class ServerError extends OAuthAuthorizationError {
const ServerError({super.errorDescription})
: super(error: OAuthAuthorizationError.serverError);
}
class TemporarilyUnavailable extends OAuthAuthorizationError {
const TemporarilyUnavailable({super.errorDescription})
: super(error: OAuthAuthorizationError.temporarilyUnavailable);
}
@@ -1,18 +0,0 @@
import 'package:core/presentation/state/failure.dart';
import 'package:core/presentation/state/success.dart';
import 'package:model/oidc/token_oidc.dart';
class RefreshTokenOIDCSuccess extends UIState {
final TokenOIDC tokenOIDC;
RefreshTokenOIDCSuccess(this.tokenOIDC);
@override
List<Object> get props => [tokenOIDC];
}
class RefreshTokenOIDCFailure extends FeatureFailure {
RefreshTokenOIDCFailure(dynamic exception) : super(exception: exception);
}