From e3fa16fe8852917ad0708ce168951fecbc31b683 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Oct 2025 19:21:04 +0000 Subject: [PATCH] Implement OAuth2 rate limiting with exponential backoff Co-authored-by: animator <615622+animator@users.noreply.github.com> --- .../lib/services/oauth2_rate_limiter.dart | 126 ++++++++++++++++++ .../lib/utils/auth/oauth2_utils.dart | 41 ++++++ 2 files changed, 167 insertions(+) create mode 100644 packages/better_networking/lib/services/oauth2_rate_limiter.dart diff --git a/packages/better_networking/lib/services/oauth2_rate_limiter.dart b/packages/better_networking/lib/services/oauth2_rate_limiter.dart new file mode 100644 index 00000000..4805dbf1 --- /dev/null +++ b/packages/better_networking/lib/services/oauth2_rate_limiter.dart @@ -0,0 +1,126 @@ +/// 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/utils/auth/oauth2_utils.dart b/packages/better_networking/lib/utils/auth/oauth2_utils.dart index ab9583fb..d0a911ba 100644 --- a/packages/better_networking/lib/utils/auth/oauth2_utils.dart +++ b/packages/better_networking/lib/utils/auth/oauth2_utils.dart @@ -7,6 +7,7 @@ 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 @@ -24,6 +25,17 @@ 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 for existing credentials first - try secure storage, then file // Try secure storage first (preferred method) try { @@ -35,6 +47,8 @@ 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); return ( oauth2.Client(credentials, identifier: identifier, secret: secret), null, @@ -177,8 +191,14 @@ Future<(oauth2.Client, OAuthCallbackServer?)> oAuth2AuthorizationCodeGrant({ } } + // Record successful authentication + OAuth2RateLimiter.recordSuccess(rateLimitKey); + return (client, callbackServer); } catch (e) { + // Record failed authentication attempt + OAuth2RateLimiter.recordFailure(rateLimitKey); + // Clean up the callback server immediately on error if (callbackServer != null) { try { @@ -199,6 +219,20 @@ Future oAuth2ClientCredentialsGrantHandler({ required AuthOAuth2Model oauth2Model, required File? credentialsFile, }) async { + // Check rate limiting + final rateLimitKey = OAuth2RateLimiter.generateKey( + 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.' + ); + } + // Try secure storage first try { final secureCredJson = await OAuth2SecureStorage.retrieveCredentials( @@ -209,6 +243,7 @@ Future oAuth2ClientCredentialsGrantHandler({ if (secureCredJson != null) { final credentials = oauth2.Credentials.fromJson(secureCredJson); if (credentials.accessToken.isNotEmpty && !credentials.isExpired) { + OAuth2RateLimiter.recordSuccess(rateLimitKey); return oauth2.Client( credentials, identifier: oauth2Model.clientId, @@ -284,11 +319,17 @@ Future oAuth2ClientCredentialsGrantHandler({ } } + // Record successful authentication + OAuth2RateLimiter.recordSuccess(rateLimitKey); + // Clean up the HTTP client httpClientManager.closeClient(requestId); return client; } catch (e) { + // Record failed authentication attempt + OAuth2RateLimiter.recordFailure(rateLimitKey); + // Clean up the HTTP client on error httpClientManager.closeClient(requestId); rethrow;