Files
Holzleitner-Lieferservice-App/lib/feature/authentication/bloc/auth_state.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

92 lines
2.8 KiB
Dart

import 'package:hl_lieferservice/feature/authentication/model/user.dart';
abstract class AuthState {}
class Unauthenticated extends AuthState {
final bool sessionExpired;
Unauthenticated({this.sessionExpired = false});
}
/// Transient state während dem PKCE-Flow (Browser-Tab offen,
/// Token-Tausch läuft).
class Authenticating extends AuthState {}
/// Erfolgreiche Authentifizierung. Trägt sowohl die "neuen"
/// Token-/Claim-Felder als auch das Legacy-User-Objekt — Letzteres
/// wird aus den Claims befüllt und mit Phase D komplett entfernt.
class Authenticated extends AuthState {
Authenticated({
required this.personalnummer,
required this.displayName,
required this.email,
required this.user,
required this.sessionId,
});
/// Personalnummer aus dem `personalnummer`-Custom-Claim. Das
/// Backend nutzt diese als Account-Identifier.
final int personalnummer;
/// Beste verfügbare Beschriftung für den Fahrer:
/// `name` > `given_name + family_name` > `preferred_username`.
final String displayName;
/// E-Mail aus dem `email`-Claim, falls vorhanden.
final String? email;
/// Legacy: das alte `User`-Domänenobjekt, aus den Claims befüllt.
/// Wird in Phase D entfernt, sobald keine Aufrufer mehr da sind.
final User user;
/// Legacy: füllt für die alten `getSessionOrThrow()`-Aufrufer
/// einen Wert — die laufen sowieso gegen ERPframe und werden in
/// Phase D abgelöst.
final String sessionId;
/// Konstruiert den State aus dem dekodierten ID-Token-Payload.
factory Authenticated.fromClaims({
required Map<String, dynamic> claims,
required String accessToken,
}) {
final personalnummerRaw = claims['personalnummer'];
final personalnummer = switch (personalnummerRaw) {
int v => v,
String v => int.parse(v),
_ => throw FormatException(
'personalnummer-Claim fehlt oder hat unerwarteten Typ '
'(${personalnummerRaw.runtimeType})',
),
};
final given = (claims['given_name'] as String?) ?? '';
final family = (claims['family_name'] as String?) ?? '';
final preferredUsername =
(claims['preferred_username'] as String?) ?? 'fahrer';
final fullName = (claims['name'] as String?)?.trim();
final displayName =
fullName != null && fullName.isNotEmpty
? fullName
: (given.isNotEmpty || family.isNotEmpty
? '$given $family'.trim()
: preferredUsername);
final email = claims['email'] as String?;
final user = User(
number: personalnummer.toString(),
firstName: given,
lastName: family,
mail: email ?? '',
);
return Authenticated(
personalnummer: personalnummer,
displayName: displayName,
email: email,
user: user,
sessionId: accessToken,
);
}
}