Skip to content

Commit e3fa16f

Browse files
Copilotanimator
andcommitted
Implement OAuth2 rate limiting with exponential backoff
Co-authored-by: animator <615622+animator@users.noreply.github.com>
1 parent 5f67d00 commit e3fa16f

File tree

2 files changed

+167
-0
lines changed

2 files changed

+167
-0
lines changed
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
/// Rate limiter for OAuth2 authentication attempts
2+
/// Implements exponential backoff to prevent abuse and brute force attacks
3+
class OAuth2RateLimiter {
4+
static final Map<String, _RateLimitState> _states = {};
5+
6+
// Maximum attempts before lockout
7+
static const int _maxAttempts = 5;
8+
9+
// Initial delay after first failure (in seconds)
10+
static const int _initialDelay = 2;
11+
12+
// Maximum delay (in seconds)
13+
static const int _maxDelay = 300; // 5 minutes
14+
15+
// Time window for reset (in minutes)
16+
static const int _resetWindow = 30;
17+
18+
/// Check if an OAuth operation can proceed
19+
/// Returns null if allowed, or DateTime when the operation can be retried
20+
static DateTime? canProceed(String key) {
21+
final state = _states[key];
22+
23+
if (state == null) {
24+
// First attempt, no restrictions
25+
return null;
26+
}
27+
28+
final now = DateTime.now();
29+
30+
// Check if we should reset the counter (time window passed)
31+
if (now.difference(state.firstAttempt).inMinutes >= _resetWindow) {
32+
_states.remove(key);
33+
return null;
34+
}
35+
36+
// Check if we're in cooldown period
37+
if (state.nextAttemptAt != null && now.isBefore(state.nextAttemptAt!)) {
38+
return state.nextAttemptAt;
39+
}
40+
41+
// Check if max attempts exceeded
42+
if (state.attemptCount >= _maxAttempts) {
43+
// Calculate next allowed attempt with exponential backoff
44+
final delaySeconds = _calculateDelay(state.attemptCount);
45+
final nextAttempt = state.lastAttempt.add(Duration(seconds: delaySeconds));
46+
47+
if (now.isBefore(nextAttempt)) {
48+
return nextAttempt;
49+
}
50+
}
51+
52+
return null;
53+
}
54+
55+
/// Record a failed authentication attempt
56+
static void recordFailure(String key) {
57+
final now = DateTime.now();
58+
final state = _states[key];
59+
60+
if (state == null) {
61+
_states[key] = _RateLimitState(
62+
firstAttempt: now,
63+
lastAttempt: now,
64+
attemptCount: 1,
65+
nextAttemptAt: null,
66+
);
67+
} else {
68+
final delaySeconds = _calculateDelay(state.attemptCount + 1);
69+
70+
_states[key] = _RateLimitState(
71+
firstAttempt: state.firstAttempt,
72+
lastAttempt: now,
73+
attemptCount: state.attemptCount + 1,
74+
nextAttemptAt: now.add(Duration(seconds: delaySeconds)),
75+
);
76+
}
77+
}
78+
79+
/// Record a successful authentication (clears the rate limit)
80+
static void recordSuccess(String key) {
81+
_states.remove(key);
82+
}
83+
84+
/// Calculate delay with exponential backoff
85+
static int _calculateDelay(int attemptCount) {
86+
if (attemptCount <= 1) return 0;
87+
88+
// Exponential backoff: 2^(n-1) seconds, capped at _maxDelay
89+
final delay = _initialDelay * (1 << (attemptCount - 2));
90+
return delay > _maxDelay ? _maxDelay : delay;
91+
}
92+
93+
/// Generate rate limit key from client credentials
94+
static String generateKey(String clientId, String tokenUrl) {
95+
return '$clientId:$tokenUrl';
96+
}
97+
98+
/// Get remaining cooldown time in seconds
99+
static int? getCooldownSeconds(String key) {
100+
final canProceedAt = canProceed(key);
101+
if (canProceedAt == null) return null;
102+
103+
final now = DateTime.now();
104+
final diff = canProceedAt.difference(now);
105+
return diff.inSeconds > 0 ? diff.inSeconds : null;
106+
}
107+
108+
/// Clear all rate limiting states (for testing or admin purposes)
109+
static void clearAll() {
110+
_states.clear();
111+
}
112+
}
113+
114+
class _RateLimitState {
115+
final DateTime firstAttempt;
116+
final DateTime lastAttempt;
117+
final int attemptCount;
118+
final DateTime? nextAttemptAt;
119+
120+
_RateLimitState({
121+
required this.firstAttempt,
122+
required this.lastAttempt,
123+
required this.attemptCount,
124+
this.nextAttemptAt,
125+
});
126+
}

packages/better_networking/lib/utils/auth/oauth2_utils.dart

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import '../../models/auth/auth_oauth2_model.dart';
77
import '../../services/http_client_manager.dart';
88
import '../../services/oauth_callback_server.dart';
99
import '../../services/oauth2_secure_storage.dart';
10+
import '../../services/oauth2_rate_limiter.dart';
1011
import '../platform_utils.dart';
1112

1213
/// Advanced OAuth2 authorization code grant handler that returns both the client and server
@@ -24,6 +25,17 @@ Future<(oauth2.Client, OAuthCallbackServer?)> oAuth2AuthorizationCodeGrant({
2425
String? state,
2526
String? scope,
2627
}) async {
28+
// Check rate limiting
29+
final rateLimitKey = OAuth2RateLimiter.generateKey(identifier, tokenEndpoint.toString());
30+
final canProceedAt = OAuth2RateLimiter.canProceed(rateLimitKey);
31+
32+
if (canProceedAt != null) {
33+
final cooldownSeconds = OAuth2RateLimiter.getCooldownSeconds(rateLimitKey);
34+
throw Exception(
35+
'OAuth2 rate limit exceeded. Please try again in ${cooldownSeconds ?? 0} seconds.'
36+
);
37+
}
38+
2739
// Check for existing credentials first - try secure storage, then file
2840
// Try secure storage first (preferred method)
2941
try {
@@ -35,6 +47,8 @@ Future<(oauth2.Client, OAuthCallbackServer?)> oAuth2AuthorizationCodeGrant({
3547
if (secureCredJson != null) {
3648
final credentials = oauth2.Credentials.fromJson(secureCredJson);
3749
if (credentials.accessToken.isNotEmpty && !credentials.isExpired) {
50+
// Successful retrieval, clear rate limit
51+
OAuth2RateLimiter.recordSuccess(rateLimitKey);
3852
return (
3953
oauth2.Client(credentials, identifier: identifier, secret: secret),
4054
null,
@@ -177,8 +191,14 @@ Future<(oauth2.Client, OAuthCallbackServer?)> oAuth2AuthorizationCodeGrant({
177191
}
178192
}
179193

194+
// Record successful authentication
195+
OAuth2RateLimiter.recordSuccess(rateLimitKey);
196+
180197
return (client, callbackServer);
181198
} catch (e) {
199+
// Record failed authentication attempt
200+
OAuth2RateLimiter.recordFailure(rateLimitKey);
201+
182202
// Clean up the callback server immediately on error
183203
if (callbackServer != null) {
184204
try {
@@ -199,6 +219,20 @@ Future<oauth2.Client> oAuth2ClientCredentialsGrantHandler({
199219
required AuthOAuth2Model oauth2Model,
200220
required File? credentialsFile,
201221
}) async {
222+
// Check rate limiting
223+
final rateLimitKey = OAuth2RateLimiter.generateKey(
224+
oauth2Model.clientId,
225+
oauth2Model.accessTokenUrl,
226+
);
227+
final canProceedAt = OAuth2RateLimiter.canProceed(rateLimitKey);
228+
229+
if (canProceedAt != null) {
230+
final cooldownSeconds = OAuth2RateLimiter.getCooldownSeconds(rateLimitKey);
231+
throw Exception(
232+
'OAuth2 rate limit exceeded. Please try again in ${cooldownSeconds ?? 0} seconds.'
233+
);
234+
}
235+
202236
// Try secure storage first
203237
try {
204238
final secureCredJson = await OAuth2SecureStorage.retrieveCredentials(
@@ -209,6 +243,7 @@ Future<oauth2.Client> oAuth2ClientCredentialsGrantHandler({
209243
if (secureCredJson != null) {
210244
final credentials = oauth2.Credentials.fromJson(secureCredJson);
211245
if (credentials.accessToken.isNotEmpty && !credentials.isExpired) {
246+
OAuth2RateLimiter.recordSuccess(rateLimitKey);
212247
return oauth2.Client(
213248
credentials,
214249
identifier: oauth2Model.clientId,
@@ -284,11 +319,17 @@ Future<oauth2.Client> oAuth2ClientCredentialsGrantHandler({
284319
}
285320
}
286321

322+
// Record successful authentication
323+
OAuth2RateLimiter.recordSuccess(rateLimitKey);
324+
287325
// Clean up the HTTP client
288326
httpClientManager.closeClient(requestId);
289327

290328
return client;
291329
} catch (e) {
330+
// Record failed authentication attempt
331+
OAuth2RateLimiter.recordFailure(rateLimitKey);
332+
292333
// Clean up the HTTP client on error
293334
httpClientManager.closeClient(requestId);
294335
rethrow;

0 commit comments

Comments
 (0)