import 'dart:convert'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:http/http.dart' as http; import 'package:http_interceptor/http_interceptor.dart'; import 'package:jwt_decoder/jwt_decoder.dart'; import '../models/user_profile.dart'; import '../utils/logging_interceptor.dart'; import '../utils/http_client_factory.dart'; class AuthConfig { final String realm; final String authServerUrl; final String clientId; final bool allowSelfSignedCertificate; const AuthConfig({ required this.realm, required this.authServerUrl, required this.clientId, this.allowSelfSignedCertificate = false, }); static const AuthConfig development = AuthConfig( realm: 'dev', authServerUrl: 'https://auth.goutezplanb.com', clientId: 'delivery-mobile-app', allowSelfSignedCertificate: true, ); static const AuthConfig production = AuthConfig( realm: 'planb-internal', authServerUrl: 'https://auth.goutezplanb.com', clientId: 'delivery-mobile-app', ); String get tokenEndpoint => '$authServerUrl/realms/$realm/protocol/openid-connect/token'; } class AuthService { static const String _tokenKey = 'auth_token'; static const String _refreshTokenKey = 'refresh_token'; final AuthConfig _config; final FlutterSecureStorage _secureStorage; final http.Client _httpClient; AuthService({ AuthConfig config = AuthConfig.development, FlutterSecureStorage? secureStorage, http.Client? httpClient, }) : _config = config, _secureStorage = secureStorage ?? const FlutterSecureStorage( aOptions: AndroidOptions( encryptedSharedPreferences: true, ), iOptions: IOSOptions( accessibility: KeychainAccessibility.first_unlock, ), mOptions: MacOsOptions( accessibility: KeychainAccessibility.first_unlock, ), ), _httpClient = httpClient ?? InterceptedClient.build( interceptors: [LoggingInterceptor()], client: HttpClientFactory.createClient( allowSelfSigned: config.allowSelfSignedCertificate, ), ); Future login({ required String username, required String password, }) async { try { final response = await _httpClient.post( Uri.parse(_config.tokenEndpoint), headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, body: { 'grant_type': 'password', 'client_id': _config.clientId, 'username': username, 'password': password, 'scope': 'openid profile offline_access', }, ); if (response.statusCode == 200) { final data = json.decode(response.body) as Map; final accessToken = data['access_token'] as String; final refreshToken = data['refresh_token'] as String?; await _secureStorage.write(key: _tokenKey, value: accessToken); if (refreshToken != null) { await _secureStorage.write(key: _refreshTokenKey, value: refreshToken); } return AuthResult.success(token: accessToken); } else if (response.statusCode == 401) { return AuthResult.error(error: 'Invalid username or password'); } else { return AuthResult.error(error: 'Authentication failed: ${response.statusCode}'); } } catch (e) { return AuthResult.error(error: 'Network error: ${e.toString()}'); } } Future refreshAccessToken() async { try { final refreshToken = await getRefreshToken(); if (refreshToken == null) { return AuthResult.error(error: 'No refresh token available'); } final response = await _httpClient.post( Uri.parse(_config.tokenEndpoint), headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, body: { 'grant_type': 'refresh_token', 'client_id': _config.clientId, 'refresh_token': refreshToken, }, ); if (response.statusCode == 200) { final data = json.decode(response.body) as Map; final accessToken = data['access_token'] as String; final newRefreshToken = data['refresh_token'] as String?; await _secureStorage.write(key: _tokenKey, value: accessToken); if (newRefreshToken != null) { await _secureStorage.write(key: _refreshTokenKey, value: newRefreshToken); } return AuthResult.success(token: accessToken); } else { await logout(); return AuthResult.error(error: 'Token refresh failed'); } } catch (e) { return AuthResult.error(error: 'Token refresh error: ${e.toString()}'); } } Future logout() async { await Future.wait([ _secureStorage.delete(key: _tokenKey), _secureStorage.delete(key: _refreshTokenKey), ]); } Future getToken() async { return await _secureStorage.read(key: _tokenKey); } Future getRefreshToken() async { return await _secureStorage.read(key: _refreshTokenKey); } bool isTokenValid(String? token) { if (token == null || token.isEmpty) return false; try { return !JwtDecoder.isExpired(token); } catch (e) { return false; } } /// Check if token expires within the specified duration (default: 5 minutes) bool isTokenExpiringSoon(String? token, {Duration threshold = const Duration(minutes: 5)}) { if (token == null || token.isEmpty) return true; try { final decodedToken = JwtDecoder.decode(token); final exp = decodedToken['exp'] as int?; if (exp == null) return true; final expirationTime = DateTime.fromMillisecondsSinceEpoch(exp * 1000); final now = DateTime.now(); final timeUntilExpiration = expirationTime.difference(now); return timeUntilExpiration <= threshold; } catch (e) { return true; } } /// Proactively refresh token if it's expiring soon /// Returns the current valid token or a newly refreshed token Future ensureValidToken() async { final currentToken = await getToken(); // If no token, return null if (currentToken == null) return null; // If token is still valid and not expiring soon, return it if (!isTokenExpiringSoon(currentToken)) { return currentToken; } // Token is expiring soon, refresh it final refreshResult = await refreshAccessToken(); return refreshResult.when( success: (newToken) => newToken, onError: (error) => null, cancelled: () => null, ); } UserProfile? decodeToken(String token) { try { final decodedToken = JwtDecoder.decode(token); return UserProfile.fromJwtClaims(decodedToken); } catch (e) { return null; } } Future isAuthenticated() async { final token = await getToken(); return isTokenValid(token); } } sealed class AuthResult { const AuthResult(); factory AuthResult.success({required String token}) => _Success(token); factory AuthResult.error({required String error}) => _Error(error); const factory AuthResult.cancelled() = _Cancelled; R when({ required R Function(String token) success, required R Function(String error) onError, required R Function() cancelled, }) { return switch (this) { _Success(:final token) => success(token), _Error(:final error) => onError(error), _Cancelled() => cancelled(), }; } } final class _Success extends AuthResult { final String token; const _Success(this.token); } final class _Error extends AuthResult { final String error; const _Error(this.error); } final class _Cancelled extends AuthResult { const _Cancelled(); }