Phase B: Token-Provider und AuthBloc robust gegen Storage-Plugin-Fehler

Beobachtung: Nach 'flutter_secure_storage' frisch dazugepackt ohne
Cold-Restart kam eine MissingPluginException auf dem AuthBloc-Stream
durch (read auf channel plugins.it_nomads.com/flutter_secure_storage)
und hat den ganzen Bloc-Event-Loop mitgerissen.

Fix:
- KeycloakOidcTokenProvider.restoreSession / _persistRefreshToken /
  logout fangen Plugin-Exceptions ab und loggen sie über debugPrint,
  statt sie hochzureichen. Restore-Pfad endet sauber mit 'kein Restore
  möglich', Login-Pfad hält den Token in Memory weiter.
- AuthBloc._handleRestore mit eigener try/catch als zweite Schutzschicht
  für jeden anderen Fehler aus dem Provider.

Bestehender Cold-Restart-Workaround (App stoppen + flutter run) für die
ursprüngliche MissingPluginException bleibt natürlich nötig — diese
Änderung sorgt nur dafür, dass künftige Storage-Probleme (Keychain
zerschossen, Restore-Backup, …) nicht die Auth komplett killen.
This commit is contained in:
Dennis Nemec
2026-05-14 23:04:12 +02:00
parent 6d7e58fc0f
commit 08824290ff
2 changed files with 36 additions and 7 deletions

View File

@ -1,6 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter_appauth/flutter_appauth.dart'; import 'package:flutter_appauth/flutter_appauth.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart';
@ -100,8 +101,21 @@ class KeycloakOidcTokenProvider implements AuthTokenProvider {
/// Versucht, eine vorhandene Session aus der Secure Storage zu /// Versucht, eine vorhandene Session aus der Secure Storage zu
/// reaktivieren. Liefert `true`, wenn anschließend ein gültiger /// reaktivieren. Liefert `true`, wenn anschließend ein gültiger
/// Access-Token verfügbar ist. /// Access-Token verfügbar ist.
///
/// Schluckt Fehler aus dem Storage-Plugin (z. B. nicht-registriertes
/// Native-Modul nach Plugin-Update ohne Cold-Restart) — der Caller
/// landet dann sauber im "kein Restore möglich"-Pfad statt mit einer
/// MissingPluginException auf dem AuthBloc-Stream.
Future<bool> restoreSession() async { Future<bool> restoreSession() async {
final stored = await _storage.read(key: _refreshTokenStorageKey); final String? stored;
try {
stored = await _storage.read(key: _refreshTokenStorageKey);
} catch (e, st) {
debugPrint(
'restoreSession: konnte Refresh-Token nicht lesen: $e\n$st',
);
return false;
}
if (stored == null || stored.isEmpty) return false; if (stored == null || stored.isEmpty) return false;
_refreshToken = stored; _refreshToken = stored;
@ -119,7 +133,11 @@ class KeycloakOidcTokenProvider implements AuthTokenProvider {
/// in der Session zu halten. /// in der Session zu halten.
Future<void> logout() async { Future<void> logout() async {
_clearSession(); _clearSession();
await _storage.delete(key: _refreshTokenStorageKey); try {
await _storage.delete(key: _refreshTokenStorageKey);
} catch (e) {
debugPrint('logout: Refresh-Token konnte nicht gelöscht werden: $e');
}
_events.add(const AuthLoggedOut()); _events.add(const AuthLoggedOut());
} }
@ -187,7 +205,13 @@ class KeycloakOidcTokenProvider implements AuthTokenProvider {
Future<void> _persistRefreshToken() async { Future<void> _persistRefreshToken() async {
final rt = _refreshToken; final rt = _refreshToken;
if (rt == null) return; if (rt == null) return;
await _storage.write(key: _refreshTokenStorageKey, value: rt); try {
await _storage.write(key: _refreshTokenStorageKey, value: rt);
} catch (e) {
// Nicht fatal — Refresh-Token bleibt in-memory verfügbar,
// wir verlieren nur das Restore-Verhalten beim nächsten Start.
debugPrint('Refresh-Token konnte nicht persistiert werden: $e');
}
} }
void _clearSession() { void _clearSession() {

View File

@ -74,10 +74,15 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
RestoreSessionRequested event, RestoreSessionRequested event,
Emitter<AuthState> emit, Emitter<AuthState> emit,
) async { ) async {
final restored = await tokenProvider.restoreSession(); try {
if (!restored) { await tokenProvider.restoreSession();
// Kein gültiger Token → bleibt bei Unauthenticated, kein Snackbar // Erfolg landet via Stream als ProviderSessionChanged.loggedIn.
// (das ist der normale Cold-Start-Pfad). // Misserfolg: bleibt bei Unauthenticated — kein Snackbar, das
// ist der normale Cold-Start-Pfad.
} catch (err, st) {
debugPrint('Restore-Session fehlgeschlagen: $err\n$st');
// State unverändert (Unauthenticated). Kein Snackbar — das
// wäre für den Cold-Start zu viel Lärm.
} }
} }