Files
Holzleitner-Lieferservice-App/lib/feature/authentication/bloc/auth_bloc.dart
Dennis Nemec 6d7e58fc0f Phase B: Keycloak OIDC (PKCE) statt Cookie-Session-Login
App-Code:
- KeycloakOidcTokenProvider: PKCE-Login via flutter_appauth, Refresh via
  Refresh-Token aus flutter_secure_storage, Session-Restore beim
  App-Start, Logout.
- AuthSessionEvent als Provider→Bloc-Brücke (LoggedIn/LoggedOut/
  SessionExpired) auf einem Broadcast-Stream.
- AuthBloc komplett umgebaut: nimmt jetzt den KeycloakOidcTokenProvider
  statt UserInfoService, mappt eingehende Provider-Events auf eigene
  Zustände. Authenticated.fromClaims() liest personalnummer + Name aus
  dem ID-Token-Payload.
- LoginPage: kein Browser+Deep-Link mehr — Button feuert
  LoginRequested, der Provider übernimmt den restlichen Flow.
- network_locator: produktiver KeycloakOidcTokenProvider, doppelt
  registriert (KeycloakOidcTokenProvider für AuthBloc,
  AuthTokenProvider für Interceptor).
- Auth-State trägt zusätzlich personalnummer/displayName/email; das
  Legacy-User-Objekt + sessionId bleiben temporär drin, damit die
  alten ERPframe-Services (Phase D) noch kompilieren.

Plattform-Setup:
- Android: appAuthRedirectScheme=holzleitner in build.gradle.kts,
  NetworkSecurityConfig erlaubt HTTP zu localhost/10.0.2.2/127.0.0.1.
- iOS: holzleitner als URL-Scheme im Info.plist, ATS-Ausnahme für
  localhost (HTTP-Keycloak im Dev-Setup).

Out of scope:
- Keine echte App-Run-Smoke — kommt mit dem User-Test.
- iOS-pod-install läuft beim ersten 'flutter run ios' automatisch.
- Old ERPframe-Services bleiben aktiv und werfen ab jetzt 401 (kein
  Cookie-Session-Token mehr) — wird in Phase D entfernt.
2026-05-14 22:59:36 +02:00

149 lines
5.0 KiB
Dart

import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hl_lieferservice/data/network/auth_session_event.dart';
import 'package:hl_lieferservice/data/network/keycloak_oidc_token_provider.dart';
import 'package:hl_lieferservice/feature/authentication/bloc/auth_event.dart';
import 'package:hl_lieferservice/feature/authentication/bloc/auth_state.dart';
import 'package:hl_lieferservice/main.dart';
import 'package:hl_lieferservice/widget/operations/bloc/operation_bloc.dart';
import 'package:hl_lieferservice/widget/operations/bloc/operation_event.dart';
/// AuthBloc bridge between der UI und dem [KeycloakOidcTokenProvider].
///
/// Eingehende UI-Events triggern den Provider (Login/Logout/Restore).
/// Provider-Ereignisse kommen über den `events`-Stream rein, werden zu
/// `ProviderSessionChanged` übersetzt und vom Bloc in Zustände überführt.
class AuthBloc extends Bloc<AuthEvent, AuthState> {
AuthBloc({required this.tokenProvider, required this.operationBloc})
: super(Unauthenticated()) {
on<LoginRequested>(_handleLogin);
on<LogoutRequested>(_handleLogout);
on<RestoreSessionRequested>(_handleRestore);
on<ProviderSessionChanged>(_handleProviderEvent);
// Legacy: ERPframe-Repos feuern bei 401.
on<SessionExpiredEvent>((event, emit) async {
await tokenProvider.logout();
if (locator.isRegistered<Authenticated>()) {
locator.unregister<Authenticated>();
}
emit(Unauthenticated(sessionExpired: true));
});
_subscription = tokenProvider.events.listen(
(event) => add(_toBlocEvent(event)),
);
}
final KeycloakOidcTokenProvider tokenProvider;
final OperationBloc operationBloc;
late final StreamSubscription<AuthSessionEvent> _subscription;
Future<void> _handleLogin(
LoginRequested event,
Emitter<AuthState> emit,
) async {
try {
emit(Authenticating());
await tokenProvider.login();
// Erfolg landet via Stream-Subscription als
// ProviderSessionChanged.loggedIn → _handleProviderEvent.
} catch (err, st) {
debugPrint('Login fehlgeschlagen: $err\n$st');
emit(Unauthenticated());
operationBloc.add(
FailOperation(
message: 'Login war nicht erfolgreich. Probieren Sie es erneut.',
),
);
}
}
Future<void> _handleLogout(
LogoutRequested event,
Emitter<AuthState> emit,
) async {
await tokenProvider.logout();
// Provider feuert AuthLoggedOut → _handleProviderEvent setzt den State.
}
Future<void> _handleRestore(
RestoreSessionRequested event,
Emitter<AuthState> emit,
) async {
final restored = await tokenProvider.restoreSession();
if (!restored) {
// Kein gültiger Token → bleibt bei Unauthenticated, kein Snackbar
// (das ist der normale Cold-Start-Pfad).
}
}
Future<void> _handleProviderEvent(
ProviderSessionChanged event,
Emitter<AuthState> emit,
) async {
switch (event.kind) {
case ProviderEventKind.loggedIn:
try {
final state = Authenticated.fromClaims(
claims: event.claims ?? const <String, dynamic>{},
accessToken: event.accessToken ?? '',
);
if (locator.isRegistered<Authenticated>()) {
locator.unregister<Authenticated>();
}
locator.registerSingleton<Authenticated>(state);
emit(state);
} catch (err, st) {
debugPrint('Konnte Claims nicht in Authenticated-State mappen: $err\n$st');
emit(Unauthenticated());
operationBloc.add(
FailOperation(
message:
'Login-Antwort vom Server unvollständig. Bitte erneut versuchen.',
),
);
}
case ProviderEventKind.loggedOut:
if (locator.isRegistered<Authenticated>()) {
locator.unregister<Authenticated>();
}
emit(Unauthenticated());
case ProviderEventKind.sessionExpired:
if (locator.isRegistered<Authenticated>()) {
locator.unregister<Authenticated>();
}
emit(Unauthenticated(sessionExpired: true));
}
}
ProviderSessionChanged _toBlocEvent(AuthSessionEvent event) {
return switch (event) {
AuthLoggedIn(:final claims) => ProviderSessionChanged(
ProviderEventKind.loggedIn,
claims: claims,
// Wir würden hier idealerweise den Access-Token mit übergeben.
// Da der Provider den Token nicht im Stream-Event mitliefert,
// bauen wir später eine Brücke. Für jetzt: leerer String —
// Authenticated.sessionId wird mit Phase D ohnehin abgeschafft.
accessToken: '',
),
AuthLoggedOut() => const ProviderSessionChanged(
ProviderEventKind.loggedOut,
),
AuthSessionExpired() => const ProviderSessionChanged(
ProviderEventKind.sessionExpired,
),
};
}
@override
Future<void> close() async {
await _subscription.cancel();
return super.close();
}
}