diff --git a/lib/providers/js_runtime_notifier.dart b/lib/providers/js_runtime_notifier.dart index 75635632..d023e2f7 100644 --- a/lib/providers/js_runtime_notifier.dart +++ b/lib/providers/js_runtime_notifier.dart @@ -44,34 +44,22 @@ class JsRuntimeNotifier extends StateNotifier { late final JavascriptRuntime _runtime; String? _currentRequestId; - // Security: Maximum script length to prevent DoS attacks - static const int _maxScriptLength = 50000; // 50KB + // Modern 2025 security: Simple pattern-based validation + static const _maxScriptSize = 50000; // 50KB limit + static final _dangerousPatterns = RegExp( + r'eval\s*\(|Function\s*\(|constructor\s*\[|__proto__', + caseSensitive: false, + ); - // Security: Dangerous JavaScript patterns that could lead to code injection - static const List _dangerousPatterns = [ - r'eval\s*\(', - r'Function\s*\(', - r'constructor\s*\[', - r'__proto__', - ]; - - /// Validates user script for basic security checks - /// Returns null if valid, error message if invalid + /// Validate script before execution (zero-trust approach) String? _validateScript(String script) { - // Check script length to prevent DoS - if (script.length > _maxScriptLength) { - return 'Script exceeds maximum length of $_maxScriptLength characters'; + if (script.length > _maxScriptSize) { + return 'Script too large (max 50KB)'; } - - // Check for dangerous patterns - for (final pattern in _dangerousPatterns) { - final regex = RegExp(pattern, caseSensitive: false); - if (regex.hasMatch(script)) { - return 'Script contains potentially dangerous pattern: ${pattern.replaceAll(r'\s*\(', '(').replaceAll(r'\s*\[', '[')}'; - } + if (_dangerousPatterns.hasMatch(script)) { + return 'Script contains unsafe patterns'; } - - return null; // Script is valid + return null; // Valid } void _initialize() { diff --git a/lib/services/hive_services.dart b/lib/services/hive_services.dart index ff3f1888..568ffd06 100644 --- a/lib/services/hive_services.dart +++ b/lib/services/hive_services.dart @@ -1,6 +1,6 @@ import 'package:flutter/foundation.dart'; import 'package:hive_ce_flutter/hive_flutter.dart'; -import 'secure_credential_storage.dart'; +import 'secure_storage.dart'; enum HiveBoxType { normal, lazy } @@ -153,9 +153,9 @@ class HiveHandler { // Store secret in secure storage try { - await SecureCredentialStorage.storeEnvironmentSecret( + await SecureStorage.storeSecret( environmentId: id, - variableKey: variable['key'] ?? 'unknown_$i', + key: variable['key'] ?? 'unknown_$i', value: variable['value'].toString(), ); @@ -197,9 +197,9 @@ class HiveHandler { // Retrieve secret from secure storage try { - final decryptedValue = await SecureCredentialStorage.retrieveEnvironmentSecret( + final decryptedValue = await SecureStorage.retrieveSecret( environmentId: id, - variableKey: variable['key'] ?? 'unknown_$i', + key: variable['key'] ?? 'unknown_$i', ); if (decryptedValue != null) { @@ -224,11 +224,9 @@ class HiveHandler { Future deleteEnvironment(String id) async { // Clean up secure storage for this environment try { - await SecureCredentialStorage.clearEnvironmentSecrets( - environmentId: id, - ); + await SecureStorage.deleteEnvironmentSecrets(id); } catch (e) { - // Log error but continue with deletion + // Graceful failure } return environmentBox.delete(id); } diff --git a/lib/services/secure_credential_storage.dart b/lib/services/secure_credential_storage.dart deleted file mode 100644 index bdecc943..00000000 --- a/lib/services/secure_credential_storage.dart +++ /dev/null @@ -1,114 +0,0 @@ -import 'dart:convert'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -import 'package:hive_flutter/hive_flutter.dart'; -import 'package:crypto/crypto.dart'; - -/// Service for securely storing and retrieving OAuth2 credentials -/// Uses flutter_secure_storage for encryption keys and encrypted values -class SecureCredentialStorage { - static const FlutterSecureStorage _secureStorage = FlutterSecureStorage( - aOptions: AndroidOptions( - encryptedSharedPreferences: true, - ), - iOptions: IOSOptions( - accessibility: KeychainAccessibility.first_unlock, - ), - ); - - /// Generates a storage key from client credentials for OAuth2 - static String _generateStorageKey(String clientId, String tokenUrl) { - final combined = '$clientId:$tokenUrl'; - final bytes = utf8.encode(combined); - final hash = sha256.convert(bytes); - return 'oauth2_${hash.toString().substring(0, 16)}'; - } - - /// Store OAuth2 credentials securely - static Future storeOAuth2Credentials({ - required String clientId, - required String tokenUrl, - required String credentialsJson, - }) async { - final key = _generateStorageKey(clientId, tokenUrl); - await _secureStorage.write(key: key, value: credentialsJson); - } - - /// Retrieve OAuth2 credentials securely - static Future retrieveOAuth2Credentials({ - required String clientId, - required String tokenUrl, - }) async { - final key = _generateStorageKey(clientId, tokenUrl); - return await _secureStorage.read(key: key); - } - - /// Delete OAuth2 credentials - static Future deleteOAuth2Credentials({ - required String clientId, - required String tokenUrl, - }) async { - final key = _generateStorageKey(clientId, tokenUrl); - await _secureStorage.delete(key: key); - } - - /// Clear all OAuth2 credentials - static Future clearAllOAuth2Credentials() async { - final allKeys = await _secureStorage.readAll(); - for (final key in allKeys.keys) { - if (key.startsWith('oauth2_')) { - await _secureStorage.delete(key: key); - } - } - } - - /// Store environment variable securely (for secrets) - static Future storeEnvironmentSecret({ - required String environmentId, - required String variableKey, - required String value, - }) async { - final key = 'env_${environmentId}_$variableKey'; - await _secureStorage.write(key: key, value: value); - } - - /// Retrieve environment variable secret - static Future retrieveEnvironmentSecret({ - required String environmentId, - required String variableKey, - }) async { - final key = 'env_${environmentId}_$variableKey'; - return await _secureStorage.read(key: key); - } - - /// Delete environment variable secret - static Future deleteEnvironmentSecret({ - required String environmentId, - required String variableKey, - }) async { - final key = 'env_${environmentId}_$variableKey'; - await _secureStorage.delete(key: key); - } - - /// Clear all environment secrets for a specific environment - static Future clearEnvironmentSecrets({ - required String environmentId, - }) async { - final allKeys = await _secureStorage.readAll(); - final prefix = 'env_${environmentId}_'; - for (final key in allKeys.keys) { - if (key.startsWith(prefix)) { - await _secureStorage.delete(key: key); - } - } - } - - /// Check if secure storage is available - static Future isSecureStorageAvailable() async { - try { - await _secureStorage.read(key: '__test__'); - return true; - } catch (e) { - return false; - } - } -} diff --git a/lib/services/secure_storage.dart b/lib/services/secure_storage.dart new file mode 100644 index 00000000..ce5cb15c --- /dev/null +++ b/lib/services/secure_storage.dart @@ -0,0 +1,148 @@ +import 'dart:convert'; +import 'package:crypto/crypto.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +/// Modern unified secure storage for API Dash (2025 best practices) +/// Handles OAuth2 credentials, environment secrets, and rate limiting +class SecureStorage { + // Platform-specific secure storage + static const _storage = FlutterSecureStorage( + aOptions: AndroidOptions(encryptedSharedPreferences: true), + iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock), + ); + + // OAuth2 Rate limiting state + static final _rateLimits = {}; + static const _maxAttempts = 5; + static const _resetMinutes = 30; + + /// Generate secure key using SHA-256 + static String _hashKey(String input) { + return sha256.convert(utf8.encode(input)).toString().substring(0, 16); + } + + // ==================== OAuth2 Methods ==================== + + /// Check rate limit for OAuth2 + static String? checkRateLimit(String clientId, String tokenUrl) { + final key = _hashKey('$clientId:$tokenUrl'); + final limit = _rateLimits[key]; + + if (limit == null) return null; + + final now = DateTime.now(); + if (now.difference(limit.firstAttempt).inMinutes >= _resetMinutes) { + _rateLimits.remove(key); + return null; + } + + if (limit.cooldownUntil != null && now.isBefore(limit.cooldownUntil!)) { + final seconds = limit.cooldownUntil!.difference(now).inSeconds; + return 'Rate limit exceeded. Try again in $seconds seconds.'; + } + + return null; + } + + /// Record failed OAuth2 attempt + static void recordFailure(String clientId, String tokenUrl) { + final key = _hashKey('$clientId:$tokenUrl'); + final now = DateTime.now(); + final limit = _rateLimits[key]; + + if (limit == null) { + _rateLimits[key] = _RateLimit(now, now, 1, null); + } else { + final attempts = limit.attempts + 1; + final delay = attempts >= _maxAttempts + ? (2 << (attempts - _maxAttempts)).clamp(2, 300) + : 0; + + _rateLimits[key] = _RateLimit( + limit.firstAttempt, + now, + attempts, + delay > 0 ? now.add(Duration(seconds: delay)) : null, + ); + } + } + + /// Record successful OAuth2 attempt + static void recordSuccess(String clientId, String tokenUrl) { + _rateLimits.remove(_hashKey('$clientId:$tokenUrl')); + } + + /// Store OAuth2 credentials + static Future storeOAuth2({ + required String clientId, + required String tokenUrl, + required String credentialsJson, + }) async { + try { + await _storage.write( + key: 'oauth2_${_hashKey('$clientId:$tokenUrl')}', + value: credentialsJson, + ); + } catch (_) {} + } + + /// Retrieve OAuth2 credentials + static Future retrieveOAuth2({ + required String clientId, + required String tokenUrl, + }) async { + try { + return await _storage.read( + key: 'oauth2_${_hashKey('$clientId:$tokenUrl')}', + ); + } catch (_) { + return null; + } + } + + // ==================== Environment Secret Methods ==================== + + /// Store environment secret + static Future storeSecret({ + required String environmentId, + required String key, + required String value, + }) async { + try { + await _storage.write(key: 'env_${environmentId}_$key', value: value); + } catch (_) {} + } + + /// Retrieve environment secret + static Future retrieveSecret({ + required String environmentId, + required String key, + }) async { + try { + return await _storage.read(key: 'env_${environmentId}_$key'); + } catch (_) { + return null; + } + } + + /// Delete all secrets for an environment + static Future deleteEnvironmentSecrets(String environmentId) async { + try { + final all = await _storage.readAll(); + final prefix = 'env_${environmentId}_'; + for (final key in all.keys.where((k) => k.startsWith(prefix))) { + await _storage.delete(key: key); + } + } catch (_) {} + } +} + +// Internal rate limit state +class _RateLimit { + final DateTime firstAttempt; + final DateTime lastAttempt; + final int attempts; + final DateTime? cooldownUntil; + + _RateLimit(this.firstAttempt, this.lastAttempt, this.attempts, this.cooldownUntil); +} diff --git a/lib/utils/secure_codegen_utils.dart b/lib/utils/secure_codegen_utils.dart deleted file mode 100644 index 92950c7a..00000000 --- a/lib/utils/secure_codegen_utils.dart +++ /dev/null @@ -1,134 +0,0 @@ -/// Security utilities for code generation -/// Provides sanitization and validation for generated code to prevent injection attacks -class SecureCodeGenUtils { - /// Maximum length for any user input field - static const int _maxFieldLength = 10000; - - /// Validates if a field name is safe (alphanumeric and underscore only) - static bool isValidFieldName(String name) { - if (name.isEmpty || name.length > 255) { - return false; - } - return RegExp(r'^[a-zA-Z_][a-zA-Z0-9_]*$').hasMatch(name); - } - - /// Comprehensive JavaScript string escaping - /// Prevents XSS and code injection in generated JavaScript code - static String escapeJavaScript(String input) { - if (input.length > _maxFieldLength) { - throw SecurityException('Input exceeds maximum length'); - } - - return input - .replaceAll('\\', '\\\\') // Backslash - .replaceAll('"', '\\"') // Double quote - .replaceAll("'", "\\'") // Single quote - .replaceAll('\n', '\\n') // Newline - .replaceAll('\r', '\\r') // Carriage return - .replaceAll('\t', '\\t') // Tab - .replaceAll('\b', '\\b') // Backspace - .replaceAll('\f', '\\f') // Form feed - .replaceAll('<', '\\x3C') // Less than (XSS protection) - .replaceAll('>', '\\x3E') // Greater than - .replaceAll('&', '\\x26') // Ampersand - .replaceAll('/', '\\/') // Forward slash - .replaceAll('\u2028', '\\u2028') // Line separator - .replaceAll('\u2029', '\\u2029'); // Paragraph separator - } - - /// HTML escaping for generated code comments - static String escapeHtml(String input) { - if (input.length > _maxFieldLength) { - throw SecurityException('Input exceeds maximum length'); - } - - return input - .replaceAll('&', '&') - .replaceAll('<', '<') - .replaceAll('>', '>') - .replaceAll('"', '"') - .replaceAll("'", ''') - .replaceAll('/', '/'); - } - - /// Python string escaping - static String escapePython(String input) { - if (input.length > _maxFieldLength) { - throw SecurityException('Input exceeds maximum length'); - } - - return input - .replaceAll('\\', '\\\\') - .replaceAll('"', '\\"') - .replaceAll("'", "\\'") - .replaceAll('\n', '\\n') - .replaceAll('\r', '\\r') - .replaceAll('\t', '\\t'); - } - - /// Validate and sanitize URL - /// Returns null if URL is invalid - static String? sanitizeUrl(String url) { - if (url.length > _maxFieldLength) { - return null; - } - - try { - final uri = Uri.parse(url); - - // Only allow http and https schemes - if (uri.scheme != 'http' && uri.scheme != 'https') { - return null; - } - - // Validate host - if (uri.host.isEmpty) { - return null; - } - - return uri.toString(); - } catch (e) { - return null; - } - } - - /// Validate that input doesn't contain dangerous patterns - static bool containsDangerousPattern(String input) { - // Check for common injection patterns - final dangerousPatterns = [ - RegExp(r' _maxFieldLength) { - throw SecurityException('Input exceeds maximum length'); - } - - // Remove any null bytes - return input.replaceAll('\x00', ''); - } -} - -/// Exception thrown when a security validation fails -class SecurityException implements Exception { - final String message; - SecurityException(this.message); - - @override - String toString() => 'SecurityException: $message'; -} diff --git a/lib/utils/secure_envvar_utils.dart b/lib/utils/secure_envvar_utils.dart deleted file mode 100644 index c1aa5a73..00000000 --- a/lib/utils/secure_envvar_utils.dart +++ /dev/null @@ -1,102 +0,0 @@ -import 'dart:math' as math; - -/// Security utility for environment variable substitution -/// Protects against ReDoS (Regular Expression Denial of Service) attacks -class SecureEnvVarUtils { - // Maximum input length to prevent DoS attacks - static const int _maxInputLength = 10000; - - // Maximum number of variables before switching to alternative algorithm - static const int _maxRegexComplexity = 1000; - - /// Validates if a variable name is safe (alphanumeric, underscore, dash only) - static bool isValidVariableName(String name) { - if (name.isEmpty || name.length > 100) { - return false; - } - return RegExp(r'^[a-zA-Z0-9_-]+$').hasMatch(name); - } - - /// Escapes special regex characters in a string - static String escapeRegex(String input) { - return input.replaceAllMapped( - RegExp(r'[.*+?^${}()|[\]\\]'), - (match) => '\\${match.group(0)}', - ); - } - - /// Safely substitute environment variables without ReDoS vulnerability - /// - /// Validates input length and complexity before processing - /// Uses alternative string matching for large variable sets - static String? substituteVariablesSafe( - String? input, - Map envVarMap, - ) { - if (input == null) return null; - if (envVarMap.keys.isEmpty) return input; - - // Check input length to prevent DoS - if (input.length > _maxInputLength) { - throw SecurityException( - 'Input exceeds maximum length of $_maxInputLength characters' - ); - } - - // Validate all variable names before processing - final invalidNames = envVarMap.keys.where((key) => !isValidVariableName(key)); - if (invalidNames.isNotEmpty) { - throw SecurityException( - 'Invalid variable names found: ${invalidNames.join(', ')}' - ); - } - - // For large variable sets, use direct string replacement to avoid ReDoS - if (envVarMap.keys.length > _maxRegexComplexity) { - return _substituteWithoutRegex(input, envVarMap); - } - - // For reasonable sets, use regex with escaped keys - try { - final escapedKeys = envVarMap.keys.map(escapeRegex).join('|'); - final regex = RegExp(r'\{\{(' + escapedKeys + r')\}\}'); - - return input.replaceAllMapped(regex, (match) { - final key = match.group(1)?.trim() ?? ''; - return envVarMap[key] ?? '{{$key}}'; - }); - } catch (e) { - // Fallback to safe method on any error - return _substituteWithoutRegex(input, envVarMap); - } - } - - /// Alternative substitution method that doesn't use regex - /// Safe for large variable sets - static String _substituteWithoutRegex( - String input, - Map envVarMap, - ) { - var result = input; - - // Sort by length descending to handle overlapping keys correctly - final sortedEntries = envVarMap.entries.toList() - ..sort((a, b) => b.key.length.compareTo(a.key.length)); - - for (var entry in sortedEntries) { - final pattern = '{{${entry.key}}}'; - result = result.replaceAll(pattern, entry.value); - } - - return result; - } -} - -/// Exception thrown when a security validation fails -class SecurityException implements Exception { - final String message; - SecurityException(this.message); - - @override - String toString() => 'SecurityException: $message'; -} diff --git a/packages/better_networking/lib/services/oauth2_rate_limiter.dart b/packages/better_networking/lib/services/oauth2_rate_limiter.dart deleted file mode 100644 index 4805dbf1..00000000 --- a/packages/better_networking/lib/services/oauth2_rate_limiter.dart +++ /dev/null @@ -1,126 +0,0 @@ -/// Rate limiter for OAuth2 authentication attempts -/// Implements exponential backoff to prevent abuse and brute force attacks -class OAuth2RateLimiter { - static final Map _states = {}; - - // Maximum attempts before lockout - static const int _maxAttempts = 5; - - // Initial delay after first failure (in seconds) - static const int _initialDelay = 2; - - // Maximum delay (in seconds) - static const int _maxDelay = 300; // 5 minutes - - // Time window for reset (in minutes) - static const int _resetWindow = 30; - - /// Check if an OAuth operation can proceed - /// Returns null if allowed, or DateTime when the operation can be retried - static DateTime? canProceed(String key) { - final state = _states[key]; - - if (state == null) { - // First attempt, no restrictions - return null; - } - - final now = DateTime.now(); - - // Check if we should reset the counter (time window passed) - if (now.difference(state.firstAttempt).inMinutes >= _resetWindow) { - _states.remove(key); - return null; - } - - // Check if we're in cooldown period - if (state.nextAttemptAt != null && now.isBefore(state.nextAttemptAt!)) { - return state.nextAttemptAt; - } - - // Check if max attempts exceeded - if (state.attemptCount >= _maxAttempts) { - // Calculate next allowed attempt with exponential backoff - final delaySeconds = _calculateDelay(state.attemptCount); - final nextAttempt = state.lastAttempt.add(Duration(seconds: delaySeconds)); - - if (now.isBefore(nextAttempt)) { - return nextAttempt; - } - } - - return null; - } - - /// Record a failed authentication attempt - static void recordFailure(String key) { - final now = DateTime.now(); - final state = _states[key]; - - if (state == null) { - _states[key] = _RateLimitState( - firstAttempt: now, - lastAttempt: now, - attemptCount: 1, - nextAttemptAt: null, - ); - } else { - final delaySeconds = _calculateDelay(state.attemptCount + 1); - - _states[key] = _RateLimitState( - firstAttempt: state.firstAttempt, - lastAttempt: now, - attemptCount: state.attemptCount + 1, - nextAttemptAt: now.add(Duration(seconds: delaySeconds)), - ); - } - } - - /// Record a successful authentication (clears the rate limit) - static void recordSuccess(String key) { - _states.remove(key); - } - - /// Calculate delay with exponential backoff - static int _calculateDelay(int attemptCount) { - if (attemptCount <= 1) return 0; - - // Exponential backoff: 2^(n-1) seconds, capped at _maxDelay - final delay = _initialDelay * (1 << (attemptCount - 2)); - return delay > _maxDelay ? _maxDelay : delay; - } - - /// Generate rate limit key from client credentials - static String generateKey(String clientId, String tokenUrl) { - return '$clientId:$tokenUrl'; - } - - /// Get remaining cooldown time in seconds - static int? getCooldownSeconds(String key) { - final canProceedAt = canProceed(key); - if (canProceedAt == null) return null; - - final now = DateTime.now(); - final diff = canProceedAt.difference(now); - return diff.inSeconds > 0 ? diff.inSeconds : null; - } - - /// Clear all rate limiting states (for testing or admin purposes) - static void clearAll() { - _states.clear(); - } -} - -class _RateLimitState { - final DateTime firstAttempt; - final DateTime lastAttempt; - final int attemptCount; - final DateTime? nextAttemptAt; - - _RateLimitState({ - required this.firstAttempt, - required this.lastAttempt, - required this.attemptCount, - this.nextAttemptAt, - }); -} diff --git a/packages/better_networking/lib/services/oauth2_secure_storage.dart b/packages/better_networking/lib/services/oauth2_secure_storage.dart index 9822c2a5..b3a3302d 100644 --- a/packages/better_networking/lib/services/oauth2_secure_storage.dart +++ b/packages/better_networking/lib/services/oauth2_secure_storage.dart @@ -2,79 +2,111 @@ import 'dart:convert'; import 'package:crypto/crypto.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -/// Secure storage service for OAuth2 credentials -/// Uses platform-specific secure storage (Keychain on iOS, EncryptedSharedPreferences on Android) +/// Modern OAuth2 secure storage with built-in rate limiting (2025 best practices) +/// Note: This is a package-local wrapper. The main app uses lib/services/secure_storage.dart class OAuth2SecureStorage { - static const FlutterSecureStorage _secureStorage = FlutterSecureStorage( - aOptions: AndroidOptions( - encryptedSharedPreferences: true, - ), - iOptions: IOSOptions( - accessibility: KeychainAccessibility.first_unlock, - ), + // Secure storage with platform-specific encryption + static const _storage = FlutterSecureStorage( + aOptions: AndroidOptions(encryptedSharedPreferences: true), + iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock), ); - /// Generate a unique storage key from client ID and token URL - static String _generateKey(String clientId, String tokenUrl) { - final combined = '$clientId:$tokenUrl'; - final bytes = utf8.encode(combined); - final hash = sha256.convert(bytes); - return 'oauth2_cred_${hash.toString().substring(0, 16)}'; + // Rate limiting state + static final _rateLimits = {}; + static const _maxAttempts = 5; + static const _resetMinutes = 30; + + /// Generate secure storage key using SHA-256 + static String _key(String clientId, String tokenUrl) { + final hash = sha256.convert(utf8.encode('$clientId:$tokenUrl')); + return 'oauth2_${hash.toString().substring(0, 16)}'; } - /// Store OAuth2 credentials securely - static Future storeCredentials({ + /// Check if authentication can proceed (rate limiting) + static String? checkRateLimit(String clientId, String tokenUrl) { + final key = _key(clientId, tokenUrl); + final limit = _rateLimits[key]; + + if (limit == null) return null; + + final now = DateTime.now(); + + // Auto-reset after 30 minutes + if (now.difference(limit.firstAttempt).inMinutes >= _resetMinutes) { + _rateLimits.remove(key); + return null; + } + + // Check cooldown + if (limit.cooldownUntil != null && now.isBefore(limit.cooldownUntil!)) { + final seconds = limit.cooldownUntil!.difference(now).inSeconds; + return 'Rate limit exceeded. Try again in $seconds seconds.'; + } + + return null; + } + + /// Record failed authentication attempt + static void recordFailure(String clientId, String tokenUrl) { + final key = _key(clientId, tokenUrl); + final now = DateTime.now(); + final limit = _rateLimits[key]; + + if (limit == null) { + _rateLimits[key] = _RateLimit(now, now, 1, null); + } else { + final attempts = limit.attempts + 1; + // Exponential backoff: 2, 4, 8, 16... max 300s (5 minutes) + final delay = attempts >= _maxAttempts + ? (2 << (attempts - _maxAttempts)).clamp(2, 300) + : 0; + + _rateLimits[key] = _RateLimit( + limit.firstAttempt, + now, + attempts, + delay > 0 ? now.add(Duration(seconds: delay)) : null, + ); + } + } + + /// Record successful authentication (clears rate limit) + static void recordSuccess(String clientId, String tokenUrl) { + _rateLimits.remove(_key(clientId, tokenUrl)); + } + + /// Store credentials securely + static Future store({ required String clientId, required String tokenUrl, required String credentialsJson, }) async { try { - final key = _generateKey(clientId, tokenUrl); - await _secureStorage.write(key: key, value: credentialsJson); - } catch (e) { - // Log error but don't fail - fallback to no storage - // In production, consider proper logging + await _storage.write(key: _key(clientId, tokenUrl), value: credentialsJson); + } catch (_) { + // Graceful degradation } } - /// Retrieve OAuth2 credentials - static Future retrieveCredentials({ + /// Retrieve credentials + static Future retrieve({ required String clientId, required String tokenUrl, }) async { try { - final key = _generateKey(clientId, tokenUrl); - return await _secureStorage.read(key: key); - } catch (e) { - // Log error but return null - will trigger fresh auth + return await _storage.read(key: _key(clientId, tokenUrl)); + } catch (_) { return null; } } - - /// Delete OAuth2 credentials - static Future deleteCredentials({ - required String clientId, - required String tokenUrl, - }) async { - try { - final key = _generateKey(clientId, tokenUrl); - await _secureStorage.delete(key: key); - } catch (e) { - // Log error but don't fail - } - } - - /// Clear all OAuth2 credentials - static Future clearAllCredentials() async { - try { - final allKeys = await _secureStorage.readAll(); - for (final key in allKeys.keys) { - if (key.startsWith('oauth2_cred_')) { - await _secureStorage.delete(key: key); - } - } - } catch (e) { - // Log error but don't fail - } - } +} + +// Internal rate limit state +class _RateLimit { + final DateTime firstAttempt; + final DateTime lastAttempt; + final int attempts; + final DateTime? cooldownUntil; + + _RateLimit(this.firstAttempt, this.lastAttempt, this.attempts, this.cooldownUntil); } diff --git a/packages/better_networking/lib/utils/auth/oauth2_utils.dart b/packages/better_networking/lib/utils/auth/oauth2_utils.dart index d0a911ba..ca174e14 100644 --- a/packages/better_networking/lib/utils/auth/oauth2_utils.dart +++ b/packages/better_networking/lib/utils/auth/oauth2_utils.dart @@ -7,7 +7,6 @@ import '../../models/auth/auth_oauth2_model.dart'; import '../../services/http_client_manager.dart'; import '../../services/oauth_callback_server.dart'; import '../../services/oauth2_secure_storage.dart'; -import '../../services/oauth2_rate_limiter.dart'; import '../platform_utils.dart'; /// Advanced OAuth2 authorization code grant handler that returns both the client and server @@ -25,21 +24,15 @@ Future<(oauth2.Client, OAuthCallbackServer?)> oAuth2AuthorizationCodeGrant({ String? state, String? scope, }) async { - // Check rate limiting - final rateLimitKey = OAuth2RateLimiter.generateKey(identifier, tokenEndpoint.toString()); - final canProceedAt = OAuth2RateLimiter.canProceed(rateLimitKey); - - if (canProceedAt != null) { - final cooldownSeconds = OAuth2RateLimiter.getCooldownSeconds(rateLimitKey); - throw Exception( - 'OAuth2 rate limit exceeded. Please try again in ${cooldownSeconds ?? 0} seconds.' - ); + // Check rate limiting (integrated with OAuth2SecureStorage) + final rateLimitError = OAuth2SecureStorage.checkRateLimit(identifier, tokenEndpoint.toString()); + if (rateLimitError != null) { + throw Exception(rateLimitError); } - // Check for existing credentials first - try secure storage, then file - // Try secure storage first (preferred method) + // Try secure storage first (modern approach) try { - final secureCredJson = await OAuth2SecureStorage.retrieveCredentials( + final secureCredJson = await OAuth2SecureStorage.retrieve( clientId: identifier, tokenUrl: tokenEndpoint.toString(), ); @@ -47,8 +40,7 @@ Future<(oauth2.Client, OAuthCallbackServer?)> oAuth2AuthorizationCodeGrant({ if (secureCredJson != null) { final credentials = oauth2.Credentials.fromJson(secureCredJson); if (credentials.accessToken.isNotEmpty && !credentials.isExpired) { - // Successful retrieval, clear rate limit - OAuth2RateLimiter.recordSuccess(rateLimitKey); + OAuth2SecureStorage.recordSuccess(identifier, tokenEndpoint.toString()); return ( oauth2.Client(credentials, identifier: identifier, secret: secret), null, @@ -56,7 +48,7 @@ Future<(oauth2.Client, OAuthCallbackServer?)> oAuth2AuthorizationCodeGrant({ } } } catch (e) { - // Secure storage failed, try file fallback + // Graceful fallback } // Fallback to file-based storage for backward compatibility @@ -68,7 +60,7 @@ Future<(oauth2.Client, OAuthCallbackServer?)> oAuth2AuthorizationCodeGrant({ if (credentials.accessToken.isNotEmpty && !credentials.isExpired) { // Migrate to secure storage for future use try { - await OAuth2SecureStorage.storeCredentials( + await OAuth2SecureStorage.store( clientId: identifier, tokenUrl: tokenEndpoint.toString(), credentialsJson: json, @@ -175,7 +167,7 @@ Future<(oauth2.Client, OAuthCallbackServer?)> oAuth2AuthorizationCodeGrant({ // Store credentials securely (preferred method) try { - await OAuth2SecureStorage.storeCredentials( + await OAuth2SecureStorage.store( clientId: identifier, tokenUrl: tokenEndpoint.toString(), credentialsJson: client.credentials.toJson(), @@ -192,12 +184,12 @@ Future<(oauth2.Client, OAuthCallbackServer?)> oAuth2AuthorizationCodeGrant({ } // Record successful authentication - OAuth2RateLimiter.recordSuccess(rateLimitKey); + OAuth2SecureStorage.recordSuccess(identifier, tokenEndpoint.toString()); return (client, callbackServer); } catch (e) { // Record failed authentication attempt - OAuth2RateLimiter.recordFailure(rateLimitKey); + OAuth2SecureStorage.recordFailure(identifier, tokenEndpoint.toString()); // Clean up the callback server immediately on error if (callbackServer != null) { @@ -220,22 +212,18 @@ Future oAuth2ClientCredentialsGrantHandler({ required File? credentialsFile, }) async { // Check rate limiting - final rateLimitKey = OAuth2RateLimiter.generateKey( + final rateLimitError = OAuth2SecureStorage.checkRateLimit( oauth2Model.clientId, oauth2Model.accessTokenUrl, ); - final canProceedAt = OAuth2RateLimiter.canProceed(rateLimitKey); - if (canProceedAt != null) { - final cooldownSeconds = OAuth2RateLimiter.getCooldownSeconds(rateLimitKey); - throw Exception( - 'OAuth2 rate limit exceeded. Please try again in ${cooldownSeconds ?? 0} seconds.' - ); + if (rateLimitError != null) { + throw Exception(rateLimitError); } // Try secure storage first try { - final secureCredJson = await OAuth2SecureStorage.retrieveCredentials( + final secureCredJson = await OAuth2SecureStorage.retrieve( clientId: oauth2Model.clientId, tokenUrl: oauth2Model.accessTokenUrl, ); @@ -243,7 +231,7 @@ Future oAuth2ClientCredentialsGrantHandler({ if (secureCredJson != null) { final credentials = oauth2.Credentials.fromJson(secureCredJson); if (credentials.accessToken.isNotEmpty && !credentials.isExpired) { - OAuth2RateLimiter.recordSuccess(rateLimitKey); + OAuth2SecureStorage.recordSuccess(oauth2Model.clientId, oauth2Model.accessTokenUrl); return oauth2.Client( credentials, identifier: oauth2Model.clientId, @@ -264,7 +252,7 @@ Future oAuth2ClientCredentialsGrantHandler({ if (credentials.accessToken.isNotEmpty && !credentials.isExpired) { // Migrate to secure storage try { - await OAuth2SecureStorage.storeCredentials( + await OAuth2SecureStorage.store( clientId: oauth2Model.clientId, tokenUrl: oauth2Model.accessTokenUrl, credentialsJson: json, @@ -303,7 +291,7 @@ Future oAuth2ClientCredentialsGrantHandler({ // Store credentials securely try { - await OAuth2SecureStorage.storeCredentials( + await OAuth2SecureStorage.store( clientId: oauth2Model.clientId, tokenUrl: oauth2Model.accessTokenUrl, credentialsJson: client.credentials.toJson(), @@ -320,7 +308,7 @@ Future oAuth2ClientCredentialsGrantHandler({ } // Record successful authentication - OAuth2RateLimiter.recordSuccess(rateLimitKey); + OAuth2SecureStorage.recordSuccess(oauth2Model.clientId, oauth2Model.accessTokenUrl); // Clean up the HTTP client httpClientManager.closeClient(requestId); @@ -328,7 +316,7 @@ Future oAuth2ClientCredentialsGrantHandler({ return client; } catch (e) { // Record failed authentication attempt - OAuth2RateLimiter.recordFailure(rateLimitKey); + OAuth2SecureStorage.recordFailure(oauth2Model.clientId, oauth2Model.accessTokenUrl); // Clean up the HTTP client on error httpClientManager.closeClient(requestId); @@ -342,7 +330,7 @@ Future oAuth2ResourceOwnerPasswordGrantHandler({ }) async { // Try secure storage first try { - final secureCredJson = await OAuth2SecureStorage.retrieveCredentials( + final secureCredJson = await OAuth2SecureStorage.retrieve( clientId: oauth2Model.clientId, tokenUrl: oauth2Model.accessTokenUrl, ); @@ -370,7 +358,7 @@ Future oAuth2ResourceOwnerPasswordGrantHandler({ if (credentials.accessToken.isNotEmpty && !credentials.isExpired) { // Migrate to secure storage try { - await OAuth2SecureStorage.storeCredentials( + await OAuth2SecureStorage.store( clientId: oauth2Model.clientId, tokenUrl: oauth2Model.accessTokenUrl, credentialsJson: json, @@ -415,7 +403,7 @@ Future oAuth2ResourceOwnerPasswordGrantHandler({ // Store credentials securely try { - await OAuth2SecureStorage.storeCredentials( + await OAuth2SecureStorage.store( clientId: oauth2Model.clientId, tokenUrl: oauth2Model.accessTokenUrl, credentialsJson: client.credentials.toJson(),