From 0025cd4a7ab561800c96bbb96cb92dbece2c4668 Mon Sep 17 00:00:00 2001 From: Sembauke Date: Tue, 1 Sep 2026 14:36:14 +0200 Subject: [PATCH 1/6] fix(auth): use Auth0 SDK login flow --- codemagic.yaml | 8 + .../authentication_service.dart | 545 +++++++++++------- .../views/login/native_login_viewmodel.dart | 30 +- 3 files changed, 359 insertions(+), 224 deletions(-) diff --git a/codemagic.yaml b/codemagic.yaml index a2b251e98..45ba8ea31 100644 --- a/codemagic.yaml +++ b/codemagic.yaml @@ -31,6 +31,10 @@ workflows: scripts: - name: Create .env file script: | + if [ -z "${AUTH0_DOMAIN:-}" ] || [ -z "${AUTH0_CLIENT_ID:-}" ]; then + echo "AUTH0_DOMAIN and AUTH0_CLIENT_ID must be configured in the Mobile environment group." + exit 1 + fi echo "HASHNODE_PUBLICATION_ID=$HASHNODE_PUBLICATION_ID" > .env echo "ALGOLIAAPPID=$ALGOLIAAPPID" >> .env echo "ALGOLIAKEY=$ALGOLIAKEY" >> .env @@ -110,6 +114,10 @@ workflows: scripts: - name: Create .env file script: | + if [ -z "${AUTH0_DOMAIN:-}" ] || [ -z "${AUTH0_CLIENT_ID:-}" ]; then + echo "AUTH0_DOMAIN and AUTH0_CLIENT_ID must be configured in the Mobile environment group." + exit 1 + fi echo "HASHNODE_PUBLICATION_ID=$HASHNODE_PUBLICATION_ID" > .env echo "ALGOLIAAPPID=$ALGOLIAAPPID" >> .env echo "ALGOLIAKEY=$ALGOLIAKEY" >> .env diff --git a/mobile-app/lib/service/authentication/authentication_service.dart b/mobile-app/lib/service/authentication/authentication_service.dart index 01d2ce430..697608a52 100644 --- a/mobile-app/lib/service/authentication/authentication_service.dart +++ b/mobile-app/lib/service/authentication/authentication_service.dart @@ -16,6 +16,22 @@ import 'package:freecodecamp/service/dio_service.dart'; import 'package:stacked_services/stacked_services.dart'; import 'package:url_launcher/url_launcher.dart'; +/// Thrown when `/mobile-login` answers successfully but without the session +/// cookies the app needs. +/// +/// Treated as a failed login rather than ignored: carrying on would leave the +/// app looking signed in while every authenticated request behind it failed. +class MissingSessionCookiesException implements Exception { + MissingSessionCookiesException(this.missing); + + /// Names of the cookies the response did not carry. + final List missing; + + @override + String toString() => + 'The login response was missing these cookies: ${missing.join(', ')}'; +} + class AuthenticationService { static final AuthenticationService _authenticationService = AuthenticationService._internal(); @@ -27,6 +43,37 @@ class AuthenticationService { final Dio _dio = DioService.dio; late final Auth0 auth0; + /// The session cookies the fCC API hands back, and the keys they are stored + /// under. Every one of them is required for an authenticated request. + static const List _sessionTokenKeys = [ + 'jwt_access_token', + 'csrf_token', + 'csrf', + ]; + + /// Cookie names in the `/mobile-login` response. Deliberately not the same + /// strings as [_sessionTokenKeys] — the `_csrf` cookie is stored under the + /// key `csrf`. + static const List _sessionCookieNames = [ + '_csrf', + 'csrf_token', + 'jwt_access_token', + ]; + + /// Callback scheme for debug builds, where App Links are unavailable because + /// the app is signed with the debug key. Must match the `auth0Scheme` + /// manifest placeholder in android/app/build.gradle.kts. + static const String _debugCallbackScheme = 'org.freecodecamp'; + + /// Requested on both login paths. offline_access is deliberately absent: the + /// access token is exchanged for an fCC session immediately, so a refresh + /// token would only be issued to be thrown away. + static const Set _scopes = {'openid', 'profile', 'email'}; + + static const Color _dialogBackground = Color(0xFF2A2A40); + static const Color _dialogButtonBackground = Color(0xFF0a0a23); + static const RoundedRectangleBorder _dialogBorder = RoundedRectangleBorder(); + String _csrf = ''; String _csrfToken = ''; String _jwtAccessToken = ''; @@ -57,12 +104,10 @@ class AuthenticationService { } Future hasRequiredTokens() async { - List requiredTokens = ['jwt_access_token', 'csrf_token', 'csrf']; - - for (String requiredToken in requiredTokens) { - if (await store.containsKey(key: requiredToken) == false || - await store.read(key: requiredToken) == null || - await store.read(key: requiredToken) == '') { + for (String requiredToken in _sessionTokenKeys) { + // NOTE: read returns null for a missing key, so it covers containsKey too + final value = await store.read(key: requiredToken); + if (value == null || value.isEmpty) { log('message: Missing token: $requiredToken'); return false; } @@ -72,30 +117,45 @@ class AuthenticationService { } Future writeTokensToStorage() async { - store.write(key: 'csrf', value: _csrf); - store.write(key: 'csrf_token', value: _csrfToken); - store.write(key: 'jwt_access_token', value: _jwtAccessToken); + await Future.wait([ + store.write(key: 'csrf', value: _csrf), + store.write(key: 'csrf_token', value: _csrfToken), + store.write(key: 'jwt_access_token', value: _jwtAccessToken), + ]); } Future setRequiredTokens() async { - _csrf = await store.read(key: 'csrf') as String; - _csrfToken = await store.read(key: 'csrf_token') as String; - _jwtAccessToken = await store.read(key: 'jwt_access_token') as String; + _csrf = await store.read(key: 'csrf') ?? ''; + _csrfToken = await store.read(key: 'csrf_token') ?? ''; + _jwtAccessToken = await store.read(key: 'jwt_access_token') ?? ''; } + /// Reads the fCC session cookies out of a `/mobile-login` response. + /// + /// Throws [MissingSessionCookiesException] when the response carries no + /// `set-cookie` header, or carries one without every cookie the app needs. + /// Nothing is assigned unless all of them are present, so a rejected + /// response cannot half-replace a session that is already in place. void extractCookies(Response res) { - for (var cookie in res.headers['set-cookie']!) { - var parsedCookie = Cookie.fromSetCookieValue(cookie); - if (parsedCookie.name == '_csrf') { - _csrf = parsedCookie.value; - } - if (parsedCookie.name == 'csrf_token') { - _csrfToken = parsedCookie.value; - } - if (parsedCookie.name == 'jwt_access_token') { - _jwtAccessToken = parsedCookie.value; - } + final cookies = {}; + + for (final cookie + in res.headers[HttpHeaders.setCookieHeader] ?? const []) { + final parsedCookie = Cookie.fromSetCookieValue(cookie); + cookies[parsedCookie.name] = parsedCookie.value; + } + + final missing = _sessionCookieNames + .where((name) => (cookies[name] ?? '').isEmpty) + .toList(); + + if (missing.isNotEmpty) { + throw MissingSessionCookiesException(missing); } + + _csrf = cookies['_csrf']!; + _csrfToken = cookies['csrf_token']!; + _jwtAccessToken = cookies['jwt_access_token']!; } Future setCurrentClientMode() async { @@ -128,6 +188,79 @@ class AuthenticationService { return FccUserModel.fromJson(data); } + /// Opens Universal Login for [connectionType]. + /// + /// Backgrounding the app while the login sheet is open can leave a web auth + /// transaction active, and every later attempt then fails with + /// `TRANSACTION_ACTIVE_ALREADY` until the app is restarted. Clearing the + /// stale transaction and retrying once keeps the user from getting stuck. + Future _webAuthLogin(String connectionType) async { + final webAuth = auth0.webAuthentication( + scheme: kReleaseMode ? null : _debugCallbackScheme, + // NOTE: the access token is exchanged for an fCC session right away and + // never read back, so storing it would only leave Auth0 tokens behind on + // the device whenever that exchange fails + useCredentialsManager: false, + ); + + final parameters = {'connection': connectionType}; + + try { + return await webAuth.login( + useHTTPS: true, + scopes: _scopes, + parameters: parameters, + ); + } on WebAuthenticationException catch (e) { + if (e.code != 'TRANSACTION_ACTIVE_ALREADY') rethrow; + + log('message: clearing a stale web auth transaction'); + WebAuthentication.cancel(); + + return webAuth.login( + useHTTPS: true, + scopes: _scopes, + parameters: parameters, + ); + } + } + + /// Authenticates with Auth0 and returns the credentials to exchange for an + /// fCC session. Email uses a one-time code; everything else is a social + /// connection through Universal Login. + Future _authenticate( + String connectionType, { + String? email, + String? otp, + }) { + if (connectionType != 'email') { + return _webAuthLogin(connectionType); + } + + return auth0.api.loginWithEmailCode( + email: email!, + verificationCode: otp!, + scopes: _scopes, + ); + } + + /// Trades an Auth0 access token for the fCC session cookies and loads the + /// user behind them. + Future _exchangeForSession(String accessToken) async { + final res = await _dio.get( + '$baseApiURL/mobile-login', + options: Options( + headers: { + 'Authorization': 'Bearer $accessToken', + }, + ), + ); + + extractCookies(res); + await writeTokensToStorage(); + await fetchUser(); + } + Future login( BuildContext context, String connectionType, { @@ -135,9 +268,91 @@ class AuthenticationService { String? otp, }) async { late final Credentials creds; - late final Response emailLoginRes; - Response? res; + _showLoadingDialog(context, connectionType); + + try { + creds = await _authenticate(connectionType, email: email, otp: otp); + } on WebAuthenticationException catch (e) { + log('message: WebAuthenticationException: ${e.message}, ' + 'code: ${e.code}, retryable: ${e.isRetryable}'); + + snackbar.showSnackbar( + title: e.isUserCancelledException + ? context.t.login_cancelled + : context.t.error_two, + message: e.isUserCancelledException ? '' : e.message, + ); + + await _abandonLogin(context); + return false; + } on ApiException catch (e) { + // NOTE: the most likely case is a wrong or expired OTP, which the login + // view reports through its own error text + log('message: ApiException: ${e.message}'); + + await _abandonLogin(context); + return false; + } + + try { + await _exchangeForSession(creds.accessToken); + } on DioException catch (err, st) { + await _reportSessionFailure( + context, + details: err.response?.data.toString() ?? err.toString(), + stackTrace: st, + ); + return false; + } on MissingSessionCookiesException catch (err, st) { + await _reportSessionFailure( + context, + details: err.toString(), + stackTrace: st, + ); + return false; + } + + if (context.mounted) { + // NOTE: closes the loading dialog, then the login view behind it + Navigator.pop(context); + Navigator.pop(context); + } + + return true; + } + + /// Closes the loading dialog and reports a failed session exchange. + Future _reportSessionFailure( + BuildContext context, { + required String details, + required StackTrace stackTrace, + }) async { + log('message: session exchange failed: $details'); + + if (!context.mounted) { + return; + } + + Navigator.pop(context); + await _showSessionErrorDialog( + context, + details: details, + stackTrace: stackTrace.toString(), + ); + } + + /// Drops the half-finished session and closes the loading dialog. + Future _abandonLogin(BuildContext context) async { + await _clearLocalSession(); + + if (context.mounted) { + Navigator.pop(context); + } + } + + void _showLoadingDialog(BuildContext context, String connectionType) { + // NOTE: deliberately not awaited; the dialog is dismissed by popping it showDialog( context: context, barrierDismissible: false, @@ -150,10 +365,8 @@ class AuthenticationService { child: SimpleDialog( title: Text(context.t.login_load_message), contentPadding: const EdgeInsets.fromLTRB(0.0, 12.0, 0.0, 24.0), - backgroundColor: const Color(0xFF2A2A40), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(0), - ), + backgroundColor: _dialogBackground, + shape: _dialogBorder, children: const [ Center( child: CircularProgressIndicator(), @@ -163,199 +376,117 @@ class AuthenticationService { ); }, ); + } - try { - if (connectionType == 'email') { - emailLoginRes = await _dio.post( - 'https://${dotenv.get('AUTH0_DOMAIN')}/oauth/token', - data: { - 'client_id': dotenv.get('AUTH0_CLIENT_ID'), - 'grant_type': 'http://auth0.com/oauth/grant-type/passwordless/otp', - 'realm': 'email', - 'username': email, - 'otp': otp, - 'scope': 'openid profile email', - }, - ); - } else { - // NOTE: scheme is only needed for Android in debug mode, as the callback URL is different in that case - creds = await auth0 - .webAuthentication(scheme: kReleaseMode ? null : 'org.freecodecamp') - .login(useHTTPS: true, parameters: {'connection': connectionType}); - } - } on WebAuthenticationException catch (e) { - log('message: WebAuthenticationException: ${e.message}'); - - // NOTE: The most likely case is that the user canceled the login - snackbar.showSnackbar( - title: context.t.login_cancelled, - message: '', - ); - - logout(); - Navigator.pop(context); - - return false; - } on DioException catch (e) { - log(e.toString()); - logout(); - Navigator.pop(context); - return false; - } + /// Reports a failed session exchange, offering to mail the details to + /// support. The same text is shown on screen and used as the mail body. + Future _showSessionErrorDialog( + BuildContext context, { + required String details, + required String stackTrace, + }) { + final message = context.t.login_email_error_message( + supportEmail, + details, + stackTrace, + ); + final subject = Uri.encodeComponent(context.t.login_email_error_subject); + final body = Uri.encodeComponent(message); - try { - String accessToken = connectionType == 'email' - ? emailLoginRes.data['access_token'] - : creds.accessToken; - res = await _dio.get( - '$baseApiURL/mobile-login', - options: Options( - headers: { - 'Authorization': 'Bearer $accessToken', - }, - ), - ); - extractCookies(res); - await writeTokensToStorage(); - await fetchUser(); - } on DioException catch (err, st) { - String subject = Uri.encodeComponent(context.t.login_email_error_subject); - Navigator.pop(context); - if (err.response != null) { - String body = Uri.encodeComponent(context.t.login_email_error_message( - AuthenticationService.supportEmail, - err.response!.data.toString(), - st.toString(), - )); - await showDialog( - context: context, - barrierDismissible: false, - routeSettings: const RouteSettings( - name: '/login/error', - ), - builder: (context) => AlertDialog( - backgroundColor: const Color(0xFF2A2A40), - title: Text(context.t.error_two), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(0), - ), - content: SingleChildScrollView( - child: SelectionArea( - child: Text(context.t.login_email_error_message( - AuthenticationService.supportEmail, - err.response!.data.toString(), - st.toString(), - )), - ), - ), - actions: [ - TextButton( - style: TextButton.styleFrom( - backgroundColor: const Color(0xFF0a0a23), - ), - onPressed: () async { - logout(); - await launchUrl(Uri.parse( - 'mailto:${AuthenticationService.supportEmail}?subject=$subject&body=$body')); - Navigator.pop(context); - }, - child: Text(context.t.email_error), - ), - TextButton( - style: TextButton.styleFrom( - backgroundColor: const Color(0xFF0a0a23), - ), - onPressed: () { - logout(); - Navigator.pop(context); - }, - child: Text(context.t.close), - ), - ], + return showDialog( + context: context, + barrierDismissible: false, + routeSettings: const RouteSettings( + name: '/login/error', + ), + builder: (context) => AlertDialog( + backgroundColor: _dialogBackground, + shape: _dialogBorder, + title: Text(context.t.error_two), + content: SingleChildScrollView( + child: SelectionArea( + child: Text(message), ), - ); - } else { - String body = Uri.encodeComponent(context.t.login_email_error_message( - AuthenticationService.supportEmail, - err.toString(), - st.toString(), - )); - await showDialog( - context: context, - barrierDismissible: false, - routeSettings: const RouteSettings( - name: '/login/error', + ), + actions: [ + _dialogAction( + label: context.t.email_error, + onPressed: () async { + await _clearLocalSession(); + await launchUrl(Uri.parse( + 'mailto:$supportEmail?subject=$subject&body=$body')); + + if (context.mounted) { + Navigator.pop(context); + } + }, ), - builder: (context) => AlertDialog( - backgroundColor: const Color(0xFF2A2A40), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(0), - ), - title: Text(context.t.error_two), - content: SingleChildScrollView( - child: SelectionArea( - child: Text(context.t.login_email_error_message( - AuthenticationService.supportEmail, - err.toString(), - st.toString(), - )), - ), - ), - actions: [ - TextButton( - style: TextButton.styleFrom( - backgroundColor: const Color(0xFF0a0a23), - ), - onPressed: () async { - logout(); - await launchUrl(Uri.parse( - 'mailto:${AuthenticationService.supportEmail}?subject=$subject&body=$body')); - Navigator.pop(context); - }, - child: Text(context.t.email_error), - ), - TextButton( - style: TextButton.styleFrom( - backgroundColor: const Color(0xFF0a0a23), - ), - onPressed: () { - logout(); - Navigator.pop(context); - }, - child: Text(context.t.close), - ), - ], + _dialogAction( + label: context.t.close, + onPressed: () async { + await _clearLocalSession(); + + if (context.mounted) { + Navigator.pop(context); + } + }, ), - ); - } - return false; - } + ], + ), + ); + } - await auth0.credentialsManager.clearCredentials(); - // ignore: unnecessary_null_comparison - if (res != null) { - Navigator.pop(context); - Navigator.pop(context); - } - return true; + Widget _dialogAction({ + required String label, + required VoidCallback onPressed, + }) { + return TextButton( + style: TextButton.styleFrom( + backgroundColor: _dialogButtonBackground, + ), + onPressed: onPressed, + child: Text(label), + ); } + /// Clears the Auth0 session cookie, then drops the local session. + /// + /// Clearing the cookie is what actually signs the user out of the device: + /// without it a valid Auth0 session survives on a shared phone, and the next + /// login can reuse it. It opens a browser tab, so this belongs on + /// user-initiated logouts only — everywhere else use [_clearLocalSession]. Future logout() async { + try { + // NOTE: unlike login this keeps the credentials manager, so that any + // tokens stored by an older build get cleared on the next logout + await auth0 + .webAuthentication(scheme: kReleaseMode ? null : _debugCallbackScheme) + .logout(useHTTPS: true); + } on WebAuthenticationException catch (e) { + // NOTE: a failed Auth0 logout must never strand the local session + log('message: WebAuthenticationException on logout: ${e.message}'); + } + + await _clearLocalSession(); + } + + Future _clearLocalSession() async { staticIsloggedIn = false; isLoggedInStream.sink.add(false); + _csrf = ''; _csrfToken = ''; _jwtAccessToken = ''; - await store.delete(key: 'csrf'); - await store.delete(key: 'csrf_token'); - await store.delete(key: 'jwt_access_token'); userModel = null; + + await Future.wait( + _sessionTokenKeys.map((key) => store.delete(key: key)), + ); } Future fetchUser() async { - late final Response res; try { - res = await _dio.get( + final res = await _dio.get( '$baseApiURL/user/session-user', options: Options( headers: { @@ -371,14 +502,10 @@ class AuthenticationService { isLoggedInStream.sink.add(true); progress.add(true); } else { - staticIsloggedIn = false; - isLoggedInStream.sink.add(false); - await logout(); + await _clearLocalSession(); } } on DioException { - staticIsloggedIn = false; - isLoggedInStream.sink.add(false); - await logout(); + await _clearLocalSession(); } } diff --git a/mobile-app/lib/ui/views/login/native_login_viewmodel.dart b/mobile-app/lib/ui/views/login/native_login_viewmodel.dart index 2fc8dc7d3..3179f7fc4 100644 --- a/mobile-app/lib/ui/views/login/native_login_viewmodel.dart +++ b/mobile-app/lib/ui/views/login/native_login_viewmodel.dart @@ -1,10 +1,10 @@ -import 'package:dio/dio.dart'; +import 'dart:developer'; + +import 'package:auth0_flutter/auth0_flutter.dart'; import 'package:flutter/cupertino.dart'; -import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:freecodecamp/app/app.locator.dart'; import 'package:freecodecamp/service/authentication/authentication_service.dart'; import 'package:freecodecamp/service/developer_service.dart'; -import 'package:freecodecamp/service/dio_service.dart'; import 'package:stacked/stacked.dart'; class NativeLoginViewModel extends BaseViewModel { @@ -12,7 +12,6 @@ class NativeLoginViewModel extends BaseViewModel { TextEditingController otpController = TextEditingController(); bool showOTPfield = false; bool incorrectOTP = false; - final Dio _dio = DioService.dio; final AuthenticationService auth = locator(); final DeveloperService developerService = locator(); @@ -63,20 +62,21 @@ class NativeLoginViewModel extends BaseViewModel { void sendOTPtoEmail() async { showOTPfield = true; notifyListeners(); - await dotenv.load(); - await _dio.post( - 'https://${dotenv.get('AUTH0_DOMAIN')}/passwordless/start', - data: { - 'client_id': dotenv.get('AUTH0_CLIENT_ID'), - 'connection': 'email', - 'email': emailController.text, - 'send': 'code', - }, - ); + try { + await auth.auth0.api.startPasswordlessWithEmail( + email: emailController.text, + passwordlessType: PasswordlessType.code, + ); + } on ApiException catch (e) { + // NOTE: without a code on its way, leaving the OTP field up would strand + // the user, so send them back to the email step to retry + log('message: ApiException on passwordless start: ${e.message}'); + showOTPfield = false; + notifyListeners(); + } } void verifyOTP(BuildContext context) async { - await dotenv.load(); bool isSuccess = await auth.login( context, 'email', From da112058c69b3d4e86cf74af6d19bb64a76bb6ae Mon Sep 17 00:00:00 2001 From: Sembauke Date: Tue, 1 Sep 2026 16:43:54 +0200 Subject: [PATCH 2/6] fix(auth): fail login when user session load fails --- .../authentication_service.dart | 31 ++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/mobile-app/lib/service/authentication/authentication_service.dart b/mobile-app/lib/service/authentication/authentication_service.dart index 697608a52..fcc46215d 100644 --- a/mobile-app/lib/service/authentication/authentication_service.dart +++ b/mobile-app/lib/service/authentication/authentication_service.dart @@ -32,6 +32,15 @@ class MissingSessionCookiesException implements Exception { 'The login response was missing these cookies: ${missing.join(', ')}'; } +/// Thrown when the session cookies were created but could not be used to load +/// the signed-in user. +class SessionUserFetchException implements Exception { + const SessionUserFetchException(); + + @override + String toString() => 'The signed-in user could not be loaded.'; +} + class AuthenticationService { static final AuthenticationService _authenticationService = AuthenticationService._internal(); @@ -258,7 +267,9 @@ class AuthenticationService { extractCookies(res); await writeTokensToStorage(); - await fetchUser(); + if (!await _loadUserSession()) { + throw const SessionUserFetchException(); + } } Future login( @@ -311,6 +322,13 @@ class AuthenticationService { stackTrace: st, ); return false; + } on SessionUserFetchException catch (err, st) { + await _reportSessionFailure( + context, + details: err.toString(), + stackTrace: st, + ); + return false; } if (context.mounted) { @@ -485,6 +503,14 @@ class AuthenticationService { } Future fetchUser() async { + await _loadUserSession(); + } + + /// Loads the user for the current session and reports whether it succeeded. + /// + /// Public callers only need the side effects from [fetchUser], but login + /// must use this result to avoid reporting success for a cleared session. + Future _loadUserSession() async { try { final res = await _dio.get( '$baseApiURL/user/session-user', @@ -501,11 +527,14 @@ class AuthenticationService { staticIsloggedIn = true; isLoggedInStream.sink.add(true); progress.add(true); + return true; } else { await _clearLocalSession(); + return false; } } on DioException { await _clearLocalSession(); + return false; } } From cef9bc45643f688bc23054dbd132dfc1180a3aa5 Mon Sep 17 00:00:00 2001 From: Sembauke Date: Tue, 1 Sep 2026 16:45:25 +0200 Subject: [PATCH 3/6] fix(auth): limit stale transaction retry to iOS --- .../lib/service/authentication/authentication_service.dart | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/mobile-app/lib/service/authentication/authentication_service.dart b/mobile-app/lib/service/authentication/authentication_service.dart index fcc46215d..305aa43c4 100644 --- a/mobile-app/lib/service/authentication/authentication_service.dart +++ b/mobile-app/lib/service/authentication/authentication_service.dart @@ -221,7 +221,10 @@ class AuthenticationService { parameters: parameters, ); } on WebAuthenticationException catch (e) { - if (e.code != 'TRANSACTION_ACTIVE_ALREADY') rethrow; + // Auth0 exposes cancel as an iOS-only API. On every other platform, + // surface the original exception instead of attempting an unsupported + // recovery. + if (e.code != 'TRANSACTION_ACTIVE_ALREADY' || !Platform.isIOS) rethrow; log('message: clearing a stale web auth transaction'); WebAuthentication.cancel(); From f17c4cc916c9a89a3c0ca8a3db1fcd316cde75a2 Mon Sep 17 00:00:00 2001 From: Sembauke Date: Tue, 1 Sep 2026 16:47:14 +0200 Subject: [PATCH 4/6] fix(auth): show email code delivery failures --- mobile-app/lib/l10n/app_en.arb | 4 ++++ mobile-app/lib/l10n/app_es.arb | 6 +++++- mobile-app/lib/l10n/app_localizations.dart | 6 ++++++ mobile-app/lib/l10n/app_localizations_en.dart | 4 ++++ mobile-app/lib/l10n/app_localizations_es.dart | 4 ++++ mobile-app/lib/l10n/app_localizations_pt.dart | 4 ++++ mobile-app/lib/l10n/app_pt.arb | 6 +++++- mobile-app/lib/ui/views/login/native_login_view.dart | 2 +- mobile-app/lib/ui/views/login/native_login_viewmodel.dart | 7 ++++++- 9 files changed, 39 insertions(+), 4 deletions(-) diff --git a/mobile-app/lib/l10n/app_en.arb b/mobile-app/lib/l10n/app_en.arb index 55f78904e..e6c1a202d 100644 --- a/mobile-app/lib/l10n/app_en.arb +++ b/mobile-app/lib/l10n/app_en.arb @@ -76,6 +76,10 @@ "@email_invalid_code": { "description": "login view (when failing to login with email)" }, + "email_code_not_sent": "We couldn't send a sign-in code. Please try again.", + "@email_code_not_sent": { + "description": "login view (when sending an email sign-in code fails)" + }, "login_data_message": "freeCodeCamp is free and your account is private by default. We use your email address to connect you to your account.", "@login_data_message": { "description": "login view" diff --git a/mobile-app/lib/l10n/app_es.arb b/mobile-app/lib/l10n/app_es.arb index b58cc6763..60e8bec80 100644 --- a/mobile-app/lib/l10n/app_es.arb +++ b/mobile-app/lib/l10n/app_es.arb @@ -74,7 +74,11 @@ }, "email_invalid_code": "The code you entered is not valid. Please check the last OTP you received and try again.", "@email_invalid_code": { - "description": "login view (when failing to login with email)" + "description": "login view (when failing to login with email)" + }, + "email_code_not_sent": "We couldn't send a sign-in code. Please try again.", + "@email_code_not_sent": { + "description": "login view (when sending an email sign-in code fails)" }, "login_data_message": "freeCodeCamp is free and your account is private by default. We use your email address to connect you to your account.", "@login_data_message": { diff --git a/mobile-app/lib/l10n/app_localizations.dart b/mobile-app/lib/l10n/app_localizations.dart index 49c5745b2..7b3bf08f7 100644 --- a/mobile-app/lib/l10n/app_localizations.dart +++ b/mobile-app/lib/l10n/app_localizations.dart @@ -214,6 +214,12 @@ abstract class AppLocalizations { /// **'The code you entered is not valid. Please check the last OTP you received and try again.'** String get email_invalid_code; + /// login view (when sending an email sign-in code fails) + /// + /// In en, this message translates to: + /// **'We couldn\'t send a sign-in code. Please try again.'** + String get email_code_not_sent; + /// login view /// /// In en, this message translates to: diff --git a/mobile-app/lib/l10n/app_localizations_en.dart b/mobile-app/lib/l10n/app_localizations_en.dart index 86795fe27..d08a78b2f 100644 --- a/mobile-app/lib/l10n/app_localizations_en.dart +++ b/mobile-app/lib/l10n/app_localizations_en.dart @@ -66,6 +66,10 @@ class AppLocalizationsEn extends AppLocalizations { String get email_invalid_code => 'The code you entered is not valid. Please check the last OTP you received and try again.'; + @override + String get email_code_not_sent => + 'We couldn\'t send a sign-in code. Please try again.'; + @override String get login_data_message => 'freeCodeCamp is free and your account is private by default. We use your email address to connect you to your account.'; diff --git a/mobile-app/lib/l10n/app_localizations_es.dart b/mobile-app/lib/l10n/app_localizations_es.dart index 039aad243..71d178aee 100644 --- a/mobile-app/lib/l10n/app_localizations_es.dart +++ b/mobile-app/lib/l10n/app_localizations_es.dart @@ -66,6 +66,10 @@ class AppLocalizationsEs extends AppLocalizations { String get email_invalid_code => 'The code you entered is not valid. Please check the last OTP you received and try again.'; + @override + String get email_code_not_sent => + 'We couldn\'t send a sign-in code. Please try again.'; + @override String get login_data_message => 'freeCodeCamp is free and your account is private by default. We use your email address to connect you to your account.'; diff --git a/mobile-app/lib/l10n/app_localizations_pt.dart b/mobile-app/lib/l10n/app_localizations_pt.dart index 34349f67f..e3ba5d558 100644 --- a/mobile-app/lib/l10n/app_localizations_pt.dart +++ b/mobile-app/lib/l10n/app_localizations_pt.dart @@ -66,6 +66,10 @@ class AppLocalizationsPt extends AppLocalizations { String get email_invalid_code => 'The code you entered is not valid. Please check the last OTP you received and try again.'; + @override + String get email_code_not_sent => + 'We couldn\'t send a sign-in code. Please try again.'; + @override String get login_data_message => 'freeCodeCamp is free and your account is private by default. We use your email address to connect you to your account.'; diff --git a/mobile-app/lib/l10n/app_pt.arb b/mobile-app/lib/l10n/app_pt.arb index c919afdbb..7beb38d7a 100644 --- a/mobile-app/lib/l10n/app_pt.arb +++ b/mobile-app/lib/l10n/app_pt.arb @@ -74,7 +74,11 @@ }, "email_invalid_code": "The code you entered is not valid. Please check the last OTP you received and try again.", "@email_invalid_code": { - "description": "login view (when failing to login with email)" + "description": "login view (when failing to login with email)" + }, + "email_code_not_sent": "We couldn't send a sign-in code. Please try again.", + "@email_code_not_sent": { + "description": "login view (when sending an email sign-in code fails)" }, "login_data_message": "freeCodeCamp is free and your account is private by default. We use your email address to connect you to your account.", "@login_data_message": { diff --git a/mobile-app/lib/ui/views/login/native_login_view.dart b/mobile-app/lib/ui/views/login/native_login_view.dart index 737e19ff3..cc07635c8 100644 --- a/mobile-app/lib/ui/views/login/native_login_view.dart +++ b/mobile-app/lib/ui/views/login/native_login_view.dart @@ -233,7 +233,7 @@ class NativeLoginView extends StatelessWidget { style: ctaButtonStyle, onPressed: model.emailFieldIsValid ? () { - model.sendOTPtoEmail(); + model.sendOTPtoEmail(context); } : null, child: Padding( diff --git a/mobile-app/lib/ui/views/login/native_login_viewmodel.dart b/mobile-app/lib/ui/views/login/native_login_viewmodel.dart index 3179f7fc4..1160c460b 100644 --- a/mobile-app/lib/ui/views/login/native_login_viewmodel.dart +++ b/mobile-app/lib/ui/views/login/native_login_viewmodel.dart @@ -3,6 +3,7 @@ import 'dart:developer'; import 'package:auth0_flutter/auth0_flutter.dart'; import 'package:flutter/cupertino.dart'; import 'package:freecodecamp/app/app.locator.dart'; +import 'package:freecodecamp/extensions/i18n_extension.dart'; import 'package:freecodecamp/service/authentication/authentication_service.dart'; import 'package:freecodecamp/service/developer_service.dart'; import 'package:stacked/stacked.dart'; @@ -59,7 +60,7 @@ class NativeLoginViewModel extends BaseViewModel { }); } - void sendOTPtoEmail() async { + void sendOTPtoEmail(BuildContext context) async { showOTPfield = true; notifyListeners(); try { @@ -73,6 +74,10 @@ class NativeLoginViewModel extends BaseViewModel { log('message: ApiException on passwordless start: ${e.message}'); showOTPfield = false; notifyListeners(); + auth.snackbar.showSnackbar( + title: context.t.error_two, + message: context.t.email_code_not_sent, + ); } } From 13acb2475336bb468fee073ec9bf18e8e387cf45 Mon Sep 17 00:00:00 2001 From: Sembauke Date: Tue, 1 Sep 2026 17:17:02 +0200 Subject: [PATCH 5/6] test(auth): cover login failure flows --- .../authentication_service.dart | 7 +- .../views/login/native_login_viewmodel.dart | 2 +- .../authentication_service_test.dart | 314 ++++++++++++++++++ .../native_login_viewmodel_test.dart | 107 ++++++ 4 files changed, 428 insertions(+), 2 deletions(-) create mode 100644 mobile-app/test/services/authentication/authentication_service_test.dart create mode 100644 mobile-app/test/viewmodels/native_login_viewmodel_test.dart diff --git a/mobile-app/lib/service/authentication/authentication_service.dart b/mobile-app/lib/service/authentication/authentication_service.dart index 305aa43c4..e0c988da9 100644 --- a/mobile-app/lib/service/authentication/authentication_service.dart +++ b/mobile-app/lib/service/authentication/authentication_service.dart @@ -83,6 +83,11 @@ class AuthenticationService { static const Color _dialogButtonBackground = Color(0xFF0a0a23); static const RoundedRectangleBorder _dialogBorder = RoundedRectangleBorder(); + @visibleForTesting + static bool Function()? isIOSOverride; + + static bool get _isIOS => isIOSOverride?.call() ?? Platform.isIOS; + String _csrf = ''; String _csrfToken = ''; String _jwtAccessToken = ''; @@ -224,7 +229,7 @@ class AuthenticationService { // Auth0 exposes cancel as an iOS-only API. On every other platform, // surface the original exception instead of attempting an unsupported // recovery. - if (e.code != 'TRANSACTION_ACTIVE_ALREADY' || !Platform.isIOS) rethrow; + if (e.code != 'TRANSACTION_ACTIVE_ALREADY' || !_isIOS) rethrow; log('message: clearing a stale web auth transaction'); WebAuthentication.cancel(); diff --git a/mobile-app/lib/ui/views/login/native_login_viewmodel.dart b/mobile-app/lib/ui/views/login/native_login_viewmodel.dart index 1160c460b..bf19efd90 100644 --- a/mobile-app/lib/ui/views/login/native_login_viewmodel.dart +++ b/mobile-app/lib/ui/views/login/native_login_viewmodel.dart @@ -60,7 +60,7 @@ class NativeLoginViewModel extends BaseViewModel { }); } - void sendOTPtoEmail(BuildContext context) async { + Future sendOTPtoEmail(BuildContext context) async { showOTPfield = true; notifyListeners(); try { diff --git a/mobile-app/test/services/authentication/authentication_service_test.dart b/mobile-app/test/services/authentication/authentication_service_test.dart new file mode 100644 index 000000000..2e342a8eb --- /dev/null +++ b/mobile-app/test/services/authentication/authentication_service_test.dart @@ -0,0 +1,314 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:auth0_flutter/auth0_flutter.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:freecodecamp/app/app.locator.dart'; +import 'package:freecodecamp/l10n/app_localizations.dart'; +import 'package:freecodecamp/service/authentication/authentication_service.dart'; +import 'package:freecodecamp/service/dio_service.dart'; +import 'package:mockito/mockito.dart'; +import 'package:stacked_services/stacked_services.dart'; + +const _authChannel = MethodChannel('auth0.com/auth0_flutter/auth'); +const _webAuthChannel = MethodChannel('auth0.com/auth0_flutter/web_auth'); +const _credentialsManagerChannel = + MethodChannel('auth0.com/auth0_flutter/credentials_manager'); + +class _MockSnackbarService extends Mock implements SnackbarService {} + +class _QueuedHttpClientAdapter implements HttpClientAdapter { + _QueuedHttpClientAdapter(this.responses); + + final List responses; + final List requests = []; + + @override + Future fetch( + RequestOptions options, + Stream? requestStream, + Future? cancelFuture, + ) async { + requests.add(options.uri); + if (responses.isEmpty) { + throw StateError('Unexpected request: ${options.uri}'); + } + return responses.removeAt(0); + } + + @override + void close({bool force = false}) {} +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late AuthenticationService authenticationService; + late Map storage; + late _QueuedHttpClientAdapter adapter; + PlatformException? authenticationError; + var webAuthLoginCalls = 0; + var webAuthCancelCalls = 0; + var webAuthLogoutCalls = 0; + + setUpAll(() { + locator.reset(); + locator.registerSingleton(NavigationService()); + locator.registerSingleton(_MockSnackbarService()); + + authenticationService = AuthenticationService(); + authenticationService.auth0 = Auth0('example.auth0.com', 'client-id'); + AuthenticationService.baseApiURL = 'https://api.example.test'; + }); + + setUp(() { + storage = {}; + FlutterSecureStorage.setMockInitialValues(storage); + adapter = _QueuedHttpClientAdapter([]); + DioService.dio.interceptors.clear(); + DioService.dio.httpClientAdapter = adapter; + AuthenticationService.isIOSOverride = null; + AuthenticationService.staticIsloggedIn = false; + authenticationError = null; + webAuthLoginCalls = 0; + webAuthCancelCalls = 0; + webAuthLogoutCalls = 0; + + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_authChannel, (call) async { + if (call.method != 'auth#loginWithEmail') { + throw UnsupportedError('Unexpected Auth0 API method: ${call.method}'); + } + if (authenticationError != null) { + throw authenticationError!; + } + return _credentials(); + }); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_webAuthChannel, (call) async { + switch (call.method) { + case 'webAuth#login': + webAuthLoginCalls++; + if (webAuthLoginCalls == 1 && + AuthenticationService.isIOSOverride != null) { + throw PlatformException( + code: 'TRANSACTION_ACTIVE_ALREADY', + message: 'A web authentication transaction is already active.', + ); + } + return _credentials(); + case 'webAuth#cancel': + webAuthCancelCalls++; + return null; + case 'webAuth#logout': + webAuthLogoutCalls++; + return null; + default: + throw UnsupportedError('Unexpected web auth method: ${call.method}'); + } + }); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_credentialsManagerChannel, (call) async { + if (call.method != 'credentialsManager#clearCredentials') { + throw UnsupportedError( + 'Unexpected credentials manager method: ${call.method}'); + } + return true; + }); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_authChannel, null); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_webAuthChannel, null); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_credentialsManagerChannel, null); + AuthenticationService.isIOSOverride = null; + }); + + Future buildContext(WidgetTester tester) async { + late BuildContext context; + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (buildContext) { + context = buildContext; + return const SizedBox(); + }, + ), + ), + ); + await tester.pump(); + + return context; + } + + Future closeSessionError(WidgetTester tester) async { + await tester.pumpAndSettle(); + final dialog = find.byType(AlertDialog); + expect(dialog, findsOneWidget); + await tester.tap( + find.descendant(of: dialog, matching: find.byType(TextButton)).last, + ); + await tester.pumpAndSettle(); + } + + testWidgets('returns false when the user-session request fails', + (tester) async { + adapter.responses.addAll([ + sessionResponse(cookies: allSessionCookies), + jsonResponse({'message': 'Session unavailable'}, 401), + ]); + final context = await buildContext(tester); + + final login = authenticationService.login( + context, + 'email', + email: 'camper@example.com', + otp: '123456', + ); + await closeSessionError(tester); + + expect(await login, isFalse); + expect(AuthenticationService.staticIsloggedIn, isFalse); + expect(storage, isEmpty); + expect(adapter.requests, hasLength(2)); + }); + + testWidgets('rejects a mobile-login response without every session cookie', + (tester) async { + adapter.responses.add( + sessionResponse(cookies: [allSessionCookies.last]), + ); + final context = await buildContext(tester); + + final login = authenticationService.login( + context, + 'email', + email: 'camper@example.com', + otp: '123456', + ); + await closeSessionError(tester); + + expect(await login, isFalse); + expect(storage, isEmpty); + expect(adapter.requests, hasLength(1)); + }); + + testWidgets('rejects a mobile-login response with an empty session cookie', + (tester) async { + adapter.responses.add( + sessionResponse(cookies: [ + '_csrf=; Path=/', + allSessionCookies[1], + allSessionCookies[2], + ]), + ); + final context = await buildContext(tester); + + final login = authenticationService.login( + context, + 'email', + email: 'camper@example.com', + otp: '123456', + ); + await closeSessionError(tester); + + expect(await login, isFalse); + expect(storage, isEmpty); + expect(adapter.requests, hasLength(1)); + }); + + testWidgets('returns false for an expired or invalid email code', + (tester) async { + authenticationError = PlatformException( + code: 'invalid_grant', + message: 'The verification code is invalid or has expired.', + ); + final context = await buildContext(tester); + + final login = authenticationService.login( + context, + 'email', + email: 'camper@example.com', + otp: '123456', + ); + await tester.pumpAndSettle(); + + expect(await login, isFalse); + expect(adapter.requests, isEmpty); + expect(find.byType(AlertDialog), findsNothing); + }); + + testWidgets('cancels and retries a stale web-auth transaction on iOS', + (tester) async { + AuthenticationService.isIOSOverride = () => true; + adapter.responses.add( + sessionResponse(cookies: [allSessionCookies.last]), + ); + final context = await buildContext(tester); + + final login = authenticationService.login(context, 'google-oauth2'); + await closeSessionError(tester); + + expect(await login, isFalse); + expect(webAuthLoginCalls, 2); + expect(webAuthCancelCalls, 1); + }); + + test('uses Auth0 logout and clears the local session', () async { + storage.addAll({ + 'csrf': 'csrf', + 'csrf_token': 'csrf-token', + 'jwt_access_token': 'access-token', + }); + + await authenticationService.logout(); + + expect(webAuthLogoutCalls, 1); + expect(storage, isEmpty); + expect(AuthenticationService.staticIsloggedIn, isFalse); + }); +} + +const allSessionCookies = [ + '_csrf=csrf; Path=/', + 'csrf_token=csrf-token; Path=/', + 'jwt_access_token=access-token; Path=/', +]; + +ResponseBody sessionResponse({required List cookies}) => + jsonResponse({}, 200, cookies: cookies); + +ResponseBody jsonResponse( + Map body, + int statusCode, { + List cookies = const [], +}) => + ResponseBody.fromString( + jsonEncode(body), + statusCode, + headers: >{ + Headers.contentTypeHeader: ['application/json'], + if (cookies.isNotEmpty) HttpHeaders.setCookieHeader: cookies, + }, + ); + +Map _credentials() => { + 'idToken': 'id-token', + 'accessToken': 'access-token', + 'refreshToken': null, + 'expiresAt': DateTime.utc(2030).toIso8601String(), + 'scopes': ['openid', 'profile', 'email'], + 'userProfile': {'sub': 'auth0|camper'}, + 'tokenType': 'Bearer', + }; diff --git a/mobile-app/test/viewmodels/native_login_viewmodel_test.dart b/mobile-app/test/viewmodels/native_login_viewmodel_test.dart new file mode 100644 index 000000000..9e5bf92ae --- /dev/null +++ b/mobile-app/test/viewmodels/native_login_viewmodel_test.dart @@ -0,0 +1,107 @@ +import 'package:auth0_flutter/auth0_flutter.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:freecodecamp/app/app.locator.dart'; +import 'package:freecodecamp/l10n/app_localizations.dart'; +import 'package:freecodecamp/service/authentication/authentication_service.dart'; +import 'package:freecodecamp/service/developer_service.dart'; +import 'package:freecodecamp/ui/views/login/native_login_viewmodel.dart'; +import 'package:mockito/mockito.dart'; +import 'package:stacked_services/stacked_services.dart'; + +import '../helpers/test_helpers.mocks.dart'; + +class _MockSnackbarService extends Mock implements SnackbarService {} + +const _authChannel = MethodChannel('auth0.com/auth0_flutter/auth'); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late MockAuthenticationService authenticationService; + late _MockSnackbarService snackbarService; + late NativeLoginViewModel viewModel; + Map? passwordlessRequest; + PlatformException? passwordlessError; + + setUp(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_authChannel, (call) async { + passwordlessRequest = call.arguments as Map; + if (passwordlessError != null) { + throw passwordlessError!; + } + return null; + }); + + authenticationService = MockAuthenticationService(); + snackbarService = _MockSnackbarService(); + when(authenticationService.auth0) + .thenReturn(Auth0('example.auth0.com', 'client-id')); + when(authenticationService.snackbar).thenReturn(snackbarService); + + locator.reset(); + locator.registerSingleton(authenticationService); + locator.registerSingleton(DeveloperService()); + viewModel = NativeLoginViewModel(); + viewModel.emailController.text = 'camper@example.com'; + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_authChannel, null); + locator.reset(); + viewModel.emailController.dispose(); + viewModel.otpController.dispose(); + }); + + Future buildContext(WidgetTester tester) async { + late BuildContext context; + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (buildContext) { + context = buildContext; + return const SizedBox(); + }, + ), + ), + ); + await tester.pump(); + + return context; + } + + testWidgets('shows the OTP field after Auth0 sends a code', (tester) async { + final context = await buildContext(tester); + + await viewModel.sendOTPtoEmail(context); + + expect(viewModel.showOTPfield, isTrue); + expect(passwordlessRequest?['email'], 'camper@example.com'); + expect( + passwordlessRequest?['passwordlessType'], PasswordlessType.code.name); + verifyZeroInteractions(snackbarService); + }); + + testWidgets('shows an error and returns to the email step when Auth0 fails', + (tester) async { + passwordlessError = PlatformException( + code: 'email_provider_error', + message: 'Email provider failed', + ); + final context = await buildContext(tester); + + await viewModel.sendOTPtoEmail(context); + + expect(viewModel.showOTPfield, isFalse); + verify(snackbarService.showSnackbar( + title: 'Error', + message: 'We couldn\'t send a sign-in code. Please try again.', + )).called(1); + }); +} From fb1d7f92534fd2a4d5ee0434dda0a6076edfeddd Mon Sep 17 00:00:00 2001 From: Sembauke Date: Tue, 1 Sep 2026 17:21:32 +0200 Subject: [PATCH 6/6] test(auth): cover successful session exchange --- .../authentication_service_test.dart | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/mobile-app/test/services/authentication/authentication_service_test.dart b/mobile-app/test/services/authentication/authentication_service_test.dart index 2e342a8eb..859ceb025 100644 --- a/mobile-app/test/services/authentication/authentication_service_test.dart +++ b/mobile-app/test/services/authentication/authentication_service_test.dart @@ -162,6 +162,34 @@ void main() { await tester.pumpAndSettle(); } + testWidgets('returns true after exchanging credentials for a user session', + (tester) async { + adapter.responses.addAll([ + sessionResponse(cookies: allSessionCookies), + jsonResponse(sessionUserResponse, 200), + ]); + final context = await buildContext(tester); + + final login = authenticationService.login( + context, + 'email', + email: 'camper@example.com', + otp: '123456', + ); + + await tester.pumpAndSettle(); + + expect(await login, isTrue); + expect(AuthenticationService.staticIsloggedIn, isTrue); + expect(storage, { + 'csrf': 'csrf', + 'csrf_token': 'csrf-token', + 'jwt_access_token': 'access-token', + }); + expect(authenticationService.userModel, isNotNull); + expect(adapter.requests, hasLength(2)); + }); + testWidgets('returns false when the user-session request fails', (tester) async { adapter.responses.addAll([ @@ -286,6 +314,64 @@ const allSessionCookies = [ 'jwt_access_token=access-token; Path=/', ]; +final sessionUserResponse = { + 'result': 'camper', + 'user': { + 'camper': { + 'id': 'camper-id', + 'email': 'camper@example.com', + 'username': 'camper', + 'name': 'Camper', + 'picture': '', + 'currentChallengeId': '', + 'emailVerified': true, + 'isEmailVerified': true, + 'isCheater': false, + 'isDonating': false, + 'isHonest': true, + 'isFrontEndCert': false, + 'isDataVisCert': false, + 'isBackEndCert': false, + 'isFullStackCert': false, + 'isRespWebDesignCert': false, + 'is2018DataVisCert': false, + 'isFrontEndLibsCert': false, + 'isJsAlgoDataStructCert': false, + 'isApisMicroservicesCert': false, + 'isInfosecQaCert': false, + 'isQaCertV7': false, + 'isInfosecCertV7': false, + 'isSciCompPyCertV7': false, + 'isDataAnalysisPyCertV7': false, + 'isMachineLearningPyCertV7': false, + 'isRelationalDatabaseCertV8': false, + 'isCollegeAlgebraPyCertV8': false, + 'isFoundationalCSharpCertV8': false, + 'joinDate': '2025-01-01T00:00:00.000Z', + 'points': 0, + 'calendar': {}, + 'completedChallenges': [], + 'completedDailyCodingChallenges': [], + 'savedChallenges': [], + 'portfolio': [], + 'yearsTopContributor': [], + 'theme': 'default', + 'profileUI': { + 'isLocked': false, + 'showAbout': false, + 'showCerts': false, + 'showDonation': false, + 'showHeatMap': false, + 'showLocation': false, + 'showName': false, + 'showPoints': false, + 'showPortfolio': false, + 'showTimeLine': false, + }, + }, + }, +}; + ResponseBody sessionResponse({required List cookies}) => jsonResponse({}, 200, cookies: cookies);