Final commit.

This commit is contained in:
Dennis Nemec
2026-06-01 17:12:28 +02:00
parent 3ecbc82885
commit a9bf8ecdd1
385 changed files with 29081 additions and 12089 deletions

View File

@ -53,4 +53,44 @@ class BackendConfig {
keycloakClientId: 'holzleitner-app',
keycloakRedirectUrl: 'holzleitner://oauth2redirect',
);
/// Konfiguration für USB-Tunnel via `adb reverse` — gedacht für Tests in
/// fremden Netzwerken, in denen das Gerät den Mac nicht über eine LAN-IP
/// erreicht. Alles zeigt auf `localhost`; der Traffic wird über den
/// USB-Bus zum Host getunnelt.
///
/// **Setup vor dem Start (Gerät per USB angesteckt):**
/// ```
/// adb reverse tcp:3000 tcp:3000 # Rust-API
/// adb reverse tcp:8080 tcp:8080 # Keycloak
/// ```
///
/// **Backend-Voraussetzungen**, damit das OIDC-Login funktioniert:
/// * Backend-Env `KEYCLOAK_ISSUER_URL=http://localhost:8080/realms/holzleitner`
/// (muss exakt mit [keycloakIssuerUrl] matchen, sonst 401 `invalid issuer`).
/// * Keycloak muss den Issuer als `localhost` ausgeben — z. B. via
/// `KC_HOSTNAME_URL=http://localhost:8080` (oder Frontend-URL im Realm),
/// sonst prägt es den Container-Hostnamen ins `iss`-Claim.
/// * Der `holzleitner://oauth2redirect`-Redirect bleibt unverändert (das
/// Custom-Scheme ist netzwerk-unabhängig).
///
/// Aktivieren ohne Code-Edit:
/// ```
/// flutter run --dart-define=HL_BACKEND=usb
/// ```
static const BackendConfig usbReverse = BackendConfig(
apiBaseUrl: 'http://localhost:3000',
keycloakIssuerUrl: 'http://localhost:8080/realms/holzleitner',
keycloakClientId: 'holzleitner-app',
keycloakRedirectUrl: 'holzleitner://oauth2redirect',
);
/// Wählt die Config anhand des Compile-Time-Flags `HL_BACKEND`:
/// * `usb` → [usbReverse] (adb-reverse-Tunnel über localhost)
/// * sonst → [localDev] (LAN-IP, Default)
///
/// So muss für einen Netzwerkwechsel nur das Build-Flag gesetzt werden,
/// nicht der Quellcode angefasst.
static const BackendConfig fromEnvironment =
String.fromEnvironment('HL_BACKEND') == 'usb' ? usbReverse : localDev;
}

View File

@ -55,6 +55,13 @@ class KeycloakOidcTokenProvider implements AuthTokenProvider {
String? _refreshToken;
Map<String, dynamic>? _idTokenClaims;
/// Single-flight-Guard: hält den gerade laufenden Refresh, damit mehrere
/// gleichzeitige Aufrufer (Bootstrap: Restore + PaymentMethodsCubit +
/// Folge-Requests) sich EINEN Refresh teilen statt parallele
/// `flutter_appauth.token()`-Calls auszulösen (die nativ blockieren/haken
/// können → App hängt nach Hot-Restart am Splash/Login).
Future<String?>? _refreshInFlight;
final StreamController<AuthSessionEvent> _events =
StreamController<AuthSessionEvent>.broadcast();
@ -166,6 +173,19 @@ class KeycloakOidcTokenProvider implements AuthTokenProvider {
final rt = _refreshToken;
if (rt == null) return null;
// Single-flight: läuft bereits ein Refresh, hängen wir uns dran, statt
// einen zweiten `flutter_appauth.token()`-Call zu starten. `??=`
// evaluiert die rechte Seite nur, wenn noch kein Refresh läuft.
return _refreshInFlight ??= _performRefresh(rt).whenComplete(() {
_refreshInFlight = null;
});
}
/// Führt EINEN Token-Refresh aus. Bei Erfolg werden die Tokens übernommen
/// und der neue Access-Token zurückgegeben (ohne Event — stiller Refresh).
/// Bei Fehler ist die Session tot: lokal aufräumen, `AuthSessionExpired`
/// emittieren, `null` zurück.
Future<String?> _performRefresh(String rt) async {
try {
final result = await _appAuth.token(
TokenRequest(
@ -187,11 +207,17 @@ class KeycloakOidcTokenProvider implements AuthTokenProvider {
return _accessToken;
} on Exception {
// Refresh hat nicht funktioniert — Session ist tot, nicht
// wiederherstellbar. Aufrufer kriegen null zurück, AuthBloc
// bekommt SessionExpired.
// wiederherstellbar. Reihenfolge bewusst: erst State leeren + Event
// feuern, DANN best-effort den Storage löschen — so kann ein
// werfendes `delete` weder das Event verschlucken noch eine Exception
// aus `currentAccessToken()` leaken.
_clearSession();
await _storage.delete(key: _refreshTokenStorageKey);
_events.add(const AuthSessionExpired());
try {
await _storage.delete(key: _refreshTokenStorageKey);
} catch (e) {
debugPrint('currentAccessToken: Refresh-Token-Delete fehlgeschlagen: $e');
}
return null;
}
}

View File

@ -17,7 +17,7 @@ import 'keycloak_oidc_token_provider.dart';
/// das reine dart-Smoke-Tool, siehe `tool/smoke_test_api.dart`).
void registerNetworking({
required GetIt locator,
BackendConfig config = BackendConfig.localDev,
BackendConfig config = BackendConfig.fromEnvironment,
}) {
locator.registerSingleton<BackendConfig>(config);