261 lines
7.7 KiB
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();
|
|
}
|