import 'package:dio/dio.dart'; import 'package:flutter_appauth/flutter_appauth.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:jwt_decoder/jwt_decoder.dart'; import 'package:injectable/injectable.dart'; import '../models/user.dart'; import 'keycloak_role_mapper.dart'; import '../../../../core/config/environment.dart'; import '../../../../core/utils/logger.dart'; /// Configuration Keycloak centralisée class KeycloakConfig { static String get baseUrl => AppConfig.keycloakBaseUrl; static const String realm = 'unionflow'; static const String clientId = 'unionflow-mobile'; static const String scopes = 'openid profile email roles'; static String get tokenEndpoint => '$baseUrl/realms/$realm/protocol/openid-connect/token'; static String get logoutEndpoint => '$baseUrl/realms/$realm/protocol/openid-connect/logout'; } /// Service d'Authentification Keycloak Épuré & DRY @lazySingleton class KeycloakAuthService { final Dio _dio = Dio(); final FlutterAppAuth _appAuth = const FlutterAppAuth(); final FlutterSecureStorage _storage = const FlutterSecureStorage( aOptions: AndroidOptions(encryptedSharedPreferences: true), iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock_this_device), ); static const String _accessK = 'kc_access'; static const String _refreshK = 'kc_refresh'; static const String _idK = 'kc_id'; /// Login via Authorization Code Flow + PKCE (AppAuth) Future loginWithAppAuth() async { try { final result = await _appAuth.authorizeAndExchangeCode( AuthorizationTokenRequest( KeycloakConfig.clientId, 'dev.lions.unionflow-mobile://auth/callback', serviceConfiguration: AuthorizationServiceConfiguration( authorizationEndpoint: '${KeycloakConfig.baseUrl}/realms/${KeycloakConfig.realm}/protocol/openid-connect/auth', tokenEndpoint: KeycloakConfig.tokenEndpoint, ), scopes: ['openid', 'profile', 'email', 'roles', 'offline_access'], additionalParameters: {'kc_locale': 'fr'}, allowInsecureConnections: true, ), ); if (result?.accessToken != null) { await _saveTokens({ 'access_token': result!.accessToken, 'refresh_token': result.refreshToken, 'id_token': result.idToken, }); return await getCurrentUser(); } } catch (e, st) { AppLogger.error('KeycloakAuthService: auth error', error: e, stackTrace: st); } return null; } static Future? _refreshFuture; /// Rafraîchissement automatique du token avec verrouillage global Future refreshToken() async { if (_refreshFuture != null) { AppLogger.info('KeycloakAuthService: waiting for ongoing refresh'); return await _refreshFuture; } _refreshFuture = _performRefresh(); try { return await _refreshFuture; } finally { _refreshFuture = null; } } Future _performRefresh() async { final refresh = await _storage.read(key: _refreshK); if (refresh == null) { AppLogger.info('KeycloakAuthService: no refresh token available'); return null; } try { AppLogger.info('KeycloakAuthService: attempting token refresh'); final response = await _dio.post( KeycloakConfig.tokenEndpoint, data: { 'client_id': KeycloakConfig.clientId, 'grant_type': 'refresh_token', 'refresh_token': refresh, }, options: Options( contentType: Headers.formUrlEncodedContentType, validateStatus: (status) => status == 200, ), ); if (response.statusCode == 200) { await _saveTokens(response.data); AppLogger.info('KeycloakAuthService: token refreshed successfully'); return response.data['access_token']; } } on DioException catch (e, st) { AppLogger.error('KeycloakAuthService: refresh error ${e.response?.statusCode}', error: e, stackTrace: st); if (e.response?.statusCode == 400) { AppLogger.info('KeycloakAuthService: refresh token invalid or expired, logging out'); await logout(); } } catch (e, st) { AppLogger.error('KeycloakAuthService: critical refresh error', error: e, stackTrace: st); } return null; } /// Retourne un access token valide, en rafraîchissant automatiquement si expiré. /// Utilisé par les datasources pour éviter d'envoyer un Bearer null ou expiré. Future getValidAccessToken() async { String? token = await _storage.read(key: _accessK); if (token == null) return null; if (JwtDecoder.isExpired(token)) { token = await refreshToken(); } return token; } /// Récupération de l'utilisateur courant + Mapage Rôles Future getCurrentUser() async { String? token = await _storage.read(key: _accessK); final idToken = await _storage.read(key: _idK); if (token == null || idToken == null) return null; if (JwtDecoder.isExpired(token)) { token = await refreshToken(); if (token == null) return null; } try { final payload = JwtDecoder.decode(token); final idPayload = JwtDecoder.decode(idToken); final roles = _extractRoles(payload); final primaryRole = KeycloakRoleMapper.mapToUserRole(roles); AppLogger.info('KeycloakAuthService: roles mapped', tag: '${primaryRole.name}'); return User( id: idPayload['sub'] ?? '', email: idPayload['email'] ?? '', firstName: idPayload['given_name'] ?? '', lastName: idPayload['family_name'] ?? '', primaryRole: primaryRole, additionalPermissions: KeycloakRoleMapper.mapToPermissions(roles), isActive: true, lastLoginAt: DateTime.now(), createdAt: DateTime.now(), ); } catch (e, st) { AppLogger.error('KeycloakAuthService: user parse error', error: e, stackTrace: st); } return null; } Future logout() async { await _storage.deleteAll(); AppLogger.info('KeycloakAuthService: session cleared'); } Future _saveTokens(Map data) async { if (data['access_token'] != null) await _storage.write(key: _accessK, value: data['access_token']); if (data['refresh_token'] != null) await _storage.write(key: _refreshK, value: data['refresh_token']); if (data['id_token'] != null) await _storage.write(key: _idK, value: data['id_token']); } List _extractRoles(Map payload) { final roles = []; if (payload['realm_access']?['roles'] != null) { roles.addAll((payload['realm_access']['roles'] as List).cast()); } if (payload['resource_access'] != null) { (payload['resource_access'] as Map).values.forEach((v) { if (v['roles'] != null) roles.addAll((v['roles'] as List).cast()); }); } return roles.where((r) => !r.startsWith('default-roles-') && r != 'offline_access').toList(); } Future getValidToken() async { final token = await _storage.read(key: _accessK); if (token != null && !JwtDecoder.isExpired(token)) return token; return await refreshToken(); } /// Vérifie le statut du compte sur le backend UnionFlow. /// /// Retourne un [AuthStatusResult] enrichi avec l'état d'onboarding, /// ou `null` en cas d'erreur réseau (on ne bloque pas en cas de doute). Future getAuthStatus(String apiBaseUrl) async { try { final token = await getValidToken(); if (token == null) return null; final response = await _dio.get( '$apiBaseUrl/api/membres/mon-statut', options: Options( headers: {'Authorization': 'Bearer $token'}, validateStatus: (s) => s != null && s < 500, ), ); if (response.statusCode == 200 && response.data is Map) { final data = response.data as Map; return AuthStatusResult( statutCompte: (data['statutCompte'] as String?) ?? 'ACTIF', onboardingState: (data['onboardingState'] as String?) ?? 'NO_SUBSCRIPTION', souscriptionId: data['souscriptionId'] as String?, waveSessionId: data['waveSessionId'] as String?, organisationId: data['organisationId'] as String?, typeOrganisation: data['typeOrganisation'] as String?, premierLoginComplet: (data['premierLoginComplet'] as bool?) ?? false, reAuthRequired: (data['reAuthRequired'] as bool?) ?? false, ); } } catch (e) { AppLogger.warning('KeycloakAuthService: impossible de vérifier statut compte: $e'); } return null; } } /// Résultat enrichi de /api/membres/mon-statut class AuthStatusResult { final String statutCompte; final String onboardingState; final String? souscriptionId; final String? waveSessionId; final String? organisationId; final String? typeOrganisation; /// true si le premier login vient d'être complété (token à rafraîchir pour avoir MEMBRE/MEMBRE_ACTIF) final bool premierLoginComplet; /// true si une réauthentification est requise (UPDATE_PASSWORD vient d'être assigné sur un ancien compte) final bool reAuthRequired; const AuthStatusResult({ required this.statutCompte, required this.onboardingState, this.souscriptionId, this.waveSessionId, this.organisationId, this.typeOrganisation, this.premierLoginComplet = false, this.reAuthRequired = false, }); bool get isActive => statutCompte == 'ACTIF'; bool get isPendingOnboarding => statutCompte == 'EN_ATTENTE_VALIDATION'; bool get isBlocked => statutCompte == 'SUSPENDU' || statutCompte == 'DESACTIVE'; }