oauth_as/rate_limit.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2// Copyright (C) 2026 Matthew Jackson
3
4//! A rate limiter the crate SHIPS, so that "the host must throttle this" is a line of code rather
5//! than a paragraph of documentation.
6//!
7//! # Why a library that "cannot rate limit" ships a rate limiter
8//!
9//! [`crate::events::RateLimiter`] is a seam because this crate never sees a request: it has no IP,
10//! no session, no TLS peer and no request context, so it cannot key a counter on the things a real
11//! throttle wants to key on. That reasoning is sound and it is unchanged. What it does NOT justify
12//! is shipping the seam EMPTY.
13//!
14//! RFC 8628 section 5.1 is explicit that the device user code's entropy is adequate only IN
15//! COMBINATION WITH rate limiting of user code entry. This crate's default user code is
16//! [`crate::server::MIN_USER_CODE_LENGTH`] symbols over a 20-symbol alphabet: 20^8 is about
17//! 2.56e10, or 2^34.6. Against an unthrottled
18//! [`crate::server::AuthorizationServer::approve_device`], at a conservative 1000 attempts per
19//! second, an attacker makes 6e5 guesses inside the default 600 second
20//! [`crate::server::ServerConfig::device_code_ttl`]. They do not need to hit one PARTICULAR code,
21//! only SOME live one, so with a pool of `N` concurrently live grants the expected number of hits
22//! per code lifetime is `6e5 * N / 2.56e10`, which passes 1 at about 43,000 live grants and is
23//! already a 2.3% chance per lifetime at 1000. A hit binds a STRANGER'S DEVICE to the ATTACKER'S
24//! account, because the attacker supplies the `subject`.
25//!
26//! So the entropy argument in section 5.1 is only half a defence, and the other half is a counter.
27//! A crate that ships the half it can and leaves the half it "cannot" as an exercise has shipped a
28//! deployment where the odds above are the real odds. [`FixedWindowRateLimiter`] is the half this
29//! crate can ship: an in-memory, per-process, weighted fixed-window counter with no new dependency,
30//! which a host installs in one line:
31//!
32//! ```
33//! # use oauth_as::{AuthorizationServer, FixedWindowRateLimiter, MemoryStorage, ServerConfig};
34//! # let config = ServerConfig::new("https://as.example", "https://as.example/device");
35//! let server = AuthorizationServer::new(config, MemoryStorage::new())
36//! .with_rate_limiter(Box::new(FixedWindowRateLimiter::new()));
37//! ```
38//!
39//! It is a FLOOR, not a ceiling. Read "What this cannot do" below before deciding it is enough,
40//! because the difference between a useful default and a false sense of safety is whether the host
41//! was told plainly what they still owe.
42//!
43//! # What this cannot do
44//!
45//! - IT IS PER PROCESS. The counters live in this process's memory and nowhere else. On a
46//! multi-node deployment EVERY NODE HAS ITS OWN COUNTERS and the effective limit is multiplied by
47//! the node count: ten nodes behind a load balancer that spreads attempts evenly means ten times
48//! the default budget, and an attacker who can pick their node gets a fresh budget per node. A
49//! deployment at that scale needs a SHARED store (Redis, a database counter, the edge proxy's own
50//! limiter) behind the same [`crate::events::RateLimiter`] trait. This type is the right answer
51//! for a single-node deployment and a useful second layer for a larger one; it is not a
52//! distributed limiter and no amount of tuning makes it one.
53//! - IT RESETS ON RESTART. In-memory means a redeploy, a crash, or an OOM kill hands the attacker a
54//! fresh budget. An attacker who can induce restarts can defeat it.
55//! - IT HAS NO CALLER IDENTITY, so the device user code budget is GLOBAL. Every user of the
56//! verification page shares one counter, because the library has no IP to separate them by. The
57//! consequence runs in both directions and the host should understand both: an attacker's
58//! failures consume budget a legitimate user might have wanted (a sustained attack degrades the
59//! verification page for everyone), and the budget must therefore be set high enough not to
60//! strangle real activation traffic. The weighting below is what makes that tension survivable,
61//! not something that removes it. A host that DOES have request context should key its own
62//! limiter on the IP or session and keep this one underneath as a backstop.
63//! - IT IS A FIXED WINDOW, NOT A SLIDING ONE. A burst straddling a window boundary can land up to
64//! twice the budget in quick succession (the tail of one window plus the head of the next). This
65//! is the classic fixed-window artefact. It is accepted here because the alternative that fixes
66//! it (a sliding log) stores a timestamp per attempt, which is exactly the unbounded
67//! attacker-driven allocation the "bounded" requirement below rules out. Set the window shorter
68//! if the doubling matters.
69//! - IT IS NOT A LOCKOUT. Nothing is disabled, no account is suspended, no client is deregistered.
70//! When the window rolls the budget is whole again, deliberately: a throttle that never lifts is
71//! an outage, and an attacker who can trigger a permanent lockout of a client id has a denial of
72//! service. Locking out is a policy decision with an operator in the loop, which is what the
73//! [`crate::events::EventSink`] channel is for. ACROSS windows that is unconditional. WITHIN a
74//! window it is a PRICE rather than a guarantee, and the failure reserve below is what sets the
75//! price: a trickle of wrong secrets can no longer empty a client's budget, so denying one
76//! `client_id` for the rest of a window costs 3000 requests a minute at the defaults instead of
77//! 30. A hundred times dearer, and still not impossible. See "Why failures cannot spend a
78//! client's whole budget" for the derivation.
79//! - IT DOES NOT REPLACE THE AUDIT CHANNEL. The counter refuses; it does not tell anyone. A
80//! deployment being held at its ceiling for hours looks identical, from the inside, to a quiet
81//! one. Install an [`crate::events::EventSink`] as well and alert on the rate of
82//! [`crate::events::Event::ClientAuthenticationFailed`].
83//!
84//! # How the budget is spent
85//!
86//! One budget per key, in abstract COST UNITS, refilled to full at the start of every window.
87//!
88//! - Every attempt that is ALLOWED costs [`ATTEMPT_COST`] (1) at [`RateLimiter::check`] time.
89//! That is the ceiling on traffic.
90//! - Every allowed attempt that then FAILS costs a further `failure_cost` at
91//! [`RateLimiter::record`] time.
92//!
93//! The weighting is the point, and it is why this implementation uses `record` rather than only
94//! `check`. A guessing attack does not show up as VOLUME, it shows up as FAILURES: an attacker
95//! spraying user codes fails essentially every time, while a legitimate user typing the code off
96//! their television screen succeeds essentially every time. A limiter that counted traffic alone
97//! would have to choose between a ceiling low enough to stop guessing (which throttles a busy
98//! verification page into an outage) and one high enough for real traffic (which is no obstacle to
99//! guessing). Charging failures ten times what successes cost lets one budget be both.
100//!
101//! # The default numbers, and why they are those numbers
102//!
103//! A default nobody can justify is worse than no default, so each of these is derived rather than
104//! chosen, and each is a [`RateLimitConfig`] field a host can move.
105//!
106//! ## Window: 60 seconds ([`DEFAULT_WINDOW`])
107//!
108//! Long enough that the fixed-window doubling artefact is bounded by something a human notices,
109//! short enough that a legitimate user caught behind somebody else's burst waits under a minute
110//! rather than being locked out of activating their device. It also makes the numbers below
111//! readable as "per minute", which matters when an operator has to reason about them at 3am.
112//!
113//! ## Device user code entry: 200 units, failures cost 10 ([`DEFAULT_DEVICE_USER_CODE_CAPACITY`],
114//! [`DEFAULT_DEVICE_USER_CODE_FAILURE_COST`])
115//!
116//! Read as two numbers at once, which is what the weighting buys:
117//!
118//! - AT MOST 200 code entries per minute per process, so a deployment can activate about 3.3
119//! devices a second on one node before the ceiling bites. That is the "do not strangle real
120//! traffic" side.
121//! - AT MOST 20 WRONG code entries per minute per process (each costs `1 + 9 = 10`). That is the
122//! RFC 8628 section 5.1 side, and it is the number the arithmetic is about.
123//!
124//! Twenty wrong codes a minute is 200 guesses inside the default 600 second code lifetime. Against
125//! a pool of 1000 concurrently live grants that is `200 * 1000 / 2.56e10`, about 7.8e-6 expected
126//! hits per code lifetime, versus the 2.3e-2 an unthrottled endpoint gives the same attacker at
127//! 1000 attempts a second: a reduction of roughly three thousand fold. Stated honestly the other
128//! way, because a security default should be stated at its worst: an attack sustained at EXACTLY
129//! this ceiling, unnoticed, against a deployment continuously holding 1000 live device grants,
130//! accumulates about 0.4 expected hits over a YEAR. That is not "impossible", it is "a year-long
131//! visible campaign for a coin flip", which is what a throttle is for: it converts minutes into a
132//! sustained, loud, long-running operation. It is also why the paragraph above says to install an
133//! event sink, and why a host with a larger live-grant pool should lower
134//! `device_user_code_capacity` (the odds scale linearly with both) or raise
135//! [`crate::server::ServerConfig::user_code_length`] (they scale by a factor of 20 per symbol).
136//!
137//! Twenty wrong entries a minute is also, as a legitimate-traffic number, generous: the user is
138//! reading the code off a screen in front of them, the alphabet excludes the vowels and digits that
139//! cause transcription errors, and this crate normalises case and hyphens before comparing. A
140//! process seeing twenty genuine mistypes a minute is a process with a UI problem.
141//!
142//! Both numbers are counted in CODE ENTRIES, which is what an [`Attempt::DeviceUserCodeEntry`] is,
143//! and one activation is not always one code entry. The `http` feature's verification page is a
144//! two-stage form (RFC 8628 section 3.3 requires an explicit confirmation step, so the user types
145//! the code, sees what it is for, and then decides), and each stage resolves the code against the
146//! store: that is two lookups, both of which answer "is this a live code" and both of which must
147//! therefore be charged, or an attacker would simply walk the code space using the cheaper stage. A
148//! deployment serving that page should read the ceiling as roughly 100 ACTIVATIONS a minute rather
149//! than 200, and one that also publishes the RFC 8628 section 3.3.1
150//! `verification_uri_complete` deep link should read it as roughly 66, because the deep link
151//! resolves the code a third time to render the page it lands on. Raise
152//! `device_user_code_capacity` accordingly.
153//!
154//! The rule that keeps the wrong-code arithmetic above intact is the other half of the same
155//! statement: ONE code entry is charged ONCE, however many times a handler happens to resolve it.
156//! A submission whose code did not match re-renders the form so the user can correct it, and that
157//! re-render must not resolve the code again — the attempt it would be charging for is the one
158//! already counted a few lines earlier in the same request. A page that charges it twice halves
159//! every number in this section without changing a constant, which is the sort of drift only
160//! arithmetic stated out loud catches.
161//!
162//! ## Client authentication: 6000 units per client id, failures cost 200
163//! ([`DEFAULT_CLIENT_AUTHENTICATION_CAPACITY`], [`DEFAULT_CLIENT_AUTHENTICATION_FAILURE_COST`])
164//!
165//! Keyed per `client_id`, which RFC 6749 section 2.2 states explicitly is not a secret, so keying
166//! on it leaks nothing. Again two numbers:
167//!
168//! - AT MOST 6000 authentications per minute per client per process, which is 100 a second: above
169//! the rate at which a single client's TOKEN traffic on a single node is already an architecture
170//! discussion, so the ceiling should not be reached by a healthy deployment. THAT SENTENCE IS
171//! ABOUT A CLIENT AND IS NOT TRUE OF A RESOURCE SERVER; see the next section, which is the one
172//! to read before setting [`crate::server::ServerConfig::resource_servers`].
173//! - THE FIRST 15 FAILED authentications per minute per client cost 200 each (`1 + 199`), which
174//! spends half the budget: that is the RFC 9700 section 4.13 credential-stuffing weighting, and
175//! the point at which it stops is the failure reserve described in the next section. The penalty
176//! accumulates in 199-unit steps and CLAMPS at the 3000-unit reserve, so 15 failures leave the
177//! counter at 2985 and the SIXTEENTH pays only the 15 units still under the clamp — 16 units in
178//! all, with its attempt unit, rather than 200 and rather than 1. The seventeenth failure and
179//! every one after it costs [`ATTEMPT_COST`] and nothing more, so failures past that point are
180//! bounded by the traffic ceiling and by nothing else.
181//!
182//! The weighting is chosen against what a client secret actually is: a machine-held value this
183//! crate mints or the host provisions, not a human-chosen password. A correctly configured client
184//! fails authentication ZERO times, so any sustained failure rate for one `client_id` is either a
185//! misconfiguration the operator wants to hear about or an attack, and both are better served by
186//! refusing than by continuing. The budget is per client id rather than global specifically so one
187//! client being stuffed cannot lock every other client out of the token endpoint.
188//!
189//! ## Resource servers introspect once per API call, and that changes the shape of this budget
190//!
191//! THE 6000 ABOVE IS DERIVED FROM A CLIENT'S TOKEN TRAFFIC, and through 0.9.1 that was the only
192//! traffic this budget carried: RFC 7662 introspection was self-only, so a client introspected
193//! tokens it had itself been issued and the volume tracked issuance. 0.9.2 opened the endpoint to
194//! a RESOURCE SERVER registered in [`crate::server::ServerConfig::resource_servers`], and a
195//! resource server does not authenticate once per grant. It authenticates ONCE PER CALL AT THE
196//! PROTECTED RESOURCE IT GUARDS, because that is what validating a bearer token on each request
197//! means. The rate is set by that API's clients, all of them, and none of them are visible here.
198//!
199//! So for a resource server's `client_id` the ceiling is not a number a healthy deployment stays
200//! under by construction. It is the protected resource's REQUEST CEILING, and 100 requests a
201//! second at an API is not an architecture discussion, it is an ordinary weekday. A deployment
202//! that registers resource servers and leaves this at the default has capped each protected
203//! resource at 100 requests a second per node without meaning to.
204//!
205//! WHAT BEING OVER IT LOOKS LIKE, which is the part worth reading twice, because it does not look
206//! like a throttle from either end. The refusal is
207//! [`crate::error::ErrorCode::InvalidClient`] — the same bare answer a wrong secret gets, and
208//! deliberately so, because a distinct code would tell an attacker they had found a live client
209//! id, which is exactly the distinction the whole credential path collapses. The resource
210//! server therefore reads its own credential as rejected, and if it fails closed — which it
211//! should — every call at the protected resource is refused for the remainder of the window. Not
212//! one client's token issuance: AUTHORIZATION FOR EVERY REQUEST, at a resource whose own traffic
213//! caused it.
214//!
215//! THE AUDIT CHANNEL IS WHERE THE TWO SEPARATE, and it is the reason an operator serving resource
216//! servers should not run without an [`crate::events::EventSink`].
217//! [`crate::events::Event::ClientAuthenticationFailed`] carries
218//! [`crate::events::ClientAuthFailure::RateLimited`] for a throttle and
219//! [`crate::events::ClientAuthFailure::SecretMismatch`] for a credential that did not verify. On
220//! the wire they are one answer; here they are two, and only one of them is fixed by rotating a
221//! secret.
222//!
223//! WHAT TO SET. Two things, and the second matters more than the first:
224//!
225//! - Size the budget for the API rather than for a client, with
226//! [`RateLimitConfig::with_client_authentication_capacity_for`], which raises ONE registration
227//! and leaves every other `client_id` where it was. Peak requests a second at the protected
228//! resource, times 60, times a margin, divided by the node count if the limiter is per node —
229//! which this one is. Raising `client_authentication_capacity` globally instead would multiply
230//! the wrong-secret volume every OTHER registration admits, and each wrong secret can cost the
231//! host an argon2id.
232//! - CACHE THE INTROSPECTION RESPONSE AT THE RESOURCE SERVER. RFC 7662 section 4 recommends
233//! exactly this, bounded by a caching period the deployment finds acceptable, and it is the only
234//! measure that changes the traffic shape rather than the ceiling: a cache keyed on the token
235//! with a lifetime of even a few seconds turns "once per API call" back into something
236//! proportional to the number of distinct live tokens. It costs the delay before a revocation
237//! is observed, which is the trade RFC 7662 section 4 names.
238//!
239//! WHY THERE IS NO SEPARATE `Attempt` VARIANT FOR INTROSPECTION, since a budget of its own is the
240//! other obvious answer. [`Attempt`] is `#[non_exhaustive]`, so ADDING one would compile
241//! everywhere — and every host [`RateLimiter`] written against 0.9.x has a wildcard arm it would
242//! land in. A host whose wildcard answers [`RateLimitDecision::Allow`], which is the common shape
243//! for "budgets I have not configured", would silently stop throttling introspection ALTOGETHER on
244//! upgrade: an endpoint that was bounded would become unbounded, without a compiler error, a
245//! configuration change, or a line in a log. That is a strictly worse failure than a ceiling that
246//! is set too low and says so, and no amount of documentation reaches a wildcard arm that is
247//! already written. The budget stays shared; what a resource server gets is a capacity of its own
248//! within it.
249//!
250//! ## Why failures cannot spend a client's whole budget
251//!
252//! [`RateLimiter::check`] is asked BEFORE the credential is examined, and the only thing it is
253//! given is the `client_id` (see [`Attempt::ClientAuthentication`]). RFC 6749 section 2.2 makes
254//! that identifier public, so the impostor and the real client arrive at this limiter looking
255//! IDENTICAL: same key, nothing else to tell them apart. Any refusal rule that is a function of the
256//! `client_id` alone therefore refuses BOTH of them or NEITHER. That is not a defect of this
257//! implementation, it is what the seam can see, and it has one consequence that has to be designed
258//! around rather than documented away: if failures could drive a `client_id`'s counter all the way
259//! to its capacity, an attacker who sent 30 wrong secrets a minute — one request every two seconds,
260//! from anywhere, needing nothing but a public identifier — would take that client's every
261//! authenticated endpoint away for the rest of the window. Token, introspection, revocation, device
262//! authorization and PAR all go through the same check. THAT LIST IS THE CLIENT'S OWN ENDPOINTS,
263//! which is what it meant when it was written; since 0.9.2 "introspection" on it is also the
264//! RESOURCE SERVER's channel, so for a registration named in
265//! [`crate::server::ServerConfig::resource_servers`] the endpoint taken away is the protected
266//! resource. The "IT IS NOT A LOCKOUT" bullet above
267//! would have been false, and it would have been false at a cost to the attacker of nothing.
268//!
269//! So HALF OF EVERY CLIENT'S BUDGET IS RESERVED FOR ATTEMPTS AND CANNOT BE SPENT BY FAILURES
270//! ([`CLIENT_AUTHENTICATION_FAILURE_CEILING_DIVISOR`]). The failure penalty accumulates in a
271//! counter of its own that saturates at half the capacity; past that line a failure still costs its
272//! [`ATTEMPT_COST`], but it can no longer eat into what the real client needs.
273//!
274//! What that buys is a PRICE and not an immunity, and it is written out as one because the
275//! difference is the whole value of the paragraph. The attempt half is charged for EVERY request,
276//! the attacker's included, so at the defaults:
277//!
278//! - The CHEAPEST complete spray is 16 wrong secrets: 199 units a failure against a 3000-unit
279//! ceiling, so 15 reach 2985 and the sixteenth clamps it. That leaves the real client
280//! `3000 - 16 = 2984` further authentications in the window, about 49 a second on one node.
281//! - Denying that `client_id` outright means spending the whole reserved half at [`ATTEMPT_COST`]
282//! apiece: 3000 requests inside one 60 second window, 50 a second, sustained. They do NOT have to
283//! be well-formed traffic — 3000 wrong secrets do it just as well, because past the ceiling a
284//! wrong secret and an ordinary request cost exactly the same one unit.
285//!
286//! 3000 requests a minute is a hundred times the 30 the same denial cost with no reserve, and it is
287//! a rate an operator's own edge already meters: the reserve converts a trickle into a flood. It
288//! does not make the denial impossible, and nothing this seam can see would, because
289//! [`RateLimiter::check`] cannot tell the impostor from the client being impersonated.
290//!
291//! What that costs, stated plainly because a security default should be stated at its worst: the
292//! hard bound on WRONG SECRETS per client per minute is no longer 30, it is the traffic ceiling
293//! less the reserve, exactly 3000 — which is the same 3000 as the denial above, because at this
294//! point a wrong secret and a denial-of-service request are the same request. That trade is worth taking here and it would NOT be worth taking
295//! for the device user code, and the difference between the two is the entropy of what is being
296//! guessed. A user code is 2^34.6 and RFC 8628 section 5.1 says so: 3000 guesses a minute against
297//! it is a real attack, which is why the device budget has no reserve and its global denial-of-
298//! service is accepted and documented above instead. A client secret is a machine-held value this
299//! crate mints as 32 hex characters; 30 guesses a minute and 3000 guesses a minute against it are
300//! the same number, which is zero. Trading a guessing bound that was never the defence for an
301//! availability property that an attacker could otherwise break for free is the right way round.
302//!
303//! THE SAME DIVISOR GOVERNS THE AUTHORIZATION-REQUEST BUDGET. This section is written in terms of
304//! client authentication only because that is where the argument is sharpest, not because the
305//! reserve is a client-authentication property: [`RateLimitConfig`] derives both ceilings from
306//! [`CLIENT_AUTHENTICATION_FAILURE_CEILING_DIVISOR`] and `record` clamps both. The reasoning
307//! transfers unchanged and arrives somewhere slightly worse.
308//! [`Attempt::AuthorizationRequest`] is keyed on nothing but a `client_id` too, and its caller has
309//! not authenticated AT ALL — the identifier is whatever arrived in the query string — so a refusal
310//! rule that is a function of that identifier alone would let anybody take a client's LOGIN PAGE
311//! away. Its numbers, derived the same way from
312//! [`DEFAULT_AUTHORIZATION_REQUEST_CAPACITY`]: a 1500-unit reserve, 9 units a refusal so 167
313//! refusals fill it (166 reach 1494 and the 167th clamps), and 1500 refused requests a minute for
314//! one `client_id` before that client's authorization endpoint closes for the rest of the window.
315//!
316//! The reserve is not configurable, for the same reason [`MAX_TRACKED_CLIENT_ID_LEN`] is not: it is
317//! not a policy, it is the property that makes the type safe to install. A host that wants failures
318//! to bite sooner lowers `client_authentication_capacity`, which moves the reserve with it.
319//!
320//! ## Tracked clients: 4096 ([`DEFAULT_MAX_TRACKED_CLIENTS`])
321//!
322//! See [`FixedWindowRateLimiter`] for the bounding argument. 4096 is chosen to comfortably exceed
323//! the number of registrations a single deployment realistically authenticates within one 60 second
324//! window while keeping the worst-case footprint under two megabytes. It caps EACH of the two
325//! per-`client_id` maps rather than the pair, so the worst case is about 768 KiB apiece and about
326//! 1.5 MiB in all; [`FixedWindowRateLimiter::tracked_clients`] and
327//! [`FixedWindowRateLimiter::tracked_authorization_clients`] report them separately.
328
329use std::collections::HashMap;
330use std::sync::Mutex;
331use std::time::{Duration, Instant};
332
333use crate::events::{Attempt, AttemptOutcome, RateLimitDecision, RateLimiter};
334
335/// What one allowed attempt costs, charged at [`RateLimiter::check`] time.
336///
337/// This is the UNIT the capacities are denominated in: a capacity of 200 means "200 attempts, if
338/// they all succeed". Fixing it at 1 rather than making it configurable keeps the two numbers a
339/// host actually reasons about (how many attempts, how much worse is a failure) down to two.
340pub const ATTEMPT_COST: u64 = 1;
341
342/// 60 seconds. See the module docs for why.
343pub const DEFAULT_WINDOW: Duration = Duration::from_secs(60);
344
345/// 200 cost units per window for [`Attempt::DeviceUserCodeEntry`]: 200 entries a minute, or 20
346/// wrong ones. See the module docs for the RFC 8628 section 5.1 arithmetic.
347pub const DEFAULT_DEVICE_USER_CODE_CAPACITY: u64 = 200;
348
349/// The EXTRA cost of a failed user code entry, on top of [`ATTEMPT_COST`], so a wrong code costs
350/// ten times a right one.
351pub const DEFAULT_DEVICE_USER_CODE_FAILURE_COST: u64 = 9;
352
353/// 6000 cost units per window per `client_id`: 6000 authentications a minute, of which the first 15
354/// failures cost 200 each. See the module docs for the RFC 9700 section 4.13 reasoning.
355///
356/// DERIVED FROM A CLIENT'S TOKEN TRAFFIC, so it is the wrong starting point for a RESOURCE SERVER
357/// registered in [`crate::server::ServerConfig::resource_servers`]: that registration
358/// authenticates once per RFC 7662 introspection and therefore once per call at the protected
359/// resource, and 100 a second is an ordinary API rather than an architecture discussion. Raise
360/// that ONE registration with [`RateLimitConfig::with_client_authentication_capacity_for`] and see
361/// "Resource servers introspect once per API call" in the module docs.
362pub const DEFAULT_CLIENT_AUTHENTICATION_CAPACITY: u64 = 6000;
363
364/// The EXTRA cost of a failed client authentication, on top of [`ATTEMPT_COST`], so a wrong
365/// credential costs two hundred times a right one until the failure ceiling is reached.
366pub const DEFAULT_CLIENT_AUTHENTICATION_FAILURE_COST: u64 = 199;
367
368/// How much of a `client_id`'s budget FAILURES may consume, as a divisor of the capacity: `2` means
369/// half of it, and half reserved for attempts.
370///
371/// Not configurable, because it is not a policy: it is what keeps "IT IS NOT A LOCKOUT" true. See
372/// "Why failures cannot spend a client's whole budget" in the module docs for the argument, which
373/// turns on [`RateLimiter::check`] having nothing but a public `client_id` to tell the real client
374/// and the impostor apart.
375pub const CLIENT_AUTHENTICATION_FAILURE_CEILING_DIVISOR: u64 = 2;
376
377/// 3000 cost units per window per `client_id` for [`Attempt::AuthorizationRequest`]: 3000 trips
378/// through the authorization endpoint a minute for one client, or 1500 refused ones.
379///
380/// The two numbers are not `3000` and `3000 / 10`. Half of this budget is reserved for attempts on
381/// exactly the terms client authentication's is (see "Why failures cannot spend a client's whole
382/// budget" in the module docs), so a refusal costs 10 units only until the 1500-unit reserve is
383/// full — 167 of them — and 1 unit each after that. The endpoint therefore admits 1500 refused
384/// requests for one `client_id` in a window, not the 300 the weighting alone would give.
385///
386/// Every one of these is a USER's browser arriving at a login page, and a busy client's traffic is
387/// genuinely bursty (a mobile app updating, a working day starting), so the ceiling has to sit well
388/// above real volume; it sits below the client-authentication ceiling because one login produces
389/// one arrival here and a stream of token, introspection and revocation calls there. Refusals are
390/// the opposite: a correctly configured client's authorization requests are refused essentially
391/// never, because its `client_id` and `redirect_uri` are constants baked into its own build. A
392/// sustained refusal rate for one client is somebody walking the redirect-URI space looking for a
393/// matcher bug, which is what `Event::AuthorizationRequestRefused` exists to make visible and what
394/// this budget exists to slow — to 1500 a window rather than to nothing, which is the price of not
395/// letting that same somebody take the login page down by walking it. A deployment
396/// that would rather have the tighter refusal bound lowers `authorization_request_capacity`, which
397/// moves the reserve with it.
398pub const DEFAULT_AUTHORIZATION_REQUEST_CAPACITY: u64 = 3000;
399
400/// The EXTRA cost of a refused authorization request, on top of [`ATTEMPT_COST`], so a refusal
401/// costs ten times an ordinary arrival.
402pub const DEFAULT_AUTHORIZATION_REQUEST_FAILURE_COST: u64 = 9;
403
404/// 60 cost units per window for [`Attempt::ClientRegistration`], globally: 60 dynamic
405/// registrations a minute, or 6 refused ones.
406///
407/// The tightest budget here, and the reason is that an RFC 7591 registration is the only request in
408/// this crate that creates a PERMANENT row. A [`crate::client::Client`] has no expiry and no sweep
409/// reclaims it, so an unthrottled registration endpoint is not a burst a window absorbs, it is
410/// unbounded growth in the host's storage. Sixty a minute is far above what a real deployment
411/// onboards and far below what a script achieves.
412///
413/// GLOBAL rather than per-anything, because a registration request names no client: the client is
414/// what it is asking to create. A host that can identify the caller — an admin session, an API key,
415/// a source address — should throttle on that in front of this, which is what the module docs say
416/// about every budget here.
417pub const DEFAULT_CLIENT_REGISTRATION_CAPACITY: u64 = 60;
418
419/// The EXTRA cost of a refused registration, on top of [`ATTEMPT_COST`], so a refusal costs ten
420/// times an accepted one. A refusal here is a `RegistrationPolicy` saying no, which is the signal
421/// that somebody is probing what the policy will accept.
422pub const DEFAULT_CLIENT_REGISTRATION_FAILURE_COST: u64 = 9;
423
424/// How many distinct `client_id` values get their own counter within a window.
425pub const DEFAULT_MAX_TRACKED_CLIENTS: usize = 4096;
426
427/// The longest `client_id` that gets its own counter, in bytes.
428///
429/// Not configurable, because it is not a policy: it is the second half of the memory bound. A
430/// `client_id` is attacker-supplied, so without a length cap a spray of 4096 identifiers of a
431/// megabyte each would be 4 GB of "bounded" map. 128 bytes is far above anything this crate mints
432/// (RFC 7591 registration produces 32 hex characters) or a host plausibly provisions; longer
433/// identifiers still authenticate normally, they just share the overflow counter described on
434/// [`FixedWindowRateLimiter`].
435pub const MAX_TRACKED_CLIENT_ID_LEN: usize = 128;
436
437/// The shortest window that can be configured.
438///
439/// A zero window would divide by zero when computing the window index, and a sub-millisecond one is
440/// indistinguishable from no limiter at all on any real clock. [`RateLimitConfig::with_window`]
441/// clamps up to this rather than rejecting, for the same reason
442/// [`crate::server::ServerConfig::user_code_length`] clamps: a misconfiguration should not become a
443/// runtime failure at the one moment a user is standing in front of a device.
444pub const MIN_WINDOW: Duration = Duration::from_millis(1);
445
446/// The knobs on [`FixedWindowRateLimiter`]. [`RateLimitConfig::default`] is the reasoned default
447/// set documented at the module level; every field is public so a host can move one without the
448/// builder.
449///
450/// `#[non_exhaustive]` because later releases will gain budgets for attempt kinds
451/// [`Attempt`] does not yet have, and adding one must not break a host that built this by hand.
452#[derive(Debug, Clone, PartialEq, Eq)]
453#[non_exhaustive]
454pub struct RateLimitConfig {
455 /// How long a budget lasts. Clamped up to [`MIN_WINDOW`] at use.
456 pub window: Duration,
457 /// Cost units per window for [`Attempt::DeviceUserCodeEntry`], globally.
458 pub device_user_code_capacity: u64,
459 /// Extra cost charged when a user code entry FAILS.
460 pub device_user_code_failure_cost: u64,
461 /// Cost units per window for [`Attempt::ClientAuthentication`], per `client_id`.
462 ///
463 /// A RESOURCE SERVER'S INTROSPECTION TRAFFIC IS CHARGED HERE, which is why this number needs
464 /// rereading in any deployment that sets [`crate::server::ServerConfig::resource_servers`]: an
465 /// RFC 7662 introspection is one client authentication, and a resource server makes one per
466 /// protected API call. Raise it for that ONE registration with
467 /// [`RateLimitConfig::with_client_authentication_capacity_for`] rather than for everybody. See
468 /// "Resource servers introspect once per API call" in the module docs.
469 pub client_authentication_capacity: u64,
470 /// Per-`client_id` exceptions to `client_authentication_capacity`, for registrations whose
471 /// honest traffic is not shaped like a client's.
472 ///
473 /// EMPTY BY DEFAULT and empty means "no exceptions": a `HashMap` that has never had an entry
474 /// inserted allocates nothing, and the lookup is skipped entirely when it is empty, so a
475 /// deployment that does not use this pays one `is_empty` per check.
476 ///
477 /// The reserve moves with the exception. Everything
478 /// [`CLIENT_AUTHENTICATION_FAILURE_CEILING_DIVISOR`] guarantees is derived from whichever
479 /// capacity applies to the identifier being charged, so a raised registration gets a raised
480 /// reserve rather than a failure penalty that saturates after a fiftieth of its budget.
481 ///
482 /// TWO THINGS IT DOES NOT DO, both of them properties of [`FixedWindowRateLimiter`]'s bound
483 /// rather than of this field. An entry for an identifier longer than
484 /// [`MAX_TRACKED_CLIENT_ID_LEN`] never applies, because such an identifier never gets a counter
485 /// of its own. And an entry does not apply in a window where the tracked map was already full
486 /// when this identifier first arrived: it shares the OVERFLOW counter then, on the shared
487 /// capacity, because a budget several identifiers share cannot carry one identifier's
488 /// exception. Both degrade toward the ordinary capacity, never away from it.
489 pub client_authentication_capacity_overrides: HashMap<Box<str>, u64>,
490 /// Extra cost charged when a client authentication FAILS.
491 pub client_authentication_failure_cost: u64,
492 /// Cost units per window for [`Attempt::AuthorizationRequest`], per `client_id`.
493 pub authorization_request_capacity: u64,
494 /// Extra cost charged when an authorization request is REFUSED.
495 pub authorization_request_failure_cost: u64,
496 /// Cost units per window for [`Attempt::ClientRegistration`], globally.
497 pub client_registration_capacity: u64,
498 /// Extra cost charged when a dynamic registration is REFUSED.
499 pub client_registration_failure_cost: u64,
500 /// How many distinct `client_id` values get their own counter within a window. See
501 /// [`FixedWindowRateLimiter`] for what happens past it.
502 pub max_tracked_clients: usize,
503}
504
505impl Default for RateLimitConfig {
506 fn default() -> Self {
507 RateLimitConfig {
508 window: DEFAULT_WINDOW,
509 device_user_code_capacity: DEFAULT_DEVICE_USER_CODE_CAPACITY,
510 device_user_code_failure_cost: DEFAULT_DEVICE_USER_CODE_FAILURE_COST,
511 client_authentication_capacity: DEFAULT_CLIENT_AUTHENTICATION_CAPACITY,
512 client_authentication_capacity_overrides: HashMap::new(),
513 client_authentication_failure_cost: DEFAULT_CLIENT_AUTHENTICATION_FAILURE_COST,
514 authorization_request_capacity: DEFAULT_AUTHORIZATION_REQUEST_CAPACITY,
515 authorization_request_failure_cost: DEFAULT_AUTHORIZATION_REQUEST_FAILURE_COST,
516 client_registration_capacity: DEFAULT_CLIENT_REGISTRATION_CAPACITY,
517 client_registration_failure_cost: DEFAULT_CLIENT_REGISTRATION_FAILURE_COST,
518 max_tracked_clients: DEFAULT_MAX_TRACKED_CLIENTS,
519 }
520 }
521}
522
523impl RateLimitConfig {
524 /// Set the window, clamped up to [`MIN_WINDOW`].
525 pub fn with_window(mut self, window: Duration) -> Self {
526 self.window = window.max(MIN_WINDOW);
527 self
528 }
529
530 /// Set the [`Attempt::DeviceUserCodeEntry`] budget: `capacity` cost units per window, with
531 /// `failure_cost` charged on top of [`ATTEMPT_COST`] for each failure.
532 ///
533 /// A capacity of 0 refuses every user code entry, which is a legitimate way to turn the
534 /// verification endpoint off; it is not treated as "unlimited".
535 pub fn with_device_user_code_budget(mut self, capacity: u64, failure_cost: u64) -> Self {
536 self.device_user_code_capacity = capacity;
537 self.device_user_code_failure_cost = failure_cost;
538 self
539 }
540
541 /// Set the per-`client_id` [`Attempt::ClientAuthentication`] budget, in the same units as
542 /// [`RateLimitConfig::with_device_user_code_budget`].
543 pub fn with_client_authentication_budget(mut self, capacity: u64, failure_cost: u64) -> Self {
544 self.client_authentication_capacity = capacity;
545 self.client_authentication_failure_cost = failure_cost;
546 self
547 }
548
549 /// Give ONE `client_id` its own [`Attempt::ClientAuthentication`] capacity, leaving every other
550 /// registration on `client_authentication_capacity`.
551 ///
552 /// WHAT THIS IS FOR, and it is one thing: a registration whose honest volume is a function of
553 /// somebody else's traffic rather than of its own. The case that exists today is a RESOURCE
554 /// SERVER declared in [`crate::server::ServerConfig::resource_servers`], which authenticates
555 /// here once per RFC 7662 introspection and therefore once per call at the protected resource
556 /// it guards — a rate set by that API's clients, not by any grant this server issued.
557 ///
558 /// It exists so that the sizing advice can be given about one registration. Raising
559 /// `client_authentication_capacity` globally would raise it for every client id an attacker
560 /// can name, and the per-client ceiling is what bounds how many WRONG SECRETS one id can push
561 /// through the host's secret verifier in a window: at the defaults 3000, and each one may cost
562 /// an argon2id. A twentyfold global raise is a twentyfold raise in that, for every
563 /// registration, to buy headroom one of them needed.
564 ///
565 /// ```
566 /// use oauth_as::rate_limit::{FixedWindowRateLimiter, RateLimitConfig};
567 ///
568 /// // 100 API calls a second at the protected resource is 6000 introspections a minute, which
569 /// // is the whole default budget. Give that one registration room and leave the rest alone.
570 /// let limiter = FixedWindowRateLimiter::with_config(
571 /// RateLimitConfig::default().with_client_authentication_capacity_for("orders-api", 120_000),
572 /// );
573 /// ```
574 ///
575 /// A capacity of 0 refuses that identifier outright, which is a legitimate way to take one
576 /// registration off the air; it is not read as "unlimited".
577 pub fn with_client_authentication_capacity_for(
578 mut self,
579 client_id: impl Into<Box<str>>,
580 capacity: u64,
581 ) -> Self {
582 self.client_authentication_capacity_overrides
583 .insert(client_id.into(), capacity);
584 self
585 }
586
587 /// Set how many distinct `client_id` values get their own counter within a window.
588 pub fn with_max_tracked_clients(mut self, max: usize) -> Self {
589 self.max_tracked_clients = max;
590 self
591 }
592
593 /// The window, never zero. Every window computation goes through this.
594 fn effective_window(&self) -> Duration {
595 self.window.max(MIN_WINDOW)
596 }
597
598 /// The most of one `client_id`'s budget that the failure penalty may ever occupy.
599 ///
600 /// Derived from the capacity at every use rather than stored, exactly as
601 /// [`RateLimitConfig::effective_window`] re-clamps: `client_authentication_capacity` is a
602 /// public field, so a host that lowers it without going through the builder must still get a
603 /// reserve that moved with it rather than one frozen at construction.
604 fn client_authentication_failure_ceiling(&self) -> u64 {
605 self.client_authentication_capacity / CLIENT_AUTHENTICATION_FAILURE_CEILING_DIVISOR
606 }
607
608 /// The capacity that applies to ONE identifier that got a counter of its own: its
609 /// `client_authentication_capacity_overrides` entry if it has one, and the shared capacity
610 /// otherwise.
611 ///
612 /// ONLY EVER ASKED FOR A TRACKED IDENTIFIER. An identifier charged against the shared overflow
613 /// counter is charged the shared capacity whatever its override says, because that counter is
614 /// not its own — see the field's docs.
615 fn client_authentication_capacity_for(&self, client_id: &str) -> u64 {
616 if self.client_authentication_capacity_overrides.is_empty() {
617 return self.client_authentication_capacity;
618 }
619 self.client_authentication_capacity_overrides
620 .get(client_id)
621 .copied()
622 .unwrap_or(self.client_authentication_capacity)
623 }
624
625 /// The failure reserve for one tracked identifier, derived from whichever capacity applies to
626 /// it so that an override moves the reserve with it. See
627 /// [`RateLimitConfig::client_authentication_failure_ceiling`].
628 fn client_authentication_failure_ceiling_for(&self, client_id: &str) -> u64 {
629 self.client_authentication_capacity_for(client_id)
630 / CLIENT_AUTHENTICATION_FAILURE_CEILING_DIVISOR
631 }
632
633 /// The authorization-endpoint failure reserve, on the same terms and re-derived at every use so
634 /// that a host lowering the capacity by assigning the public field still gets a reserve that
635 /// moved with it.
636 fn authorization_request_failure_ceiling(&self) -> u64 {
637 self.authorization_request_capacity / CLIENT_AUTHENTICATION_FAILURE_CEILING_DIVISOR
638 }
639}
640
641/// One `client_id`'s two counters for one window, kept apart so that the failure penalty can be
642/// bounded independently of the attempts.
643///
644/// Two counters rather than one sum is the whole of the fix for the lockout described in the module
645/// docs: a single counter cannot express "failures have spent as much as they are allowed to spend
646/// and attempts have not", because by the time the two are added together the information that
647/// distinguishes them is gone.
648#[derive(Debug, Default)]
649struct ClientBudget {
650 /// Cost charged at [`RateLimiter::check`] time: [`ATTEMPT_COST`] per ALLOWED attempt.
651 attempts: u64,
652 /// Cost charged at [`RateLimiter::record`] time for FAILURES, saturating at the failure ceiling
653 /// of whichever budget this is — [`RateLimitConfig::client_authentication_failure_ceiling`] or
654 /// [`RateLimitConfig::authorization_request_failure_ceiling`] — so that it can never reach the
655 /// capacity on its own.
656 failures: u64,
657}
658
659/// The counters for one window. Replaced wholesale when the window rolls.
660#[derive(Debug, Default)]
661struct Window {
662 /// Which window these counters belong to: elapsed since the limiter's base instant, divided by
663 /// the configured window length. A change means everything below is stale.
664 index: u64,
665 /// Cost spent on [`Attempt::DeviceUserCodeEntry`], which needs no map: the library has no
666 /// caller identity to key it on, so there is exactly one counter.
667 device_user_code: u64,
668 /// Cost spent per `client_id`, bounded by `max_tracked_clients` entries and
669 /// [`MAX_TRACKED_CLIENT_ID_LEN`] bytes of key.
670 clients: HashMap<Box<str>, ClientBudget>,
671 /// The shared counters for every `client_id` that did not get its own. See
672 /// [`FixedWindowRateLimiter`].
673 ///
674 /// It carries the same two-counter split, and for the same reason: an attacker who has filled
675 /// the map can also spray FAILURES at it, and if those failures could reach the shared capacity
676 /// then every untracked client — including a legitimate one whose first authentication of the
677 /// window arrives late — would be refused for the rest of the window on the strength of about
678 /// thirty requests. The reserve does not make that refusal impossible, it makes it cost the
679 /// whole reserved half in requests — 3000 in a window at the defaults, against 30 without it.
680 overflow: ClientBudget,
681 /// Cost spent on [`Attempt::AuthorizationRequest`], per `client_id`, in the same bounded map
682 /// as client authentication and with the same two-counter split.
683 ///
684 /// A separate map rather than a second field on `ClientBudget`, because the two budgets are
685 /// about different things: one client hammering `/authorize` must not spend the budget its own
686 /// token requests need, and a client that cannot authenticate must still be able to be
687 /// throttled at the endpoint that writes records.
688 authorization: HashMap<Box<str>, ClientBudget>,
689 /// The shared authorization-endpoint counter for identifiers that did not get their own.
690 authorization_overflow: ClientBudget,
691 /// Cost spent on [`Attempt::ClientRegistration`], which needs no map: a registration request
692 /// names no client, because the client does not exist yet.
693 registration: u64,
694}
695
696/// An in-memory, per-process, weighted fixed-window [`RateLimiter`] the crate ships so that
697/// throttling is one line rather than a project.
698///
699/// Read the module documentation before installing it. In particular: it is PER PROCESS, so on a
700/// multi-node deployment the effective limit is multiplied by the node count.
701///
702/// # How it is bounded
703///
704/// A limiter that grows a map keyed on an attacker-supplied `client_id` is itself a denial of
705/// service, so the maps are bounded three ways at once and every bound is a hard one.
706///
707/// THERE ARE TWO MAPS, which is the first thing to hold on to, because every figure below is a
708/// figure per map: one keyed on `client_id` for [`Attempt::ClientAuthentication`] and one for
709/// [`Attempt::AuthorizationRequest`], kept apart so that neither endpoint can spend the other's
710/// budget (see `Window::authorization_counter`). Each is capped INDEPENDENTLY, so every bound
711/// below is doubled in total. The authorization map is the one an attacker reaches first, because
712/// its caller has not authenticated: the identifier it is keyed on is whatever arrived in the
713/// query string.
714///
715/// 1. AT MOST `max_tracked_clients` ENTRIES PER MAP, so at most `2 * max_tracked_clients` entries
716/// in all — 8192 at the defaults. When a map is full, an identifier that is not already in it is
717/// charged against that map's single shared OVERFLOW counter instead of getting an entry of its
718/// own. Nothing is allocated for it.
719/// 2. AT MOST [`MAX_TRACKED_CLIENT_ID_LEN`] BYTES OF KEY. A longer identifier goes straight to the
720/// overflow counter, so the worst case is bounded in bytes and not only in entries. Per map:
721/// 4096 keys of 128 bytes on the heap is 512 KiB, and the table holding them is 8192 slots (a
722/// `HashMap` keeps its load under 7/8, so 4096 entries take the next power of two up) of 32
723/// bytes each — a 16-byte `Box<str>` handle and a `ClientBudget`'s two `u64`s — which is
724/// 256 KiB. About 768 KiB a map, so about 1.5 MiB for both at the defaults.
725/// 3. AT MOST ONE WINDOW OF LIFETIME. BOTH maps are cleared when the window rolls, which costs
726/// nothing semantically because every counter in them was about to be reset anyway. No entry
727/// survives a window, so there is no eviction policy to get wrong and no slow leak of keys that
728/// were seen once.
729///
730/// [`FixedWindowRateLimiter::tracked_clients`] and
731/// [`FixedWindowRateLimiter::tracked_authorization_clients`] report the two maps separately, so a
732/// host — and this crate's own gates — can SEE both bounds rather than watch one and infer the
733/// other.
734///
735/// Each overflow counter FAILS CLOSED, which is the important half: a spray of a million distinct
736/// identifiers does not get a million fresh budgets, it gets one budget shared between all of them,
737/// so the spray throttles itself harder than a repeat offender would. The cost of that choice, and
738/// it is a real one, is that a legitimate client whose first authentication of a window arrives
739/// after an attacker has filled the map shares the overflow counter for the rest of that window.
740/// That is a bounded, self-clearing degradation, and it is preferable to the alternative (evicting
741/// live counters to make room) which would let an attacker RESET a budget on demand by spraying,
742/// turning the limiter off exactly when it is needed.
743///
744/// # Cost
745///
746/// One [`Mutex`] and one [`HashMap`] per limiter, allocated when the host constructs it and never
747/// otherwise: a host that does not install this pays nothing, and [`crate::events::Hooks`] is
748/// unchanged by its existence. Each check is one lock, one integer division and at most two hash
749/// lookups — three when `client_authentication_capacity_overrides` is non-empty, and the extra one
750/// is skipped entirely by an `is_empty` when it is not. The lock is held only for the arithmetic,
751/// never across a store call or an await.
752#[derive(Debug)]
753pub struct FixedWindowRateLimiter {
754 config: RateLimitConfig,
755 /// The instant windows are measured from. [`Instant`] and not [`std::time::SystemTime`] on
756 /// purpose: it is monotonic, so an NTP step or a host clock adjustment cannot hand an attacker
757 /// a free budget by moving the wall clock backwards.
758 base: Instant,
759 state: Mutex<Window>,
760}
761
762impl Default for FixedWindowRateLimiter {
763 fn default() -> Self {
764 Self::with_config(RateLimitConfig::default())
765 }
766}
767
768impl FixedWindowRateLimiter {
769 /// A limiter with the reasoned defaults documented at the module level.
770 pub fn new() -> Self {
771 Self::default()
772 }
773
774 /// A limiter with a host's own budgets.
775 pub fn with_config(config: RateLimitConfig) -> Self {
776 FixedWindowRateLimiter {
777 config,
778 base: Instant::now(),
779 state: Mutex::new(Window::default()),
780 }
781 }
782
783 /// The configuration in force.
784 pub fn config(&self) -> &RateLimitConfig {
785 &self.config
786 }
787
788 /// How many `client_id` values currently hold a CLIENT-AUTHENTICATION counter of their own.
789 ///
790 /// Exposed so a host (and this crate's own gate on the bound) can SEE that the map is bounded
791 /// rather than trust that it is. Never exceeds `max_tracked_clients`.
792 ///
793 /// This is ONE of the two bounded maps, and it is not the one an attacker reaches first: see
794 /// [`FixedWindowRateLimiter::tracked_authorization_clients`], which a gate watching only this
795 /// number is blind to.
796 pub fn tracked_clients(&self) -> usize {
797 self.lock().clients.len()
798 }
799
800 /// How many `client_id` values currently hold an AUTHORIZATION-REQUEST counter of their own.
801 ///
802 /// The sibling of [`FixedWindowRateLimiter::tracked_clients`], and the one to watch if only one
803 /// is watched: this map is filled by callers who have not authenticated at all, because the
804 /// identifier an `/authorize` request is keyed on is whatever arrived in the query string,
805 /// whereas the client-authentication map is filled by callers who at least presented a
806 /// credential. It is capped by the same `max_tracked_clients` and never exceeds it.
807 pub fn tracked_authorization_clients(&self) -> usize {
808 self.lock().authorization.len()
809 }
810
811 /// Poisoning is recovered from rather than propagated: a panic somewhere else in the process
812 /// must not turn this limiter into a source of panics, and the worst a poisoned counter can be
813 /// is arithmetically stale for the rest of one window.
814 fn lock(&self) -> std::sync::MutexGuard<'_, Window> {
815 self.state.lock().unwrap_or_else(|e| e.into_inner())
816 }
817
818 /// Which window `now` falls in. Fixed windows anchored at [`FixedWindowRateLimiter::base`],
819 /// which is what makes the roll deterministic: advancing by more than one whole window always
820 /// crosses at least one boundary, whatever phase the caller started in.
821 fn window_index(&self, now: Instant) -> u64 {
822 let window = self.config.effective_window().as_nanos();
823 let elapsed = now.saturating_duration_since(self.base).as_nanos();
824 // `window` is at least MIN_WINDOW, so this cannot divide by zero. The saturating cast only
825 // matters after roughly 584 years of uptime, and saturating there is still correct: the
826 // index simply stops advancing, and a limiter that stops rolling refuses rather than
827 // admits.
828 (elapsed / window).min(u128::from(u64::MAX)) as u64
829 }
830
831 /// Charge `cost` if the budget can pay for it, and say whether it could.
832 ///
833 /// On refusal the counter is NOT advanced. That is deliberate: it keeps a denied flood from
834 /// growing the counter without bound (it pins at the capacity instead of overflowing), and it
835 /// means the budget is spent by attempts that actually happened.
836 fn charge(counter: &mut u64, capacity: u64, cost: u64) -> RateLimitDecision {
837 if cost > capacity.saturating_sub(*counter) {
838 return RateLimitDecision::Deny;
839 }
840 *counter += cost;
841 RateLimitDecision::Allow
842 }
843
844 /// Add `cost` to an already-allowed attempt's counter, clamped at the capacity so it can
845 /// neither overflow nor grow past the point where it changes any answer.
846 fn penalise(counter: &mut u64, capacity: u64, cost: u64) {
847 *counter = counter.saturating_add(cost).min(capacity);
848 }
849
850 /// The decision, at an explicit instant. [`RateLimiter::check`] is this with
851 /// [`Instant::now`]; the split exists so `src/tests/rate_limit.rs` can drive the window
852 /// boundary exactly instead of sleeping.
853 fn check_at(&self, attempt: Attempt<'_>, now: Instant) -> RateLimitDecision {
854 let index = self.window_index(now);
855 let mut state = self.lock();
856 state.roll_to(index);
857 // Matched WITHOUT a wildcard arm. `Attempt` is `#[non_exhaustive]` for hosts, but this
858 // module is inside the crate that declares it, so an exhaustive match here turns "somebody
859 // added a VARIANT and did not give it a budget" into a compile error rather than into a
860 // silently unbudgeted one.
861 //
862 // Note what that does NOT cover, because the difference has been read the wrong way round
863 // before: it says nothing about an ENDPOINT that is unthrottled. An endpoint with no
864 // `Attempt` variant of its own never reaches this match at all, so no exhaustiveness check
865 // here can notice it. Only a variant that exists is protected by this, and adding the
866 // variant is the step a compiler cannot prompt anyone to take.
867 //
868 // WHAT IS UNTHROTTLED TODAY, since this comment is the only place the crate enumerates it:
869 // NOTHING that takes a credential or writes a record. Every endpoint that does is behind
870 // one of the four variants below.
871 //
872 // The list has shrunk twice and both entries are recorded here rather than deleted,
873 // because this is where a reader comes to find out which endpoints are exposed and a
874 // comment that has silently drifted is worse than none. The authorization endpoint and RFC
875 // 7591 registration left the list in 0.9.1 (`Attempt::AuthorizationRequest` and
876 // `Attempt::ClientRegistration`). RFC 7592 registration MANAGEMENT left it in 0.9.2:
877 // `read_registration`, `update_registration` and `delete_registration` share one
878 // `authenticate_registration`, which now asks with `Attempt::ClientAuthentication` keyed
879 // on the `client_id` being managed. It spends the SAME per-client budget as the token
880 // endpoint, deliberately: it is the same client's other bearer credential, and the
881 // destructive one (RFC 7592 s2.3 deletes the registration and everything it was issued).
882 //
883 // What that leaves genuinely unthrottled is the endpoints that take no credential and
884 // write nothing: the RFC 8414 metadata document, the RFC 7517 JWKS, and the RFC 9728
885 // resource metadata. They are static documents, and a limiter in front of a static
886 // document is the host's CDN's job rather than this crate's.
887 match attempt {
888 Attempt::DeviceUserCodeEntry => {
889 let capacity = self.config.device_user_code_capacity;
890 Self::charge(&mut state.device_user_code, capacity, ATTEMPT_COST)
891 }
892 Attempt::ClientAuthentication { client_id } => {
893 let shared = self.config.client_authentication_capacity;
894 // Read BEFORE the counter is borrowed, and read only for a tracked identifier: an
895 // untracked one is charged against the shared overflow counter, which several
896 // identifiers are spending at once and which therefore cannot be given any one of
897 // their exceptions.
898 let overridden = self.config.client_authentication_capacity_for(client_id);
899 let max_tracked = self.config.max_tracked_clients;
900 let (budget, tracked) = state.client_counter(client_id, max_tracked);
901 let capacity = if tracked { overridden } else { shared };
902 // The attempt is charged against what the FAILURE counter has not already taken,
903 // which is how the two budgets share one capacity without either being able to
904 // exhaust the other's half. See the module docs.
905 let headroom = capacity.saturating_sub(budget.failures);
906 Self::charge(&mut budget.attempts, headroom, ATTEMPT_COST)
907 }
908 Attempt::AuthorizationRequest { client_id } => {
909 let capacity = self.config.authorization_request_capacity;
910 let max_tracked = self.config.max_tracked_clients;
911 let budget = state.authorization_counter(client_id, max_tracked);
912 let headroom = capacity.saturating_sub(budget.failures);
913 Self::charge(&mut budget.attempts, headroom, ATTEMPT_COST)
914 }
915 Attempt::ClientRegistration => {
916 let capacity = self.config.client_registration_capacity;
917 Self::charge(&mut state.registration, capacity, ATTEMPT_COST)
918 }
919 }
920 }
921
922 /// The outcome report, at an explicit instant. See [`FixedWindowRateLimiter::check_at`].
923 fn record_at(&self, attempt: Attempt<'_>, outcome: AttemptOutcome, now: Instant) {
924 // A success has already paid `ATTEMPT_COST` at check time and owes nothing more. Only
925 // failures are charged again, because failures are what a guessing attack is made of.
926 if outcome == AttemptOutcome::Succeeded {
927 return;
928 }
929 let index = self.window_index(now);
930 let mut state = self.lock();
931 // The window may have rolled between the check and the report. Charging the penalty to the
932 // NEW window is the safe direction: it can only make the limiter stricter, whereas skipping
933 // it would let an attacker time their guesses to land the penalty in a window nobody reads.
934 state.roll_to(index);
935 match attempt {
936 Attempt::DeviceUserCodeEntry => {
937 let capacity = self.config.device_user_code_capacity;
938 let cost = self.config.device_user_code_failure_cost;
939 Self::penalise(&mut state.device_user_code, capacity, cost);
940 }
941 Attempt::ClientAuthentication { client_id } => {
942 // Clamped at the FAILURE CEILING and not at the capacity, which is the whole of the
943 // lockout fix: past the ceiling a failure has already cost its `ATTEMPT_COST` at
944 // check time and costs nothing further, so no number of failures can take the
945 // reserved half of this client's budget away from the client itself.
946 let shared = self.config.client_authentication_failure_ceiling();
947 // The reserve is derived from whichever capacity applies to this identifier, on
948 // the same terms as the charge above and with the same tracked-only rule.
949 let overridden = self
950 .config
951 .client_authentication_failure_ceiling_for(client_id);
952 let cost = self.config.client_authentication_failure_cost;
953 let max_tracked = self.config.max_tracked_clients;
954 let (budget, tracked) = state.client_counter(client_id, max_tracked);
955 let ceiling = if tracked { overridden } else { shared };
956 Self::penalise(&mut budget.failures, ceiling, cost);
957 }
958 Attempt::AuthorizationRequest { client_id } => {
959 // Same split, same reasoning as client authentication: a refused authorization
960 // request is what walking the `client_id`/`redirect_uri` space looks like, so it
961 // costs more than an honest one — but it may not spend the half of the budget the
962 // honest client's own users need, or an attacker would take a client's login page
963 // off the air by guessing at it.
964 let ceiling = self.config.authorization_request_failure_ceiling();
965 let cost = self.config.authorization_request_failure_cost;
966 let max_tracked = self.config.max_tracked_clients;
967 let budget = state.authorization_counter(client_id, max_tracked);
968 Self::penalise(&mut budget.failures, ceiling, cost);
969 }
970 Attempt::ClientRegistration => {
971 // No reserve here, and deliberately: there is no client to protect from anyone
972 // else's failures, because a registration request names no client. The budget is
973 // global for the same reason the device one is, and an operator who needs a higher
974 // ceiling has a `RegistrationPolicy` that should be doing the deciding.
975 let capacity = self.config.client_registration_capacity;
976 let cost = self.config.client_registration_failure_cost;
977 Self::penalise(&mut state.registration, capacity, cost);
978 }
979 }
980 }
981}
982
983impl Window {
984 /// Start a fresh budget if `index` is not the window these counters belong to.
985 ///
986 /// Clearing the map here is the whole of the eviction policy (see [`FixedWindowRateLimiter`]):
987 /// every counter in it was about to be reset to zero anyway, so dropping the keys costs no
988 /// information and bounds every entry's lifetime at one window.
989 fn roll_to(&mut self, index: u64) {
990 if self.index == index {
991 return;
992 }
993 self.index = index;
994 self.device_user_code = 0;
995 self.registration = 0;
996 self.authorization_overflow = ClientBudget::default();
997 self.authorization.clear();
998 self.overflow = ClientBudget::default();
999 // `clear` keeps the allocated capacity, which is already bounded by `max_tracked_clients`,
1000 // so the map never grows across windows and the common case does not re-allocate.
1001 self.clients.clear();
1002 }
1003
1004 /// The authorization-endpoint budget this `client_id` is charged against, on exactly the terms
1005 /// [`Window::client_counter`] describes: its own if it has one or can have one, and the shared
1006 /// overflow budget otherwise.
1007 ///
1008 /// A SECOND map rather than a third counter on `ClientBudget`, because the two budgets protect
1009 /// different things and must not be able to spend each other: a client being hammered at
1010 /// `/authorize` must still be able to redeem the codes it already issued, and a client that
1011 /// cannot authenticate must still be throttled at the endpoint that writes records. The memory
1012 /// bound is unchanged in kind and doubled in size, which is stated on
1013 /// [`FixedWindowRateLimiter`] rather than left for a reader to work out.
1014 fn authorization_counter(&mut self, client_id: &str, max_tracked: usize) -> &mut ClientBudget {
1015 if client_id.len() > MAX_TRACKED_CLIENT_ID_LEN {
1016 return &mut self.authorization_overflow;
1017 }
1018 if !self.authorization.contains_key(client_id) {
1019 if self.authorization.len() >= max_tracked {
1020 return &mut self.authorization_overflow;
1021 }
1022 self.authorization
1023 .insert(Box::from(client_id), ClientBudget::default());
1024 }
1025 self.authorization
1026 .get_mut(client_id)
1027 .expect("the entry was just confirmed or inserted")
1028 }
1029
1030 /// The budget this `client_id` is charged against: its own if it has one or can have one, and
1031 /// the shared overflow budget otherwise.
1032 ///
1033 /// The `bool` says whether the identifier got a counter OF ITS OWN. It is what decides which
1034 /// capacity is charged: only a tracked identifier may be charged a
1035 /// `client_authentication_capacity_overrides` entry, because the overflow counter is shared
1036 /// with every other untracked identifier and cannot carry one identifier's exception.
1037 fn client_counter(&mut self, client_id: &str, max_tracked: usize) -> (&mut ClientBudget, bool) {
1038 if client_id.len() > MAX_TRACKED_CLIENT_ID_LEN {
1039 return (&mut self.overflow, false);
1040 }
1041 // Two hash lookups on the hit path rather than one. A single `match self.clients.get_mut()`
1042 // with an insert in the `None` arm does not compile under this crate's MSRV borrow checker
1043 // (the `&mut` from the failed lookup is held across the arm), and the alternative that does
1044 // compile, `entry(client_id.to_string())`, would allocate an owned key on EVERY call
1045 // including the overwhelming majority that hit an existing entry. Two lookups of a short
1046 // string is the cheaper of the two.
1047 if !self.clients.contains_key(client_id) {
1048 if self.clients.len() >= max_tracked {
1049 return (&mut self.overflow, false);
1050 }
1051 // The only allocation on this path, and only for a `client_id` seen for the first time
1052 // this window. `Box<str>` rather than `String`: the key is never grown, so the spare
1053 // capacity word a `String` carries would be paid on every tracked client for nothing.
1054 self.clients
1055 .insert(Box::from(client_id), ClientBudget::default());
1056 }
1057 (
1058 self.clients
1059 .get_mut(client_id)
1060 .expect("the entry was just confirmed or inserted"),
1061 true,
1062 )
1063 }
1064}
1065
1066impl RateLimiter for FixedWindowRateLimiter {
1067 fn check(&self, attempt: Attempt<'_>) -> RateLimitDecision {
1068 self.check_at(attempt, Instant::now())
1069 }
1070
1071 fn record(&self, attempt: Attempt<'_>, outcome: AttemptOutcome) {
1072 self.record_at(attempt, outcome, Instant::now());
1073 }
1074}
1075
1076#[cfg(test)]
1077#[path = "tests/rate_limit.rs"]
1078mod tests;