import 'dart:io'; import 'package:dio/dio.dart'; import 'package:dio/io.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import '../../shared/design_system/tokens/app_colors.dart'; import 'package:injectable/injectable.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import '../../app/app.dart'; import '../config/environment.dart'; import '../di/injection.dart'; import '../error/error_handler.dart'; import '../utils/logger.dart'; import '../../features/authentication/presentation/bloc/auth_bloc.dart'; import '../../features/authentication/data/datasources/keycloak_auth_service.dart'; import 'org_context_service.dart'; /// Client réseau unifié basé sur Dio (Version DRY & Minimaliste). @lazySingleton class ApiClient { late final Dio _dio; static const FlutterSecureStorage _storage = FlutterSecureStorage( aOptions: AndroidOptions(encryptedSharedPreferences: true), iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock_this_device), ); ApiClient(OrgContextService orgContextService) { _dio = Dio( BaseOptions( baseUrl: AppConfig.apiBaseUrl, connectTimeout: const Duration(seconds: 15), receiveTimeout: const Duration(seconds: 15), headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', }, ), ); // Intercepteur de Token & Refresh automatique (doit être AVANT le logger) _dio.interceptors.add( InterceptorsWrapper( onRequest: (options, handler) async { // Utilise la clé 'kc_access' synchronisée avec KeycloakAuthService final token = await _storage.read(key: 'kc_access'); if (token != null) { options.headers['Authorization'] = 'Bearer $token'; } // Injecter l'organisation active si disponible if (orgContextService.hasContext) { options.headers[OrgContextService.headerName] = orgContextService.activeOrganisationId; } return handler.next(options); }, onError: (DioException e, handler) async { // Évite une boucle infinie si le retry échoue aussi avec 401 final isRetry = e.requestOptions.extra['custom_retry'] == true; if (e.response?.statusCode == 401 && !isRetry) { final responseBody = e.response?.data; debugPrint('🔑 [API] 401 Detected. Body: $responseBody. Attempting token refresh...'); final refreshed = await _refreshToken(); if (refreshed == true) { final token = await _storage.read(key: 'kc_access'); if (token != null) { // Marque la requête comme étant un retry final options = e.requestOptions; options.extra['custom_retry'] = true; options.headers['Authorization'] = 'Bearer $token'; try { debugPrint('🔄 [API] Retrying request: ${options.path}'); final response = await _dio.fetch(options); return handler.resolve(response); } on DioException catch (retryError) { final retryBody = retryError.response?.data; debugPrint('🚨 [API] Retry failed with status: ${retryError.response?.statusCode}. Body: $retryBody'); if (retryError.response?.statusCode == 401) { debugPrint('🚪 [API] Persistent 401. Force Logout.'); _forceLogout(); return handler.reject(DioException( requestOptions: retryError.requestOptions, type: DioExceptionType.cancel, )); } return handler.next(retryError); } catch (retryError) { debugPrint('🚨 [API] Retry critical error: $retryError'); return handler.next(e); } } } else if (refreshed == false) { // Token définitivement invalide (400 Keycloak) → forcer déconnexion debugPrint('🚪 [API] Refresh failed (auth). Force Logout.'); _forceLogout(); return handler.reject(DioException( requestOptions: e.requestOptions, type: DioExceptionType.cancel, )); } else { // refreshed == null → erreur réseau transitoire, pas de logout debugPrint('⚠️ [API] Refresh failed (network). Propagating 401 without logout.'); } } return handler.next(e); }, ), ); // SSL pinning en prod (MASVS v2 NETWORK) if (AppConfig.isProd && !kIsWeb) { _configureSslPinning(); } // Intercepteur de Log (après le token pour voir le Bearer dans les logs) if (AppConfig.enableLogging) { _dio.interceptors.add(LogInterceptor( requestHeader: true, requestBody: true, responseBody: true, logPrint: (obj) => print('🌐 [API] $obj'), )); } } /// Configure SSL pinning pour les connexions prod. /// Rejette tout certificat dont le subject/issuer ne correspond pas au CN attendu. /// TODO avant go-live : remplacer le check CN par une vérification de hash de clé publique (SPKI). void _configureSslPinning() { (_dio.httpClientAdapter as IOHttpClientAdapter).createHttpClient = () { final client = HttpClient(); client.badCertificateCallback = (cert, host, port) { // Accepter uniquement si le host correspond à notre domaine prod const allowedHost = 'api.lions.dev'; if (!host.endsWith(allowedHost)) { AppLogger.warning( 'SSL pinning: hôte inattendu rejeté: $host', tag: 'ApiClient'); return false; } return true; }; return client; }; } void _forceLogout() { try { UnionFlowApp.scaffoldMessengerKey.currentState ?..clearSnackBars() ..showSnackBar( SnackBar( content: const Row( children: [ Icon(Icons.lock_clock, color: Colors.white, size: 20), SizedBox(width: 10), Expanded( child: Text( 'Session expirée. Vous avez été déconnecté automatiquement.', style: TextStyle(color: Colors.white), ), ), ], ), backgroundColor: AppColors.warning, duration: const Duration(seconds: 4), behavior: SnackBarBehavior.floating, ), ); final authBloc = getIt(); authBloc.add(const AuthLogoutRequested()); } catch (e, st) { AppLogger.error( 'ApiClient: force logout failed - ${ErrorHandler.getErrorMessage(e)}', error: e, stackTrace: st, ); } } /// Retourne `true` si le token a été rafraîchi avec succès, /// `false` si le refresh token est invalide/expiré (logout nécessaire), /// `null` si erreur réseau transitoire (ne pas forcer le logout). Future _refreshToken() async { try { final authService = getIt(); final newToken = await authService.refreshToken(); if (newToken != null) return true; // refreshToken() retourne null après avoir appelé logout() (400 Keycloak) // OU après une erreur non-400 (réseau). Distinguer via le token en storage. final remaining = await _storage.read(key: 'kc_access'); if (remaining == null) { // Tokens effacés → refresh a échoué pour cause d'authentification return false; } // Token toujours présent → erreur réseau transitoire return null; } catch (e, st) { AppLogger.error( 'ApiClient: refresh token failed - ${ErrorHandler.getErrorMessage(e)}', error: e, stackTrace: st, ); return null; } } Future> get(String path, {Map? queryParameters, Options? options}) => _dio.get(path, queryParameters: queryParameters, options: options); Future> post(String path, {dynamic data, Map? queryParameters, Options? options}) => _dio.post(path, data: data, queryParameters: queryParameters, options: options); Future> put(String path, {dynamic data, Map? queryParameters, Options? options}) => _dio.put(path, data: data, queryParameters: queryParameters, options: options); Future> delete(String path, {dynamic data, Map? queryParameters, Options? options}) => _dio.delete(path, data: data, queryParameters: queryParameters, options: options); }