oauth_as/events.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2// Copyright (C) 2026 Matthew Jackson
3
4//! The host seams this library cannot fill for itself: an AUDIT EVENT channel and a RATE LIMITING
5//! decision point, plus the one slot ([`Hooks`]) the server carries for both of them and for the
6//! client secret verifier ([`crate::client::SecretVerifier`]).
7//!
8//! # Why these live here and not in the host's own code
9//!
10//! Both answer questions only the library can ask and only the host can answer.
11//!
12//! - OBSERVATION. This crate revokes a whole token family when it detects authorization code
13//! replay (RFC 9700 section 4.1.1) or refresh token reuse (OAuth 2.1 draft section 6.1, RFC 9700
14//! section 4.14.2). Those are the crate's most serious security behaviours and their entire
15//! value is that somebody NOTICES: a revocation that appears in no log is an incident nobody
16//! investigates. Nothing outside the library can see either event, because the evidence (a
17//! consumed code presented twice, a spent refresh record presented again) exists only inside the
18//! grant machinery.
19//! - THROTTLING. RFC 8628 section 5.1 makes the device user code's entropy adequate only IN
20//! COMBINATION WITH rate limiting of code entry. The library knows an attempt happened and
21//! whether it failed; it does NOT know the caller, the IP, the session or the user, because it
22//! never sees a request. So the library asks and reports, and the host counts and decides.
23//!
24//! That reasoning explains why this is a SEAM. It never justified shipping the seam EMPTY, and
25//! as of 0.9.0 the crate does not: [`crate::rate_limit::FixedWindowRateLimiter`] is a counter a
26//! host installs in one line, with defaults derived from the section 5.1 arithmetic. It is a
27//! floor rather than a ceiling (it is per process, so on a multi-node deployment the effective
28//! limit is multiplied by the node count); its module docs say plainly what it cannot do.
29//!
30//! # Zero cost until enabled
31//!
32//! The crate doc promises a host that never turns something on pays nothing for it, and this
33//! module is built to keep that promise structurally rather than by intention:
34//!
35//! - [`Hooks`] is ONE pointer wide. The seams behind it are four in a default build (an
36//! [`EventSink`], a [`RateLimiter`], a [`crate::client::SecretVerifier`] and a
37//! [`crate::registration::RegistrationPolicy`]) and six with `jar` and `jwt` (a
38//! `RequestObjectKeys` and an `Es256Verifier`). Held as separate `Option<Box<dyn _>>` fields they
39//! would be 16 bytes each on every [`crate::server::AuthorizationServer`] value, so 64 bytes paid
40//! by every host and 96 by one enabling both features; instead they live inside a boxed struct
41//! that is not allocated at all until something is installed.
42//!
43//! The registration policy is worth naming rather than counting, because it is the seam whose
44//! ABSENCE is the security behaviour: with none installed, every RFC 7591 registration is REFUSED
45//! (see [`Hooks::registration_policy`]), which is the opposite default to the rate limiter's.
46//! - [`Hooks::emit`] takes a CLOSURE, not an [`Event`]. With no sink installed the closure is
47//! never called, so the event is never built: no allocation, no formatting, no vtable dispatch,
48//! just one null check on a pointer that is already in cache. `tests/events.rs` measures exactly
49//! that with a counting allocator and with a closure that panics if it is ever run.
50//! - Events are delivered SYNCHRONOUSLY, on the calling task. This crate has no background task by
51//! design (see the crate docs) and does not gain one here; a host that wants buffering owns a
52//! channel and writes three lines of [`EventSink`].
53//!
54//! # What events may carry, and what they may never carry
55//!
56//! Security finding C13 hand-wrote `Debug` on every type in this crate that holds a credential, so
57//! that a host's `tracing::debug!(?request)` could not become a plaintext credential leak. An event
58//! channel is a second way out of the process for the same values, and it goes to the same logs,
59//! so it is held to the same rule: NO access token, NO refresh token, NO authorization code, NO
60//! device code, NO user code, NO client secret, NO PKCE verifier. `tests/events.rs` scans the
61//! [`Event`] declaration and fails if a field named after one of those appears.
62//!
63//! What events DO carry is what an incident response needs to act:
64//!
65//! - `client_id`, which RFC 6749 section 2.2 states is not a secret.
66//! - the `subject`, where there is one. This is the host's own user identifier, which the host
67//! already put into this crate; it is not a credential (holding it authenticates nobody), though
68//! a host in a privacy-regulated setting may want to treat it as personal data in its logs.
69//! - the `family_id` of a refresh chain. This one is worth justifying, because it is the only
70//! opaque server-minted string in the whole set. It is SAFE to log because it is not a
71//! credential in any sense the protocol recognises: it is accepted at no endpoint, it appears in
72//! no request and no response, it is never given to a client, and possessing it lets nobody
73//! obtain, refresh or introspect a token. Its only power is to NAME a set of records in the
74//! host's own store, which is precisely the correlation an operator needs to answer "what else
75//! did this compromised grant issue" and to call [`crate::store::Storage::revoke_token_family`]
76//! by hand. The alternative, logging the tokens themselves, is the leak this rule exists to
77//! prevent; the alternative of logging nothing makes the revocation untraceable.
78
79use crate::client::SecretVerifier;
80#[cfg(feature = "client-assertion")]
81use crate::client_assertion::AssertionFailure;
82#[cfg(feature = "dpop")]
83use crate::dpop::DpopFailure;
84use crate::error::ErrorCode;
85use crate::grant::GrantType;
86use crate::registration::RegistrationPolicy;
87use crate::scope::ScopeSet;
88use crate::token::TokenTypeHint;
89
90/// Why a client failed to authenticate (RFC 6749 section 5.2 `invalid_client`).
91///
92/// The WIRE collapses all of these into one `invalid_client`, deliberately, so an attacker cannot
93/// probe which client ids exist. The AUDIT channel separates them just as deliberately: the host
94/// is not the attacker, and "a thousand unknown client ids" and "a thousand wrong secrets for one
95/// real client" are different incidents with different responses.
96///
97/// Shared by BOTH planes: it is the reason carried by
98/// [`Event::ClientAuthenticationFailed`] (the token plane) and by
99/// [`Event::ClientRegistrationAuthenticationFailed`] (the RFC 7592 management plane). One
100/// vocabulary rather than two, because a host counting credential guesses wants to count the same
101/// shapes wherever they happen; WHICH plane an attempt arrived on is the event variant, not this
102/// enum, so a sink can separate them without having to learn a second set of names.
103#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
104#[non_exhaustive]
105pub enum ClientAuthFailure {
106 /// No registration exists for the presented `client_id`.
107 UnknownClient,
108 /// The registration exists and the presented credential did not verify (or none was presented
109 /// for a confidential client, or one was presented for a public one).
110 SecretMismatch,
111 /// The host's own [`RateLimiter`] refused the attempt before it was evaluated.
112 RateLimited,
113 /// The registration is dynamic (RFC 7591) and its `client_secret_expires_at` has passed, so the
114 /// secret is no longer a credential however correct it is.
115 ///
116 /// Separated from [`ClientAuthFailure::SecretMismatch`] because the response differs and the
117 /// urgency differs. This is not an attack: it is a client that missed a rotation window this
118 /// server announced when it registered them, and the fix is to re-register rather than to
119 /// investigate. A run of these after a rotation deadline is expected; a run of them before one
120 /// means the deployment's clock or its issued lifetimes are wrong.
121 SecretExpired,
122 /// The registration is PUBLIC and the endpoint the caller reached admits confidential clients
123 /// only: RFC 7662 section 2.1 introspection, and the RFC 6749 section 4.4 client credentials
124 /// grant.
125 ///
126 /// Not a wrong credential — no credential was ever in play. A public registration has no
127 /// secret, so "authenticated as a public client" is a sentence true of every caller on the
128 /// internet, and naming a client id is not authentication.
129 ///
130 /// IT EXISTS SO THAT THE WIRE DOES NOT HAVE TO SAY IT. Through 0.9.1 both endpoints answered
131 /// this case with an `invalid_client` CARRYING A DESCRIPTION ("introspection requires a
132 /// confidential client"), while an unknown client id and a confidential client with the wrong
133 /// secret both got a BARE `invalid_client` — so the description sorted "this id is registered,
134 /// and it is public" from everything else, which is the enumeration
135 /// [`ClientAuthFailure::UnknownClient`] and [`ClientAuthFailure::SecretMismatch`] are collapsed
136 /// on the wire to prevent. The description is gone; the fact is here instead, in the channel
137 /// where the reader is not the attacker. It is also the sentence an operator actually needs,
138 /// because the usual cause is a resource server registered with the wrong
139 /// `token_endpoint_auth_method` rather than an attack.
140 NotConfidential,
141 /// MANAGEMENT PLANE ONLY. The `client_id` names a client the HOST provisioned itself, which
142 /// carries no RFC 7591 registration record and therefore no registration access token that
143 /// could ever verify.
144 ///
145 /// Separated from [`ClientAuthFailure::UnknownClient`] because it says something that one does
146 /// not: the client id was REAL. A run of these is somebody walking a deployment's static client
147 /// ids looking for one that happens to be dynamically registered and therefore rewritable
148 /// through RFC 7592 section 2.2; a run of `UnknownClient` is somebody who has not found a live
149 /// id yet. The wire tells the caller neither (both are the same `401`).
150 NoDynamicRegistration,
151 /// The registration authenticates with RFC 8705 mutual TLS and NO certificate reached
152 /// this crate. Worth separating from a mismatch: in practice it usually means the TLS
153 /// terminator is not configured to request, verify or forward a client certificate, which
154 /// is an operational fault affecting every mutual-TLS client at once rather than an
155 /// attack on one of them.
156 #[cfg(feature = "mtls")]
157 NoCertificatePresented,
158 /// A certificate was presented and did not match the registration (RFC 8705 section 2.1
159 /// subject values, or section 2.2 thumbprints). This one IS the attack shape: a caller
160 /// holding some valid certificate trying to be a client it is not.
161 #[cfg(feature = "mtls")]
162 CertificateMismatch,
163 /// The registration exists and an RFC 7523 client assertion was presented that did not verify:
164 /// a bad signature, an `alg` the registration does not use, an audience naming another server,
165 /// an expired assertion, or a `jti` that had already been spent.
166 ///
167 /// Separated from [`ClientAuthFailure::SecretMismatch`] because the responses differ. A run of
168 /// wrong secrets is credential stuffing; a run of REPLAYED assertions is somebody who has
169 /// captured a client's traffic, which is a different incident and a much worse one.
170 ///
171 /// CARRIES THE REASON, because collapsing the nine into one told the operator nothing they
172 /// could act on. `AssertionFailure` documents itself as existing "for the host's audit channel,
173 /// where the reader is not the attacker", and until 0.9.1 the server discarded it here, so a
174 /// burst of these was indistinguishable between clock skew on the client
175 /// (`Expired`/`NotYetValid`, fix NTP), a key rotation the registration did not follow
176 /// (`BadSignature`, fix the registration), and assertions captured at another authorization
177 /// server and replayed here (`WrongAudience`, an incident). The mutual-TLS arm beside it
178 /// already forwarded its failure verbatim, which is how the omission was found.
179 #[cfg(feature = "client-assertion")]
180 AssertionInvalid {
181 /// Which of the RFC 7523 section 3 checks the assertion failed. Never reaches the wire:
182 /// every one of them is the same `invalid_client` there.
183 reason: AssertionFailure,
184 },
185}
186
187/// The OPERATOR's sentence, never the client's. Everything here is what the wire deliberately
188/// refuses to distinguish (see the type's docs), so these strings must not reach a response body.
189impl std::fmt::Display for ClientAuthFailure {
190 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
191 f.write_str(match self {
192 ClientAuthFailure::UnknownClient => "no registration for that client_id",
193 ClientAuthFailure::SecretMismatch => "the presented client credential did not verify",
194 ClientAuthFailure::RateLimited => "the host's rate limiter refused the attempt",
195 ClientAuthFailure::SecretExpired => {
196 "the registration's client_secret_expires_at has passed"
197 }
198 ClientAuthFailure::NotConfidential => {
199 "that client id is registered as a public client, and this endpoint admits \
200 confidential clients only"
201 }
202 ClientAuthFailure::NoDynamicRegistration => {
203 "that client id exists but was provisioned by the host, so it has no registration \
204 access token"
205 }
206 #[cfg(feature = "mtls")]
207 ClientAuthFailure::NoCertificatePresented => {
208 "the registration authenticates with mutual TLS and no certificate was presented"
209 }
210 #[cfg(feature = "mtls")]
211 ClientAuthFailure::CertificateMismatch => {
212 "the presented certificate is not one this registration authenticates with"
213 }
214 #[cfg(feature = "client-assertion")]
215 // The REASON is appended rather than dropped: `AssertionFailure` already writes one
216 // sentence per check, and this is the channel those sentences were written for.
217 ClientAuthFailure::AssertionInvalid { reason } => {
218 return write!(f, "the client assertion did not verify: {reason}")
219 }
220 })
221 }
222}
223
224/// It is the `Err` payload of `crate::mtls::authenticate_via_mtls`, so a host handling that with
225/// `?` or collecting it into a `Box<dyn Error>` needs this, exactly as `DpopFailure` and
226/// `AssertionFailure` do for theirs. (Plain text rather than intra-doc links: those two types are
227/// behind features this one is not, so a link would dangle in a default build.)
228impl std::error::Error for ClientAuthFailure {}
229
230/// Something the authorization server did, or refused to do, worth recording.
231///
232/// Every field borrows: an event costs no allocation to build, which is what lets a host with a
233/// sink installed pay only for what its sink chooses to keep. See the module docs for the rule on
234/// what may and may not appear here.
235///
236/// `#[non_exhaustive]`: later releases will add events, and adding one must not be a breaking change
237/// for a host that matched on this. The RFC 7591 and 7592 registration events below arrived exactly
238/// that way and are here now, as did [`Event::DpopProofRefused`], which this paragraph named as
239/// the next candidate until 0.9.1 added it.
240#[derive(Debug, Clone, PartialEq, Eq)]
241#[non_exhaustive]
242pub enum Event<'a> {
243 /// A client failed to authenticate at a token-plane endpoint.
244 ClientAuthenticationFailed {
245 /// The `client_id` presented, which may not name any registration.
246 client_id: &'a str,
247 /// Which of the two indistinguishable-on-the-wire failures this actually was.
248 failure: ClientAuthFailure,
249 },
250 /// An access token was issued (and possibly a refresh token with it).
251 TokenIssued {
252 /// The client the grant was issued to.
253 client_id: &'a str,
254 /// Which grant produced it.
255 grant_type: GrantType,
256 /// The resource owner, absent for `client_credentials` (RFC 6749 section 4.4 has none).
257 subject: Option<&'a str>,
258 /// The granted scope, borrowed rather than rendered: a sink that does not want it pays
259 /// nothing, and one that does can format it itself.
260 scope: &'a ScopeSet,
261 /// The refresh chain this issuance belongs to, when it has one. See the module docs for
262 /// why this identifier is safe to log.
263 family_id: Option<&'a str>,
264 /// Whether a refresh token was issued alongside the access token.
265 refresh_issued: bool,
266 },
267 /// A grant request was refused, with the RFC 6749 section 5.2 code the client was told.
268 GrantRefused {
269 /// The client that asked.
270 client_id: &'a str,
271 /// The grant it asked for.
272 grant_type: GrantType,
273 /// The error code that went back on the wire.
274 error: ErrorCode,
275 },
276 /// An RFC 9449 DPoP proof was refused, and WHY.
277 ///
278 /// No `client_id`, and its absence is the honest shape rather than an omission: the proof is
279 /// checked BEFORE anything authenticates (see `AuthorizationServer::verify_dpop`), because it
280 /// binds to the REQUEST rather than to the grant, so at the moment this fires there is no
281 /// established identity to name. A host that wants to correlate has its own request context,
282 /// exactly as [`Attempt::DeviceUserCodeEntry`] requires.
283 ///
284 /// Every one of these is the same `invalid_dpop_proof` on the wire. The distinction is the
285 /// operator's, and `DpopFailure` documents itself as existing for exactly that; through 0.9.0
286 /// the server discarded it and emitted NO event at all, so a deployment could not tell a
287 /// client with a skewed clock from one whose proofs were being captured and replayed.
288 #[cfg(feature = "dpop")]
289 DpopProofRefused {
290 /// Which of the RFC 9449 section 4.3 checks the proof failed.
291 failure: DpopFailure,
292 },
293 /// A user approved a device grant at the host's verification UI (RFC 8628 section 3.3).
294 DeviceGrantApproved {
295 /// The device's client.
296 client_id: &'a str,
297 /// The user who approved it.
298 subject: &'a str,
299 },
300 /// A user refused a device grant, which the device will next see as `access_denied`.
301 DeviceGrantDenied {
302 /// The device's client.
303 client_id: &'a str,
304 },
305 /// EVIDENCE OF COMPROMISE. An authorization code was presented after it had already been
306 /// redeemed (RFC 6749 section 4.1.2, RFC 9700 section 4.1.1). The server has refused the
307 /// replay and revoked what the code minted; a code is a value that leaks into logs, `Referer`
308 /// headers and browser history, so a replay means either a leak or an attack in progress.
309 AuthorizationCodeReplayDetected {
310 /// The client the code was issued to.
311 client_id: &'a str,
312 /// The refresh family that was revoked, when the code's chain was still reachable.
313 family_id: Option<&'a str>,
314 /// Whether the refresh family was actually revoked (a chain already swept leaves nothing to
315 /// kill, and a store that refuses the revocation kills nothing either).
316 tokens_revoked: bool,
317 /// Whether ANY step of the compromise response failed to persist: the family revocation,
318 /// the fallback deletion of the access token the code minted, or the write that puts the
319 /// consumed code record back so the NEXT replay is still detectable.
320 ///
321 /// This exists because the wire cannot carry the news. A replayed code is answered
322 /// `invalid_grant` however badly the store is behaving, since the party being answered is
323 /// whoever holds the leaked code, so this event is the ONLY signal a deployment gets that a
324 /// code was replayed and the only place the truth about what was done can be told. An event
325 /// that overstates the containment is worse than no event: it is what an operator reads
326 /// while deciding not to investigate.
327 ///
328 /// `true` means credentials the server intended to destroy may still be live. Treat it as
329 /// an incident that needs a human, not as a storage warning.
330 containment_failed: bool,
331 },
332 /// EVIDENCE OF COMPROMISE, and the most serious event here. A superseded refresh token was
333 /// presented, which means two parties hold it (OAuth 2.1 draft section 6.1, RFC 9700 section
334 /// 4.14.2). The whole family has been revoked, which also logs out the legitimate client, so
335 /// this is an event a host will be asked about.
336 RefreshTokenReuseDetected {
337 /// The client that presented the spent token.
338 client_id: &'a str,
339 /// The revoked family. See the module docs for why this is safe to log.
340 family_id: &'a str,
341 /// How many records the family revocation removed. `0` when the revocation itself failed,
342 /// which `containment_failed` is what tells apart from a family that was already swept.
343 records_revoked: u64,
344 /// Whether the family revocation failed to persist, exactly as on
345 /// [`Event::AuthorizationCodeReplayDetected`].
346 ///
347 /// The reuse is real and is reported either way: the token was consumed before this was
348 /// judged, so the server cannot un-know that two parties hold it, and the presenter is
349 /// answered `invalid_grant` whatever the store did. This event is therefore the ONLY place
350 /// the truth about what was actually done can be told, and an event that overstates the
351 /// containment is worse than no event — it is what an operator reads while deciding not to
352 /// investigate.
353 ///
354 /// `true` means the compromised grant's tokens ARE STILL LIVE, up to their own expiry: the
355 /// access tokens the family issued still authorize, and the refresh chain the thief rotated
356 /// away can still be rotated again. The presented token's `Spent` record is put back so a
357 /// further presentation is still detected as reuse, but nothing was revoked. Treat it as an
358 /// incident that needs a human, not as a storage warning.
359 containment_failed: bool,
360 },
361 /// A token was revoked through the RFC 7009 endpoint.
362 TokenRevoked {
363 /// The client that revoked it (section 2.1 requires it to be the owner).
364 client_id: &'a str,
365 /// Which kind was removed.
366 token_type: TokenTypeHint,
367 /// The presented token IS revoked; this says whether the RFC 7009 section 2.1 SHOULD that
368 /// follows it succeeded. Revoking a refresh token also invalidates the access tokens of the
369 /// same grant, and that cascade is deliberately non-fatal: turning a completed revocation
370 /// into an error would tell an honest client nothing happened when the token it named is
371 /// already gone. Non-fatal is not the same as unreported, and `true` here means the
372 /// client's access tokens from that grant are still live for up to one access token TTL.
373 ///
374 /// Always `false` for an access token: there is no grant-wide cascade to attempt.
375 cascade_failed: bool,
376 },
377 /// A resource owner WITHDREW a consent, and everything issued under it was revoked.
378 ///
379 /// The one event in this set a USER causes rather than a client, and it is worth recording for
380 /// the same reason the two compromise events are: the cascade logs a client out of an account
381 /// it was in the middle of working with, so a host will be asked what happened.
382 /// `records_revoked` is how many tokens, codes and approved device grants the withdrawal
383 /// actually removed, which is what distinguishes "the user ended a live session" from "the
384 /// user revoked something that had already expired".
385 #[cfg(feature = "consent")]
386 ConsentWithdrawn {
387 /// The client that may no longer act for this user.
388 client_id: &'a str,
389 /// The user who withdrew it.
390 subject: &'a str,
391 /// How many records the cascade removed.
392 records_revoked: u64,
393 },
394 /// A client registered itself through RFC 7591 dynamic client registration.
395 ///
396 /// Worth watching even in a deployment that meant to enable this: RFC 7591 section 5 is
397 /// explicit that an open registration endpoint lets anyone create clients, so the RATE of this
398 /// event is the signal that an abuse policy is not holding. Carries no credential, so neither
399 /// the issued client secret nor the registration access token reaches the host's logs.
400 ClientRegistered {
401 /// The identifier the server minted.
402 client_id: &'a str,
403 },
404 /// A caller failed to authenticate at the RFC 7592 registration MANAGEMENT endpoint.
405 ///
406 /// The sibling of [`Event::ClientAuthenticationFailed`], and separate from it on purpose: the
407 /// two planes are guessed at for different reasons, and a sink that could not tell them apart
408 /// would report a management-plane brute force as token-endpoint noise. What is being guessed
409 /// here is the registration access token, and RFC 7592 section 2.2 lets its holder replace the
410 /// whole metadata document INCLUDING `redirect_uris`, which is where this client's
411 /// authorization codes are delivered. A landed guess is therefore not "an attacker can act as
412 /// this client"; it is "an attacker can have this client's codes sent to a URI they chose".
413 ///
414 /// Emitted for all four refusals of a management request, which the wire deliberately cannot
415 /// tell apart (they are one `401`, because distinguishing them is an enumeration oracle over
416 /// the client table): an attempt this host's [`RateLimiter`] denied before the store was
417 /// touched ([`ClientAuthFailure::RateLimited`]), an unknown `client_id`
418 /// ([`ClientAuthFailure::UnknownClient`]), a host-provisioned client that has no registration
419 /// at all ([`ClientAuthFailure::NoDynamicRegistration`]), and a registration access token that
420 /// did not verify ([`ClientAuthFailure::SecretMismatch`]).
421 ///
422 /// The limited one is emitted but NOT recorded as an attempt: the attempt never happened, so
423 /// there is no outcome to report and charging it would bill the caller twice for one try.
424 ///
425 /// Carries no credential: not the presented token, not a prefix of it, not its length. See the
426 /// module docs for the rule.
427 ClientRegistrationAuthenticationFailed {
428 /// The `client_id` the request named, which may name no registration. Not a secret
429 /// (RFC 6749 section 2.2).
430 client_id: &'a str,
431 /// Which of the four indistinguishable-on-the-wire refusals this actually was.
432 failure: ClientAuthFailure,
433 },
434 /// A registration was rewritten through RFC 7592 section 2.2.
435 ///
436 /// An update can change the redirect URIs, which is the whole of where this client's
437 /// authorization codes may be delivered, so it deserves the same attention as the original
438 /// registration.
439 ClientRegistrationUpdated {
440 /// The registration that changed.
441 client_id: &'a str,
442 },
443 /// A registration was deleted through RFC 7592 section 2.3, taking with it every token,
444 /// refresh chain and outstanding authorization code it held.
445 ClientRegistrationDeleted {
446 /// The registration that is now gone.
447 client_id: &'a str,
448 },
449 /// The host's own [`crate::registration::RegistrationPolicy`] refused a registration or an
450 /// update.
451 ///
452 /// Distinct from [`Event::ClientRegistrationAuthenticationFailed`], and the distinction is the
453 /// point: that one is somebody who could not prove who they are, this one is somebody who
454 /// DID and was then told no. On the management plane the caller has already presented a valid
455 /// registration access token, so a stream of these is a client with a working credential
456 /// repeatedly attempting something the deployment's policy forbids, which is a different
457 /// investigation from a brute force and was previously invisible in both directions.
458 ///
459 /// It is also the only signal a host gets that its own policy is doing anything at all. The
460 /// wire answer is a bare `401` chosen so that a policy refusing on CONTENT does not confirm
461 /// what content it dislikes (see `register_dynamic_client`), which means the operator learns
462 /// nothing from it either.
463 ///
464 /// Carries no credential: not the initial access token, not the registration access token, and
465 /// not the rejected metadata document, which is attacker-supplied text of the host's own
466 /// choosing to log or not.
467 ClientRegistrationRefusedByPolicy {
468 /// The registration being updated, for an RFC 7592 section 2.2 management request. `None`
469 /// for an initial RFC 7591 registration, which has no `client_id` yet: the refusal happens
470 /// before one is minted, deliberately, so that a refused attempt allocates nothing.
471 client_id: Option<&'a str>,
472 },
473}
474
475/// Where events go. The host implements this; the library never logs anything itself.
476///
477/// `on_event` is called SYNCHRONOUSLY, inside the request the host is already driving, and it takes
478/// `&self` so the sink is shared. Two consequences a host should design for: a slow sink slows the
479/// token endpoint, and a panicking sink panics the request. A host doing anything expensive should
480/// push onto a channel here and do the work elsewhere; this crate will not own that thread.
481pub trait EventSink: Send + Sync {
482 /// Record one event. Must not panic and should not block.
483 fn on_event(&self, event: Event<'_>);
484}
485
486/// Something a caller is attempting that a host may want to throttle.
487///
488/// Deliberately carries no credential: not the user code being tried (RFC 8628 section 6.1 makes
489/// it the credential a human types) and not the secret. It also cannot carry an IP or a session,
490/// because this library never sees a request; a host correlates using its own request context,
491/// which it still holds at the moment it calls into the server.
492#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
493#[non_exhaustive]
494pub enum Attempt<'a> {
495 /// A client is authenticating at a token-plane endpoint, or at the RFC 7592 registration
496 /// management plane. The `client_id` is included because RFC 6749 section 2.2 makes it
497 /// explicitly not a secret, so a limiter may key on it.
498 ///
499 /// ONE BUDGET FOR A CLIENT'S TWO BEARER CREDENTIALS, deliberately. The client secret and the
500 /// registration access token answer the same question — may this caller keep presenting
501 /// credentials as this `client_id` — and separate budgets would let an attacker stuffing the
502 /// management plane stay under a limiter watching the token endpoint, while the credential
503 /// they are guessing at is the more powerful of the two (RFC 7592 section 2.3 deletes the
504 /// registration and everything it was issued).
505 ///
506 /// IT ALSO CARRIES A RESOURCE SERVER'S INTROSPECTION TRAFFIC, and a limiter keying on this
507 /// needs to know that, because it is the one caller whose volume is not a function of anything
508 /// this server issued. A registration named in
509 /// [`crate::server::ServerConfig::resource_servers`] authenticates here once per RFC 7662
510 /// introspection, which is once per call at the protected resource it guards. There is
511 /// deliberately no introspection-specific variant: adding one to this `#[non_exhaustive]` enum
512 /// would land in the wildcard arm of every limiter already written, and a wildcard that
513 /// answers [`RateLimitDecision::Allow`] would silently stop throttling the endpoint on
514 /// upgrade. See "Resource servers introspect once per API call" in [`crate::rate_limit`] for
515 /// how to give one `client_id` a capacity of its own instead.
516 ClientAuthentication {
517 /// The presented identifier, which may name no registration.
518 client_id: &'a str,
519 },
520 /// A user code was entered at the host's verification UI
521 /// ([`crate::server::AuthorizationServer::approve_device`] /
522 /// [`crate::server::AuthorizationServer::deny_device`]). This is the attempt RFC 8628 section
523 /// 5.1 requires a deployment to rate limit.
524 DeviceUserCodeEntry,
525 /// A request at the AUTHORIZATION endpoint
526 /// ([`crate::server::AuthorizationServer::validate_authorization_request`], and again where an
527 /// approved request is turned into a code).
528 ///
529 /// The endpoint that takes NO CREDENTIAL at all, which is why it wants a variant of its own
530 /// rather than sharing [`Attempt::ClientAuthentication`]'s: there is nothing here for a limiter
531 /// counting credential guesses to count, and the `client_id` is the presented one (RFC 6749
532 /// section 2.2 makes it public), so a caller may name any registration they like. What a
533 /// deployment is bounding is work and STORAGE: every request costs a `get_client`, and an
534 /// approved one WRITES an authorization code record that nothing but
535 /// [`crate::store::Storage::sweep_expired`] reclaims.
536 ///
537 /// Checked twice on a completed flow, deliberately, because the two points cost different
538 /// things: the validation is a read, and the issuance is the write. A limiter that charged the
539 /// second to the first would let a caller who validates once issue without further charge.
540 AuthorizationRequest {
541 /// The `client_id` the request named, which may name no registration.
542 client_id: &'a str,
543 },
544 /// An RFC 7591 dynamic client registration
545 /// ([`crate::server::AuthorizationServer::register_dynamic_client`]).
546 ///
547 /// THE ONE UNBOUNDED WRITE IN THE CRATE, and the reason is that a [`crate::client::Client`] has
548 /// no expiry: no deadline, no sweep, nothing that ever reclaims one. Every other attacker-driven
549 /// record this server writes (a code, a token, a device grant, a replay `jti`) carries a
550 /// deadline and is reclaimed by [`crate::store::Storage::sweep_expired`], so an unthrottled
551 /// endpoint there is a burst. Here it is permanent growth, one row per request, forever.
552 ///
553 /// NO `client_id`, and its absence is deliberate rather than an oversight: at the moment this
554 /// is checked no identifier has been minted, and none is minted for a refusal, so a refused
555 /// registration allocates nothing at all. A limiter that wants to key on the caller has to use
556 /// its own request context, exactly as [`Attempt::DeviceUserCodeEntry`] requires.
557 ClientRegistration,
558}
559
560/// What a [`RateLimiter`] decided.
561#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
562pub enum RateLimitDecision {
563 /// Proceed.
564 Allow,
565 /// Refuse without evaluating the credential.
566 Deny,
567}
568
569/// How an attempt turned out, reported back so a limiter can count FAILURES rather than traffic.
570#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
571pub enum AttemptOutcome {
572 /// The credential verified / the code matched a live grant.
573 Succeeded,
574 /// It did not. This is the signal a guessing attack produces.
575 Failed,
576}
577
578/// The host's throttle. THIS LIBRARY DOES NOT RATE LIMIT ANYTHING, and cannot: it never sees a
579/// request, so it has no caller, no IP, no session and no user to count against.
580///
581/// RFC 8628 section 5.1 is explicit that the device user code's entropy is sufficient only in
582/// combination with rate limiting of user code entry, so for any deployment offering the device
583/// grant this is not optional in practice, only optional in the type system.
584///
585/// A HOST DOES NOT HAVE TO WRITE ONE. [`crate::rate_limit::FixedWindowRateLimiter`] is an
586/// implementation this crate ships, in memory, with no new dependency and with defaults derived
587/// from the section 5.1 arithmetic. Implement this trait yourself when you have something the
588/// library does not: a request IP, a session, a user, or a store shared across nodes.
589///
590/// # MUST NOT PANIC, and should not block
591///
592/// Both methods MUST answer for every input. There is no error channel and none is needed:
593/// [`RateLimitDecision::Allow`] and [`RateLimitDecision::Deny`] are both always available, and a
594/// limiter that cannot reach its shared counter should decide which of the two its deployment
595/// prefers rather than unwinding. This crate catches no unwind anywhere on a request path.
596///
597/// NAMING THE CONSEQUENCE, once per method, because the two sit at different points of a request:
598///
599/// - [`RateLimiter::check`] runs BEFORE any credential is evaluated, which is the whole point of
600/// it, and that puts it on the paths an UNAUTHENTICATED caller reaches: the authorization
601/// request, RFC 8628 section 5.1 user-code entry, and client authentication at the token
602/// endpoint. A panic here is remotely reachable by anyone who can open a socket, and it takes
603/// the request down before the throttle that would have limited how often they could try it.
604/// - [`RateLimiter::record`] runs AFTER the request has done its work, including after the store
605/// writes it drove. A panic here unwinds a request whose records are already written and whose
606/// response never reaches the client: the credential was spent, the client was told nothing, and
607/// the only place the two could have been reconciled was the response that was lost.
608///
609/// Blocking is the same argument one notch quieter. `check` is called inline on the caller's
610/// executor thread, so a limiter that waits on a network round trip to a shared counter adds that
611/// wait to every request, and on a current-thread runtime it adds it to every OTHER request too.
612pub trait RateLimiter: Send + Sync {
613 /// Decide whether `attempt` may proceed. Called BEFORE any credential is evaluated, so a
614 /// `Deny` costs the attacker a lookup and tells them nothing.
615 fn check(&self, attempt: Attempt<'_>) -> RateLimitDecision;
616
617 /// Report how an allowed attempt turned out. Defaults to doing nothing, so a host that only
618 /// wants a hard ceiling implements one method.
619 fn record(&self, attempt: Attempt<'_>, outcome: AttemptOutcome) {
620 let _ = (attempt, outcome);
621 }
622}
623
624/// The installed seams. Boxed as a unit (see [`Hooks`]) so that installing none of them allocates
625/// anything at all.
626#[derive(Default)]
627struct Installed {
628 events: Option<Box<dyn EventSink>>,
629 rate_limiter: Option<Box<dyn RateLimiter>>,
630 secret_verifier: Option<Box<dyn SecretVerifier>>,
631 registration_policy: Option<Box<dyn RegistrationPolicy>>,
632 #[cfg(feature = "jar")]
633 request_object_keys: Option<Box<dyn crate::par::RequestObjectKeys>>,
634 /// The host's ES256 backend for VERIFICATION (RFC 9449 DPoP proofs, RFC 9101 request objects,
635 /// RFC 7523 client assertions). `Arc` rather than `Box` because it is also what a host hands
636 /// to [`crate::signer_conformance`] and may share with its own resource-server half.
637 #[cfg(feature = "jwt")]
638 es256_verifier: Option<std::sync::Arc<dyn crate::jwt::Es256Verifier>>,
639}
640
641/// The server's slot for the host seams: exactly one pointer wide, and null until the host
642/// installs something.
643///
644/// This shape is the design decision the module docs argue for. Holding the seams as separate
645/// `Option<Box<dyn _>>` fields directly on [`crate::server::AuthorizationServer`] would add 16
646/// bytes each, 64 in a default build and 96 with `jar` and `jwt`, to every server value in every
647/// deployment, including every deployment that installs nothing, and
648/// `tests/allocation.rs` holds that type to a size budget precisely so a convenience like that
649/// cannot be paid for silently.
650#[derive(Default)]
651pub struct Hooks(Option<Box<Installed>>);
652
653impl Hooks {
654 /// An empty slot: nothing installed, nothing allocated.
655 pub fn new() -> Self {
656 Hooks(None)
657 }
658
659 fn installed(&mut self) -> &mut Installed {
660 // The one allocation this module can make, and only on a host's explicit install call.
661 self.0.get_or_insert_with(Default::default)
662 }
663
664 // THE INSTALLERS ARE `pub(crate)`, and the READERS below are `pub`. That asymmetry is the
665 // whole shape of this type: `Hooks` is a LAYOUT decision (one nullable pointer, size-gated in
666 // `tests/allocation.rs`), not a second installation API. A host installs through the six
667 // `AuthorizationServer::with_*` builders, which are the one verb for the one job; it reads
668 // through `AuthorizationServer::hooks()` when it wants to emit onto the same channel or
669 // consult its own limiter. Through 0.9.0 both spellings were public and only one of them was
670 // reachable, because `AuthorizationServer::new` builds its own `Hooks` and hands out only a
671 // shared reference: every in-tree caller of the `install_*` form was a test.
672
673 /// Install the audit sink, replacing any previous one.
674 pub(crate) fn install_event_sink(&mut self, sink: Box<dyn EventSink>) {
675 self.installed().events = Some(sink);
676 }
677
678 /// Install the rate limiter, replacing any previous one.
679 pub(crate) fn install_rate_limiter(&mut self, limiter: Box<dyn RateLimiter>) {
680 self.installed().rate_limiter = Some(limiter);
681 }
682
683 /// Install the client secret verifier, replacing any previous one.
684 pub(crate) fn install_secret_verifier(&mut self, verifier: Box<dyn SecretVerifier>) {
685 self.installed().secret_verifier = Some(verifier);
686 }
687
688 /// Install the RFC 7591 registration policy, replacing any previous one.
689 pub(crate) fn install_registration_policy(&mut self, policy: Box<dyn RegistrationPolicy>) {
690 self.installed().registration_policy = Some(policy);
691 }
692
693 /// Install the RFC 9101 request object verification keys, replacing any previous source.
694 #[cfg(feature = "jar")]
695 pub(crate) fn install_request_object_keys(
696 &mut self,
697 keys: Box<dyn crate::par::RequestObjectKeys>,
698 ) {
699 self.installed().request_object_keys = Some(keys);
700 }
701
702 /// Install the ES256 backend used to VERIFY signatures, replacing any previous one.
703 #[cfg(feature = "jwt")]
704 pub(crate) fn install_es256_verifier(
705 &mut self,
706 verifier: std::sync::Arc<dyn crate::jwt::Es256Verifier>,
707 ) {
708 self.installed().es256_verifier = Some(verifier);
709 }
710
711 /// The installed ES256 verifier, or `None`.
712 ///
713 /// `None` is NOT read as "accept anything": every caller refuses instead. Callers inside this
714 /// crate reach the verifier through a private resolver on `AuthorizationServer`, which is what
715 /// applies the `jwt-p256` fallback; this method reports only what the HOST installed, so that
716 /// fallback lives in exactly one place.
717 #[cfg(feature = "jwt")]
718 pub fn es256_verifier(&self) -> Option<&std::sync::Arc<dyn crate::jwt::Es256Verifier>> {
719 match &self.0 {
720 Some(installed) => installed.es256_verifier.as_ref(),
721 None => None,
722 }
723 }
724
725 /// The installed RFC 9101 request object key source.
726 ///
727 /// `None` is NOT read as "accept anything", for the same reason as
728 /// [`Hooks::registration_policy`] and the opposite of the [`RateLimiter`] default: a server
729 /// that cannot check a signature must refuse the request, because "cannot check" must never
730 /// read as "checked out".
731 #[cfg(feature = "jar")]
732 pub fn request_object_keys(&self) -> Option<&dyn crate::par::RequestObjectKeys> {
733 match &self.0 {
734 Some(installed) => installed.request_object_keys.as_deref(),
735 None => None,
736 }
737 }
738
739 /// Whether an event sink is installed.
740 ///
741 /// Call sites use this to decide whether to CLONE a value that is about to be consumed and
742 /// would otherwise be unavailable by the time the event can honestly be emitted (a client id
743 /// moved into a grant record, say). An unobserved host takes the `false` branch and pays
744 /// nothing; an observed one pays one small clone for the record it asked for.
745 pub fn is_observed(&self) -> bool {
746 match &self.0 {
747 Some(installed) => installed.events.is_some(),
748 None => false,
749 }
750 }
751
752 /// Emit an event, building it ONLY if a sink is installed.
753 ///
754 /// The closure is the whole point: see the module docs. With no sink this compiles down to a
755 /// null check and a return.
756 pub fn emit<'a, F>(&self, event: F)
757 where
758 F: FnOnce() -> Event<'a>,
759 {
760 if let Some(installed) = &self.0 {
761 if let Some(sink) = &installed.events {
762 sink.on_event(event());
763 }
764 }
765 }
766
767 /// Ask the host's limiter whether `attempt` may proceed. With none installed the answer is
768 /// [`RateLimitDecision::Allow`]: a library with no notion of a caller has no business
769 /// inventing a throttling policy, and failing closed here would break every host that has not
770 /// yet written one.
771 ///
772 /// "Allow" is therefore the answer for a host that installed nothing, and RFC 8628 section 5.1
773 /// says that host is running an under-protected verification endpoint. Install
774 /// [`crate::rate_limit::FixedWindowRateLimiter`] if you have nothing better.
775 pub fn check(&self, attempt: Attempt<'_>) -> RateLimitDecision {
776 match &self.0 {
777 Some(installed) => match &installed.rate_limiter {
778 Some(limiter) => limiter.check(attempt),
779 None => RateLimitDecision::Allow,
780 },
781 None => RateLimitDecision::Allow,
782 }
783 }
784
785 /// Report an outcome to the host's limiter, if any.
786 pub fn record(&self, attempt: Attempt<'_>, outcome: AttemptOutcome) {
787 if let Some(installed) = &self.0 {
788 if let Some(limiter) = &installed.rate_limiter {
789 limiter.record(attempt, outcome);
790 }
791 }
792 }
793
794 /// The installed client secret verifier, for [`crate::client::ClientAuth::verify_with`].
795 pub fn secret_verifier(&self) -> Option<&dyn SecretVerifier> {
796 match &self.0 {
797 Some(installed) => installed.secret_verifier.as_deref(),
798 None => None,
799 }
800 }
801
802 /// The installed RFC 7591 registration policy.
803 ///
804 /// `None` means the host installed none, and that is NOT read as "allow": see
805 /// [`RegistrationPolicy`]. It is the opposite of the [`RateLimiter`] default above, and
806 /// deliberately so. An absent limiter means the host has not written a throttling policy yet,
807 /// and refusing every request would break a host that never asked for throttling. An absent
808 /// registration policy means the host turned on an endpoint that mints clients and said
809 /// nothing about who may use it, and RFC 7591 section 5 is explicit about what an open one
810 /// costs.
811 pub fn registration_policy(&self) -> Option<&dyn RegistrationPolicy> {
812 match &self.0 {
813 Some(installed) => installed.registration_policy.as_deref(),
814 None => None,
815 }
816 }
817}
818
819#[cfg(test)]
820#[path = "tests/events.rs"]
821mod tests;