Files
iiEsaywebUIapp/lib/core/auth/auth_state_manager.dart
cogwheel 601defa3ee fix(auth): Improve network readiness handling on cold start
Add robust network readiness gate for authentication to prevent
race conditions with Cloudflare tunnels. Implement retry mechanism
for API connectivity checks and silent login attempts when
credentials are available. Enhance error handling and logging for
network-related authentication challenges.
2026-02-05 19:26:16 +05:30

1437 lines
44 KiB
Dart

import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
// Types are used through app_providers.dart
import '../providers/app_providers.dart';
import '../models/user.dart';
import '../services/optimized_storage_service.dart';
import 'token_validator.dart';
import 'auth_cache_manager.dart';
import 'webview_cookie_helper.dart';
import '../utils/debug_logger.dart';
import '../utils/user_avatar_utils.dart';
part 'auth_state_manager.g.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 User? user;
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,
User? 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,
credentialError, // Invalid credentials - need re-login
}
/// Unified auth state manager - single source of truth for all auth operations
@Riverpod(keepAlive: true)
class AuthStateManager extends _$AuthStateManager {
final AuthCacheManager _cacheManager = AuthCacheManager();
Future<bool>? _silentLoginFuture;
// Prevent infinite retry loops
int _retryCount = 0;
static const int _maxRetries = 3;
DateTime? _lastRetryTime;
AuthState get _current =>
state.asData?.value ?? const AuthState(status: AuthStatus.initial);
void _set(AuthState next, {bool cache = false}) {
final storage = ref.read(optimizedStorageServiceProvider);
if (next.user != null && next.isAuthenticated) {
// Persist user and avatar asynchronously without blocking state update
unawaited(_persistUserWithAvatar(next, storage));
} else if (!next.isAuthenticated) {
unawaited(
storage.saveLocalUser(null).onError((error, stack) {
DebugLogger.error(
'Failed to clear local user on logout',
scope: 'auth/persistence',
error: error,
stackTrace: stack,
);
}),
);
unawaited(
storage.saveLocalUserAvatar(null).onError((error, stack) {
DebugLogger.error(
'Failed to clear local user avatar on logout',
scope: 'auth/persistence',
error: error,
stackTrace: stack,
);
}),
);
}
state = AsyncValue.data(next);
if (cache) {
_cacheManager.cacheAuthState(next);
}
}
Future<void> _persistUserWithAvatar(
AuthState authState,
OptimizedStorageService storage,
) async {
try {
final api = ref.read(apiServiceProvider);
final user = authState.user!;
final resolvedAvatar = resolveUserProfileImageUrl(
api,
deriveUserProfileImage(user),
);
final userWithAvatar =
resolvedAvatar != null && resolvedAvatar != user.profileImage
? user.copyWith(profileImage: resolvedAvatar)
: user;
await storage.saveLocalUser(userWithAvatar);
if (resolvedAvatar != null) {
await storage.saveLocalUserAvatar(resolvedAvatar);
}
} catch (error, stack) {
DebugLogger.error(
'Failed to persist user with avatar',
scope: 'auth/persistence',
error: error,
stackTrace: stack,
);
}
}
void _update(
AuthState Function(AuthState current) transform, {
bool cache = false,
}) {
final next = transform(_current);
_set(next, cache: cache);
}
@override
Future<AuthState> build() async {
await _initialize();
return _current;
}
/// Initialize auth state from storage
Future<void> _initialize() async {
_update(
(current) =>
current.copyWith(status: AuthStatus.loading, isLoading: true),
);
try {
final storage = ref.read(optimizedStorageServiceProvider);
// On cold start, secure storage (iOS Keychain) can be slow or
// transiently fail. Retry a few times before giving up to avoid
// incorrectly showing the sign-in page.
String? token;
const maxAttempts = 3;
for (var attempt = 1; attempt <= maxAttempts; attempt++) {
token = await storage.getAuthToken();
if (token != null) break;
// Only retry if this might be a cold start issue
if (attempt < maxAttempts) {
DebugLogger.auth(
'Token read returned null, retrying ($attempt/$maxAttempts)',
);
// Exponential backoff: 50ms, 100ms
await Future.delayed(Duration(milliseconds: 50 * attempt));
}
}
if (token != null && token.isNotEmpty) {
DebugLogger.auth('Found stored token during initialization');
// Check if stored token is an API key - force logout if so
if (TokenValidator.isApiKey(token)) {
DebugLogger.auth('Detected API key token, forcing logout');
await storage.deleteAuthToken();
await storage.deleteSavedCredentials();
_update(
(current) => current.copyWith(
status: AuthStatus.credentialError,
error: 'apiKeyNoLongerSupported',
isLoading: false,
clearToken: true,
),
);
return;
}
// Fast path: trust token format to avoid blocking startup on network
final formatOk = _isValidTokenFormat(token);
if (formatOk) {
// Network readiness gate: Wait for API to be reachable before
// transitioning to authenticated state. This prevents race conditions
// on cold starts with Cloudflare tunnels where the tunnel connection
// may not be established yet.
final apiReady = await _waitForApiReadiness();
if (!apiReady) {
DebugLogger.auth(
'API not reachable on cold start - keeping loading state',
);
// Keep loading state and retry via silent login if we have creds
final hasCreds = await storage.hasCredentials();
if (hasCreds) {
DebugLogger.auth(
'Has credentials - attempting silent login after API ready',
);
// Schedule a delayed retry that will wait for network.
// Allow time for network stack to stabilize after initial failure.
unawaited(
Future.delayed(const Duration(milliseconds: 500), () async {
if (!ref.mounted) return;
try {
final retryReady = await _waitForApiReadiness(
timeout: const Duration(seconds: 10),
);
if (!ref.mounted) return;
if (retryReady) {
await _performSilentLogin();
} else {
_update(
(current) => current.copyWith(
status: AuthStatus.error,
error: 'Unable to connect to server',
isLoading: false,
),
);
}
} catch (e, stack) {
if (!ref.mounted) return;
DebugLogger.error(
'delayed-retry-failed',
scope: 'auth/state',
error: e,
stackTrace: stack,
);
_update(
(current) => current.copyWith(
status: AuthStatus.error,
error: 'Connection retry failed',
isLoading: false,
),
);
}
}),
);
return;
}
// No credentials - show error
_update(
(current) => current.copyWith(
status: AuthStatus.error,
error: 'Unable to connect to server',
isLoading: false,
),
);
return;
}
_update(
(current) => current.copyWith(
status: AuthStatus.authenticated,
token: token,
isLoading: false,
clearError: true,
),
cache: true,
);
try {
final cachedUser = await storage.getLocalUser();
if (cachedUser != null) {
// Restore cached avatar as well
final cachedAvatar = await storage.getLocalUserAvatar();
final userWithAvatar =
cachedAvatar != null &&
cachedAvatar.isNotEmpty &&
cachedUser.profileImage != cachedAvatar
? cachedUser.copyWith(profileImage: cachedAvatar)
: cachedUser;
_update(
(current) => current.copyWith(user: userWithAvatar),
cache: true,
);
DebugLogger.auth('Restored user from cache');
}
} catch (_) {}
// Update API service with token and kick off dependent background work
_updateApiServiceToken(token);
_preloadDefaultModel();
_loadUserData();
_prefetchConversations();
// Background server validation; if it fails, invalidate token gracefully
final validToken = token; // Capture non-null token for closure
Future.microtask(() async {
try {
final ok = await _validateToken(validToken);
DebugLogger.auth('Deferred token validation result: $ok');
if (!ok) {
await onTokenInvalidated();
}
} catch (_) {}
});
} else {
// Token format invalid; clear and require login
DebugLogger.auth('Token format invalid, deleting token');
await storage.deleteAuthToken();
_update(
(current) => current.copyWith(
status: AuthStatus.unauthenticated,
isLoading: false,
clearToken: true,
clearError: true,
),
);
}
} else {
// No token found after retries. Check if we have saved credentials
// and attempt silent login immediately to avoid showing sign-in page.
final hasCreds = await storage.hasCredentials();
if (hasCreds) {
DebugLogger.auth(
'No token but credentials exist - attempting silent login',
);
// Keep loading state while we attempt silent login
// This prevents the router from redirecting to sign-in
await _performSilentLogin();
// _performSilentLogin() updates state appropriately on both success
// and failure (e.g., AuthStatus.error for network issues), so we
// return here to preserve that state.
return;
}
// No credentials - set to unauthenticated
DebugLogger.auth('No token or credentials found');
_update(
(current) => current.copyWith(
status: AuthStatus.unauthenticated,
isLoading: false,
clearToken: true,
clearError: true,
),
);
}
} catch (e) {
DebugLogger.error('auth-init-failed', scope: 'auth/state', error: e);
_update(
(current) => current.copyWith(
status: AuthStatus.error,
error: 'Failed to initialize auth: $e',
isLoading: false,
),
);
}
}
/// Perform login with JWT token.
///
/// Note: API keys (sk-...) are not supported for streaming.
///
/// [authType] specifies the source of the token for credential storage:
/// - 'token': Manual JWT entry (default)
/// - 'sso': Token obtained via SSO/OAuth flow
Future<bool> loginWithApiKey(
String apiKey, {
bool rememberCredentials = false,
String authType = 'token',
}) async {
_update(
(current) => current.copyWith(
status: AuthStatus.loading,
isLoading: true,
clearError: true,
),
);
try {
// Validate token is not empty
if (apiKey.trim().isEmpty) {
throw Exception('Token cannot be empty');
}
final tokenStr = apiKey.trim();
// Reject API keys - they don't support streaming
if (TokenValidator.isApiKey(tokenStr)) {
throw Exception('apiKeyNotSupported');
}
// Ensure API service is available
await _ensureApiServiceAvailable();
final api = ref.read(apiServiceProvider);
if (api == null) {
throw Exception('No server connection available');
}
// Validate token format
if (!_isValidTokenFormat(tokenStr)) {
throw Exception('Invalid token format');
}
// Update API service with the API key
_updateApiServiceToken(tokenStr);
// Validate by attempting to fetch user info
try {
await api.getCurrentUser(); // Just validate, don't store user data yet
// Save token to storage
final storage = ref.read(optimizedStorageServiceProvider);
await storage.saveAuthToken(tokenStr);
// Save JWT token if requested
if (rememberCredentials) {
final activeServer = await ref.read(activeServerProvider.future);
if (activeServer != null) {
// Store JWT as a special credential type
await storage.saveCredentials(
serverId: activeServer.id,
username: 'jwt_user', // Special username to indicate JWT auth
password: tokenStr, // Store JWT in password field
authType: authType, // 'token' for manual entry, 'sso' for OAuth
);
}
}
// Update state (without user data initially)
_update(
(current) => current.copyWith(
status: AuthStatus.authenticated,
token: tokenStr,
isLoading: false,
clearError: true,
),
cache: true,
);
// Update API service with token and kick off dependent background work
_updateApiServiceToken(tokenStr);
_preloadDefaultModel();
// Load user data in background (consistent with credentials method)
_loadUserData();
_prefetchConversations();
DebugLogger.auth('JWT token login successful');
return true;
} catch (e) {
// If user fetch fails, the token might be invalid
throw Exception('Invalid token or insufficient permissions');
}
} catch (e, stack) {
DebugLogger.error(
'api-key-login-failed',
scope: 'auth/state',
error: e,
stackTrace: stack,
);
_update(
(current) => current.copyWith(
status: AuthStatus.error,
error: e.toString(),
isLoading: false,
clearToken: true,
),
);
rethrow;
}
}
/// Perform login with credentials
Future<bool> login(
String username,
String password, {
bool rememberCredentials = false,
}) async {
_update(
(current) => current.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,
);
}
}
// Update state and API service
_update(
(current) => current.copyWith(
status: AuthStatus.authenticated,
token: tokenStr,
isLoading: false,
clearError: true,
),
cache: true,
);
_updateApiServiceToken(tokenStr);
_preloadDefaultModel();
// Load user data in background
_loadUserData();
_prefetchConversations();
DebugLogger.auth('Login successful');
return true;
} catch (e, stack) {
DebugLogger.error(
'login-failed',
scope: 'auth/state',
error: e,
stackTrace: stack,
);
_update(
(current) => current.copyWith(
status: AuthStatus.error,
error: e.toString(),
isLoading: false,
clearToken: true,
),
);
rethrow;
}
}
/// Perform login with LDAP credentials.
///
/// LDAP uses username (not email) for authentication.
/// The server must have LDAP enabled, otherwise this will throw an error.
Future<bool> ldapLogin(
String username,
String password, {
bool rememberCredentials = false,
}) async {
_update(
(current) => current.copyWith(
status: AuthStatus.loading,
isLoading: true,
clearError: true,
),
);
try {
// Ensure API service is available
await _ensureApiServiceAvailable();
final api = ref.read(apiServiceProvider);
if (api == null) {
throw Exception('No server connection available');
}
// Perform LDAP login API call
final response = await api.ldapLogin(username, password);
// Check if notifier is still mounted after async call
if (!ref.mounted) return false;
// 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);
if (!ref.mounted) return false;
// Save JWT token for re-authentication if requested
// We store the token (not the raw LDAP password) for security:
// - JWT tokens can be revoked server-side
// - Avoids storing the user's directory password
// - Consistent with SSO token storage approach
if (rememberCredentials) {
final activeServer = await ref.read(activeServerProvider.future);
if (!ref.mounted) return false;
if (activeServer != null) {
await storage.saveCredentials(
serverId: activeServer.id,
// Prefix with ldap: to preserve original username for debugging
// while indicating this is token-based auth
username: 'ldap:$username',
password: tokenStr, // Store JWT token, not LDAP password
authType: 'ldap', // Track that this originated from LDAP login
);
}
}
if (!ref.mounted) return false;
// Update state and API service
_update(
(current) => current.copyWith(
status: AuthStatus.authenticated,
token: tokenStr,
isLoading: false,
clearError: true,
),
cache: true,
);
_updateApiServiceToken(tokenStr);
_preloadDefaultModel();
// Load user data in background
_loadUserData();
_prefetchConversations();
DebugLogger.auth('LDAP login successful');
return true;
} catch (e, stack) {
DebugLogger.error(
'ldap-login-failed',
scope: 'auth/state',
error: e,
stackTrace: stack,
);
if (ref.mounted) {
_update(
(current) => current.copyWith(
status: AuthStatus.error,
error: e.toString(),
isLoading: false,
clearToken: true,
),
);
}
rethrow;
}
}
/// 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));
}
}
/// Wait for the API to be reachable (network readiness gate).
///
/// On cold starts with Cloudflare tunnels or other proxy setups, the network
/// connection may not be established immediately. This method performs a
/// health check with retries to ensure we don't show the wrong screen due to
/// a race condition between auth state initialization and network readiness.
///
/// Returns true if the API is reachable within the timeout, false otherwise.
Future<bool> _waitForApiReadiness({
Duration timeout = const Duration(seconds: 3),
Duration retryDelay = const Duration(milliseconds: 300),
}) async {
final stopwatch = Stopwatch()..start();
// First ensure the API service provider is available
await _ensureApiServiceAvailable(timeout: const Duration(seconds: 1));
while (stopwatch.elapsed < timeout) {
if (!ref.mounted) return false;
final api = ref.read(apiServiceProvider);
if (api == null) {
await Future.delayed(retryDelay);
continue;
}
try {
// Use checkHealth which hits the /health endpoint
final healthy = await api.checkHealth();
if (healthy) {
DebugLogger.auth(
'API readiness confirmed in ${stopwatch.elapsedMilliseconds}ms',
);
return true;
}
} catch (e) {
DebugLogger.auth(
'API readiness check failed (${stopwatch.elapsedMilliseconds}ms): $e',
);
}
// Wait before retrying
if (stopwatch.elapsed + retryDelay < timeout) {
await Future.delayed(retryDelay);
} else {
break;
}
}
DebugLogger.auth(
'API readiness timed out after ${stopwatch.elapsedMilliseconds}ms',
);
return false;
}
/// Perform silent auto-login with saved credentials
Future<bool> silentLogin() async {
// Coalesce concurrent calls (e.g., UI + interceptor retry)
if (_silentLoginFuture != null) {
return await _silentLoginFuture!;
}
final thisAttempt = _performSilentLogin();
_silentLoginFuture = thisAttempt;
try {
return await thisAttempt;
} finally {
if (identical(_silentLoginFuture, thisAttempt)) {
_silentLoginFuture = null;
}
}
}
Future<bool> _performSilentLogin() async {
_update(
(current) => current.copyWith(
status: AuthStatus.loading,
isLoading: true,
clearError: true,
),
);
try {
final storage = ref.read(optimizedStorageServiceProvider);
final savedCredentials = await storage.getSavedCredentials();
if (savedCredentials == null) {
_update(
(current) => current.copyWith(
status: AuthStatus.unauthenticated,
isLoading: false,
clearError: true,
),
);
return false;
}
final serverId = savedCredentials['serverId']!;
final username = savedCredentials['username']!;
final password = savedCredentials['password']!;
// Ensure the saved server still exists before switching
final serverConfigs = await ref.read(serverConfigsProvider.future);
final hasServer = serverConfigs.any((config) => config.id == serverId);
if (!hasServer) {
await storage.deleteSavedCredentials();
await storage.setActiveServerId(null);
ref.invalidate(serverConfigsProvider);
ref.invalidate(activeServerProvider);
_update(
(current) => current.copyWith(
status: AuthStatus.error,
error:
'Saved server configuration is no longer available. Please reconnect.',
isLoading: false,
),
);
return false;
}
// Set active server once we know it exists
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);
_update(
(current) => current.copyWith(
status: AuthStatus.error,
error: 'Server configuration not found',
isLoading: false,
),
);
return false;
}
// Attempt login based on auth type
final authType = savedCredentials['authType'] ?? 'credentials';
// Handle JWT token-based authentication (includes legacy prefixes)
// LDAP now also stores JWT tokens for re-auth (not raw passwords)
if (username == 'api_key_user' ||
username == 'jwt_user' ||
username.startsWith('ldap:') ||
authType == 'token' ||
authType == 'sso' ||
authType == 'ldap') {
// This is a saved JWT token (manual entry, SSO, or LDAP-obtained)
// For LDAP, we store the JWT token returned by the server, not the
// original password, for security reasons
return await loginWithApiKey(
password, // This is the JWT token
rememberCredentials: false,
authType: authType,
);
} else {
// Standard credentials login (default)
return await login(username, password, rememberCredentials: false);
}
} catch (e, stack) {
DebugLogger.error(
'silent-login-failed',
scope: 'auth/state',
error: e,
stackTrace: stack,
);
String errorMessage = e.toString();
// Don't clear credentials on connection errors - only clear on actual auth failures
// Check if this is a genuine auth failure vs network issue
final isNetworkError =
e.toString().contains('SocketException') ||
e.toString().contains('Connection') ||
e.toString().contains('timeout') ||
e.toString().contains('NetworkImage');
if (!isNetworkError &&
(e.toString().contains('401') ||
e.toString().contains('403') ||
e.toString().contains('authentication') ||
e.toString().contains('unauthorized'))) {
// Only clear credentials if this is a real auth failure, not a network issue
final storage = ref.read(optimizedStorageServiceProvider);
try {
DebugLogger.auth('Clearing invalid credentials after auth failure');
await storage.deleteSavedCredentials();
} catch (deleteError, deleteStack) {
DebugLogger.error(
'silent-login-credential-clear-failed',
scope: 'auth/state',
error: deleteError,
stackTrace: deleteStack,
);
errorMessage =
'$errorMessage. Also failed to clear saved '
'credentials; please clear Conduit credentials from '
'system settings.';
}
// Set credential error status to trigger login page
_update(
(current) => current.copyWith(
status: AuthStatus.credentialError,
error: errorMessage,
isLoading: false,
clearToken: true,
),
);
return false;
} else if (isNetworkError) {
DebugLogger.auth(
'Silent login failed due to network error - keeping credentials',
);
errorMessage = 'Connection issue - please check your network';
// Set general error status to trigger connection issue page
_update(
(current) => current.copyWith(
status: AuthStatus.error,
error: errorMessage,
isLoading: false,
),
);
return false;
}
// Unknown error type - treat as connection issue but keep credentials
if (errorMessage.trim().isEmpty) {
errorMessage = 'Connection issue - please try again shortly';
}
DebugLogger.auth(
'Silent login failed with unknown error - keeping credentials',
);
_update(
(current) => current.copyWith(
status: AuthStatus.error,
error: errorMessage,
isLoading: false,
),
);
return false;
}
}
/// Reset retry counter (called when user manually retries)
void resetRetryCounter() {
_retryCount = 0;
_lastRetryTime = null;
DebugLogger.auth('Retry counter reset for manual retry');
}
/// Handle auth issues (called by API service)
/// This shows connection issue page instead of logging out
void onAuthIssue() {
DebugLogger.auth('Auth issue detected - showing connection issue page');
// Don't clear token or user data - just set error state
// The router will show connection issue page
_update(
(current) => current.copyWith(
status: AuthStatus.error,
error: 'Connection issue - please check your connection',
clearError: false,
),
);
}
/// Handle token invalidation (called by API service for explicit token expiry)
/// This is only used when we need to clear the token for re-login attempts
Future<void> onTokenInvalidated() async {
// Prevent infinite retry loops
final now = DateTime.now();
if (_lastRetryTime != null &&
now.difference(_lastRetryTime!).inSeconds < 5) {
_retryCount++;
if (_retryCount >= _maxRetries) {
DebugLogger.auth(
'Max retry attempts reached - stopping silent re-login',
);
_update(
(current) => current.copyWith(
status: AuthStatus.error,
error: 'Connection issue - please retry manually',
clearError: false,
),
);
// Reset after 30 seconds to allow manual retry
Future.delayed(const Duration(seconds: 30), () {
_retryCount = 0;
_lastRetryTime = null;
});
return;
}
} else {
// Reset counter if enough time has passed
_retryCount = 0;
}
_lastRetryTime = now;
// Avoid spamming logs if multiple requests invalidate at once
final reloginInProgress = _silentLoginFuture != null;
if (!reloginInProgress) {
DebugLogger.auth(
'Auth token invalidated - attempting silent re-login (attempt ${_retryCount + 1}/$_maxRetries)',
);
}
final storage = ref.read(optimizedStorageServiceProvider);
try {
await storage.deleteAuthToken();
DebugLogger.auth('Cleared invalidated token from secure storage');
} catch (e, stack) {
DebugLogger.error(
'token-delete-failed',
scope: 'auth/state',
error: e,
stackTrace: stack,
);
}
_updateApiServiceToken(null);
_update(
(current) => current.copyWith(
status: AuthStatus.tokenExpired,
error: 'Session expired - please sign in again',
clearToken: true,
clearUser: true,
isLoading: false,
),
);
// Attempt silent re-login if credentials are available
final hasCredentials = await storage.getSavedCredentials() != null;
if (hasCredentials && !reloginInProgress) {
DebugLogger.auth('Attempting silent re-login after token invalidation');
final success = await silentLogin();
if (success) {
// Reset retry counter on success
_retryCount = 0;
_lastRetryTime = null;
}
}
}
/// Logout user and clear auth data while preserving server configuration.
/// Server settings (URL, custom headers, self-signed cert) are kept so users
/// can quickly re-login. Users can navigate to server connection page to
/// change server settings if needed.
Future<void> logout() async {
_update(
(current) =>
current.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) {
DebugLogger.warning(
'server-logout-failed',
scope: 'auth/state',
data: {'error': e.toString()},
);
}
}
// Clear auth data but preserve server configs (URL, headers, cert settings)
final storage = ref.read(optimizedStorageServiceProvider);
await storage.clearAuthData();
_updateApiServiceToken(null);
// Clear all WebView data (cookies, localStorage, cache) to ensure
// fresh SSO sessions on next login
try {
await WebViewCookieHelper.clearAllWebViewData();
} catch (e) {
DebugLogger.warning(
'webview-data-clear-failed',
scope: 'auth/state',
data: {'error': e.toString()},
);
}
// Keep active server ID so router redirects to sign-in page, not server
// connection page. Users can navigate to server settings if they need to
// change server configuration.
// Note: toolsListProvider is NOT invalidated here because:
// 1. clearAuthData() already deletes the tools cache from storage
// 2. The provider has auth checks that prevent API calls when logged out
// 3. When user logs back in, the provider will rebuild with fresh data
// Clear auth cache manager
_cacheManager.clearAuthCache();
// Update state
_update(
(current) => current.copyWith(
status: AuthStatus.unauthenticated,
isLoading: false,
clearToken: true,
clearUser: true,
clearError: true,
),
);
DebugLogger.auth(
'Logout complete - auth data cleared, server config preserved for quick re-login',
);
} catch (e, stack) {
DebugLogger.error(
'logout-failed',
scope: 'auth/state',
error: e,
stackTrace: stack,
);
// Even if logout fails, clear local state where possible
final storage = ref.read(optimizedStorageServiceProvider);
try {
await storage.clearAuthData();
} catch (clearError) {
DebugLogger.error(
'logout-clear-failed',
scope: 'auth/state',
error: clearError,
);
}
// Keep active server ID for redirect to sign-in page
_cacheManager.clearAuthCache();
_update(
(current) => current.copyWith(
status: AuthStatus.unauthenticated,
isLoading: false,
clearToken: true,
clearUser: true,
error:
'Logout error: $e. Some data may remain stored; '
'please clear app data from your device settings if needed.',
),
);
_updateApiServiceToken(null);
}
}
/// Preload the default model as soon as authentication succeeds.
void _preloadDefaultModel() {
Future.microtask(() async {
if (!ref.mounted) return;
try {
await ref.read(defaultModelProvider.future);
DebugLogger.auth('Default model preload requested');
} catch (e) {
if (!ref.mounted) return;
DebugLogger.warning(
'default-model-preload-failed',
scope: 'auth/state',
data: {'error': e.toString()},
);
}
});
}
/// Prime the conversations list so navigation drawers show real data after login.
void _prefetchConversations() {
Future.microtask(() {
if (!ref.mounted) return;
try {
refreshConversationsCache(ref, includeFolders: true);
DebugLogger.auth('Conversations prefetch scheduled');
} catch (e) {
if (!ref.mounted) return;
DebugLogger.warning(
'conversation-prefetch-failed',
scope: 'auth/state',
data: {'error': e.toString()},
);
}
});
}
/// 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
final current = _current;
if (current.token != null) {
final jwtUserInfo = TokenValidator.extractUserInfo(current.token!);
if (jwtUserInfo != null) {
final userFromJwt = _userFromJwtClaims(jwtUserInfo);
if (userFromJwt != null) {
DebugLogger.auth('Extracted user info from JWT token');
_update((current) => current.copyWith(user: userFromJwt));
}
// 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) {
DebugLogger.warning(
'user-data-load-failed',
scope: 'auth/state',
data: {'error': e.toString()},
);
// 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);
final current = _current;
if (api != null && current.isAuthenticated) {
// Check if we already have user data from token validation
if (current.user != null) {
DebugLogger.auth('user-data-present-from-token', scope: 'auth/state');
return;
}
final user = await api.getCurrentUser();
_update((current) => current.copyWith(user: user));
DebugLogger.auth('Loaded complete user data from server');
}
} catch (e) {
DebugLogger.warning(
'server-user-data-load-failed',
scope: 'auth/state',
data: {'error': e.toString()},
);
// 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) {
DebugLogger.auth(
'Using cached token validation result: ${cachedResult.isValid}',
);
return cachedResult.isValid;
}
// Fast format validation first
final formatResult = TokenValidator.validateTokenFormat(token);
if (!formatResult.isValid) {
DebugLogger.warning(
'token-format-invalid',
scope: 'auth/state',
data: {'message': formatResult.message},
);
TokenValidationCache.cacheResult(token, formatResult);
return false;
}
// If format is valid but token is expiring soon, try server validation
if (formatResult.isExpiringSoon) {
DebugLogger.auth('token-expiring-soon', scope: 'auth/state');
}
// Server validation (async with timeout)
try {
final api = ref.read(apiServiceProvider);
if (api == null) {
DebugLogger.warning('token-validation-no-api', scope: 'auth/state');
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 &&
_current.isAuthenticated) {
_update((current) => current.copyWith(user: validationUser));
DebugLogger.auth('Cached user data from token validation');
}
TokenValidationCache.cacheResult(token, serverResult);
DebugLogger.auth(
'Server token validation: ${serverResult.isValid} - ${serverResult.message}',
);
return serverResult.isValid;
} catch (e) {
DebugLogger.warning(
'token-validation-failed',
scope: 'auth/state',
data: {'error': e.toString()},
);
// 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',
};
}
User? _userFromJwtClaims(Map<String, dynamic> claims) {
final id =
(claims['sub'] ?? claims['username'] ?? claims['email'])
?.toString()
.trim() ??
'';
final username =
(claims['username'] ?? claims['name'])?.toString().trim() ?? '';
final emailValue = claims['email'];
final email = emailValue == null ? '' : emailValue.toString().trim();
if (id.isEmpty && username.isEmpty && email.isEmpty) {
return null;
}
String resolvedRole = 'user';
final roles = claims['roles'];
if (roles is List && roles.isNotEmpty) {
resolvedRole = roles.first.toString();
} else if (roles is String && roles.isNotEmpty) {
resolvedRole = roles;
}
return User(
id: id.isNotEmpty
? id
: (username.isNotEmpty ? username : email.ifEmptyReturn('user')),
username: username.ifEmptyReturn(
email.ifEmptyReturn(id.ifEmptyReturn('user')),
),
email: email,
role: resolvedRole,
isActive: true,
);
}
}
extension _StringFallbackExtension on String {
String ifEmptyReturn(String fallback) => isEmpty ? fallback : this;
}