chore: initial release

This commit is contained in:
cogwheel0
2025-08-10 01:20:45 +05:30
commit 758615813f
218 changed files with 67743 additions and 0 deletions

View File

@@ -0,0 +1,146 @@
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
/// Consistent authentication interceptor for all API requests
/// Implements security requirements from OpenAPI specification
class ApiAuthInterceptor extends Interceptor {
String? _authToken;
// Callbacks for auth events
void Function()? onAuthTokenInvalid;
Future<void> Function()? onTokenInvalidated;
// Public endpoints that don't require authentication
static const Set<String> _publicEndpoints = {
'/health',
'/api/v1/auths/signin',
'/api/v1/auths/signup',
'/api/v1/auths/signup/enabled',
'/api/v1/auths/trusted-header-auth',
'/ollama/api/ps',
'/ollama/api/version',
'/docs',
'/openapi.json',
'/swagger',
'/api/docs',
};
// Endpoints that have optional authentication (work without but better with)
static const Set<String> _optionalAuthEndpoints = {
'/api/models',
'/api/v1/configs/models',
};
ApiAuthInterceptor({
String? authToken,
this.onAuthTokenInvalid,
this.onTokenInvalidated,
}) : _authToken = authToken;
void updateAuthToken(String? token) {
_authToken = token;
}
String? get authToken => _authToken;
/// Check if endpoint requires authentication based on OpenAPI spec
bool _requiresAuth(String path) {
// Direct public endpoint match
if (_publicEndpoints.contains(path)) {
return false;
}
// Check for partial matches (e.g., /ollama/* endpoints)
for (final publicPattern in _publicEndpoints) {
if (publicPattern.endsWith('*') &&
path.startsWith(
publicPattern.substring(0, publicPattern.length - 1),
)) {
return false;
}
}
// All other endpoints require authentication per OpenAPI spec
return true;
}
/// Check if endpoint is better with auth but works without
bool _hasOptionalAuth(String path) {
return _optionalAuthEndpoints.contains(path);
}
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
final path = options.path;
final requiresAuth = _requiresAuth(path);
final hasOptionalAuth = _hasOptionalAuth(path);
debugPrint(
'DEBUG: Auth interceptor for $path - requires: $requiresAuth, optional: $hasOptionalAuth, token present: ${_authToken != null}',
);
if (requiresAuth) {
// Strictly required authentication
if (_authToken == null || _authToken!.isEmpty) {
final error = DioException(
requestOptions: options,
response: Response(
requestOptions: options,
statusCode: 401,
data: {'detail': 'Authentication required for this endpoint'},
),
type: DioExceptionType.badResponse,
);
handler.reject(error);
return;
}
options.headers['Authorization'] = 'Bearer $_authToken';
} else if (hasOptionalAuth &&
_authToken != null &&
_authToken!.isNotEmpty) {
// Optional authentication - add if available
options.headers['Authorization'] = 'Bearer $_authToken';
}
// Add other common headers for API consistency
options.headers['Content-Type'] ??= 'application/json';
options.headers['Accept'] ??= 'application/json';
handler.next(options);
}
@override
void onError(DioException err, ErrorInterceptorHandler handler) {
final statusCode = err.response?.statusCode;
final path = err.requestOptions.path;
// Handle authentication errors consistently
if (statusCode == 401) {
// 401 always indicates invalid/expired auth token
debugPrint('DEBUG: 401 Unauthorized on $path - clearing auth token');
_clearAuthToken();
} else if (statusCode == 403) {
// 403 on protected endpoints indicates insufficient permissions or invalid token
final requiresAuth = _requiresAuth(path);
final optionalAuth = _hasOptionalAuth(path);
if (requiresAuth && !optionalAuth) {
debugPrint(
'DEBUG: 403 Forbidden on protected endpoint $path - clearing auth token',
);
_clearAuthToken();
} else {
debugPrint(
'DEBUG: 403 Forbidden on public/optional endpoint $path - keeping auth token',
);
}
}
handler.next(err);
}
void _clearAuthToken() {
_authToken = null;
onAuthTokenInvalid?.call();
onTokenInvalidated?.call();
}
}

View File

@@ -0,0 +1,194 @@
import 'package:flutter/foundation.dart';
import 'auth_state_manager.dart';
/// Comprehensive caching manager for auth-related operations
/// Reduces redundant operations and improves app performance
class AuthCacheManager {
static final AuthCacheManager _instance = AuthCacheManager._internal();
factory AuthCacheManager() => _instance;
AuthCacheManager._internal();
// Cache for various auth-related operations
final Map<String, dynamic> _cache = {};
final Map<String, DateTime> _cacheTimestamps = {};
// Cache timeouts for different types of data
static const Duration _shortCache = Duration(
minutes: 2,
); // For frequently changing data
static const Duration _mediumCache = Duration(
minutes: 5,
); // For moderately stable data
static const Duration _longCache = Duration(minutes: 15); // For stable data
// Cache keys
static const String _userDataKey = 'user_data';
static const String _serverConnectionKey = 'server_connection';
static const String _credentialsExistKey = 'credentials_exist';
static const String _serverConfigsKey = 'server_configs';
/// Cache user data with medium timeout
void cacheUserData(dynamic userData) {
_cache[_userDataKey] = userData;
_cacheTimestamps[_userDataKey] = DateTime.now();
debugPrint('DEBUG: User data cached');
}
/// Get cached user data
dynamic getCachedUserData() {
if (_isCacheValid(_userDataKey, _mediumCache)) {
debugPrint('DEBUG: Using cached user data');
return _cache[_userDataKey];
}
return null;
}
/// Cache server connection status with short timeout
void cacheServerConnection(bool isConnected) {
_cache[_serverConnectionKey] = isConnected;
_cacheTimestamps[_serverConnectionKey] = DateTime.now();
}
/// Get cached server connection status
bool? getCachedServerConnection() {
if (_isCacheValid(_serverConnectionKey, _shortCache)) {
return _cache[_serverConnectionKey] as bool?;
}
return null;
}
/// Cache credentials existence with medium timeout
void cacheCredentialsExist(bool exist) {
_cache[_credentialsExistKey] = exist;
_cacheTimestamps[_credentialsExistKey] = DateTime.now();
}
/// Get cached credentials existence
bool? getCachedCredentialsExist() {
if (_isCacheValid(_credentialsExistKey, _mediumCache)) {
return _cache[_credentialsExistKey] as bool?;
}
return null;
}
/// Cache server configurations with long timeout
void cacheServerConfigs(List<dynamic> configs) {
_cache[_serverConfigsKey] = configs;
_cacheTimestamps[_serverConfigsKey] = DateTime.now();
}
/// Get cached server configurations
List<dynamic>? getCachedServerConfigs() {
if (_isCacheValid(_serverConfigsKey, _longCache)) {
return _cache[_serverConfigsKey] as List<dynamic>?;
}
return null;
}
/// Check if cache entry is valid
bool _isCacheValid(String key, Duration timeout) {
final timestamp = _cacheTimestamps[key];
if (timestamp == null) return false;
return DateTime.now().difference(timestamp) < timeout;
}
/// Clear specific cache entry
void clearCacheEntry(String key) {
_cache.remove(key);
_cacheTimestamps.remove(key);
debugPrint('DEBUG: Cache entry cleared: $key');
}
/// Clear all auth-related cache
void clearAuthCache() {
_cache.clear();
_cacheTimestamps.clear();
debugPrint('DEBUG: All auth cache cleared');
}
/// Clear expired cache entries
void cleanExpiredCache() {
final now = DateTime.now();
final expiredKeys = <String>[];
for (final entry in _cacheTimestamps.entries) {
// Use the longest timeout for cleanup to be conservative
if (now.difference(entry.value) > _longCache) {
expiredKeys.add(entry.key);
}
}
for (final key in expiredKeys) {
_cache.remove(key);
_cacheTimestamps.remove(key);
}
if (expiredKeys.isNotEmpty) {
debugPrint('DEBUG: Cleaned ${expiredKeys.length} expired cache entries');
}
}
/// Get cache statistics for monitoring
Map<String, dynamic> getCacheStats() {
final now = DateTime.now();
final stats = <String, dynamic>{};
stats['totalEntries'] = _cache.length;
stats['entries'] = <String, Map<String, dynamic>>{};
for (final key in _cache.keys) {
final timestamp = _cacheTimestamps[key];
if (timestamp != null) {
stats['entries'][key] = {
'age': now.difference(timestamp).inSeconds,
'hasData': _cache[key] != null,
};
}
}
return stats;
}
/// Optimize cache by removing least recently used entries if cache gets too large
void optimizeCache() {
const maxCacheSize = 20; // Reasonable limit for auth cache
if (_cache.length <= maxCacheSize) return;
// Sort by timestamp (oldest first)
final sortedEntries = _cacheTimestamps.entries.toList()
..sort((a, b) => a.value.compareTo(b.value));
// Remove oldest entries
final entriesToRemove = sortedEntries.length - maxCacheSize;
for (int i = 0; i < entriesToRemove; i++) {
final key = sortedEntries[i].key;
_cache.remove(key);
_cacheTimestamps.remove(key);
}
debugPrint('DEBUG: Cache optimized, removed $entriesToRemove old entries');
}
/// Cache state from AuthState for quick access
void cacheAuthState(AuthState authState) {
if (authState.user != null) {
cacheUserData(authState.user);
}
// Don't cache loading or error states
if (authState.status == AuthStatus.authenticated) {
_cache['auth_status'] = authState.status;
_cacheTimestamps['auth_status'] = DateTime.now();
}
}
/// Get cached auth status
AuthStatus? getCachedAuthStatus() {
if (_isCacheValid('auth_status', _shortCache)) {
return _cache['auth_status'] as AuthStatus?;
}
return null;
}
}

View File

@@ -0,0 +1,562 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
// Types are used through app_providers.dart
import '../providers/app_providers.dart';
import '../models/user.dart';
import 'token_validator.dart';
import 'auth_cache_manager.dart';
/// Comprehensive auth state representation
@immutable
class AuthState {
const AuthState({
required this.status,
this.token,
this.user,
this.error,
this.isLoading = false,
});
final AuthStatus status;
final String? token;
final dynamic user; // Replace with proper User type
final String? error;
final bool isLoading;
bool get isAuthenticated =>
status == AuthStatus.authenticated && token != null;
bool get hasValidToken => token != null && token!.isNotEmpty;
bool get needsLogin =>
status == AuthStatus.unauthenticated || status == AuthStatus.tokenExpired;
AuthState copyWith({
AuthStatus? status,
String? token,
dynamic user,
String? error,
bool? isLoading,
bool clearToken = false,
bool clearUser = false,
bool clearError = false,
}) {
return AuthState(
status: status ?? this.status,
token: clearToken ? null : (token ?? this.token),
user: clearUser ? null : (user ?? this.user),
error: clearError ? null : (error ?? this.error),
isLoading: isLoading ?? this.isLoading,
);
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is AuthState &&
other.status == status &&
other.token == token &&
other.user == user &&
other.error == error &&
other.isLoading == isLoading;
}
@override
int get hashCode => Object.hash(status, token, user, error, isLoading);
@override
String toString() =>
'AuthState(status: $status, hasToken: ${token != null}, hasUser: ${user != null}, error: $error, isLoading: $isLoading)';
}
enum AuthStatus {
initial,
loading,
authenticated,
unauthenticated,
tokenExpired,
error,
}
/// Unified auth state manager - single source of truth for all auth operations
class AuthStateManager extends StateNotifier<AuthState> {
AuthStateManager(this._ref)
: super(const AuthState(status: AuthStatus.initial)) {
_initialize();
}
final Ref _ref;
final AuthCacheManager _cacheManager = AuthCacheManager();
/// Initialize auth state from storage
Future<void> _initialize() async {
state = state.copyWith(status: AuthStatus.loading, isLoading: true);
try {
final storage = _ref.read(optimizedStorageServiceProvider);
final token = await storage.getAuthToken();
if (token != null && token.isNotEmpty) {
// Validate token before setting authenticated state
final isValid = await _validateToken(token);
if (isValid) {
state = state.copyWith(
status: AuthStatus.authenticated,
token: token,
isLoading: false,
clearError: true,
);
// Update API service with token
_updateApiServiceToken(token);
// Load user data in background
_loadUserData();
} else {
// Token is invalid, clear it
await storage.deleteAuthToken();
state = state.copyWith(
status: AuthStatus.unauthenticated,
isLoading: false,
clearToken: true,
clearError: true,
);
}
} else {
state = state.copyWith(
status: AuthStatus.unauthenticated,
isLoading: false,
clearToken: true,
clearError: true,
);
}
} catch (e) {
debugPrint('ERROR: Auth initialization failed: $e');
state = state.copyWith(
status: AuthStatus.error,
error: 'Failed to initialize auth: $e',
isLoading: false,
);
}
}
/// Perform login with credentials
Future<bool> login(
String username,
String password, {
bool rememberCredentials = false,
}) async {
state = state.copyWith(
status: AuthStatus.loading,
isLoading: true,
clearError: true,
);
try {
// Ensure API service is available (active server/provider rebuild race)
await _ensureApiServiceAvailable();
final api = _ref.read(apiServiceProvider);
if (api == null) {
throw Exception('No server connection available');
}
// Perform login API call
final response = await api.login(username, password);
// Extract and validate token
final token = response['token'] ?? response['access_token'];
if (token == null || token.toString().trim().isEmpty) {
throw Exception('No authentication token received');
}
final tokenStr = token.toString();
if (!_isValidTokenFormat(tokenStr)) {
throw Exception('Invalid authentication token format');
}
// Save token to storage
final storage = _ref.read(optimizedStorageServiceProvider);
await storage.saveAuthToken(tokenStr);
// Save credentials if requested
if (rememberCredentials) {
final activeServer = await _ref.read(activeServerProvider.future);
if (activeServer != null) {
await storage.saveCredentials(
serverId: activeServer.id,
username: username,
password: password,
);
await storage.setRememberCredentials(true);
}
}
// Update state and API service
state = state.copyWith(
status: AuthStatus.authenticated,
token: tokenStr,
isLoading: false,
clearError: true,
);
_updateApiServiceToken(tokenStr);
// Cache the successful auth state
_cacheManager.cacheAuthState(state);
// Load user data in background
_loadUserData();
debugPrint('DEBUG: Login successful');
return true;
} catch (e) {
debugPrint('ERROR: Login failed: $e');
state = state.copyWith(
status: AuthStatus.error,
error: e.toString(),
isLoading: false,
clearToken: true,
);
return false;
}
}
/// Wait briefly until the API service becomes available
Future<void> _ensureApiServiceAvailable({
Duration timeout = const Duration(seconds: 2),
}) async {
final end = DateTime.now().add(timeout);
while (DateTime.now().isBefore(end)) {
final api = _ref.read(apiServiceProvider);
if (api != null) return;
await Future.delayed(const Duration(milliseconds: 50));
}
}
/// Perform silent auto-login with saved credentials
Future<bool> silentLogin() async {
state = state.copyWith(
status: AuthStatus.loading,
isLoading: true,
clearError: true,
);
try {
final storage = _ref.read(optimizedStorageServiceProvider);
final savedCredentials = await storage.getSavedCredentials();
if (savedCredentials == null) {
state = state.copyWith(
status: AuthStatus.unauthenticated,
isLoading: false,
clearError: true,
);
return false;
}
final serverId = savedCredentials['serverId']!;
final username = savedCredentials['username']!;
final password = savedCredentials['password']!;
// Set active server if needed
await storage.setActiveServerId(serverId);
_ref.invalidate(activeServerProvider);
// Wait for server connection
final activeServer = await _ref.read(activeServerProvider.future);
if (activeServer == null) {
await storage.setActiveServerId(null);
state = state.copyWith(
status: AuthStatus.error,
error: 'Server configuration not found',
isLoading: false,
);
return false;
}
// Attempt login
return await login(username, password, rememberCredentials: false);
} catch (e) {
debugPrint('ERROR: Silent login failed: $e');
// Clear invalid credentials on auth errors
if (e.toString().contains('401') ||
e.toString().contains('403') ||
e.toString().contains('authentication') ||
e.toString().contains('unauthorized')) {
final storage = _ref.read(optimizedStorageServiceProvider);
await storage.deleteSavedCredentials();
}
state = state.copyWith(
status: AuthStatus.unauthenticated,
error: e.toString(),
isLoading: false,
clearToken: true,
);
return false;
}
}
/// Handle token invalidation (called by API service)
Future<void> onTokenInvalidated() async {
debugPrint('DEBUG: Auth token invalidated');
// Clear token from storage
final storage = _ref.read(optimizedStorageServiceProvider);
await storage.deleteAuthToken();
// Update state
state = state.copyWith(
status: AuthStatus.tokenExpired,
clearToken: true,
clearUser: true,
clearError: true,
);
// Attempt silent re-login if credentials are available
final hasCredentials = await storage.getSavedCredentials() != null;
if (hasCredentials) {
debugPrint('DEBUG: Attempting silent re-login after token invalidation');
await silentLogin();
}
}
/// Logout user
Future<void> logout() async {
state = state.copyWith(status: AuthStatus.loading, isLoading: true);
try {
// Call server logout if possible
final api = _ref.read(apiServiceProvider);
if (api != null) {
try {
await api.logout();
} catch (e) {
debugPrint('Warning: Server logout failed: $e');
}
}
// Clear all local auth data
final storage = _ref.read(optimizedStorageServiceProvider);
await storage.clearAuthData();
// Update state
state = state.copyWith(
status: AuthStatus.unauthenticated,
isLoading: false,
clearToken: true,
clearUser: true,
clearError: true,
);
debugPrint('DEBUG: Logout complete');
} catch (e) {
debugPrint('ERROR: Logout failed: $e');
// Even if logout fails, clear local state
state = state.copyWith(
status: AuthStatus.unauthenticated,
isLoading: false,
clearToken: true,
clearUser: true,
error: 'Logout error: $e',
);
}
}
/// Load user data in background with JWT extraction fallback
Future<void> _loadUserData() async {
try {
// First try to extract user info from JWT token if available
if (state.token != null) {
final jwtUserInfo = TokenValidator.extractUserInfo(state.token!);
if (jwtUserInfo != null) {
debugPrint('DEBUG: Extracted user info from JWT token');
state = state.copyWith(user: jwtUserInfo);
// Still try to load from server in background for complete data
Future.microtask(() => _loadServerUserData());
return;
}
}
// Fall back to server data loading
await _loadServerUserData();
} catch (e) {
debugPrint('Warning: Failed to load user data: $e');
// Don't update state on user data load failure
}
}
/// Load complete user data from server
Future<void> _loadServerUserData() async {
try {
final api = _ref.read(apiServiceProvider);
if (api != null && state.isAuthenticated) {
// Check if we already have user data from token validation
if (state.user != null) {
debugPrint(
'DEBUG: User data already available from token validation',
);
return;
}
final user = await api.getCurrentUser();
state = state.copyWith(user: user);
debugPrint('DEBUG: Loaded complete user data from server');
}
} catch (e) {
debugPrint('Warning: Failed to load server user data: $e');
// Don't update state on server data load failure - keep JWT data if available
}
}
/// Update API service with current token
void _updateApiServiceToken(String token) {
final api = _ref.read(apiServiceProvider);
api?.updateAuthToken(token);
}
/// Validate token format using advanced validation
bool _isValidTokenFormat(String token) {
final result = TokenValidator.validateTokenFormat(token);
return result.isValid;
}
/// Validate token with comprehensive validation (format + server)
Future<bool> _validateToken(String token) async {
// Check cache first
final cachedResult = TokenValidationCache.getCachedResult(token);
if (cachedResult != null) {
debugPrint(
'DEBUG: Using cached token validation result: ${cachedResult.isValid}',
);
return cachedResult.isValid;
}
// Fast format validation first
final formatResult = TokenValidator.validateTokenFormat(token);
if (!formatResult.isValid) {
debugPrint('DEBUG: Token format invalid: ${formatResult.message}');
TokenValidationCache.cacheResult(token, formatResult);
return false;
}
// If format is valid but token is expiring soon, try server validation
if (formatResult.isExpiringSoon) {
debugPrint('DEBUG: Token expiring soon, validating with server');
}
// Server validation (async with timeout)
try {
final api = _ref.read(apiServiceProvider);
if (api == null) {
debugPrint('DEBUG: No API service available for token validation');
return formatResult.isValid; // Fall back to format validation
}
User? validationUser;
final serverResult = await TokenValidator.validateTokenWithServer(
token,
() async {
// Update API with token for validation
api.updateAuthToken(token);
// Try to fetch user data as validation
validationUser = await api.getCurrentUser();
return validationUser!;
},
);
// Store the user data if validation was successful
if (serverResult.isValid &&
validationUser != null &&
state.isAuthenticated) {
state = state.copyWith(user: validationUser);
debugPrint('DEBUG: Cached user data from token validation');
}
TokenValidationCache.cacheResult(token, serverResult);
debugPrint(
'DEBUG: Server token validation: ${serverResult.isValid} - ${serverResult.message}',
);
return serverResult.isValid;
} catch (e) {
debugPrint('DEBUG: Token server validation failed: $e');
// On network error, fall back to format validation if it was valid
return formatResult.isValid;
}
}
/// Check if user has saved credentials (with caching)
Future<bool> hasSavedCredentials() async {
// Check cache first
final cachedResult = _cacheManager.getCachedCredentialsExist();
if (cachedResult != null) {
return cachedResult;
}
try {
final storage = _ref.read(optimizedStorageServiceProvider);
final hasCredentials = await storage.hasCredentials();
// Cache the result
_cacheManager.cacheCredentialsExist(hasCredentials);
return hasCredentials;
} catch (e) {
return false;
}
}
/// Refresh current auth state
Future<void> refresh() async {
// Clear cache before refresh to ensure fresh data
_cacheManager.clearAuthCache();
TokenValidationCache.clearCache();
await _initialize();
}
/// Clean up expired caches (called periodically)
void cleanupCaches() {
_cacheManager.cleanExpiredCache();
_cacheManager.optimizeCache();
}
/// Get performance statistics
Map<String, dynamic> getPerformanceStats() {
return {
'authCache': _cacheManager.getCacheStats(),
'tokenValidationCache': 'Managed by TokenValidationCache',
'storageCache': 'Managed by OptimizedStorageService',
};
}
}
/// Provider for the unified auth state manager
final authStateManagerProvider =
StateNotifierProvider<AuthStateManager, AuthState>((ref) {
return AuthStateManager(ref);
});
/// Computed providers for common auth state queries
final isAuthenticatedProvider = Provider<bool>((ref) {
return ref.watch(
authStateManagerProvider.select((state) => state.isAuthenticated),
);
});
final authTokenProvider2 = Provider<String?>((ref) {
return ref.watch(authStateManagerProvider.select((state) => state.token));
});
final authUserProvider = Provider<dynamic>((ref) {
return ref.watch(authStateManagerProvider.select((state) => state.user));
});
final authErrorProvider2 = Provider<String?>((ref) {
return ref.watch(authStateManagerProvider.select((state) => state.error));
});
final isAuthLoadingProvider = Provider<bool>((ref) {
return ref.watch(authStateManagerProvider.select((state) => state.isLoading));
});

View File

@@ -0,0 +1,259 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:crypto/crypto.dart';
/// JWT token validation utilities
class TokenValidator {
static const Duration _validationTimeout = Duration(seconds: 5);
/// Validate JWT token format and expiry without network call
static TokenValidationResult validateTokenFormat(String token) {
try {
// Basic format check
if (token.isEmpty || token.length < 10) {
return TokenValidationResult.invalid('Token too short');
}
// Check if it looks like a JWT (has at least 2 dots)
final parts = token.split('.');
if (parts.length < 3) {
return TokenValidationResult.invalid('Invalid JWT format');
}
// Try to decode the payload to check expiry
try {
final payload = _decodeJWTPayload(parts[1]);
final exp = payload['exp'] as int?;
if (exp != null) {
final expiryTime = DateTime.fromMillisecondsSinceEpoch(exp * 1000);
final now = DateTime.now();
if (expiryTime.isBefore(now)) {
return TokenValidationResult.expired('Token expired');
}
// Check if token expires soon (within 5 minutes)
final fiveMinutesFromNow = now.add(const Duration(minutes: 5));
if (expiryTime.isBefore(fiveMinutesFromNow)) {
return TokenValidationResult.expiringSoon(
'Token expires soon',
expiryTime,
);
}
}
return TokenValidationResult.valid(
'Token format valid',
expiryData: exp != null
? DateTime.fromMillisecondsSinceEpoch(exp * 1000)
: null,
);
} catch (e) {
// If we can't decode JWT, treat as opaque token
debugPrint(
'DEBUG: Could not decode JWT payload, treating as opaque token: $e',
);
return TokenValidationResult.valid('Opaque token format valid');
}
} catch (e) {
return TokenValidationResult.invalid('Token validation error: $e');
}
}
/// Validate token with server (async with timeout)
static Future<TokenValidationResult> validateTokenWithServer(
String token,
Future<dynamic> Function() serverValidationCall,
) async {
try {
// First check format
final formatResult = validateTokenFormat(token);
if (!formatResult.isValid) {
return formatResult;
}
// If format is good, try server validation with timeout
final validationFuture = serverValidationCall();
final result = await validationFuture.timeout(
_validationTimeout,
onTimeout: () => throw Exception('Token validation timeout'),
);
return TokenValidationResult.valid(
'Server validation successful',
serverData: result,
);
} catch (e) {
if (e.toString().contains('timeout')) {
return TokenValidationResult.networkError(
'Validation timeout - using cached result',
);
} else if (e.toString().contains('401') || e.toString().contains('403')) {
return TokenValidationResult.invalid('Server rejected token');
} else {
return TokenValidationResult.networkError(
'Network error during validation: $e',
);
}
}
}
/// Decode JWT payload (without signature verification)
static Map<String, dynamic> _decodeJWTPayload(String base64Payload) {
// Add padding if needed
String padded = base64Payload;
while (padded.length % 4 != 0) {
padded += '=';
}
// Decode base64
final decoded = base64Url.decode(padded);
final jsonString = utf8.decode(decoded);
return jsonDecode(jsonString) as Map<String, dynamic>;
}
/// Extract user information from JWT token (if available)
static Map<String, dynamic>? extractUserInfo(String token) {
try {
final parts = token.split('.');
if (parts.length < 3) return null;
final payload = _decodeJWTPayload(parts[1]);
// Extract common user fields
return {
'sub': payload['sub'], // Subject (user ID)
'username':
payload['username'] ??
payload['name'] ??
payload['preferred_username'],
'email': payload['email'],
'roles': payload['roles'] ?? payload['groups'],
'exp': payload['exp'],
'iat': payload['iat'], // Issued at
};
} catch (e) {
debugPrint('DEBUG: Could not extract user info from token: $e');
return null;
}
}
/// Generate a cache key for token validation results
static String generateCacheKey(String token) {
final bytes = utf8.encode(token);
final digest = sha256.convert(bytes);
return digest.toString().substring(
0,
16,
); // Use first 16 chars as cache key
}
}
/// Result of token validation
class TokenValidationResult {
const TokenValidationResult._(
this.isValid,
this.status,
this.message, {
this.expiryData,
this.serverData,
});
const TokenValidationResult.valid(
String message, {
DateTime? expiryData,
dynamic serverData,
}) : this._(
true,
TokenValidationStatus.valid,
message,
expiryData: expiryData,
serverData: serverData,
);
const TokenValidationResult.invalid(String message)
: this._(false, TokenValidationStatus.invalid, message);
const TokenValidationResult.expired(String message)
: this._(false, TokenValidationStatus.expired, message);
const TokenValidationResult.expiringSoon(String message, DateTime expiryTime)
: this._(
true,
TokenValidationStatus.expiringSoon,
message,
expiryData: expiryTime,
);
const TokenValidationResult.networkError(String message)
: this._(false, TokenValidationStatus.networkError, message);
final bool isValid;
final TokenValidationStatus status;
final String message;
final DateTime? expiryData;
final dynamic serverData;
bool get isExpired => status == TokenValidationStatus.expired;
bool get isExpiringSoon => status == TokenValidationStatus.expiringSoon;
bool get hasNetworkError => status == TokenValidationStatus.networkError;
@override
String toString() =>
'TokenValidationResult(isValid: $isValid, status: $status, message: $message)';
}
enum TokenValidationStatus {
valid,
invalid,
expired,
expiringSoon,
networkError,
}
/// Cache for token validation results
class TokenValidationCache {
static final Map<String, _CacheEntry> _cache = {};
static const Duration _cacheTimeout = Duration(minutes: 5);
static void cacheResult(String token, TokenValidationResult result) {
final key = TokenValidator.generateCacheKey(token);
_cache[key] = _CacheEntry(result, DateTime.now());
// Clean old entries
_cleanCache();
}
static TokenValidationResult? getCachedResult(String token) {
final key = TokenValidator.generateCacheKey(token);
final entry = _cache[key];
if (entry != null &&
DateTime.now().difference(entry.timestamp) < _cacheTimeout) {
return entry.result;
}
return null;
}
static void clearCache() {
_cache.clear();
}
static void _cleanCache() {
final now = DateTime.now();
_cache.removeWhere(
(key, entry) => now.difference(entry.timestamp) > _cacheTimeout,
);
}
}
class _CacheEntry {
const _CacheEntry(this.result, this.timestamp);
final TokenValidationResult result;
final DateTime timestamp;
}