diff --git a/docs/adr/0066-robust-oidc-guessing.md b/docs/adr/0066-robust-oidc-guessing.md new file mode 100644 index 000000000..a141172c3 --- /dev/null +++ b/docs/adr/0066-robust-oidc-guessing.md @@ -0,0 +1,22 @@ +# 66. Robust OIDC Guessing + +Date: 2025-06-27 + +## Status + +Accepted + +## Context + +- The users are forced to manually enter the server url when the app fails to lookup the oidc configuration + +## Decision + +- The app will try to guess the oidc url based on the email address with common prefixes + - `email.domain` + - `jmap.email.domain` + - `autodiscover.email.domain` + +## Consequences + +- The oidc discovery is more robust diff --git a/lib/features/login/domain/model/oidc_guessing_origin.dart b/lib/features/login/domain/model/oidc_guessing_origin.dart new file mode 100644 index 000000000..79dc98954 --- /dev/null +++ b/lib/features/login/domain/model/oidc_guessing_origin.dart @@ -0,0 +1,24 @@ +import 'package:tmail_ui_user/features/email/presentation/utils/email_utils.dart'; + +enum OidcGuessingOrigin { + empty(origin: ''), + autoDiscover(origin: 'autodiscover'), + jmap(origin: 'jmap'); + + const OidcGuessingOrigin({required this.origin}); + + final String origin; + + String url(String email) { + if (!EmailUtils.isEmailAddressValid(email)) { + throw ArgumentError('Invalid email address: $email'); + } + final emailDomain = email.split('@').last; + return switch (this) { + OidcGuessingOrigin.empty => 'https://$emailDomain', + _ => origin.trim().isEmpty + ? OidcGuessingOrigin.empty.url(email) + : 'https://$origin.$emailDomain', + }; + } +} diff --git a/lib/features/login/domain/state/dns_lookup_to_get_jmap_url_state.dart b/lib/features/login/domain/state/dns_lookup_to_get_jmap_url_state.dart index 89835772b..5af64ae6d 100644 --- a/lib/features/login/domain/state/dns_lookup_to_get_jmap_url_state.dart +++ b/lib/features/login/domain/state/dns_lookup_to_get_jmap_url_state.dart @@ -13,6 +13,10 @@ class DNSLookupToGetJmapUrlSuccess extends UIState { } class DNSLookupToGetJmapUrlFailure extends FeatureFailure { + DNSLookupToGetJmapUrlFailure( + dynamic exception, { + required this.email, + }) : super(exception: exception); - DNSLookupToGetJmapUrlFailure(dynamic exception) : super(exception: exception); + final String email; } \ No newline at end of file diff --git a/lib/features/login/domain/state/try_guessing_web_finger_state.dart b/lib/features/login/domain/state/try_guessing_web_finger_state.dart new file mode 100644 index 000000000..e45651318 --- /dev/null +++ b/lib/features/login/domain/state/try_guessing_web_finger_state.dart @@ -0,0 +1,18 @@ +import 'package:core/presentation/state/failure.dart'; +import 'package:core/presentation/state/success.dart'; +import 'package:model/oidc/response/oidc_response.dart'; + +class TryingGuessingWebFinger extends LoadingState {} + +class TryGuessingWebFingerSuccess extends UIState { + TryGuessingWebFingerSuccess(this.oidcResponse); + + final OIDCResponse oidcResponse; + + @override + List get props => [oidcResponse]; +} + +class TryGuessingWebFingerFailure extends FeatureFailure { + TryGuessingWebFingerFailure({super.exception}); +} \ No newline at end of file diff --git a/lib/features/login/domain/usecases/dns_lookup_to_get_jmap_url_interactor.dart b/lib/features/login/domain/usecases/dns_lookup_to_get_jmap_url_interactor.dart index 948e8af94..2d6399b34 100644 --- a/lib/features/login/domain/usecases/dns_lookup_to_get_jmap_url_interactor.dart +++ b/lib/features/login/domain/usecases/dns_lookup_to_get_jmap_url_interactor.dart @@ -15,7 +15,10 @@ class DNSLookupToGetJmapUrlInteractor { final jmapUrl = await _loginRepository.dnsLookupToGetJmapUrl(emailAddress); yield Right(DNSLookupToGetJmapUrlSuccess(jmapUrl)); } catch (e) { - yield Left(DNSLookupToGetJmapUrlFailure(e)); + yield Left(DNSLookupToGetJmapUrlFailure( + e, + email: emailAddress, + )); } } } \ No newline at end of file diff --git a/lib/features/login/domain/usecases/try_guessing_web_finger_interactor.dart b/lib/features/login/domain/usecases/try_guessing_web_finger_interactor.dart new file mode 100644 index 000000000..c003abdcb --- /dev/null +++ b/lib/features/login/domain/usecases/try_guessing_web_finger_interactor.dart @@ -0,0 +1,62 @@ +import 'dart:async'; +import 'package:core/presentation/state/failure.dart'; +import 'package:core/presentation/state/success.dart'; +import 'package:core/utils/app_logger.dart'; +import 'package:dartz/dartz.dart'; +import 'package:model/oidc/request/oidc_request.dart'; +import 'package:model/oidc/response/oidc_response.dart'; +import 'package:tmail_ui_user/features/login/domain/repository/authentication_oidc_repository.dart'; +import 'package:tmail_ui_user/features/login/domain/state/try_guessing_web_finger_state.dart'; + +class TryGuessingWebFingerInteractor { + const TryGuessingWebFingerInteractor(this._authenticationOIDCRepository); + + final AuthenticationOIDCRepository _authenticationOIDCRepository; + + Stream> execute( + List oidcRequests, + ) async* { + try { + yield Right(TryingGuessingWebFinger()); + final futures = oidcRequests.map(_checkOIDCAvailableFromOidcRequest).toList(); + final completer = Completer(); + + for (final future in futures) { + future.then((response) { + if (response != null && !completer.isCompleted) { + completer.complete(response); + } + }); + } + + Future.wait(futures).then((_) { + if (!completer.isCompleted) { + completer.complete(null); + } + }); + + final firstNonNullResponse = await completer.future; + + if (firstNonNullResponse == null) { + yield Left(TryGuessingWebFingerFailure()); + } else { + yield Right(TryGuessingWebFingerSuccess(firstNonNullResponse)); + } + } catch (e) { + logError('$runtimeType::execute(): Exception = $e'); + yield Left(TryGuessingWebFingerFailure(exception: e)); + } + } + + Future _checkOIDCAvailableFromOidcRequest( + OIDCRequest oidcRequest, + ) async { + try { + return await _authenticationOIDCRepository.checkOIDCIsAvailable( + oidcRequest, + ); + } catch (_) { + return null; + } + } +} diff --git a/lib/features/login/presentation/extensions/generate_oidc_guessing_urls.dart b/lib/features/login/presentation/extensions/generate_oidc_guessing_urls.dart new file mode 100644 index 000000000..390563afc --- /dev/null +++ b/lib/features/login/presentation/extensions/generate_oidc_guessing_urls.dart @@ -0,0 +1,19 @@ +import 'package:model/oidc/request/oidc_request.dart'; +import 'package:tmail_ui_user/features/email/presentation/utils/email_utils.dart'; +import 'package:tmail_ui_user/features/login/domain/model/oidc_guessing_origin.dart'; +import 'package:tmail_ui_user/features/login/presentation/login_controller.dart'; + +extension GenerateOidcGuessingUrls on LoginController { + List generateOidcGuessingUrls(String email) { + if (!EmailUtils.isEmailAddressValid(email)) { + return []; + } + + return OidcGuessingOrigin.values.map( + (guessingOrigin) => OIDCRequest( + baseUrl: guessingOrigin.url(email), + resourceUrl: guessingOrigin.url(email) + ) + ).toList(); + } +} \ No newline at end of file diff --git a/lib/features/login/presentation/login_bindings.dart b/lib/features/login/presentation/login_bindings.dart index b5f52702c..42dc64ae3 100644 --- a/lib/features/login/presentation/login_bindings.dart +++ b/lib/features/login/presentation/login_bindings.dart @@ -25,6 +25,7 @@ import 'package:tmail_ui_user/features/login/domain/usecases/get_stored_oidc_con import 'package:tmail_ui_user/features/login/domain/usecases/get_token_oidc_interactor.dart'; import 'package:tmail_ui_user/features/login/domain/usecases/save_login_url_on_mobile_interactor.dart'; import 'package:tmail_ui_user/features/login/domain/usecases/save_login_username_on_mobile_interactor.dart'; +import 'package:tmail_ui_user/features/login/domain/usecases/try_guessing_web_finger_interactor.dart'; import 'package:tmail_ui_user/features/login/presentation/login_controller.dart'; import 'package:tmail_ui_user/features/starting_page/data/datasource/saas_authentication_datasource.dart'; import 'package:tmail_ui_user/features/starting_page/data/datasource_impl/saas_authentication_datasource_impl.dart'; @@ -52,6 +53,7 @@ class LoginBindings extends BaseBindings { Get.find(), Get.find(), Get.find(), + Get.find(), )); } @@ -112,6 +114,9 @@ class LoginBindings extends BaseBindings { Get.find(), Get.find(), )); + Get.lazyPut(() => TryGuessingWebFingerInteractor( + Get.find(), + )); } @override diff --git a/lib/features/login/presentation/login_controller.dart b/lib/features/login/presentation/login_controller.dart index e46f42249..6820e2554 100644 --- a/lib/features/login/presentation/login_controller.dart +++ b/lib/features/login/presentation/login_controller.dart @@ -38,6 +38,7 @@ import 'package:tmail_ui_user/features/login/domain/state/get_authentication_inf import 'package:tmail_ui_user/features/login/domain/state/get_oidc_configuration_state.dart'; import 'package:tmail_ui_user/features/login/domain/state/get_stored_oidc_configuration_state.dart'; import 'package:tmail_ui_user/features/login/domain/state/get_token_oidc_state.dart'; +import 'package:tmail_ui_user/features/login/domain/state/try_guessing_web_finger_state.dart'; import 'package:tmail_ui_user/features/login/domain/usecases/authenticate_oidc_on_browser_interactor.dart'; import 'package:tmail_ui_user/features/login/domain/usecases/authentication_user_interactor.dart'; import 'package:tmail_ui_user/features/login/domain/usecases/check_oidc_is_available_interactor.dart'; @@ -50,6 +51,8 @@ import 'package:tmail_ui_user/features/login/domain/usecases/get_stored_oidc_con import 'package:tmail_ui_user/features/login/domain/usecases/get_token_oidc_interactor.dart'; import 'package:tmail_ui_user/features/login/domain/usecases/save_login_url_on_mobile_interactor.dart'; import 'package:tmail_ui_user/features/login/domain/usecases/save_login_username_on_mobile_interactor.dart'; +import 'package:tmail_ui_user/features/login/domain/usecases/try_guessing_web_finger_interactor.dart'; +import 'package:tmail_ui_user/features/login/presentation/extensions/generate_oidc_guessing_urls.dart'; import 'package:tmail_ui_user/features/login/presentation/extensions/handle_openid_configuration.dart'; import 'package:tmail_ui_user/features/login/presentation/login_form_type.dart'; import 'package:tmail_ui_user/features/login/presentation/model/login_arguments.dart'; @@ -58,6 +61,7 @@ import 'package:tmail_ui_user/features/starting_page/domain/usecase/sign_in_twak import 'package:tmail_ui_user/main/deep_links/deep_link_data.dart'; import 'package:tmail_ui_user/main/deep_links/deep_links_manager.dart'; import 'package:tmail_ui_user/main/deep_links/open_app_deep_link_data.dart'; +import 'package:tmail_ui_user/main/exceptions/remote_exception.dart'; import 'package:tmail_ui_user/main/localizations/app_localizations.dart'; import 'package:tmail_ui_user/main/routes/app_routes.dart'; import 'package:tmail_ui_user/main/routes/route_navigation.dart'; @@ -80,6 +84,7 @@ class LoginController extends ReloadableController { final GetAllRecentLoginUsernameOnMobileInteractor _getAllRecentLoginUsernameOnMobileInteractor; final DNSLookupToGetJmapUrlInteractor _dnsLookupToGetJmapUrlInteractor; final SignInTwakeWorkplaceInteractor _signInTwakeWorkplaceInteractor; + final TryGuessingWebFingerInteractor _tryGuessingWebFingerInteractor; final TextEditingController urlInputController = TextEditingController(); final TextEditingController usernameInputController = TextEditingController(); @@ -111,6 +116,7 @@ class LoginController extends ReloadableController { this._getAllRecentLoginUsernameOnMobileInteractor, this._dnsLookupToGetJmapUrlInteractor, this._signInTwakeWorkplaceInteractor, + this._tryGuessingWebFingerInteractor, ); @override @@ -164,6 +170,8 @@ class LoginController extends ReloadableController { SmartDialog.dismiss(); clearAllData(); } else if (failure is DNSLookupToGetJmapUrlFailure) { + _handleDNSLookupToGetJmapUrlFailure(failure); + } else if (failure is TryGuessingWebFingerFailure) { _username = null; _clearTextInputField(); _showBaseUrlForm(); @@ -188,6 +196,9 @@ class LoginController extends ReloadableController { _loginSuccessAction(success); } else if (success is DNSLookupToGetJmapUrlSuccess) { _handleDNSLookupToGetJmapUrlSuccess(success); + } else if (success is TryGuessingWebFingerSuccess) { + onBaseUrlChange(success.oidcResponse.subject); + getOIDCConfiguration(success.oidcResponse); } else if (success is SignInTwakeWorkplaceSuccess) { _synchronizeTokenAndGetSession( baseUri: success.baseUri, @@ -217,6 +228,10 @@ class LoginController extends ReloadableController { } else if (failure is GetSessionFailure) { SmartDialog.dismiss(); clearAllData(); + } else if (connectionErrorWhenLookupDns(failure)) { + _handleDNSLookupToGetJmapUrlFailure( + failure as DNSLookupToGetJmapUrlFailure, + ); } else { super.handleUrgentException(failure: failure, exception: exception); } @@ -232,6 +247,19 @@ class LoginController extends ReloadableController { ); } + bool connectionErrorWhenLookupDns(Failure? failure) { + return failure is DNSLookupToGetJmapUrlFailure && + failure.exception is ConnectionError; + } + + void _handleDNSLookupToGetJmapUrlFailure( + DNSLookupToGetJmapUrlFailure failure, + ) { + consumeState(_tryGuessingWebFingerInteractor.execute( + generateOidcGuessingUrls(failure.email), + )); + } + void _registerDeepLinks() { _deepLinksManager = getBinding(); _deepLinksManager?.clearPendingDeepLinkData(); diff --git a/test/features/login/domain/model/oidc_guessing_origin_test.dart b/test/features/login/domain/model/oidc_guessing_origin_test.dart new file mode 100644 index 000000000..43f09f460 --- /dev/null +++ b/test/features/login/domain/model/oidc_guessing_origin_test.dart @@ -0,0 +1,42 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tmail_ui_user/features/login/domain/model/oidc_guessing_origin.dart'; + +void main() { + group('OidcGuessingOrigin enum', () { + test('url() returns correct URL for empty origin', () { + const email = 'user@example.com'; + const origin = OidcGuessingOrigin.empty; + expect(origin.url(email), 'https://example.com'); + }); + + test('url() returns correct URL for autoDiscover origin', () { + const email = 'user@example.com'; + const origin = OidcGuessingOrigin.autoDiscover; + expect(origin.url(email), 'https://autodiscover.example.com'); + }); + + test('url() returns correct URL for jmap origin', () { + const email = 'user@example.com'; + const origin = OidcGuessingOrigin.jmap; + expect(origin.url(email), 'https://jmap.example.com'); + }); + + test('url() throws error for invalid email', () { + const email = 'invalid_email@@example.com'; + const origin = OidcGuessingOrigin.empty; + expect(() => origin.url(email), throwsArgumentError); + }); + + test('url() returns correct URL for jmap origin with quoted local part', () { + const email = '"user"@example.com'; + const origin = OidcGuessingOrigin.jmap; + expect(origin.url(email), 'https://jmap.example.com'); + }); + + test('url() returns correct URL for jmap origin with quoted local part containing @', () { + const email = '"user@local"@example.com'; + const origin = OidcGuessingOrigin.jmap; + expect(origin.url(email), 'https://jmap.example.com'); + }); + }); +} \ No newline at end of file diff --git a/test/features/login/domain/usecases/try_guessing_web_finger_interactor_test.dart b/test/features/login/domain/usecases/try_guessing_web_finger_interactor_test.dart new file mode 100644 index 000000000..4e9bf23a3 --- /dev/null +++ b/test/features/login/domain/usecases/try_guessing_web_finger_interactor_test.dart @@ -0,0 +1,123 @@ +import 'dart:async'; +import 'package:dartz/dartz.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:tmail_ui_user/features/login/domain/repository/authentication_oidc_repository.dart'; +import 'package:tmail_ui_user/features/login/domain/usecases/try_guessing_web_finger_interactor.dart'; +import 'package:core/presentation/state/failure.dart'; +import 'package:core/presentation/state/success.dart'; +import 'package:model/oidc/request/oidc_request.dart'; +import 'package:model/oidc/response/oidc_response.dart'; +import 'package:tmail_ui_user/features/login/domain/state/try_guessing_web_finger_state.dart'; + +@GenerateMocks([AuthenticationOIDCRepository]) +import 'try_guessing_web_finger_interactor_test.mocks.dart'; + +void main() { + late MockAuthenticationOIDCRepository mockRepository; + late TryGuessingWebFingerInteractor interactor; + + final oidcRequest1 = OIDCRequest( + baseUrl: 'https://example1.com', + resourceUrl: 'https://example1.com/.well-known/openid-configuration' + ); + final oidcRequest2 = OIDCRequest( + baseUrl: 'https://example2.com', + resourceUrl: 'https://example2.com/.well-known/openid-configuration' + ); + final successResponse = OIDCResponse('subject', []); + + setUp(() { + mockRepository = MockAuthenticationOIDCRepository(); + interactor = TryGuessingWebFingerInteractor(mockRepository); + }); + + test('should return first successful response immediately', () async { + // Arrange + final completer1 = Completer(); + final completer2 = Completer(); + + when(mockRepository.checkOIDCIsAvailable(oidcRequest1)) + .thenAnswer((_) => completer1.future); + when(mockRepository.checkOIDCIsAvailable(oidcRequest2)) + .thenAnswer((_) => completer2.future); + + // Act + final stream = interactor.execute([oidcRequest1, oidcRequest2]); + final futureResults = stream.toList(); + + completer1.complete(successResponse); + await Future.delayed(Duration.zero); // Allow stream to process + + // Assert + final results = await futureResults; + expect(results, [ + Right(TryingGuessingWebFinger()), + Right(TryGuessingWebFingerSuccess(successResponse)), + ]); + + verifyInOrder([ + mockRepository.checkOIDCIsAvailable(oidcRequest1), + mockRepository.checkOIDCIsAvailable(oidcRequest2), + ]); + + // Verify second request was made but not completed + expect(completer2.isCompleted, isFalse); + }); + + test('should return failure when all responses are null', () async { + // Arrange + when(mockRepository.checkOIDCIsAvailable(any)) + .thenAnswer((_) async => throw Exception()); + + // Act + final results = await interactor.execute([oidcRequest1, oidcRequest2]).toList(); + + // Assert + expect(results[0], Right(TryingGuessingWebFinger())); + expect(results[1], isA>()); + expect((results[1] as Left).value, isA()); + }); + + test('should handle async response order correctly', () async { + // Arrange + final completer1 = Completer(); + final completer2 = Completer(); + + when(mockRepository.checkOIDCIsAvailable(oidcRequest1)) + .thenAnswer((_) => completer1.future); + when(mockRepository.checkOIDCIsAvailable(oidcRequest2)) + .thenAnswer((_) => completer2.future); + + // Act + final stream = interactor.execute([oidcRequest1, oidcRequest2]); + final futureResults = stream.toList(); + + completer2.complete(successResponse); + await Future.delayed(Duration.zero); + completer1.completeError(Exception('Timeout')); + + final results = await futureResults; + + // Assert + expect(results, [ + Right(TryingGuessingWebFinger()), + Right(TryGuessingWebFingerSuccess(successResponse)), + ]); + }); + + test('should handle exceptions properly', () async { + // Arrange + when(mockRepository.checkOIDCIsAvailable(any)) + .thenAnswer((_) => Future.error(Exception('Test error'))); + + // Act + final results = await interactor.execute([oidcRequest1]).toList(); + + // Assert + expect(results[0], Right(TryingGuessingWebFinger())); + expect(results[1], isA>()); + expect((results[1] as Left).value, isA()); + }); +} diff --git a/test/features/login/presentation/login_controller_test.dart b/test/features/login/presentation/login_controller_test.dart index 9f958c5db..63a6bad78 100644 --- a/test/features/login/presentation/login_controller_test.dart +++ b/test/features/login/presentation/login_controller_test.dart @@ -34,6 +34,7 @@ import 'package:tmail_ui_user/features/login/domain/usecases/get_stored_oidc_con import 'package:tmail_ui_user/features/login/domain/usecases/get_token_oidc_interactor.dart'; import 'package:tmail_ui_user/features/login/domain/usecases/save_login_url_on_mobile_interactor.dart'; import 'package:tmail_ui_user/features/login/domain/usecases/save_login_username_on_mobile_interactor.dart'; +import 'package:tmail_ui_user/features/login/domain/usecases/try_guessing_web_finger_interactor.dart'; import 'package:tmail_ui_user/features/login/domain/usecases/update_account_cache_interactor.dart'; import 'package:tmail_ui_user/features/login/presentation/extensions/handle_openid_configuration.dart'; import 'package:tmail_ui_user/features/login/presentation/login_controller.dart'; @@ -71,6 +72,7 @@ import 'login_controller_test.mocks.dart'; MockSpec(), MockSpec(), MockSpec(), + MockSpec(), MockSpec(), MockSpec(), MockSpec(), @@ -94,6 +96,7 @@ void main() { late MockGetAllRecentLoginUsernameOnMobileInteractor mockGetAllRecentLoginUsernameOnMobileInteractor; late MockDNSLookupToGetJmapUrlInteractor mockDNSLookupToGetJmapUrlInteractor; late MockSignInTwakeWorkplaceInteractor mockSignInTwakeWorkplaceInteractor; + late MockTryGuessingWebFingerInteractor mockTryGuessingWebFingerInteractor; late MockGetSessionInteractor mockGetSessionInteractor; late MockGetAuthenticatedAccountInteractor mockGetAuthenticatedAccountInteractor; late MockUpdateAccountCacheInteractor mockUpdateAccountCacheInteractor; @@ -128,6 +131,7 @@ void main() { mockGetAllRecentLoginUsernameOnMobileInteractor = MockGetAllRecentLoginUsernameOnMobileInteractor(); mockDNSLookupToGetJmapUrlInteractor = MockDNSLookupToGetJmapUrlInteractor(); mockSignInTwakeWorkplaceInteractor = MockSignInTwakeWorkplaceInteractor(); + mockTryGuessingWebFingerInteractor = MockTryGuessingWebFingerInteractor(); // mock reloadable controller mockGetSessionInteractor = MockGetSessionInteractor(); @@ -191,6 +195,7 @@ void main() { mockGetAllRecentLoginUsernameOnMobileInteractor, mockDNSLookupToGetJmapUrlInteractor, mockSignInTwakeWorkplaceInteractor, + mockTryGuessingWebFingerInteractor, ); });