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:
@ -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<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)';
|
||||
}
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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 <String, dynamic>{}));
|
||||
@ -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<String?> _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<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({
|
||||
required String? accessToken,
|
||||
required String? refreshToken,
|
||||
|
||||
Reference in New Issue
Block a user