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) <noreply@anthropic.com>
This commit is contained in:
@ -2,6 +2,7 @@ import 'dart:async';
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/services.dart' show PlatformException;
|
||||||
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';
|
||||||
|
|
||||||
@ -62,6 +63,12 @@ class KeycloakOidcTokenProvider implements AuthTokenProvider {
|
|||||||
/// können → App hängt nach Hot-Restart am Splash/Login).
|
/// können → App hängt nach Hot-Restart am Splash/Login).
|
||||||
Future<String?>? _refreshInFlight;
|
Future<String?>? _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<void>? _loginInFlight;
|
||||||
|
|
||||||
final StreamController<AuthSessionEvent> _events =
|
final StreamController<AuthSessionEvent> _events =
|
||||||
StreamController<AuthSessionEvent>.broadcast();
|
StreamController<AuthSessionEvent>.broadcast();
|
||||||
|
|
||||||
@ -80,27 +87,34 @@ class KeycloakOidcTokenProvider implements AuthTokenProvider {
|
|||||||
|
|
||||||
/// Triggert den PKCE-Login-Flow. Wirft, wenn der User abbricht oder
|
/// Triggert den PKCE-Login-Flow. Wirft, wenn der User abbricht oder
|
||||||
/// Keycloak einen Fehler liefert.
|
/// Keycloak einen Fehler liefert.
|
||||||
Future<void> login() async {
|
///
|
||||||
final result = await _appAuth.authorizeAndExchangeCode(
|
/// Deduped: läuft bereits ein Login, wird dieselbe Future zurückgegeben
|
||||||
AuthorizationTokenRequest(
|
/// statt ein zweites `authorizeAndExchangeCode` zu starten (das nativ mit
|
||||||
_config.keycloakClientId,
|
/// „Connection already in progress" bräche → Browser bliebe zu). Nach
|
||||||
redirectUrl,
|
/// einem UI-Timeout gibt der AuthBloc die Sperre via [discardPendingLogin]
|
||||||
discoveryUrl: _discoveryUrl,
|
/// frei, damit ein erneuter Tap wieder einen frischen Browser-Tab öffnet.
|
||||||
scopes: const ['openid', 'profile'],
|
Future<void> login() {
|
||||||
// Lokales Dev-Setup hat HTTP-Keycloak — der Default-iOS-Browser
|
final existing = _loginInFlight;
|
||||||
// würde sonst abbrechen. In Produktion (HTTPS) ist das ein
|
if (existing != null) return existing;
|
||||||
// No-Op und kann bleiben.
|
late final Future<void> flow;
|
||||||
allowInsecureConnections: true,
|
flow = _login().whenComplete(() {
|
||||||
// Wichtig auf Android: ohne `prompt=login` würde Keycloak bei
|
// Nur freigeben, wenn WIR noch die aktive Login-Future sind. Sonst
|
||||||
// bestehender SSO-Session sofort 302 nach holzleitner://...
|
// würde die späte Completion eines per Timeout verworfenen Versuchs
|
||||||
// antworten, Chrome schließt den Custom Tab dabei so schnell,
|
// die Sperre eines inzwischen neu gestarteten Logins nullen.
|
||||||
// dass der Redirect-Intent unsere RedirectUriReceiverActivity
|
if (identical(_loginInFlight, flow)) _loginInFlight = null;
|
||||||
// gar nicht erreicht — AppAuth meldet stattdessen
|
});
|
||||||
// "User cancelled flow". Erzwingen der Login-Maske → echter
|
_loginInFlight = flow;
|
||||||
// User-Click → sauberer Intent-Dispatch.
|
return flow;
|
||||||
promptValues: const ['login'],
|
}
|
||||||
),
|
|
||||||
);
|
/// 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<void> _login() async {
|
||||||
|
final result = await _authorize();
|
||||||
|
|
||||||
_applyTokens(
|
_applyTokens(
|
||||||
accessToken: result.accessToken,
|
accessToken: result.accessToken,
|
||||||
@ -113,6 +127,52 @@ class KeycloakOidcTokenProvider implements AuthTokenProvider {
|
|||||||
_events.add(AuthLoggedIn(_idTokenClaims ?? const <String, dynamic>{}));
|
_events.add(AuthLoggedIn(_idTokenClaims ?? const <String, dynamic>{}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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<AuthorizationTokenResponse> _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<void>.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
|
/// 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.
|
||||||
|
|||||||
@ -60,6 +60,9 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
|||||||
LoginRequested event,
|
LoginRequested event,
|
||||||
Emitter<AuthState> emit,
|
Emitter<AuthState> emit,
|
||||||
) async {
|
) 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 {
|
try {
|
||||||
emit(Authenticating());
|
emit(Authenticating());
|
||||||
await tokenProvider.login().timeout(_loginTimeout);
|
await tokenProvider.login().timeout(_loginTimeout);
|
||||||
@ -72,7 +75,12 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
|||||||
// State dann auf `Authenticated`. Kein Schaden, nur „nachträgliche
|
// State dann auf `Authenticated`. Kein Schaden, nur „nachträgliche
|
||||||
// Anmeldung". Bei späterer Exception passiert nichts; das Future
|
// Anmeldung". Bei späterer Exception passiert nichts; das Future
|
||||||
// ist hier nicht mehr awaited.
|
// 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.');
|
debugPrint('Login-Timeout nach ${_loginTimeout.inSeconds}s.');
|
||||||
|
tokenProvider.discardPendingLogin();
|
||||||
emit(Unauthenticated(loginTimedOut: true));
|
emit(Unauthenticated(loginTimedOut: true));
|
||||||
} catch (err, st) {
|
} catch (err, st) {
|
||||||
debugPrint('Login fehlgeschlagen: $err\n$st');
|
debugPrint('Login fehlgeschlagen: $err\n$st');
|
||||||
|
|||||||
Reference in New Issue
Block a user