1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
// SPDX-License-Identifier: MIT OR Apache-2.0
// Copyright (C) 2026 Matthew Jackson
//! A rate limiter the crate SHIPS, so that "the host must throttle this" is a line of code rather
//! than a paragraph of documentation.
//!
//! # Why a library that "cannot rate limit" ships a rate limiter
//!
//! [`crate::events::RateLimiter`] is a seam because this crate never sees a request: it has no IP,
//! no session, no TLS peer and no request context, so it cannot key a counter on the things a real
//! throttle wants to key on. That reasoning is sound and it is unchanged. What it does NOT justify
//! is shipping the seam EMPTY.
//!
//! RFC 8628 section 5.1 is explicit that the device user code's entropy is adequate only IN
//! COMBINATION WITH rate limiting of user code entry. This crate's default user code is
//! [`crate::server::MIN_USER_CODE_LENGTH`] symbols over a 20-symbol alphabet: 20^8 is about
//! 2.56e10, or 2^34.6. Against an unthrottled
//! [`crate::server::AuthorizationServer::approve_device`], at a conservative 1000 attempts per
//! second, an attacker makes 6e5 guesses inside the default 600 second
//! [`crate::server::ServerConfig::device_code_ttl`]. They do not need to hit one PARTICULAR code,
//! only SOME live one, so with a pool of `N` concurrently live grants the expected number of hits
//! per code lifetime is `6e5 * N / 2.56e10`, which passes 1 at about 43,000 live grants and is
//! already a 2.3% chance per lifetime at 1000. A hit binds a STRANGER'S DEVICE to the ATTACKER'S
//! account, because the attacker supplies the `subject`.
//!
//! So the entropy argument in section 5.1 is only half a defence, and the other half is a counter.
//! A crate that ships the half it can and leaves the half it "cannot" as an exercise has shipped a
//! deployment where the odds above are the real odds. [`FixedWindowRateLimiter`] is the half this
//! crate can ship: an in-memory, per-process, weighted fixed-window counter with no new dependency,
//! which a host installs in one line:
//!
//! ```
//! # use oauth_as::{AuthorizationServer, FixedWindowRateLimiter, MemoryStorage, ServerConfig};
//! # let config = ServerConfig::new("https://as.example", "https://as.example/device");
//! let server = AuthorizationServer::new(config, MemoryStorage::new())
//! .with_rate_limiter(Box::new(FixedWindowRateLimiter::new()));
//! ```
//!
//! It is a FLOOR, not a ceiling. Read "What this cannot do" below before deciding it is enough,
//! because the difference between a useful default and a false sense of safety is whether the host
//! was told plainly what they still owe.
//!
//! # What this cannot do
//!
//! - IT IS PER PROCESS. The counters live in this process's memory and nowhere else. On a
//! multi-node deployment EVERY NODE HAS ITS OWN COUNTERS and the effective limit is multiplied by
//! the node count: ten nodes behind a load balancer that spreads attempts evenly means ten times
//! the default budget, and an attacker who can pick their node gets a fresh budget per node. A
//! deployment at that scale needs a SHARED store (Redis, a database counter, the edge proxy's own
//! limiter) behind the same [`crate::events::RateLimiter`] trait. This type is the right answer
//! for a single-node deployment and a useful second layer for a larger one; it is not a
//! distributed limiter and no amount of tuning makes it one.
//! - IT RESETS ON RESTART. In-memory means a redeploy, a crash, or an OOM kill hands the attacker a
//! fresh budget. An attacker who can induce restarts can defeat it.
//! - IT HAS NO CALLER IDENTITY, so the device user code budget is GLOBAL. Every user of the
//! verification page shares one counter, because the library has no IP to separate them by. The
//! consequence runs in both directions and the host should understand both: an attacker's
//! failures consume budget a legitimate user might have wanted (a sustained attack degrades the
//! verification page for everyone), and the budget must therefore be set high enough not to
//! strangle real activation traffic. The weighting below is what makes that tension survivable,
//! not something that removes it. A host that DOES have request context should key its own
//! limiter on the IP or session and keep this one underneath as a backstop.
//! - IT IS A FIXED WINDOW, NOT A SLIDING ONE. A burst straddling a window boundary can land up to
//! twice the budget in quick succession (the tail of one window plus the head of the next). This
//! is the classic fixed-window artefact. It is accepted here because the alternative that fixes
//! it (a sliding log) stores a timestamp per attempt, which is exactly the unbounded
//! attacker-driven allocation the "bounded" requirement below rules out. Set the window shorter
//! if the doubling matters.
//! - IT IS NOT A LOCKOUT. Nothing is disabled, no account is suspended, no client is deregistered.
//! When the window rolls the budget is whole again, deliberately: a throttle that never lifts is
//! an outage, and an attacker who can trigger a permanent lockout of a client id has a denial of
//! service. Locking out is a policy decision with an operator in the loop, which is what the
//! [`crate::events::EventSink`] channel is for.
//! - IT DOES NOT REPLACE THE AUDIT CHANNEL. The counter refuses; it does not tell anyone. A
//! deployment being held at its ceiling for hours looks identical, from the inside, to a quiet
//! one. Install an [`crate::events::EventSink`] as well and alert on the rate of
//! [`crate::events::Event::ClientAuthenticationFailed`].
//!
//! # How the budget is spent
//!
//! One budget per key, in abstract COST UNITS, refilled to full at the start of every window.
//!
//! - Every attempt that is ALLOWED costs [`ATTEMPT_COST`] (1) at [`RateLimiter::check`] time.
//! That is the ceiling on traffic.
//! - Every allowed attempt that then FAILS costs a further `failure_cost` at
//! [`RateLimiter::record`] time.
//!
//! The weighting is the point, and it is why this implementation uses `record` rather than only
//! `check`. A guessing attack does not show up as VOLUME, it shows up as FAILURES: an attacker
//! spraying user codes fails essentially every time, while a legitimate user typing the code off
//! their television screen succeeds essentially every time. A limiter that counted traffic alone
//! would have to choose between a ceiling low enough to stop guessing (which throttles a busy
//! verification page into an outage) and one high enough for real traffic (which is no obstacle to
//! guessing). Charging failures ten times what successes cost lets one budget be both.
//!
//! # The default numbers, and why they are those numbers
//!
//! A default nobody can justify is worse than no default, so each of these is derived rather than
//! chosen, and each is a [`RateLimitConfig`] field a host can move.
//!
//! ## Window: 60 seconds ([`DEFAULT_WINDOW`])
//!
//! Long enough that the fixed-window doubling artefact is bounded by something a human notices,
//! short enough that a legitimate user caught behind somebody else's burst waits under a minute
//! rather than being locked out of activating their device. It also makes the numbers below
//! readable as "per minute", which matters when an operator has to reason about them at 3am.
//!
//! ## Device user code entry: 200 units, failures cost 10 ([`DEFAULT_DEVICE_USER_CODE_CAPACITY`],
//! [`DEFAULT_DEVICE_USER_CODE_FAILURE_COST`])
//!
//! Read as two numbers at once, which is what the weighting buys:
//!
//! - AT MOST 200 code entries per minute per process, so a deployment can activate about 3.3
//! devices a second on one node before the ceiling bites. That is the "do not strangle real
//! traffic" side.
//! - AT MOST 20 WRONG code entries per minute per process (each costs `1 + 9 = 10`). That is the
//! RFC 8628 section 5.1 side, and it is the number the arithmetic is about.
//!
//! Twenty wrong codes a minute is 200 guesses inside the default 600 second code lifetime. Against
//! a pool of 1000 concurrently live grants that is `200 * 1000 / 2.56e10`, about 7.8e-6 expected
//! hits per code lifetime, versus the 2.3e-2 an unthrottled endpoint gives the same attacker at
//! 1000 attempts a second: a reduction of roughly three thousand fold. Stated honestly the other
//! way, because a security default should be stated at its worst: an attack sustained at EXACTLY
//! this ceiling, unnoticed, against a deployment continuously holding 1000 live device grants,
//! accumulates about 0.4 expected hits over a YEAR. That is not "impossible", it is "a year-long
//! visible campaign for a coin flip", which is what a throttle is for: it converts minutes into a
//! sustained, loud, long-running operation. It is also why the paragraph above says to install an
//! event sink, and why a host with a larger live-grant pool should lower
//! `device_user_code_capacity` (the odds scale linearly with both) or raise
//! [`crate::server::ServerConfig::user_code_length`] (they scale by a factor of 20 per symbol).
//!
//! Twenty wrong entries a minute is also, as a legitimate-traffic number, generous: the user is
//! reading the code off a screen in front of them, the alphabet excludes the vowels and digits that
//! cause transcription errors, and this crate normalises case and hyphens before comparing. A
//! process seeing twenty genuine mistypes a minute is a process with a UI problem.
//!
//! ## Client authentication: 6000 units per client id, failures cost 200
//! ([`DEFAULT_CLIENT_AUTHENTICATION_CAPACITY`], [`DEFAULT_CLIENT_AUTHENTICATION_FAILURE_COST`])
//!
//! Keyed per `client_id`, which RFC 6749 section 2.2 states explicitly is not a secret, so keying
//! on it leaks nothing. Again two numbers:
//!
//! - AT MOST 6000 authentications per minute per client per process, which is 100 a second: above
//! the rate at which a single client's token traffic on a single node is already an architecture
//! discussion, so the ceiling should not be reached by a healthy deployment.
//! - AT MOST 30 FAILED authentications per minute per client (each costs `1 + 199 = 200`), which is
//! the RFC 9700 section 4.13 credential-stuffing number.
//!
//! Thirty is chosen against what a client secret actually is: a machine-held value this crate mints
//! or the host provisions, not a human-chosen password. A correctly configured client fails
//! authentication ZERO times, so any sustained failure rate for one `client_id` is either a
//! misconfiguration the operator wants to hear about or an attack, and both are better served by
//! refusing than by continuing. The budget is per client id rather than global specifically so one
//! client being stuffed cannot lock every other client out of the token endpoint.
//!
//! ## Tracked clients: 4096 ([`DEFAULT_MAX_TRACKED_CLIENTS`])
//!
//! See [`FixedWindowRateLimiter`] for the bounding argument. 4096 is chosen to comfortably exceed
//! the number of registrations a single deployment realistically authenticates within one 60 second
//! window while keeping the worst-case footprint under a megabyte.
use HashMap;
use Mutex;
use ;
use crate;
/// What one allowed attempt costs, charged at [`RateLimiter::check`] time.
///
/// This is the UNIT the capacities are denominated in: a capacity of 200 means "200 attempts, if
/// they all succeed". Fixing it at 1 rather than making it configurable keeps the two numbers a
/// host actually reasons about (how many attempts, how much worse is a failure) down to two.
pub const ATTEMPT_COST: u64 = 1;
/// 60 seconds. See the module docs for why.
pub const DEFAULT_WINDOW: Duration = from_secs;
/// 200 cost units per window for [`Attempt::DeviceUserCodeEntry`]: 200 entries a minute, or 20
/// wrong ones. See the module docs for the RFC 8628 section 5.1 arithmetic.
pub const DEFAULT_DEVICE_USER_CODE_CAPACITY: u64 = 200;
/// The EXTRA cost of a failed user code entry, on top of [`ATTEMPT_COST`], so a wrong code costs
/// ten times a right one.
pub const DEFAULT_DEVICE_USER_CODE_FAILURE_COST: u64 = 9;
/// 6000 cost units per window per `client_id`: 6000 authentications a minute, or 30 failed ones.
/// See the module docs for the RFC 9700 section 4.13 reasoning.
pub const DEFAULT_CLIENT_AUTHENTICATION_CAPACITY: u64 = 6000;
/// The EXTRA cost of a failed client authentication, on top of [`ATTEMPT_COST`], so a wrong
/// credential costs two hundred times a right one.
pub const DEFAULT_CLIENT_AUTHENTICATION_FAILURE_COST: u64 = 199;
/// How many distinct `client_id` values get their own counter within a window.
pub const DEFAULT_MAX_TRACKED_CLIENTS: usize = 4096;
/// The longest `client_id` that gets its own counter, in bytes.
///
/// Not configurable, because it is not a policy: it is the second half of the memory bound. A
/// `client_id` is attacker-supplied, so without a length cap a spray of 4096 identifiers of a
/// megabyte each would be 4 GB of "bounded" map. 128 bytes is far above anything this crate mints
/// (RFC 7591 registration produces 32 hex characters) or a host plausibly provisions; longer
/// identifiers still authenticate normally, they just share the overflow counter described on
/// [`FixedWindowRateLimiter`].
pub const MAX_TRACKED_CLIENT_ID_LEN: usize = 128;
/// The shortest window that can be configured.
///
/// A zero window would divide by zero when computing the window index, and a sub-millisecond one is
/// indistinguishable from no limiter at all on any real clock. [`RateLimitConfig::with_window`]
/// clamps up to this rather than rejecting, for the same reason
/// [`crate::server::ServerConfig::user_code_length`] clamps: a misconfiguration should not become a
/// runtime failure at the one moment a user is standing in front of a device.
pub const MIN_WINDOW: Duration = from_millis;
/// The knobs on [`FixedWindowRateLimiter`]. [`RateLimitConfig::default`] is the reasoned default
/// set documented at the module level; every field is public so a host can move one without the
/// builder.
///
/// `#[non_exhaustive]` because later releases will gain budgets for attempt kinds
/// [`Attempt`] does not yet have, and adding one must not break a host that built this by hand.
/// The counters for one window. Replaced wholesale when the window rolls.
/// An in-memory, per-process, weighted fixed-window [`RateLimiter`] the crate ships so that
/// throttling is one line rather than a project.
///
/// Read the module documentation before installing it. In particular: it is PER PROCESS, so on a
/// multi-node deployment the effective limit is multiplied by the node count.
///
/// # How it is bounded
///
/// A limiter that grows a map keyed on an attacker-supplied `client_id` is itself a denial of
/// service, so the map is bounded three ways at once and every bound is a hard one:
///
/// 1. AT MOST `max_tracked_clients` ENTRIES. When the map is full, an identifier that is not
/// already in it is charged against a single shared OVERFLOW counter instead of getting an entry
/// of its own. Nothing is allocated for it.
/// 2. AT MOST [`MAX_TRACKED_CLIENT_ID_LEN`] BYTES OF KEY. A longer identifier goes straight to the
/// overflow counter, so the worst case is bounded in bytes and not only in entries: 4096 * (128
/// + 8 + `HashMap` overhead), comfortably under a megabyte at the defaults.
/// 3. AT MOST ONE WINDOW OF LIFETIME. The whole map is cleared when the window rolls, which costs
/// nothing semantically because every counter in it was about to be reset anyway. No entry
/// survives a window, so there is no eviction policy to get wrong and no slow leak of keys that
/// were seen once.
///
/// The overflow counter FAILS CLOSED, which is the important half: a spray of a million distinct
/// identifiers does not get a million fresh budgets, it gets one budget shared between all of them,
/// so the spray throttles itself harder than a repeat offender would. The cost of that choice, and
/// it is a real one, is that a legitimate client whose first authentication of a window arrives
/// after an attacker has filled the map shares the overflow counter for the rest of that window.
/// That is a bounded, self-clearing degradation, and it is preferable to the alternative (evicting
/// live counters to make room) which would let an attacker RESET a budget on demand by spraying,
/// turning the limiter off exactly when it is needed.
///
/// # Cost
///
/// One [`Mutex`] and one [`HashMap`] per limiter, allocated when the host constructs it and never
/// otherwise: a host that does not install this pays nothing, and [`crate::events::Hooks`] is
/// unchanged by its existence. Each check is one lock, one integer division and at most two hash
/// lookups. The lock is held only for the arithmetic, never across a store call or an await.