ionic-planb-logistic-app-fl.../lib/services/auth_service.dart

261 lines
7.7 KiB
Dart

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<AuthResult> 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<String, dynamic>;
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<AuthResult> 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<String, dynamic>;
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<void> logout() async {
await Future.wait([
_secureStorage.delete(key: _tokenKey),
_secureStorage.delete(key: _refreshTokenKey),
]);
}
Future<String?> getToken() async {
return await _secureStorage.read(key: _tokenKey);
}
Future<String?> 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<String?> 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<bool> 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<R>({
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();
}