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.
This commit is contained in:
@ -7,13 +7,85 @@ class Unauthenticated extends AuthState {
|
||||
Unauthenticated({this.sessionExpired = false});
|
||||
}
|
||||
|
||||
/// Transient state while [SetAuthenticatedEvent] is being processed and the
|
||||
/// user info is being fetched from the server.
|
||||
/// 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 {
|
||||
User user;
|
||||
String sessionId;
|
||||
Authenticated({
|
||||
required this.personalnummer,
|
||||
required this.displayName,
|
||||
required this.email,
|
||||
required this.user,
|
||||
required this.sessionId,
|
||||
});
|
||||
|
||||
Authenticated({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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user