Ajoute KeycloakAuthService.getValidAccessToken() qui vérifie l'expiration du JWT et rafraîchit automatiquement avant de retourner le token. FinanceWorkflowRemoteDatasource et MessagingRemoteDatasource injectent désormais KeycloakAuthService au lieu de FlutterSecureStorage directement, ce qui évite d'envoyer un Bearer expiré et d'obtenir un 401 silencieux.
263 lines
9.6 KiB
Dart
263 lines
9.6 KiB
Dart
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<User?> 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<String?>? _refreshFuture;
|
|
|
|
/// Rafraîchissement automatique du token avec verrouillage global
|
|
Future<String?> 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<String?> _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<String?> 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<User?> 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<void> logout() async {
|
|
await _storage.deleteAll();
|
|
AppLogger.info('KeycloakAuthService: session cleared');
|
|
}
|
|
|
|
Future<void> _saveTokens(Map<String, dynamic> 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<String> _extractRoles(Map<String, dynamic> payload) {
|
|
final roles = <String>[];
|
|
if (payload['realm_access']?['roles'] != null) {
|
|
roles.addAll((payload['realm_access']['roles'] as List).cast<String>());
|
|
}
|
|
if (payload['resource_access'] != null) {
|
|
(payload['resource_access'] as Map).values.forEach((v) {
|
|
if (v['roles'] != null) roles.addAll((v['roles'] as List).cast<String>());
|
|
});
|
|
}
|
|
return roles.where((r) => !r.startsWith('default-roles-') && r != 'offline_access').toList();
|
|
}
|
|
|
|
Future<String?> 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<AuthStatusResult?> 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';
|
|
}
|