feat: API auth with custom headers

This commit is contained in:
cogwheel
2025-08-16 15:51:27 +05:30
parent 37dece4263
commit b33069fdea
21 changed files with 1854 additions and 736 deletions

View File

@@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart';
/// Implements security requirements from OpenAPI specification
class ApiAuthInterceptor extends Interceptor {
String? _authToken;
final Map<String, String> customHeaders;
// Callbacks for auth events
void Function()? onAuthTokenInvalid;
@@ -35,6 +36,7 @@ class ApiAuthInterceptor extends Interceptor {
String? authToken,
this.onAuthTokenInvalid,
this.onTokenInvalidated,
this.customHeaders = const {},
}) : _authToken = authToken;
void updateAuthToken(String? token) {
@@ -102,6 +104,21 @@ class ApiAuthInterceptor extends Interceptor {
options.headers['Authorization'] = 'Bearer $_authToken';
}
// Add custom headers from server config (with safety checks)
if (customHeaders.isNotEmpty) {
customHeaders.forEach((key, value) {
// Don't override critical headers that we manage
final lowerKey = key.toLowerCase();
if (lowerKey != 'authorization' &&
lowerKey != 'content-type' &&
lowerKey != 'accept') {
options.headers[key] = value;
} else {
debugPrint('WARNING: Skipping reserved header override attempt: $key');
}
});
}
// Add other common headers for API consistency
options.headers['Content-Type'] ??= 'application/json';
options.headers['Accept'] ??= 'application/json';

View File

@@ -95,8 +95,10 @@ class AuthStateManager extends StateNotifier<AuthState> {
final token = await storage.getAuthToken();
if (token != null && token.isNotEmpty) {
debugPrint('DEBUG: Found stored token during initialization: ${token.substring(0, 10)}...');
// Validate token before setting authenticated state
final isValid = await _validateToken(token);
debugPrint('DEBUG: Token validation result: $isValid');
if (isValid) {
state = state.copyWith(
status: AuthStatus.authenticated,
@@ -112,6 +114,7 @@ class AuthStateManager extends StateNotifier<AuthState> {
_loadUserData();
} else {
// Token is invalid, clear it
debugPrint('DEBUG: Token validation failed, deleting token');
await storage.deleteAuthToken();
state = state.copyWith(
status: AuthStatus.unauthenticated,
@@ -138,6 +141,98 @@ class AuthStateManager extends StateNotifier<AuthState> {
}
}
/// Perform login with API key
Future<bool> loginWithApiKey(
String apiKey, {
bool rememberCredentials = false,
}) async {
state = state.copyWith(
status: AuthStatus.loading,
isLoading: true,
clearError: true,
);
try {
// Validate API key format
if (apiKey.trim().isEmpty) {
throw Exception('API key cannot be empty');
}
// Ensure API service is available
await _ensureApiServiceAvailable();
final api = _ref.read(apiServiceProvider);
if (api == null) {
throw Exception('No server connection available');
}
// Use API key directly as Bearer token
final tokenStr = apiKey.trim();
// Validate token format (consistent with credentials method)
if (!_isValidTokenFormat(tokenStr)) {
throw Exception('Invalid API key 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 API key if requested (for convenience, though less secure than credentials)
if (rememberCredentials) {
final activeServer = await _ref.read(activeServerProvider.future);
if (activeServer != null) {
// Store API key as a special credential type
await storage.saveCredentials(
serverId: activeServer.id,
username: 'api_key_user', // Special username to indicate API key auth
password: tokenStr, // Store API key in password field
);
await storage.setRememberCredentials(true);
}
}
// Update state (without user data initially)
state = state.copyWith(
status: AuthStatus.authenticated,
token: tokenStr,
isLoading: false,
clearError: true,
);
// Update API service with token
_updateApiServiceToken(tokenStr);
// Cache the successful auth state
_cacheManager.cacheAuthState(state);
// Load user data in background (consistent with credentials method)
_loadUserData();
debugPrint('DEBUG: API key login successful');
return true;
} catch (e) {
// If user fetch fails, the API key might be invalid
throw Exception('Invalid API key or insufficient permissions');
}
} catch (e) {
debugPrint('ERROR: API key login failed: $e');
state = state.copyWith(
status: AuthStatus.error,
error: e.toString(),
isLoading: false,
clearToken: true,
);
return false;
}
}
/// Perform login with credentials
Future<bool> login(
String username,
@@ -272,8 +367,14 @@ class AuthStateManager extends StateNotifier<AuthState> {
return false;
}
// Attempt login
return await login(username, password, rememberCredentials: false);
// Attempt login (detect API key vs normal credentials)
if (username == 'api_key_user') {
// This is a saved API key
return await loginWithApiKey(password, rememberCredentials: false);
} else {
// Normal username/password credentials
return await login(username, password, rememberCredentials: false);
}
} catch (e) {
debugPrint('ERROR: Silent login failed: $e');

View File

@@ -6,7 +6,7 @@ import 'package:crypto/crypto.dart';
class TokenValidator {
static const Duration _validationTimeout = Duration(seconds: 5);
/// Validate JWT token format and expiry without network call
/// Validate token format (supports both JWT and API key formats)
static TokenValidationResult validateTokenFormat(String token) {
try {
// Basic format check
@@ -14,10 +14,20 @@ class TokenValidator {
return TokenValidationResult.invalid('Token too short');
}
// Check if it's an API key format (starts with sk- or similar)
if (token.startsWith('sk-') || token.startsWith('api-') || token.startsWith('key-')) {
// API key format - validate differently
if (token.length < 20) {
return TokenValidationResult.invalid('API key too short');
}
return TokenValidationResult.valid('API key format valid');
}
// 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');
// Not JWT format, treat as opaque token
return TokenValidationResult.valid('Opaque token format valid');
}
// Try to decode the payload to check expiry