From 83d52364a550a0151bf98019a974ff5fcfc460c8 Mon Sep 17 00:00:00 2001 From: Dennis Nemec Date: Tue, 1 Sep 2026 16:06:58 +0200 Subject: [PATCH] fix(auth): erneuter Login-Tap oeffnet zuverlaessig wieder den Browser Nach fehlgeschlagenem/timeout Login blieb der Browser beim naechsten Tap zu: ein per UI-Timeout verwaister flutter_appauth-Flow blockierte nativ ('Connection already in progress'). - Provider dedupet login() (nur EIN authorizeAndExchangeCode gleichzeitig, race-sicher via identical-Guard) + discardPendingLogin() gibt die Sperre nach UI-Timeout frei, damit der naechste Tap frisch startet - Best-Effort-Retry, falls der native Flow noch offen ist - AuthBloc: discardPendingLogin() bei Timeout; kein zweiter Handler waehrend Authenticating Co-Authored-By: Claude Opus 4.8 (1M context) --- .../network/keycloak_oidc_token_provider.dart | 102 ++++++++++++++---- .../authentication/bloc/auth_bloc.dart | 8 ++ 2 files changed, 89 insertions(+), 21 deletions(-) diff --git a/lib/data/network/keycloak_oidc_token_provider.dart b/lib/data/network/keycloak_oidc_token_provider.dart index 4353539..1c7e941 100644 --- a/lib/data/network/keycloak_oidc_token_provider.dart +++ b/lib/data/network/keycloak_oidc_token_provider.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:convert'; import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart' show PlatformException; import 'package:flutter_appauth/flutter_appauth.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; @@ -62,6 +63,12 @@ class KeycloakOidcTokenProvider implements AuthTokenProvider { /// können → App hängt nach Hot-Restart am Splash/Login). Future? _refreshInFlight; + /// Dedup-Sperre für den interaktiven Login: es darf zu jedem Zeitpunkt nur + /// EIN `authorizeAndExchangeCode` laufen. Ein zweiter Aufruf, während noch + /// einer offen ist, würde nativ mit „Connection already in progress" + /// abbrechen — dann bliebe der Browser zu. `null` = kein Login läuft. + Future? _loginInFlight; + final StreamController _events = StreamController.broadcast(); @@ -80,27 +87,34 @@ class KeycloakOidcTokenProvider implements AuthTokenProvider { /// 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'], - ), - ); + /// + /// Deduped: läuft bereits ein Login, wird dieselbe Future zurückgegeben + /// statt ein zweites `authorizeAndExchangeCode` zu starten (das nativ mit + /// „Connection already in progress" bräche → Browser bliebe zu). Nach + /// einem UI-Timeout gibt der AuthBloc die Sperre via [discardPendingLogin] + /// frei, damit ein erneuter Tap wieder einen frischen Browser-Tab öffnet. + Future login() { + final existing = _loginInFlight; + if (existing != null) return existing; + late final Future flow; + flow = _login().whenComplete(() { + // Nur freigeben, wenn WIR noch die aktive Login-Future sind. Sonst + // würde die späte Completion eines per Timeout verworfenen Versuchs + // die Sperre eines inzwischen neu gestarteten Logins nullen. + if (identical(_loginInFlight, flow)) _loginInFlight = null; + }); + _loginInFlight = flow; + return flow; + } + + /// Gibt eine (typischerweise per UI-Timeout „aufgegebene") laufende + /// Login-Future frei. Der native Flow selbst ist nicht abbrechbar — wir + /// lösen nur unsere Dedup-Sperre, damit der nächste [login]-Aufruf einen + /// neuen Authorize-Request (frischer Browser-Tab) starten darf. + void discardPendingLogin() => _loginInFlight = null; + + Future _login() async { + final result = await _authorize(); _applyTokens( accessToken: result.accessToken, @@ -113,6 +127,52 @@ class KeycloakOidcTokenProvider implements AuthTokenProvider { _events.add(AuthLoggedIn(_idTokenClaims ?? const {})); } + /// Führt den nativen Authorize+Exchange aus. Blockiert ein vom vorigen + /// (verwaisten) Versuch nativ noch offener Flow den Aufruf mit + /// „Connection already in progress", warten wir kurz und versuchen es + /// EINMAL erneut — meist ist der alte Custom-Tab dann geschlossen. + Future _authorize() async { + try { + return await _appAuth.authorizeAndExchangeCode(_authRequest()); + } on PlatformException catch (e) { + if (_looksLikeInProgress(e)) { + debugPrint('Login: nativer Flow noch offen — einmaliger Retry.'); + await Future.delayed(const Duration(milliseconds: 400)); + return _appAuth.authorizeAndExchangeCode(_authRequest()); + } + rethrow; + } + } + + AuthorizationTokenRequest _authRequest() => 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'], + ); + + /// Heuristik: erkennt den „ein Authorize läuft bereits"-Fehler von + /// AppAuth (Android). Kein stabiler Code über Plattformen hinweg, daher + /// Code + Message defensiv prüfen. + static bool _looksLikeInProgress(PlatformException e) { + final haystack = '${e.code} ${e.message}'.toLowerCase(); + return haystack.contains('already in progress') || + haystack.contains('in progress') || + haystack.contains('concurrent'); + } + /// Versucht, eine vorhandene Session aus der Secure Storage zu /// reaktivieren. Liefert `true`, wenn anschließend ein gültiger /// Access-Token verfügbar ist. diff --git a/lib/feature/authentication/bloc/auth_bloc.dart b/lib/feature/authentication/bloc/auth_bloc.dart index 2d23fca..0730ccb 100644 --- a/lib/feature/authentication/bloc/auth_bloc.dart +++ b/lib/feature/authentication/bloc/auth_bloc.dart @@ -60,6 +60,9 @@ class AuthBloc extends Bloc { LoginRequested event, Emitter emit, ) async { + // Läuft bereits ein interaktiver Login (Spinner sichtbar), keinen + // zweiten Flow starten. Der Provider dedupet zusätzlich nativ. + if (state is Authenticating) return; try { emit(Authenticating()); await tokenProvider.login().timeout(_loginTimeout); @@ -72,7 +75,12 @@ class AuthBloc extends Bloc { // State dann auf `Authenticated`. Kein Schaden, nur „nachträgliche // Anmeldung". Bei späterer Exception passiert nichts; das Future // ist hier nicht mehr awaited. + // + // Wichtig: die Dedup-Sperre im Provider freigeben, sonst würde der + // nächste „Anmelden"-Tap denselben (aufgegebenen) Flow wiederver- + // wenden statt einen frischen Browser-Tab zu öffnen. debugPrint('Login-Timeout nach ${_loginTimeout.inSeconds}s.'); + tokenProvider.discardPendingLogin(); emit(Unauthenticated(loginTimedOut: true)); } catch (err, st) { debugPrint('Login fehlgeschlagen: $err\n$st');