diff --git a/lib/data/network/auth_token_provider.dart b/lib/data/network/auth_token_provider.dart index 3f500f4..260f3d1 100644 --- a/lib/data/network/auth_token_provider.dart +++ b/lib/data/network/auth_token_provider.dart @@ -5,5 +5,25 @@ abstract interface class AuthTokenProvider { /// Liefert einen aktuell gültigen Access-Token oder `null`, wenn /// keine Session aktiv ist. Darf bei Bedarf einen Refresh anstoßen. + /// + /// Wirft [AuthTemporarilyUnavailableException], wenn zwar eine Session + /// existiert, der Token aber wegen eines **vorübergehenden** Problems + /// (Netz/VPN/Timeout) gerade nicht erneuert werden konnte. Future currentAccessToken(); } + +/// Der Access-Token konnte gerade nicht erneuert werden, obwohl die Session +/// (Refresh-Token) weiterhin gültig sein dürfte — typisch: Mobilfunk-/VPN- +/// Aussetzer, Timeout, Login-Server kurz nicht erreichbar. +/// +/// Bewusst **kein** „Session abgelaufen": der Aufrufer soll den Request als +/// Verbindungsfehler behandeln und später erneut versuchen, statt den Fahrer +/// auszuloggen. +class AuthTemporarilyUnavailableException implements Exception { + const AuthTemporarilyUnavailableException(this.cause); + + final Object cause; + + @override + String toString() => 'AuthTemporarilyUnavailableException($cause)'; +} diff --git a/lib/data/network/holzleitner_auth_interceptor.dart b/lib/data/network/holzleitner_auth_interceptor.dart index 818bdbc..6c694bd 100644 --- a/lib/data/network/holzleitner_auth_interceptor.dart +++ b/lib/data/network/holzleitner_auth_interceptor.dart @@ -22,6 +22,20 @@ class HolzleitnerAuthInterceptor extends Interceptor { if (token != null) { options.headers['Authorization'] = 'Bearer $token'; } + } on AuthTemporarilyUnavailableException catch (e) { + // Token ließ sich wegen eines Netz-/VPN-Problems nicht erneuern. NICHT + // tokenlos weiterschicken — das Backend würde mit 401 antworten und die + // UI fälschlich „Sitzung abgelaufen" zeigen. Stattdessen sauber als + // Verbindungsfehler abbrechen; der nächste Request versucht es erneut. + return handler.reject( + DioException( + requestOptions: options, + type: DioExceptionType.connectionError, + error: e, + message: + 'Anmeldung konnte nicht erneuert werden (keine Verbindung zum Login-Server)', + ), + ); } catch (e, stack) { // TODO Phase B: hier ein strukturiertes Logging-Framework // einhängen statt print. diff --git a/lib/data/network/keycloak_oidc_token_provider.dart b/lib/data/network/keycloak_oidc_token_provider.dart index 1c7e941..e866825 100644 --- a/lib/data/network/keycloak_oidc_token_provider.dart +++ b/lib/data/network/keycloak_oidc_token_provider.dart @@ -194,7 +194,15 @@ class KeycloakOidcTokenProvider implements AuthTokenProvider { if (stored == null || stored.isEmpty) return false; _refreshToken = stored; - final token = await currentAccessToken(); + final String? token; + try { + token = await currentAccessToken(); + } on AuthTemporarilyUnavailableException catch (e) { + // Beim App-Start gerade kein Netz/VPN: Refresh-Token NICHT verwerfen — + // der nächste Start (oder Login) kann die Session wieder aufnehmen. + debugPrint('restoreSession: Refresh vorübergehend nicht möglich: $e'); + return false; + } if (token == null) return false; _events.add(AuthLoggedIn(_idTokenClaims ?? const {})); @@ -243,8 +251,15 @@ class KeycloakOidcTokenProvider implements AuthTokenProvider { /// Führt EINEN Token-Refresh aus. Bei Erfolg werden die Tokens übernommen /// und der neue Access-Token zurückgegeben (ohne Event — stiller Refresh). - /// Bei Fehler ist die Session tot: lokal aufräumen, `AuthSessionExpired` - /// emittieren, `null` zurück. + /// + /// Fehler werden unterschieden: + /// * **Abgelehnt** (OAuth `invalid_grant` o. ä.: Refresh-Token abgelaufen/ + /// widerrufen, SSO-Session beendet) → Session ist tot: lokal aufräumen, + /// `AuthSessionExpired` emittieren, `null` zurück. + /// * **Vorübergehend** (Netz/VPN/Timeout/Server kurz weg) → Session + /// BEHALTEN; noch gültigen Access-Token weiterverwenden, sonst + /// [AuthTemporarilyUnavailableException] werfen. Früher beendete jeder + /// Netz-Hänger die Session und löschte den Refresh-Token. Future _performRefresh(String rt) async { try { final result = await _appAuth.token( @@ -265,8 +280,24 @@ class KeycloakOidcTokenProvider implements AuthTokenProvider { ); await _persistRefreshToken(); return _accessToken; - } on Exception { - // Refresh hat nicht funktioniert — Session ist tot, nicht + } on Exception catch (e) { + if (!_isSessionRejected(e)) { + // Vorübergehender Fehler (Mobilfunk-/VPN-Aussetzer, Timeout, Keycloak + // kurz nicht erreichbar): die Session ist NICHT tot. Refresh-Token + // behalten und beim nächsten Request erneut versuchen — sonst zwingt + // jeder Netz-Hänger den Fahrer zum Neu-Login. + debugPrint('Token-Refresh vorübergehend fehlgeschlagen: $e'); + final cached = _accessToken; + final expiresAt = _expiresAt; + if (cached != null && + expiresAt != null && + expiresAt.isAfter(DateTime.now().toUtc())) { + // Bisheriger Access-Token ist noch (kurz) gültig → weiterverwenden. + return cached; + } + throw AuthTemporarilyUnavailableException(e); + } + // Keycloak hat den Refresh-Token ABGELEHNT — Session ist tot, nicht // wiederherstellbar. Reihenfolge bewusst: erst State leeren + Event // feuern, DANN best-effort den Storage löschen — so kann ein // werfendes `delete` weder das Event verschlucken noch eine Exception @@ -282,6 +313,30 @@ class KeycloakOidcTokenProvider implements AuthTokenProvider { } } + /// OAuth-Fehler, bei denen ein erneuter Versuch sinnlos ist: der + /// Authorization-Server hat den Refresh endgültig abgelehnt. + static const Set _fatalOAuthErrors = { + FlutterAppAuthOAuthError.invalidGrant, + FlutterAppAuthOAuthError.invalidClient, + FlutterAppAuthOAuthError.unauthorizedClient, + }; + + /// `true`, wenn der Fehler eine echte **Ablehnung** durch Keycloak ist + /// (Session wirklich beendet). Alles andere — Netzwerk, Timeout, 5xx — + /// gilt als vorübergehend. Primär über den strukturierten OAuth-Fehlercode + /// von `flutter_appauth`; als Fallback die Textsuche nach `invalid_grant`. + static bool _isSessionRejected(Object e) { + if (e is FlutterAppAuthPlatformException) { + final oauthError = e.platformErrorDetails.error; + if (oauthError != null) return _fatalOAuthErrors.contains(oauthError); + } + if (e is PlatformException) { + final haystack = '${e.code} ${e.message} ${e.details}'.toLowerCase(); + return haystack.contains('invalid_grant'); + } + return false; + } + void _applyTokens({ required String? accessToken, required String? refreshToken, diff --git a/lib/feature/delivery/detail/presentation/steps/step_summary.dart b/lib/feature/delivery/detail/presentation/steps/step_summary.dart index 6d5bbaa..d91ef45 100644 --- a/lib/feature/delivery/detail/presentation/steps/step_summary.dart +++ b/lib/feature/delivery/detail/presentation/steps/step_summary.dart @@ -390,16 +390,32 @@ class _PaymentMethodPicker extends StatelessWidget { ); } if (state is PaymentMethodsFailed) { + final onError = Theme.of(context).colorScheme.onErrorContainer; return Card( margin: EdgeInsets.zero, color: Theme.of(context).colorScheme.errorContainer, child: Padding( - padding: const EdgeInsets.all(16), - child: Text( - state.message, - style: TextStyle( - color: Theme.of(context).colorScheme.onErrorContainer, - ), + padding: const EdgeInsets.fromLTRB(16, 8, 8, 8), + child: Row( + children: [ + Expanded( + child: Text( + state.message, + style: TextStyle(color: onError), + ), + ), + // Fehler ist kein Endzustand: der Fahrer kann das Laden + // direkt hier erneut anstoßen (z. B. nach Netz-Aussetzer). + TextButton.icon( + onPressed: () => + context.read().load(), + icon: Icon(Icons.refresh, color: onError), + label: Text( + 'Erneut laden', + style: TextStyle(color: onError), + ), + ), + ], ), ), ); diff --git a/lib/widget/app.dart b/lib/widget/app.dart index a2db61a..1010444 100644 --- a/lib/widget/app.dart +++ b/lib/widget/app.dart @@ -4,6 +4,7 @@ import 'package:hl_lieferservice/bloc/app_bloc.dart'; import 'package:hl_lieferservice/data/cache/attachment_cache.dart'; import 'package:hl_lieferservice/data/network/keycloak_oidc_token_provider.dart'; import 'package:hl_lieferservice/feature/authentication/bloc/auth_bloc.dart'; +import 'package:hl_lieferservice/feature/authentication/bloc/auth_state.dart'; import 'package:hl_lieferservice/feature/authentication/bloc/auth_event.dart'; import 'package:hl_lieferservice/feature/authentication/presentation/login_enforcer.dart'; import 'package:hl_lieferservice/main.dart' show locator; @@ -142,8 +143,18 @@ class _DeliveryAppState extends State { } if (state is AppConfigLoaded) { - return LoginEnforcer( - child: CarSelectionEnforcer(child: Home()), + // Stammdaten nach JEDEM erfolgreichen (Re-)Login neu laden. + // Der Cubit lädt zwar beim App-Start — aber ohne Session + // endet das in 401 („Sitzung abgelaufen") und blieb bisher + // für immer im Fehlerzustand hängen, auch nach dem Login. + return BlocListener( + listenWhen: (prev, curr) => + curr is Authenticated && prev is! Authenticated, + listener: (context, _) => + context.read().load(), + child: LoginEnforcer( + child: CarSelectionEnforcer(child: Home()), + ), ); }