/// Service d'Authentification Keycloak via WebView /// /// Implémentation professionnelle et sécurisée de l'authentification OAuth2/OIDC /// avec Keycloak utilisant WebView pour contourner les limitations HTTPS de flutter_appauth. /// /// Fonctionnalités : /// - Flow OAuth2 Authorization Code avec PKCE /// - Gestion sécurisée des tokens JWT /// - Support HTTP/HTTPS /// - Gestion complète des erreurs et timeouts /// - Validation rigoureuse des paramètres /// - Logging détaillé pour le debugging library keycloak_webview_auth_service; import 'dart:async'; import 'dart:convert'; import 'dart:math'; import 'package:crypto/crypto.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:http/http.dart' as http; import 'package:jwt_decoder/jwt_decoder.dart'; import '../models/user.dart'; import '../models/user_role.dart'; import 'keycloak_role_mapper.dart'; import '../../../../core/config/environment.dart'; /// Configuration Keycloak pour l'authentification WebView class KeycloakWebViewConfig { /// URL de base de l'instance Keycloak (depuis AppConfig) static String get baseUrl => AppConfig.keycloakBaseUrl; /// Realm UnionFlow static const String realm = 'unionflow'; /// Client ID pour l'application mobile static const String clientId = 'unionflow-mobile'; /// URL de redirection après authentification static const String redirectUrl = 'dev.lions.unionflow-mobile://auth/callback'; /// Scopes OAuth2 demandés static const List scopes = ['openid', 'profile', 'email', 'roles']; /// Timeout pour les requêtes HTTP (en secondes) static const int httpTimeoutSeconds = 30; /// Timeout pour l'authentification WebView (en secondes) static const int authTimeoutSeconds = 300; // 5 minutes /// Endpoints calculés static String get authorizationEndpoint => '$baseUrl/realms/$realm/protocol/openid-connect/auth'; static String get tokenEndpoint => '$baseUrl/realms/$realm/protocol/openid-connect/token'; static String get userInfoEndpoint => '$baseUrl/realms/$realm/protocol/openid-connect/userinfo'; static String get logoutEndpoint => '$baseUrl/realms/$realm/protocol/openid-connect/logout'; static String get jwksEndpoint => '$baseUrl/realms/$realm/protocol/openid-connect/certs'; } /// Résultat de l'authentification WebView class WebViewAuthResult { final String accessToken; final String idToken; final String? refreshToken; final int expiresIn; final String tokenType; final List scopes; const WebViewAuthResult({ required this.accessToken, required this.idToken, this.refreshToken, required this.expiresIn, required this.tokenType, required this.scopes, }); /// Création depuis la réponse token de Keycloak factory WebViewAuthResult.fromTokenResponse(Map response) { return WebViewAuthResult( accessToken: response['access_token'] ?? '', idToken: response['id_token'] ?? '', refreshToken: response['refresh_token'], expiresIn: response['expires_in'] ?? 3600, tokenType: response['token_type'] ?? 'Bearer', scopes: (response['scope'] as String?)?.split(' ') ?? [], ); } } /// Exceptions spécifiques à l'authentification WebView class KeycloakWebViewAuthException implements Exception { final String message; final String? code; final dynamic originalError; const KeycloakWebViewAuthException( this.message, { this.code, this.originalError, }); @override String toString() => 'KeycloakWebViewAuthException: $message${code != null ? ' (Code: $code)' : ''}'; } /// Service d'authentification Keycloak via WebView /// /// Implémentation complète et sécurisée du flow OAuth2 Authorization Code avec PKCE class KeycloakWebViewAuthService { // Stockage sécurisé des tokens static const FlutterSecureStorage _secureStorage = FlutterSecureStorage( aOptions: AndroidOptions( encryptedSharedPreferences: true, ), iOptions: IOSOptions( accessibility: KeychainAccessibility.first_unlock_this_device, ), ); // Clés de stockage sécurisé — alignées avec KeycloakAuthService pour éviter IC-03 // KeycloakAuthService lit 'kc_access' / 'kc_refresh' / 'kc_id' ; ApiClient aussi. static const String _accessTokenKey = 'kc_access'; static const String _idTokenKey = 'kc_id'; static const String _refreshTokenKey = 'kc_refresh'; static const String _userInfoKey = 'keycloak_webview_user_info'; static const String _authStateKey = 'keycloak_webview_auth_state'; // Client HTTP avec timeout configuré static final http.Client _httpClient = http.Client(); /// Génère un code verifier PKCE sécurisé static String _generateCodeVerifier() { const String charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~'; final Random random = Random.secure(); return List.generate(128, (i) => charset[random.nextInt(charset.length)]).join(); } /// Génère le code challenge PKCE à partir du verifier static String _generateCodeChallenge(String verifier) { final List bytes = utf8.encode(verifier); final Digest digest = sha256.convert(bytes); return base64Url.encode(digest.bytes).replaceAll('=', ''); } /// Génère un state sécurisé pour la protection CSRF static String _generateState() { final Random random = Random.secure(); final List bytes = List.generate(32, (i) => random.nextInt(256)); return base64Url.encode(bytes).replaceAll('=', ''); } /// Construit l'URL d'autorisation Keycloak avec tous les paramètres static Future> _buildAuthorizationUrl() async { final String codeVerifier = _generateCodeVerifier(); final String codeChallenge = _generateCodeChallenge(codeVerifier); final String state = _generateState(); // Stocker les paramètres pour la validation ultérieure await _secureStorage.write( key: _authStateKey, value: jsonEncode({ 'code_verifier': codeVerifier, 'state': state, 'timestamp': DateTime.now().millisecondsSinceEpoch, }), ); final Map params = { 'response_type': 'code', 'client_id': KeycloakWebViewConfig.clientId, 'redirect_uri': KeycloakWebViewConfig.redirectUrl, 'scope': KeycloakWebViewConfig.scopes.join(' '), 'state': state, 'code_challenge': codeChallenge, 'code_challenge_method': 'S256', 'kc_locale': 'fr', 'prompt': 'login', }; final String queryString = params.entries .map((e) => '${Uri.encodeComponent(e.key)}=${Uri.encodeComponent(e.value)}') .join('&'); final String authUrl = '${KeycloakWebViewConfig.authorizationEndpoint}?$queryString'; debugPrint('🔐 URL d\'autorisation générée: $authUrl'); return { 'url': authUrl, 'state': state, 'code_verifier': codeVerifier, }; } /// Valide la réponse de redirection et extrait le code d'autorisation static Future _validateCallbackAndExtractCode( String callbackUrl, String expectedState, ) async { debugPrint('🔍 Validation du callback: $callbackUrl'); final Uri uri = Uri.parse(callbackUrl); // Vérifier que c'est bien notre URL de redirection if (!callbackUrl.startsWith(KeycloakWebViewConfig.redirectUrl)) { throw const KeycloakWebViewAuthException( 'URL de callback invalide', code: 'INVALID_CALLBACK_URL', ); } // Vérifier la présence d'erreurs final String? error = uri.queryParameters['error']; if (error != null) { final String? errorDescription = uri.queryParameters['error_description']; throw KeycloakWebViewAuthException( 'Erreur d\'authentification: ${errorDescription ?? error}', code: error, ); } // Valider le state pour la protection CSRF final String? receivedState = uri.queryParameters['state']; if (receivedState != expectedState) { throw const KeycloakWebViewAuthException( 'State invalide - possible attaque CSRF', code: 'INVALID_STATE', ); } // Extraire le code d'autorisation final String? code = uri.queryParameters['code']; if (code == null || code.isEmpty) { throw const KeycloakWebViewAuthException( 'Code d\'autorisation manquant', code: 'MISSING_AUTH_CODE', ); } debugPrint('✅ Code d\'autorisation extrait avec succès'); return code; } /// Échange le code d'autorisation contre des tokens static Future _exchangeCodeForTokens( String authCode, String codeVerifier, ) async { debugPrint('🔄 Échange du code d\'autorisation contre les tokens...'); try { final Map body = { 'grant_type': 'authorization_code', 'client_id': KeycloakWebViewConfig.clientId, 'code': authCode, 'redirect_uri': KeycloakWebViewConfig.redirectUrl, 'code_verifier': codeVerifier, }; final http.Response response = await _httpClient .post( Uri.parse(KeycloakWebViewConfig.tokenEndpoint), headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json', }, body: body, ) .timeout(const Duration(seconds: KeycloakWebViewConfig.httpTimeoutSeconds)); debugPrint('📡 Réponse token endpoint: ${response.statusCode}'); if (response.statusCode != 200) { final String errorBody = response.body; debugPrint('❌ Erreur échange tokens: $errorBody'); Map? errorJson; try { errorJson = jsonDecode(errorBody); } catch (e) { // Ignore JSON parsing errors } final String errorMessage = errorJson?['error_description'] ?? errorJson?['error'] ?? 'Erreur HTTP ${response.statusCode}'; throw KeycloakWebViewAuthException( 'Échec de l\'échange de tokens: $errorMessage', code: errorJson?['error'], ); } final Map tokenResponse = jsonDecode(response.body); // Valider la présence des tokens requis if (!tokenResponse.containsKey('access_token') || !tokenResponse.containsKey('id_token')) { throw const KeycloakWebViewAuthException( 'Tokens manquants dans la réponse', code: 'MISSING_TOKENS', ); } debugPrint('✅ Tokens reçus avec succès'); return WebViewAuthResult.fromTokenResponse(tokenResponse); } on TimeoutException { throw const KeycloakWebViewAuthException( 'Timeout lors de l\'échange des tokens', code: 'TIMEOUT', ); } catch (e) { if (e is KeycloakWebViewAuthException) rethrow; throw KeycloakWebViewAuthException( 'Erreur lors de l\'échange des tokens: $e', originalError: e, ); } } /// Stocke les tokens de manière sécurisée static Future _storeTokens(WebViewAuthResult authResult) async { debugPrint('💾 Stockage sécurisé des tokens...'); try { await Future.wait([ _secureStorage.write(key: _accessTokenKey, value: authResult.accessToken), _secureStorage.write(key: _idTokenKey, value: authResult.idToken), if (authResult.refreshToken != null) _secureStorage.write(key: _refreshTokenKey, value: authResult.refreshToken!), ]); debugPrint('✅ Tokens stockés avec succès'); } catch (e) { throw KeycloakWebViewAuthException( 'Erreur lors du stockage des tokens: $e', originalError: e, ); } } /// Valide et parse un token JWT /// /// Stratégie de validation JWT : /// - Côté mobile : vérification de l'expiration et de l'issuer uniquement. /// La signature JWT n'est PAS vérifiée côté mobile car : /// 1. Le token est envoyé au backend dans chaque requête API (header Authorization) /// 2. Le backend Quarkus valide la signature via JWKS (quarkus-oidc) /// 3. Toutes les communications passent par HTTPS en production /// 4. Le token est stocké dans FlutterSecureStorage (EncryptedSharedPreferences / Keychain) /// - Si une validation de signature locale est requise, implémenter via le /// endpoint JWKS : `KeycloakWebViewConfig.jwksEndpoint` static Map _parseAndValidateJWT(String token, String tokenType) { try { // Vérifier l'expiration if (JwtDecoder.isExpired(token)) { throw KeycloakWebViewAuthException( '$tokenType expiré', code: 'TOKEN_EXPIRED', ); } // Parser le payload final Map payload = JwtDecoder.decode(token); // Validations de base if (payload['iss'] == null) { throw const KeycloakWebViewAuthException( 'Token JWT invalide: issuer manquant', code: 'INVALID_JWT', ); } // Vérifier l'issuer final String expectedIssuer = '${KeycloakWebViewConfig.baseUrl}/realms/${KeycloakWebViewConfig.realm}'; if (payload['iss'] != expectedIssuer) { throw KeycloakWebViewAuthException( 'Token JWT invalide: issuer incorrect (attendu: $expectedIssuer, reçu: ${payload['iss']})', code: 'INVALID_ISSUER', ); } debugPrint('✅ $tokenType validé avec succès'); return payload; } catch (e) { if (e is KeycloakWebViewAuthException) rethrow; throw KeycloakWebViewAuthException( 'Erreur lors de la validation du $tokenType: $e', originalError: e, ); } } /// Méthode principale d'authentification /// /// Retourne les paramètres nécessaires pour lancer la WebView d'authentification static Future> prepareAuthentication() async { debugPrint('🚀 Préparation de l\'authentification WebView...'); try { // Nettoyer les données d'authentification précédentes await clearAuthData(); // Générer l'URL d'autorisation avec PKCE final Map authParams = await _buildAuthorizationUrl(); debugPrint('✅ Authentification préparée avec succès'); return authParams; } catch (e) { throw KeycloakWebViewAuthException( 'Erreur lors de la préparation de l\'authentification: $e', originalError: e, ); } } /// Traite le callback de redirection et finalise l'authentification static Future handleAuthCallback(String callbackUrl) async { debugPrint('🔄 Traitement du callback d\'authentification...'); debugPrint('📋 URL de callback: $callbackUrl'); try { // Récupérer les paramètres d'authentification stockés debugPrint('🔍 Récupération de l\'état d\'authentification...'); final String? authStateJson = await _secureStorage.read(key: _authStateKey); if (authStateJson == null) { debugPrint('❌ État d\'authentification manquant'); throw const KeycloakWebViewAuthException( 'État d\'authentification manquant', code: 'MISSING_AUTH_STATE', ); } final Map authState = jsonDecode(authStateJson); final String expectedState = authState['state']; final String codeVerifier = authState['code_verifier']; debugPrint('✅ État d\'authentification récupéré'); // Valider le callback et extraire le code debugPrint('🔍 Validation du callback...'); final String authCode = await _validateCallbackAndExtractCode( callbackUrl, expectedState, ); debugPrint('✅ Code d\'autorisation extrait: ${authCode.substring(0, 10)}...'); // Échanger le code contre des tokens debugPrint('🔄 Échange du code contre les tokens...'); final WebViewAuthResult authResult = await _exchangeCodeForTokens( authCode, codeVerifier, ); debugPrint('✅ Tokens reçus avec succès'); // Stocker les tokens debugPrint('💾 Stockage des tokens...'); await _storeTokens(authResult); debugPrint('✅ Tokens stockés'); // Créer l'utilisateur depuis les tokens debugPrint('👤 Création de l\'utilisateur...'); final User user = await _createUserFromTokens(authResult); debugPrint('✅ Utilisateur créé: ${user.fullName}'); // Nettoyer l'état d'authentification temporaire await _secureStorage.delete(key: _authStateKey); debugPrint('🎉 Authentification WebView terminée avec succès'); return user; } catch (e, stackTrace) { debugPrint('💥 Erreur lors du traitement du callback: $e'); debugPrint('📋 Stack trace: $stackTrace'); // Nettoyer en cas d'erreur await _secureStorage.delete(key: _authStateKey); if (e is KeycloakWebViewAuthException) rethrow; throw KeycloakWebViewAuthException( 'Erreur lors du traitement du callback: $e', originalError: e, ); } } /// Crée un utilisateur depuis les tokens JWT static Future _createUserFromTokens(WebViewAuthResult authResult) async { debugPrint('👤 Création de l\'utilisateur depuis les tokens...'); try { // Parser et valider les tokens final Map accessTokenPayload = _parseAndValidateJWT( authResult.accessToken, 'Access Token', ); final Map idTokenPayload = _parseAndValidateJWT( authResult.idToken, 'ID Token', ); // Extraire les informations utilisateur final String userId = idTokenPayload['sub'] ?? ''; final String email = idTokenPayload['email'] ?? ''; final String firstName = idTokenPayload['given_name'] ?? ''; final String lastName = idTokenPayload['family_name'] ?? ''; if (userId.isEmpty || email.isEmpty) { throw const KeycloakWebViewAuthException( 'Informations utilisateur manquantes dans les tokens', code: 'MISSING_USER_INFO', ); } // Extraire les rôles Keycloak final List keycloakRoles = _extractKeycloakRoles(accessTokenPayload); // Mapper vers notre système de rôles final UserRole primaryRole = KeycloakRoleMapper.mapToUserRole(keycloakRoles); debugPrint('🔐 [AUTH WebView] Rôles: $keycloakRoles → UserRole: ${primaryRole.name} (${primaryRole.displayName})'); final List permissions = KeycloakRoleMapper.mapToPermissions(keycloakRoles); // Créer l'utilisateur final User user = User( id: userId, email: email, firstName: firstName, lastName: lastName, primaryRole: primaryRole, organizationContexts: const [], additionalPermissions: permissions, revokedPermissions: const [], preferences: const UserPreferences( language: 'fr', theme: 'system', notificationsEnabled: true, emailNotifications: true, pushNotifications: true, dashboardLayout: 'adaptive', timezone: 'Europe/Paris', ), lastLoginAt: DateTime.now(), createdAt: DateTime.now(), isActive: true, ); // Stocker les informations utilisateur await _secureStorage.write( key: _userInfoKey, value: jsonEncode(user.toJson()), ); debugPrint('✅ Utilisateur créé: ${user.fullName} (${user.primaryRole.displayName})'); return user; } catch (e) { if (e is KeycloakWebViewAuthException) rethrow; throw KeycloakWebViewAuthException( 'Erreur lors de la création de l\'utilisateur: $e', originalError: e, ); } } /// Extrait les rôles Keycloak depuis le payload du token static List _extractKeycloakRoles(Map tokenPayload) { try { final List roles = []; // Rôles realm final Map? realmAccess = tokenPayload['realm_access']; if (realmAccess != null && realmAccess['roles'] is List) { roles.addAll(List.from(realmAccess['roles'])); } // Rôles client final Map? resourceAccess = tokenPayload['resource_access']; if (resourceAccess != null) { final Map? clientAccess = resourceAccess[KeycloakWebViewConfig.clientId]; if (clientAccess != null && clientAccess['roles'] is List) { roles.addAll(List.from(clientAccess['roles'])); } } // Filtrer les rôles système return roles.where((role) => !role.startsWith('default-roles-') && role != 'offline_access' && role != 'uma_authorization' ).toList(); } catch (e) { debugPrint('💥 Erreur extraction rôles: $e'); return []; } } /// Nettoie toutes les données d'authentification static Future clearAuthData() async { debugPrint('🧹 Nettoyage des données d\'authentification...'); try { await Future.wait([ _secureStorage.delete(key: _accessTokenKey), _secureStorage.delete(key: _idTokenKey), _secureStorage.delete(key: _refreshTokenKey), _secureStorage.delete(key: _userInfoKey), _secureStorage.delete(key: _authStateKey), ]); debugPrint('✅ Données d\'authentification nettoyées'); } catch (e) { debugPrint('⚠️ Erreur lors du nettoyage: $e'); } } /// Vérifie si l'utilisateur est authentifié static Future isAuthenticated() async { try { final String? accessToken = await _secureStorage.read(key: _accessTokenKey); if (accessToken == null) { return false; } // Vérifier si le token est expiré return !JwtDecoder.isExpired(accessToken); } catch (e) { debugPrint('💥 Erreur vérification authentification: $e'); return false; } } /// Récupère l'utilisateur authentifié static Future getCurrentUser() async { try { final String? userInfoJson = await _secureStorage.read(key: _userInfoKey); if (userInfoJson == null) { return null; } final Map userJson = jsonDecode(userInfoJson); return User.fromJson(userJson); } catch (e) { debugPrint('💥 Erreur récupération utilisateur: $e'); return null; } } /// Déconnecte l'utilisateur static Future logout() async { debugPrint('🚪 Déconnexion de l\'utilisateur...'); try { // Nettoyer les données locales await clearAuthData(); debugPrint('✅ Déconnexion réussie'); return true; } catch (e) { debugPrint('💥 Erreur déconnexion: $e'); return false; } } }