import 'package:dio/dio.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 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 Direct Access Grant (Username/Password) Future login(String username, String password) async { try { final response = await _dio.post( KeycloakConfig.tokenEndpoint, data: { 'client_id': KeycloakConfig.clientId, 'grant_type': 'password', 'username': username, 'password': password, 'scope': KeycloakConfig.scopes, }, options: Options(contentType: Headers.formUrlEncodedContentType), ); if (response.statusCode == 200) { await _saveTokens(response.data); 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; } /// 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(); } }