fix(auth): Session ueberlebt Netz-Aussetzer; Zahlungsmethoden laden nach Re-Login neu

Kunde meldete haeufig 'Sitzung abgelaufen' und danach dauerhaft rotes
'Sitzung abgelaufen' bei den Zahlungsmethoden trotz erfolgreichem Login.

Token-Refresh:
- _performRefresh wertete JEDEN Fehler als tote Session (Logout + Refresh-
  Token geloescht) - auch Mobilfunk-/VPN-Aussetzer. Jetzt gilt nur noch eine
  echte Ablehnung durch Keycloak (OAuth invalid_grant o. ae.) als abgelaufen;
  voruebergehende Fehler behalten die Session, nutzen den noch gueltigen
  Access-Token weiter oder werfen AuthTemporarilyUnavailableException
- Interceptor schickt Requests nicht mehr tokenlos weiter (ergab 401 ->
  irrefuehrendes 'Sitzung abgelaufen'), sondern bricht als Verbindungsfehler ab
- restoreSession verwirft den Refresh-Token bei fehlendem Netz nicht mehr

Zahlungsmethoden:
- Cubit lud nur einmal beim App-Start (vor dem Login -> 401) und blieb danach
  fuer immer im Fehlerzustand. Jetzt Reload bei jedem Wechsel auf Authenticated
- Fehlerkarte in der Uebersicht hat einen 'Erneut laden'-Button

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Dennis Nemec
2026-09-17 11:59:56 +03:00
parent a027c93c62
commit 7cf17466b3
5 changed files with 129 additions and 13 deletions

View File

@ -5,5 +5,25 @@
abstract interface class AuthTokenProvider { abstract interface class AuthTokenProvider {
/// Liefert einen aktuell gültigen Access-Token oder `null`, wenn /// Liefert einen aktuell gültigen Access-Token oder `null`, wenn
/// keine Session aktiv ist. Darf bei Bedarf einen Refresh anstoßen. /// 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<String?> currentAccessToken(); Future<String?> 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)';
}

View File

@ -22,6 +22,20 @@ class HolzleitnerAuthInterceptor extends Interceptor {
if (token != null) { if (token != null) {
options.headers['Authorization'] = 'Bearer $token'; 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) { } catch (e, stack) {
// TODO Phase B: hier ein strukturiertes Logging-Framework // TODO Phase B: hier ein strukturiertes Logging-Framework
// einhängen statt print. // einhängen statt print.

View File

@ -194,7 +194,15 @@ class KeycloakOidcTokenProvider implements AuthTokenProvider {
if (stored == null || stored.isEmpty) return false; if (stored == null || stored.isEmpty) return false;
_refreshToken = stored; _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; if (token == null) return false;
_events.add(AuthLoggedIn(_idTokenClaims ?? const <String, dynamic>{})); _events.add(AuthLoggedIn(_idTokenClaims ?? const <String, dynamic>{}));
@ -243,8 +251,15 @@ class KeycloakOidcTokenProvider implements AuthTokenProvider {
/// Führt EINEN Token-Refresh aus. Bei Erfolg werden die Tokens übernommen /// Führt EINEN Token-Refresh aus. Bei Erfolg werden die Tokens übernommen
/// und der neue Access-Token zurückgegeben (ohne Event — stiller Refresh). /// 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<String?> _performRefresh(String rt) async { Future<String?> _performRefresh(String rt) async {
try { try {
final result = await _appAuth.token( final result = await _appAuth.token(
@ -265,8 +280,24 @@ class KeycloakOidcTokenProvider implements AuthTokenProvider {
); );
await _persistRefreshToken(); await _persistRefreshToken();
return _accessToken; return _accessToken;
} on Exception { } on Exception catch (e) {
// Refresh hat nicht funktioniert — Session ist tot, nicht 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 // wiederherstellbar. Reihenfolge bewusst: erst State leeren + Event
// feuern, DANN best-effort den Storage löschen — so kann ein // feuern, DANN best-effort den Storage löschen — so kann ein
// werfendes `delete` weder das Event verschlucken noch eine Exception // 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<String> _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({ void _applyTokens({
required String? accessToken, required String? accessToken,
required String? refreshToken, required String? refreshToken,

View File

@ -390,17 +390,33 @@ class _PaymentMethodPicker extends StatelessWidget {
); );
} }
if (state is PaymentMethodsFailed) { if (state is PaymentMethodsFailed) {
final onError = Theme.of(context).colorScheme.onErrorContainer;
return Card( return Card(
margin: EdgeInsets.zero, margin: EdgeInsets.zero,
color: Theme.of(context).colorScheme.errorContainer, color: Theme.of(context).colorScheme.errorContainer,
child: Padding( child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.fromLTRB(16, 8, 8, 8),
child: Row(
children: [
Expanded(
child: Text( child: Text(
state.message, state.message,
style: TextStyle( style: TextStyle(color: onError),
color: Theme.of(context).colorScheme.onErrorContainer,
), ),
), ),
// Fehler ist kein Endzustand: der Fahrer kann das Laden
// direkt hier erneut anstoßen (z. B. nach Netz-Aussetzer).
TextButton.icon(
onPressed: () =>
context.read<PaymentMethodsCubit>().load(),
icon: Icon(Icons.refresh, color: onError),
label: Text(
'Erneut laden',
style: TextStyle(color: onError),
),
),
],
),
), ),
); );
} }

View File

@ -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/cache/attachment_cache.dart';
import 'package:hl_lieferservice/data/network/keycloak_oidc_token_provider.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_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/bloc/auth_event.dart';
import 'package:hl_lieferservice/feature/authentication/presentation/login_enforcer.dart'; import 'package:hl_lieferservice/feature/authentication/presentation/login_enforcer.dart';
import 'package:hl_lieferservice/main.dart' show locator; import 'package:hl_lieferservice/main.dart' show locator;
@ -142,8 +143,18 @@ class _DeliveryAppState extends State<DeliveryApp> {
} }
if (state is AppConfigLoaded) { if (state is AppConfigLoaded) {
return LoginEnforcer( // 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<AuthBloc, AuthState>(
listenWhen: (prev, curr) =>
curr is Authenticated && prev is! Authenticated,
listener: (context, _) =>
context.read<PaymentMethodsCubit>().load(),
child: LoginEnforcer(
child: CarSelectionEnforcer(child: Home()), child: CarSelectionEnforcer(child: Home()),
),
); );
} }