import 'dart:async'; import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:flutter_appauth/flutter_appauth.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'auth_session_event.dart'; import 'auth_token_provider.dart'; import 'backend_config.dart'; /// OIDC-Token-Provider gegen Keycloak (PKCE Authorization Code Flow). /// /// Verantwortlich für: /// * **Login** über `flutter_appauth` (Browser-Tab → Keycloak → /// RedirectURI `holzleitner://oauth2redirect`). /// * **Token-Refresh** mit dem persistierten Refresh-Token. /// * **Session-Restore** beim App-Start (Refresh-Token aus Secure /// Storage einlesen + sofortigen Refresh anstoßen). /// * **Logout**: lokale Token droppen + Secure Storage säubern. /// /// Access-Token und ID-Token-Claims liegen im Speicher (flüchtig), /// der Refresh-Token wird in der Plattform-Secure-Storage abgelegt /// (Keychain / EncryptedSharedPreferences). /// /// Events werden über [events] gebroadcastet — der AuthBloc abonniert /// und reagiert. Diese Indirektion vermeidet eine Abhängigkeit auf die /// Bloc-Schicht. class KeycloakOidcTokenProvider implements AuthTokenProvider { KeycloakOidcTokenProvider({ required BackendConfig config, required this.issuerUrl, required this.redirectUrl, FlutterAppAuth? appAuth, FlutterSecureStorage? storage, }) : _config = config, _appAuth = appAuth ?? const FlutterAppAuth(), _storage = storage ?? const FlutterSecureStorage(); final BackendConfig _config; final FlutterAppAuth _appAuth; final FlutterSecureStorage _storage; /// Vollständige Issuer-URL ohne `/.well-known/...`-Suffix. /// Beispiel: `http://localhost:8080/realms/holzleitner`. final String issuerUrl; /// Redirect-URI, die im Keycloak-Client erlaubt ist und auf das /// Custom-Scheme der App matcht. Beispiel: /// `holzleitner://oauth2redirect`. final String redirectUrl; String? _accessToken; DateTime? _expiresAt; String? _refreshToken; Map? _idTokenClaims; /// Single-flight-Guard: hält den gerade laufenden Refresh, damit mehrere /// gleichzeitige Aufrufer (Bootstrap: Restore + PaymentMethodsCubit + /// Folge-Requests) sich EINEN Refresh teilen statt parallele /// `flutter_appauth.token()`-Calls auszulösen (die nativ blockieren/haken /// können → App hängt nach Hot-Restart am Splash/Login). Future? _refreshInFlight; final StreamController _events = StreamController.broadcast(); /// Stream der Auth-Events. Wird vom AuthBloc abonniert. Stream get events => _events.stream; /// Discovery-URL für `flutter_appauth` — wird intern auch geprüft, /// bevor PKCE losläuft. String get _discoveryUrl => '$issuerUrl/.well-known/openid-configuration'; /// ID-Token-Claims der aktuellen Session, oder `null` wenn nicht /// angemeldet. Map? get idTokenClaims => _idTokenClaims; bool get isAuthenticated => _accessToken != null; /// Triggert den PKCE-Login-Flow. Wirft, wenn der User abbricht oder /// Keycloak einen Fehler liefert. Future login() async { final result = await _appAuth.authorizeAndExchangeCode( AuthorizationTokenRequest( _config.keycloakClientId, redirectUrl, discoveryUrl: _discoveryUrl, scopes: const ['openid', 'profile'], // Lokales Dev-Setup hat HTTP-Keycloak — der Default-iOS-Browser // würde sonst abbrechen. In Produktion (HTTPS) ist das ein // No-Op und kann bleiben. allowInsecureConnections: true, // Wichtig auf Android: ohne `prompt=login` würde Keycloak bei // bestehender SSO-Session sofort 302 nach holzleitner://... // antworten, Chrome schließt den Custom Tab dabei so schnell, // dass der Redirect-Intent unsere RedirectUriReceiverActivity // gar nicht erreicht — AppAuth meldet stattdessen // "User cancelled flow". Erzwingen der Login-Maske → echter // User-Click → sauberer Intent-Dispatch. promptValues: const ['login'], ), ); _applyTokens( accessToken: result.accessToken, refreshToken: result.refreshToken, idToken: result.idToken, expiresAt: result.accessTokenExpirationDateTime, ); await _persistRefreshToken(); _events.add(AuthLoggedIn(_idTokenClaims ?? const {})); } /// Versucht, eine vorhandene Session aus der Secure Storage zu /// reaktivieren. Liefert `true`, wenn anschließend ein gültiger /// 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 restoreSession() async { 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; _refreshToken = stored; final token = await currentAccessToken(); if (token == null) return false; _events.add(AuthLoggedIn(_idTokenClaims ?? const {})); return true; } /// Bricht die Session ab — droppt alle Tokens lokal. Ein /// serverseitiges `endSession` (Keycloak Single-Logout) machen wir /// bewusst nicht: der Refresh-Token läuft beim Server normal aus, /// und der ID-Token-Hint würde uns zwingen, den rohen ID-Token mit /// in der Session zu halten. Future logout() async { _clearSession(); try { await _storage.delete(key: _refreshTokenStorageKey); } catch (e) { debugPrint('logout: Refresh-Token konnte nicht gelöscht werden: $e'); } _events.add(const AuthLoggedOut()); } @override Future currentAccessToken() async { final cached = _accessToken; final expiresAt = _expiresAt; final now = DateTime.now().toUtc(); // Schwellwert 30 s: Pufferzeit gegen Clock-Drift und // mid-flight-Expiry. if (cached != null && expiresAt != null && expiresAt.isAfter(now.add(const Duration(seconds: 30)))) { return cached; } final rt = _refreshToken; if (rt == null) return null; // Single-flight: läuft bereits ein Refresh, hängen wir uns dran, statt // einen zweiten `flutter_appauth.token()`-Call zu starten. `??=` // evaluiert die rechte Seite nur, wenn noch kein Refresh läuft. return _refreshInFlight ??= _performRefresh(rt).whenComplete(() { _refreshInFlight = null; }); } /// 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. Future _performRefresh(String rt) async { try { final result = await _appAuth.token( TokenRequest( _config.keycloakClientId, redirectUrl, discoveryUrl: _discoveryUrl, refreshToken: rt, scopes: const ['openid', 'profile'], allowInsecureConnections: true, ), ); _applyTokens( accessToken: result.accessToken, refreshToken: result.refreshToken ?? rt, idToken: result.idToken, expiresAt: result.accessTokenExpirationDateTime, ); await _persistRefreshToken(); return _accessToken; } on Exception { // Refresh hat nicht funktioniert — 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 // aus `currentAccessToken()` leaken. _clearSession(); _events.add(const AuthSessionExpired()); try { await _storage.delete(key: _refreshTokenStorageKey); } catch (e) { debugPrint('currentAccessToken: Refresh-Token-Delete fehlgeschlagen: $e'); } return null; } } void _applyTokens({ required String? accessToken, required String? refreshToken, required String? idToken, required DateTime? expiresAt, }) { _accessToken = accessToken; _refreshToken = refreshToken; _expiresAt = expiresAt?.toUtc(); if (idToken != null) { _idTokenClaims = _decodeJwtPayload(idToken); } } Future _persistRefreshToken() async { final rt = _refreshToken; if (rt == null) return; 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() { _accessToken = null; _expiresAt = null; _refreshToken = null; _idTokenClaims = null; } /// Dispose-Hook für Tests / Hot-Restarts. Future dispose() async { await _events.close(); } static const String _refreshTokenStorageKey = 'holzleitner_keycloak_refresh_token'; /// Dekodiert das Payload-Segment eines JWT. Wirft bei strukturellen /// Auffälligkeiten — Signatur wird **nicht** geprüft (das macht der /// Server bei jedem Request neu). static Map _decodeJwtPayload(String token) { final parts = token.split('.'); if (parts.length != 3) { throw FormatException('Token-Format unerwartet: ${parts.length} Teile'); } String payload = parts[1]; // base64url ohne Padding → wieder padden, sonst wirft base64Url.decode. switch (payload.length % 4) { case 0: break; case 2: payload += '=='; case 3: payload += '='; default: throw const FormatException('Token-Payload hat ungültige Länge'); } final bytes = base64Url.decode(payload); final json = utf8.decode(bytes); final decoded = jsonDecode(json); if (decoded is! Map) { throw const FormatException('Token-Payload ist kein JSON-Objekt'); } return decoded; } }