oauth_as/consent.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2// Copyright (C) 2026 Matthew Jackson
3
4//! Consent records, consent withdrawal, and RFC 9470 step-up authentication.
5//!
6//! Two things a real deployment needs that nothing else in this crate provided.
7//!
8//! # 1. A consent is a UNIT, and a unit can be withdrawn
9//!
10//! Before this module the only durable grouping this server had was the refresh chain's
11//! `family_id` (see [`crate::token::RefreshTokenRecord::family_id`]), which exists so that RFC 9700
12//! section 4.14.2 reuse detection can revoke "the tokens issued for that authorization grant".
13//! That grouping is too NARROW to answer the question a user asks. A user does not ask "end the
14//! chain that started on Tuesday"; they ask "this application should no longer act for me", and one
15//! application acting for one user accumulates many families over time, because every fresh trip
16//! through the authorization endpoint mints another one.
17//!
18//! So a [`ConsentRecord`] is the broader unit, keyed by (client, subject), and
19//! [`crate::store::Storage::revoke_consent`] is [`crate::store::Storage::revoke_token_family`] at
20//! that broader granularity: the same "remove every record reachable from this unit" primitive,
21//! asked of a bigger unit, in ONE storage operation so a host's database can do the whole cascade
22//! in one transaction. A withdrawal that left tokens alive would be the whole feature failing
23//! silently, and silently is the worst way for it to fail, because the user has been told they
24//! stopped something they did not. `tests/consent.rs` attacks exactly that case.
25//!
26//! # 2. This library cannot authenticate anybody, and says so
27//!
28//! RFC 9470 is about the AUTHENTICATION behind an authorization: a resource server decides the
29//! request it just received needs a stronger or a fresher login than the token reflects, and says
30//! so with an `insufficient_user_authentication` challenge (section 3). The client then repeats its
31//! authorization request carrying `acr_values` and `max_age` (section 4, the parameters OpenID
32//! Connect Core section 3.1.2.1 defines), and the authorization server is expected to act on them
33//! (section 5) and to report what it did as `acr` and `auth_time` (section 6).
34//!
35//! SECTION 6 HAS TWO SUBSECTIONS AND THIS CRATE ANSWERS BOTH, because a token reaches a resource
36//! server two ways: 6.1 is the RFC 9068 JWT access token, read offline by a server that never
37//! introspects, and 6.2 is RFC 7662 introspection, which is all an OPAQUE token has. Through 0.9.1
38//! only 6.2 was answered, which left the step-up invisible to exactly the deployment that verifies
39//! signatures locally.
40//!
41//! This crate has no login page, no session store, no password, no second factor, and no way to
42//! challenge a user, and it will not grow any of them: that is the same boundary the crate docs
43//! draw around the HTTP listener and persistence. So the division of labour is blunt, and worth
44//! stating in full rather than leaving to be discovered:
45//!
46//! - the HOST authenticates the user, by whatever means, and REPORTS the result as an
47//! [`Authentication`]: when it happened, and which authentication context class it satisfied. The
48//! library takes that report at face value, because it has nothing to check it against. A host
49//! that stamps `auth_time` with the current instant on every request has disabled `max_age` for
50//! itself and no code here can tell.
51//! - the LIBRARY records that report on the consent, on the authorization code, and on the tokens
52//! the code mints, and it ENFORCES `max_age` and `acr_values` against it
53//! ([`AuthenticationRequirement::satisfied_by`]). Enforcement is the half that must not be left
54//! to the host: a `max_age` a host is trusted to check for itself is a `max_age` that gets
55//! checked in whichever code path somebody remembered.
56//! - the library CANNOT re-authenticate anyone in response to a failure. It answers
57//! [`crate::error::ErrorCode::InsufficientUserAuthentication`] and the host decides whether that
58//! becomes a fresh login prompt or a refusal.
59//!
60//! # Allocation
61//!
62//! [`Authentication`] hangs off its three records as an `Option<Box<Authentication>>`: one null
63//! pointer for the common case of a host that reports nothing, and one small allocation for a host
64//! that does. Its `acr`, and the consent record's identifier and subject, are `Box<str>` rather
65//! than `String` for the reason [`crate::token::IssuedToken::jkt`] gives: they are written once and
66//! never appended to, so a `String`'s growable capacity would be 8 dead bytes on every record a
67//! store holds. Everything here is feature gated, so a build without `consent` carries neither the
68//! pointers nor the code that reads them.
69
70use std::time::{Duration, SystemTime};
71
72use serde::{Deserialize, Serialize};
73
74use crate::client::ClientId;
75use crate::error::{ErrorCode, ErrorResponse};
76use crate::scope::ScopeSet;
77
78/// The largest number of RFC 8707 resource indicators one [`ConsentRecord`] will accumulate.
79///
80/// # Why this one matters more than the per-request caps
81///
82/// [`crate::server::MAX_RESOURCE_INDICATORS`] bounds what a single request may ask for, and that
83/// cost dies with the request. This bounds what a (client, subject) relationship accumulates over
84/// its whole life. The list is only ever widened by [`ConsentRecord::extend`], it is never pruned,
85/// and [`ConsentRecord::covers`] walks it linearly on every authorization request that consults the
86/// record. Without a bound, a client naming one fresh indicator per request buys a record that
87/// grows forever and a check that gets slower forever, and the deployment never gets that back.
88///
89/// # Why 32
90///
91/// It is twice the per-request cap, which is the smallest number that is not simply the per-request
92/// cap in disguise: a relationship legitimately widens over time, so a user who approves one set of
93/// resources today and a different set next month should not hit the ceiling on the second visit.
94/// Beyond that, a single (client, subject) pair spanning more than thirty-two distinct resource
95/// servers is a client acting for the user across an estate large enough that per-resource consent
96/// has stopped meaning anything to the person granting it, which is a product problem this number
97/// makes visible rather than a limit it creates.
98pub const MAX_CONSENT_RESOURCES: usize = 32;
99
100/// The largest number of authentication context classes one `acr_values` parameter may name
101/// (RFC 9470 section 4, OpenID Connect Core section 3.1.2.1).
102///
103/// # Why a cap at all
104///
105/// `acr_values` is ONE parameter carrying a space-delimited list, and it arrives unauthenticated,
106/// before any user interaction, at `GET /authorize` and at the RFC 9126 push. Parsing it stores one
107/// `Box<str>` per non-empty segment, which is one heap allocation per segment, so without a bound
108/// the segment count is whatever the request line or the body allowed. The cheapest input is
109/// `"a a a ..."` at two bytes a token: 64 KiB of body, which is
110/// `crate::http::MAX_BODY_BYTES`, is about 32,768 allocations from a single parameter, and the
111/// GET form is worse because a URL is not bounded by that constant at all.
112///
113/// That is the exact shape `crate::http::MAX_FORM_PARAMETERS` exists to refuse, and it slipped
114/// past because it is one parameter rather than many: that constant's own arithmetic puts 64 KiB of
115/// `&a=b` pairs at about 2,300 parameters, so this one parameter bought an order of magnitude more
116/// work than the case the parameter cap was introduced for.
117///
118/// # Why 16
119///
120/// `acr_values` is an ORDERED PREFERENCE list, not a set to enumerate: OpenID Connect Core section
121/// 3.1.2.1 has the AS satisfy the first class it can, so the entries past the first few are already
122/// alternatives nobody expects to be reached. No published profile lists more than a handful. It is
123/// also the number [`crate::server::MAX_RESOURCE_INDICATORS`] and
124/// `rar::MAX_AUTHORIZATION_DETAILS_ELEMENTS` use for the other repeatable things one request can
125/// carry, so a reader does not have to hold a third number.
126///
127/// # It refuses, it does not truncate
128///
129/// Truncating would answer a resource server's step-up challenge with a class the user never
130/// satisfied, or drop the one class the client could actually meet, and tell nobody. That is the
131/// same failure [`crate::server::MAX_RESOURCE_INDICATORS`] refuses for RFC 8707 and
132/// [`crate::rar`] refuses for unknown members. `invalid_request` is the honest answer.
133pub const MAX_ACR_VALUES: usize = 16;
134
135/// What the HOST says about how, and when, it authenticated the resource owner.
136///
137/// This is a REPORT, not a proof. See the module docs: this crate cannot authenticate anyone and
138/// has no way to check this against anything, so it records it and holds requests to it.
139#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
140pub struct Authentication {
141 /// When the user actually authenticated. OpenID Connect Core section 2 defines `auth_time` as
142 /// the "time when the End-User authentication occurred", and RFC 9470 section 6 is what makes
143 /// it worth carrying: `max_age` is meaningless without an instant to measure from. It is
144 /// reported through both of that section's channels, the JWT claim of 6.1 and the introspection
145 /// member of 6.2.
146 ///
147 /// NOT "when this request arrived". A host that conflates the two makes every request look
148 /// freshly authenticated, which is the one mistake that turns this whole mechanism into
149 /// decoration.
150 pub auth_time: SystemTime,
151 /// The authentication context class the host says was satisfied: OpenID Connect Core section 2
152 /// `acr`. `None` means the host reported none, which can satisfy no `acr_values` request.
153 ///
154 /// The VALUES are the host's own vocabulary. This crate compares them as opaque strings and
155 /// deliberately knows nothing about what any of them means: there is no registry it could check
156 /// against, and a library that pretended to understand "phr" would be asserting something about
157 /// a login flow it has never seen.
158 ///
159 /// `Box<str>`, not `String`: written once at the moment the host reports it and never appended
160 /// to. Same reasoning as [`crate::token::IssuedToken::jkt`].
161 pub acr: Option<Box<str>>,
162}
163
164impl Authentication {
165 /// A report with no `acr`, for an authentication that happened at `auth_time`.
166 ///
167 /// `auth_time` is WHEN THE USER ACTUALLY LOGGED IN, not when this request arrived, and this
168 /// constructor is the place that distinction is lost or kept. Passing `SystemTime::now()` here
169 /// on every request makes every login look seconds old, which satisfies every `max_age` a
170 /// client can ask for and turns RFC 9470 step-up into decoration for that deployment. Nothing
171 /// downstream can detect it: see the module docs on which half of this boundary is the host's.
172 pub fn at(auth_time: SystemTime) -> Self {
173 Authentication {
174 auth_time,
175 acr: None,
176 }
177 }
178
179 /// Name the authentication context class this login satisfied.
180 ///
181 /// `&str` rather than `impl Into<String>`: a generic here monomorphizes once per argument type
182 /// at every call site, and the value is going into a `Box<str>` either way.
183 pub fn with_acr(mut self, acr: &str) -> Self {
184 self.acr = Some(acr.into());
185 self
186 }
187
188 /// How old this authentication is at `now`, or `None` if it is stamped in the future.
189 ///
190 /// A future `auth_time` is not an error here: clocks on separate machines disagree, and the one
191 /// decision this feeds ([`AuthenticationRequirement::satisfied_by`]) reads `None` as "no
192 /// elapsed time", which is the reading that cannot lock a user out over a clock skew.
193 pub fn age(&self, now: SystemTime) -> Option<Duration> {
194 now.duration_since(self.auth_time).ok()
195 }
196}
197
198/// A persisted record that one resource owner granted one client a set of permissions.
199///
200/// Keyed by (`client_id`, `subject`): one live consent per pair, widened in place when the user
201/// approves something further or re-authenticates. That key is what makes withdrawal answerable,
202/// and it is deliberately COARSER than the refresh chain's `family_id`, because a user withdrawing
203/// consent means "not this application, not for me, not any more" rather than "not this chain".
204#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
205pub struct ConsentRecord {
206 /// The server-minted identifier for this consent.
207 ///
208 /// Opaque, and NOT a credential: it is accepted at no endpoint, it authenticates nobody, and
209 /// its only power is to NAME this record in the host's own store, exactly as `family_id` names
210 /// a chain (the [`crate::events`] module docs make that argument in full). It is still minted
211 /// from OS randomness rather than derived from the client and the subject, because naming a
212 /// record that can end a user's sessions should not be something a third party can do by
213 /// guessing two strings it already knows.
214 ///
215 /// `Box<str>`: written once at creation and never appended to.
216 pub consent_id: Box<str>,
217 /// The client the user granted.
218 pub client_id: ClientId,
219 /// The resource owner who granted it, in the host's own vocabulary for users. `Box<str>` for
220 /// the same reason as `consent_id`.
221 pub subject: Box<str>,
222 /// The scope the user approved, accumulated across grants: this is what "remembered consent"
223 /// remembers.
224 pub scope: ScopeSet,
225 /// The RFC 8707 resource indicators the user approved this client to obtain tokens for. Empty
226 /// means the user approved no audience restriction, which is NOT the same as approving every
227 /// resource: see [`ConsentRecord::covers`].
228 ///
229 /// A `Vec` and not a `Box<[_]>`, unlike everything else here, because this is the one field
230 /// that genuinely grows: [`ConsentRecord::extend`] widens it in place as a user approves more,
231 /// and a boxed slice would mean reallocating the list on every widening to save 8 bytes on a
232 /// record there is one of per (client, user) pair.
233 pub resource: Vec<String>,
234 /// When this consent was first recorded. Widening does not move it: a user asking "since when
235 /// has this application been able to act for me" means the beginning, not the last change.
236 pub granted_at: SystemTime,
237 /// The authentication the host reported at the most recent approval, if any. See
238 /// [`Authentication`], and the module docs on whose job this is.
239 pub authentication: Option<Box<Authentication>>,
240}
241
242/// The RFC 9396 `authorization_details` an authorization request is ASKING FOR, in a shape that
243/// exists in every feature configuration.
244///
245/// A WRAPPER rather than the details themselves, and the reason is structural, the same one
246/// `crate::server`'s `GrantedDetails` records about `issue`: an argument can carry a `cfg`, but the
247/// ARGUMENT AT A CALL SITE cannot, so a gated parameter on [`ConsentRecord::covers`] would make
248/// every host's approval resolver — the one place this crate asks a host to write a comparison —
249/// duplicate that call under a `cfg` of its own. A host that writes one call site cannot get it
250/// wrong in the configuration it does not build.
251///
252/// Without `rar` this type has no fields, so it is zero sized, [`RequestedDetails::none`] compiles
253/// to nothing, and a deployment that never enabled RFC 9396 pays for none of it.
254///
255/// `#[non_exhaustive]` because its field set varies with a feature, which is the rule
256/// `tests/host_api_shape.rs` gates for every public type in this crate. Both fields are private
257/// already, so the attribute costs a host nothing: the two constructors below are the only way to
258/// make one in any build.
259#[non_exhaustive]
260#[derive(Debug, Clone, Copy, Default)]
261pub struct RequestedDetails<'a> {
262 /// `None` for an empty request as well as an absent one: "asked for no detail" and "asked for
263 /// an empty array" are the same request, and collapsing them here means
264 /// [`ConsentRecord::covers`] has one case to reason about rather than two.
265 #[cfg(feature = "rar")]
266 details: Option<&'a crate::rar::AuthorizationDetails>,
267 /// Without `rar` there is nothing to borrow, and the lifetime parameter still has to be used.
268 #[cfg(not(feature = "rar"))]
269 borrow: std::marker::PhantomData<&'a ()>,
270}
271
272impl<'a> RequestedDetails<'a> {
273 /// The request asks for no rich authorization detail. What a deployment without RFC 9396
274 /// always passes, and what a request that sent no `authorization_details` means.
275 pub fn none() -> Self {
276 RequestedDetails::default()
277 }
278
279 /// What the request in hand is asking for: the `http` feature hands a host exactly this value
280 /// as `ApprovalRequest::authorization_details`.
281 #[cfg(feature = "rar")]
282 pub fn of(details: &'a crate::rar::AuthorizationDetails) -> Self {
283 RequestedDetails {
284 details: (!details.is_empty()).then_some(details),
285 }
286 }
287
288 /// Whether the request asked for any detail at all.
289 fn is_empty(&self) -> bool {
290 #[cfg(feature = "rar")]
291 {
292 self.details.is_none()
293 }
294 #[cfg(not(feature = "rar"))]
295 {
296 let _ = self.borrow;
297 true
298 }
299 }
300}
301
302impl ConsentRecord {
303 /// Whether this record already covers a request for `scope`, `resource` and `details`.
304 ///
305 /// This is the whole of what "remembered consent" means in this crate, and it ANSWERS a
306 /// question rather than making a decision: the `http` feature's
307 /// `ServiceBuilder::with_approval_resolver` is where the answer is turned into one. Nothing in
308 /// this crate approves an authorization request on the strength of a `true` here.
309 ///
310 /// The first two halves are SUBSET tests against what was approved, never equality and never a
311 /// widening: a request for less than was granted is covered, and a request for one token more
312 /// is not. Requesting a resource that was never approved is not covered either, because RFC
313 /// 8707 section 2 makes the resource the audience the token will be good at, so treating
314 /// "approved for no particular resource" as "approved for that one" would let a remembered
315 /// consent grow an audience the user never saw.
316 ///
317 /// # A request carrying `authorization_details` is NEVER covered
318 ///
319 /// Whatever this record holds. A [`ConsentRecord`] records a scope and a resource list and
320 /// nothing else, so there is no approved element for a requested one to be compared against,
321 /// and the only two answers available are "not covered" and "covered because the SCOPE
322 /// matched". The second is the direction this method may not fail in: it would wave through an
323 /// RFC 9396 element — an amount, a creditor account, an `identifier` — that the resource owner
324 /// was never shown, on the strength of a scope string that RFC 9396 exists precisely because it
325 /// cannot express such a thing. That is the same failure the resource half above refuses, one
326 /// level more specific, and it is worse there because the element is the transaction.
327 ///
328 /// This is a REFUSAL and not a limitation to be worked around, and the reason is that the two
329 /// answers are nearly the same answer. The widest comparison this crate could ever soundly make
330 /// is [`crate::rar::AuthorizationDetail::is_narrowing_of`]: a requested element is covered only
331 /// when an approved one is identical to it in every type-defined member, since section 6.1 says
332 /// the AS cannot know what any of those members mean. For a payment that is the SAME payment
333 /// again. So the coverage a details field would buy is "ask me once per distinct transaction,
334 /// not once per request", and a remembered approval of one transaction is not an approval of
335 /// the next one anyway.
336 ///
337 /// A host that wants a user asked about a detail exactly once therefore does it at the seam
338 /// that can: the approval resolver is handed the parsed elements as
339 /// `ApprovalRequest::authorization_details`, alongside this answer, and decides.
340 pub fn covers(
341 &self,
342 scope: &ScopeSet,
343 resource: &[String],
344 details: RequestedDetails<'_>,
345 ) -> bool {
346 scope.is_subset(&self.scope)
347 && resource.iter().all(|r| self.resource.contains(r))
348 && details.is_empty()
349 }
350
351 /// Widen this record to also cover `scope` and `resource`.
352 ///
353 /// Accumulating rather than replacing, because a user who approves `write` today has not
354 /// withdrawn the `read` they approved last month, and a record that replaced would re-prompt
355 /// for something they can see in their own consent list. Narrowing is not done here at all: the
356 /// way to take something back is [`crate::server::AuthorizationServer::withdraw_consent`],
357 /// which also revokes what the grant issued, and a silent narrowing that left live tokens
358 /// holding the removed scope would be the same lie this module exists to avoid.
359 ///
360 /// RFC 9396 `authorization_details` are NOT accumulated here, because they are not recorded at
361 /// all: see [`ConsentRecord::covers`] for why a remembered consent never covers a request that
362 /// carries any, and why the coverage a details field would buy is one repeat of an identical
363 /// transaction rather than a class of them.
364 pub fn extend(&mut self, scope: &ScopeSet, resource: &[String]) {
365 if !scope.is_subset(&self.scope) {
366 let merged = self
367 .scope
368 .iter()
369 .chain(scope.iter())
370 .map(|s| s.as_str())
371 .collect::<Vec<&str>>();
372 // Both sets were parsed by `ScopeSet` already, so the union of their tokens cannot fail
373 // to parse. On the impossible error the OLD set stands: failing to widen re-prompts a
374 // user, and that is the harmless direction to fail in.
375 if let Ok(widened) = ScopeSet::from_tokens(merged) {
376 self.scope = widened;
377 }
378 }
379 // CAPPED at [`MAX_CONSENT_RESOURCES`], and this is the one bound in the crate whose cost is
380 // DURABLE rather than per request. `self.resource` is the union of every resource ever
381 // approved for one (client, subject) pair; it is only ever widened here and pruned nowhere,
382 // and `covers` scans it linearly on every authorization request that consults the record.
383 // A client that names one fresh resource indicator per authorization request therefore
384 // bought a permanently larger record and a permanently slower check, and nothing anywhere
385 // said no.
386 //
387 // The cap refuses to GROW; it never removes. Dropping something already approved would
388 // narrow a consent silently while tokens carrying it are still live, which is the same lie
389 // this method's docs refuse to tell about narrowing generally.
390 //
391 // Failing here is the HARMLESS direction, and it is the same direction the scope union
392 // above already fails in: a resource that did not fit is not covered, `covers` answers
393 // false, and the host re-prompts the user. The dangerous direction would be reporting
394 // coverage that was never recorded.
395 let room = MAX_CONSENT_RESOURCES.saturating_sub(self.resource.len());
396 self.resource.reserve(resource.len().min(room));
397 for r in resource {
398 if self.resource.len() >= MAX_CONSENT_RESOURCES {
399 break;
400 }
401 if !self.resource.contains(r) {
402 self.resource.push(r.clone());
403 }
404 }
405 }
406}
407
408/// What the client asked for about the USER's authentication, from an authorization request.
409///
410/// RFC 9470 section 4 carries exactly two parameters, both defined by OpenID Connect Core section
411/// 3.1.2.1, and this crate implements only those two. Reading two of OpenID Connect's parameters
412/// does not make this OpenID Connect: there is no `id_token`, no UserInfo endpoint and no claims
413/// model here, and all three are off this crate's list on purpose.
414#[derive(Debug, Clone, PartialEq, Eq, Default)]
415pub struct AuthenticationRequirement {
416 /// Requested authentication context classes, in order of preference, from the space-delimited
417 /// `acr_values` parameter. Empty means the client asked for none.
418 ///
419 /// Satisfied when the host's reported `acr` is ANY of these. OpenID Connect Core section 3.1.2.1
420 /// makes `acr_values` a voluntary, ordered preference rather than a demand, so honouring a later
421 /// entry is legal; RFC 9470 section 4 is what turns it into a requirement here, because the
422 /// whole point of the exchange is that a resource server has already refused the token the
423 /// previous `acr` produced.
424 ///
425 /// Bounded by [`MAX_ACR_VALUES`] when it comes off the wire: a parameter naming more classes
426 /// than that is REFUSED rather than truncated. A requirement a host builds itself is its own
427 /// data and is not bounded here, for the reason `rar::AuthorizationDetails::from_elements`
428 /// gives: these bounds defend against an unauthenticated stranger, not against the deployment.
429 pub acr_values: Vec<Box<str>>,
430 /// The RFC 9470 section 4 / OpenID Connect Core section 3.1.2.1 `max_age`: how old the user's
431 /// authentication may be. `None` means the client did not constrain it.
432 ///
433 /// `max_age=0` is a real and meaningful value, not an absent one: it means re-authenticate now.
434 /// It is kept as `Some(Duration::ZERO)` for that reason, and any elapsed time at all fails it.
435 pub max_age: Option<Duration>,
436}
437
438impl AuthenticationRequirement {
439 /// No requirement at all: what an ordinary authorization request carries.
440 pub fn none() -> Self {
441 AuthenticationRequirement::default()
442 }
443
444 /// Collect the two RFC 9470 section 4 parameters from already-decoded `(name, value)` query
445 /// pairs, the same shape [`crate::authorization::AuthorizationRequest::from_pairs`] takes.
446 ///
447 /// This is a CONVENIENCE over [`AuthenticationRequirement::from_request`], for a host that
448 /// holds query pairs and nothing else. It is not what this crate's own endpoints use: they
449 /// build the requirement from the RESOLVED request, because for an RFC 9126 pushed request or
450 /// an RFC 9101 signed one the query is not where these parameters live, and reading it anyway
451 /// both drops the ones that were sent and honours ones that were not.
452 ///
453 /// A repeated parameter keeps the FIRST occurrence, matching `from_pairs` and for the same
454 /// reason: RFC 6749 section 3.1 says a parameter MUST NOT appear more than once, and last-wins
455 /// is the smuggling-friendly choice when two intermediaries disagree about which copy counts.
456 pub fn from_pairs<I, K, V>(pairs: I) -> Result<Self, ErrorResponse>
457 where
458 I: IntoIterator<Item = (K, V)>,
459 K: AsRef<str>,
460 V: AsRef<str>,
461 {
462 let mut acr_values = None;
463 let mut max_age = None;
464 for (k, v) in pairs {
465 match k.as_ref() {
466 "acr_values" if acr_values.is_none() => acr_values = Some(v),
467 "max_age" if max_age.is_none() => max_age = Some(v),
468 _ => {}
469 }
470 }
471 AuthenticationRequirement::from_raw(
472 acr_values.as_ref().map(AsRef::as_ref),
473 max_age.as_ref().map(AsRef::as_ref),
474 )
475 }
476
477 /// The requirement an already-resolved authorization request carries.
478 ///
479 /// THE one source of these two parameters for every path into the authorization endpoint. A
480 /// plain RFC 6749 request populated the fields from its query, an RFC 9126 pushed request from
481 /// the record it stored at push time, and an RFC 9101 signed request from the claims inside
482 /// the signature; each of the three is the only text that path is allowed to trust, and this
483 /// reads whichever one it was handed.
484 pub fn from_request(
485 request: &crate::authorization::AuthorizationRequest<'_>,
486 ) -> Result<Self, ErrorResponse> {
487 AuthenticationRequirement::from_raw(
488 request.acr_values.as_deref(),
489 request.max_age.as_deref(),
490 )
491 }
492
493 /// The parse itself, over the two raw parameter values.
494 fn from_raw(acr_values: Option<&str>, max_age: Option<&str>) -> Result<Self, ErrorResponse> {
495 let mut out = AuthenticationRequirement::none();
496 if let Some(raw) = acr_values {
497 // One pass to COUNT and one to fill: `acr_values` is a short space-delimited list, and
498 // a counting pass allocates nothing, so the second pass can reserve exactly what it
499 // stores instead of reallocating its way there.
500 //
501 // The count is over NON-EMPTY segments, which is what the fill stores, so a parameter
502 // of nothing but spaces counts zero rather than one per space.
503 //
504 // `take(MAX_ACR_VALUES + 1)` is what makes the refusal cheap: it stops reading at the
505 // seventeenth class, so an oversized parameter is neither counted in full nor stored at
506 // all. This bounds the STORED count and not merely a reservation. A cap on the
507 // reservation alone bounds nothing: the `extend` below stores one `Box<str>`, which is
508 // one heap allocation, per segment, so it is the segment count that a request gets to
509 // choose and therefore the segment count that has to be refused. See [`MAX_ACR_VALUES`].
510 let count = raw
511 .split(' ')
512 .filter(|s| !s.is_empty())
513 .take(MAX_ACR_VALUES + 1)
514 .count();
515 if count > MAX_ACR_VALUES {
516 return Err(ErrorResponse::new(ErrorCode::InvalidRequest).with_description(
517 "acr_values names more authentication context classes than this server accepts",
518 ));
519 }
520 out.acr_values = Vec::with_capacity(count);
521 out.acr_values.extend(
522 raw.split(' ')
523 .filter(|s| !s.is_empty())
524 .map(Box::<str>::from),
525 );
526 }
527 if let Some(raw) = max_age {
528 // Refused rather than ignored. OpenID Connect Core section 3.1.2.1 makes this
529 // the "Maximum Authentication Age... Specified as the number of seconds", so a
530 // value that is not that is not a weaker request, it is an unintelligible one,
531 // and treating it as absent would answer a step-up challenge with a token that
532 // never had the freshness the resource server asked for.
533 let secs: u64 = raw.parse().map_err(|_| {
534 ErrorResponse::new(ErrorCode::InvalidRequest)
535 .with_description("max_age must be a non-negative number of seconds")
536 })?;
537 out.max_age = Some(Duration::from_secs(secs));
538 }
539 Ok(out)
540 }
541
542 /// Whether this asks for nothing, in which case no check has to run at all.
543 pub fn is_empty(&self) -> bool {
544 self.acr_values.is_empty() && self.max_age.is_none()
545 }
546
547 /// Hold a host's reported authentication to this requirement.
548 ///
549 /// The ORDER of the two checks is deliberate: freshness first, then class. A user whose login is
550 /// both too old and of the wrong class is told to log in again, which is the action that fixes
551 /// either problem, and it avoids telling a client which `acr` a stale session had.
552 ///
553 /// An absent report fails any non-empty requirement. "The host told us nothing" must never read
554 /// as "there is nothing to check": that reading is what makes an unwired host silently satisfy
555 /// every step-up challenge it is ever sent.
556 pub fn satisfied_by(
557 &self,
558 authentication: Option<&Authentication>,
559 now: SystemTime,
560 ) -> Result<(), StepUpFailure> {
561 if self.is_empty() {
562 return Ok(());
563 }
564 let authentication = match authentication {
565 Some(a) => a,
566 None => return Err(StepUpFailure::NotReported),
567 };
568 if let Some(max_age) = self.max_age {
569 // `age` is `None` only for an `auth_time` in the future, which reads as zero elapsed
570 // time (see `Authentication::age`) and therefore passes.
571 if authentication.age(now).unwrap_or_default() > max_age {
572 return Err(StepUpFailure::Stale);
573 }
574 }
575 if !self.acr_values.is_empty() {
576 match &authentication.acr {
577 Some(acr) => {
578 if !self
579 .acr_values
580 .iter()
581 .any(|want| want.as_ref() == acr.as_ref())
582 {
583 return Err(StepUpFailure::AcrNotMet);
584 }
585 }
586 // A host that reported no `acr` has not satisfied a request for a specific one.
587 // Same rule as the absent report above, one level down.
588 None => return Err(StepUpFailure::AcrNotMet),
589 }
590 }
591 Ok(())
592 }
593}
594
595/// Why a host's reported authentication did not satisfy an [`AuthenticationRequirement`].
596///
597/// Fieldless on purpose, and not only for the size of it. The `error_description` these produce goes
598/// back to the CLIENT through the authorization response redirect (RFC 6749 section 4.1.2.1), and
599/// the client is not entitled to learn when the user last logged in or which `acr` they hold; "not
600/// fresh enough" is the whole of what it needs in order to decide to ask again.
601#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
602#[non_exhaustive]
603pub enum StepUpFailure {
604 /// The host reported no authentication at all, and the request asked about one.
605 NotReported,
606 /// The authentication is older than the request's `max_age`.
607 Stale,
608 /// The reported `acr` is not one of the requested `acr_values` (or none was reported).
609 AcrNotMet,
610}
611
612impl StepUpFailure {
613 /// The developer-facing description this failure carries onto the wire.
614 ///
615 /// `&'static str`, so a refusal allocates nothing for it. The authorization endpoint is a path
616 /// whose rate an unauthenticated caller sets, and an allocation per refusal there is a cost an
617 /// attacker chooses.
618 pub fn description(self) -> &'static str {
619 match self {
620 StepUpFailure::NotReported => "no user authentication was reported for this request",
621 StepUpFailure::Stale => "the user authentication is older than the requested max_age",
622 StepUpFailure::AcrNotMet => {
623 "the user authentication does not satisfy the requested acr_values"
624 }
625 }
626 }
627
628 /// The RFC 9470 section 3 error this failure is reported as.
629 ///
630 /// `insufficient_user_authentication` is registered by RFC 9470 for the RESOURCE server's
631 /// challenge, and section 4 names no code for the authorization server's own refusal. This crate
632 /// uses the same one, deliberately, because it is the code the client has just been handed by
633 /// the resource server and re-sending it says exactly the true thing: the authentication is
634 /// still not sufficient. The alternative, a bare `invalid_request`, tells a client its
635 /// parameters were malformed and invites it to retry the identical request.
636 pub fn error_response(self) -> ErrorResponse {
637 ErrorResponse::new(ErrorCode::InsufficientUserAuthentication)
638 .with_description(self.description())
639 }
640}
641
642impl std::fmt::Display for StepUpFailure {
643 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
644 f.write_str(match self {
645 StepUpFailure::NotReported => "no authentication reported",
646 StepUpFailure::Stale => "authentication older than max_age",
647 StepUpFailure::AcrNotMet => "acr_values not satisfied",
648 })
649 }
650}
651
652/// The other error-shaped types in this crate ([`crate::dpop::DpopFailure`],
653/// [`crate::client_assertion::AssertionFailure`], [`crate::mtls::MtlsRegistrationError`]) are all
654/// `std::error::Error`, and a host that puts one of them behind `?` or in a `Box<dyn Error>` has to
655/// be able to do the same with this one. `Display` above is the whole implementation.
656impl std::error::Error for StepUpFailure {}
657
658impl From<StepUpFailure> for ErrorResponse {
659 fn from(failure: StepUpFailure) -> ErrorResponse {
660 failure.error_response()
661 }
662}
663
664/// Build the RFC 9470 section 3 `WWW-Authenticate` challenge a RESOURCE SERVER sends when the token
665/// it received is valid but the authentication behind it is not enough.
666///
667/// This crate is an authorization server, not a resource server, so this is a HELPER for the host's
668/// own resource servers rather than something this server ever sends: the same posture as
669/// [`crate::resource_metadata`], where the type is defined here and publishing it is the resource's
670/// job. It is here because the challenge and the authorization request that answers it have to agree
671/// on the spelling of two parameters, and one module owning both is how they stay in agreement.
672///
673/// `scheme` is the RFC 6750 section 3 (or RFC 9449 section 7.1) authentication scheme the resource
674/// server challenges with, normally `Bearer` or `DPoP`. Section 3 puts `error`, `error_description`,
675/// `acr_values` and `max_age` in the challenge; `acr_values` is omitted when empty rather than sent
676/// blank, because an empty list reads as "no class is acceptable".
677///
678/// # What this does to the values, and why it is not only escaping
679///
680/// The values are emitted inside quoted strings, so a `"` or a `\` in an `acr` value would forge the
681/// parameter that follows it. Both are ESCAPED, per the `quoted-string` rule of RFC 9110 section
682/// 5.6.4, rather than the value being rejected: this crate does not own the host's `acr` vocabulary
683/// and has no business refusing a value it merely has to transmit.
684///
685/// Escaping is not enough on its own, and reading section 5.6.4 as though it were is the defect the
686/// 0.9.1 audit found here. `qdtext` is `HTAB / SP / %x21 / %x23-5B / %x5D-7E / obs-text`, and
687/// `quoted-pair` is `"\" ( HTAB / SP / VCHAR / obs-text )`: NEITHER production admits a control
688/// character, so a CR, an LF or a DEL cannot be escaped into a legal `quoted-string` at all. It can
689/// only be removed, and until this was fixed it was passed through verbatim, which is a header
690/// break emitted out of a value this doc called escaped. Reachability is not theoretical: the
691/// parameter type is exactly [`AuthenticationRequirement::acr_values`], which
692/// [`AuthenticationRequirement::from_pairs`] fills from the client's own query parameter, and the
693/// whole point of this helper is that those classes come back out in a header.
694///
695/// So anything outside `HTAB`, `SP`, `%x21..=%x7E` and `%x80..=%xFF` is DROPPED. Dropping rather
696/// than refusing keeps the signature total, and the two are the same answer in practice: a value
697/// with a control character in it is not a class name any host defined, so there is nothing to
698/// preserve. Every byte a `quoted-string` does admit survives, non-ASCII included.
699///
700/// `scheme` is written outside any quoted string, where no escaping exists at all, so it is filtered
701/// to the RFC 9110 section 5.6.2 `tchar` set for the same reason. `Bearer` and `DPoP` pass through
702/// untouched; anything that would forge the rest of the header does not survive to do it.
703///
704/// One allocation for every value this crate can produce: the buffer is sized up front from the
705/// parts that go into it, and filtering only ever shortens what goes in. A value that is nothing but
706/// quotes and backslashes escapes to twice its length and would grow the buffer once; that is a
707/// `String`'s ordinary behaviour and not a bound anyone relies on.
708pub fn step_up_challenge(
709 scheme: &str,
710 acr_values: &[Box<str>],
711 max_age: Option<Duration>,
712) -> String {
713 use std::fmt::Write as _;
714
715 const ERROR: &str = " error=\"insufficient_user_authentication\"";
716 const DESCRIPTION: &str =
717 ", error_description=\"the user authentication does not meet the requirements of this \
718 resource\"";
719 let acr_len: usize = acr_values.iter().map(|a| a.len() + 3).sum();
720 let mut out = String::with_capacity(
721 scheme.len() + ERROR.len() + DESCRIPTION.len() + acr_len + max_age.map_or(0, |_| 32),
722 );
723 out.extend(scheme.chars().filter(|c| is_tchar(*c)));
724 out.push_str(ERROR);
725 out.push_str(DESCRIPTION);
726 if !acr_values.is_empty() {
727 out.push_str(", acr_values=\"");
728 for (i, acr) in acr_values.iter().enumerate() {
729 if i > 0 {
730 out.push(' ');
731 }
732 push_quoted(&mut out, acr);
733 }
734 out.push('"');
735 }
736 if let Some(max_age) = max_age {
737 // Written through the same buffer rather than through an intermediate `to_string`, so the
738 // whole challenge is still the one allocation the capacity above sized.
739 let _ = write!(out, ", max_age=\"{}\"", max_age.as_secs());
740 }
741 out
742}
743
744/// Append `value` as the inside of an RFC 9110 section 5.6.4 `quoted-string`: `"` and `\` escaped,
745/// and everything the grammar cannot carry dropped.
746///
747/// The two rules are not alternatives, and treating them as one was the bug. `quoted-string` is
748/// `DQUOTE *( qdtext / quoted-pair ) DQUOTE` with
749///
750/// ```text
751/// qdtext = HTAB / SP / %x21 / %x23-5B / %x5D-7E / obs-text
752/// quoted-pair = "\" ( HTAB / SP / VCHAR / obs-text )
753/// obs-text = %x80-FF
754/// ```
755///
756/// so `"` (%x22) and `\` (%x5C) are the two characters that are legal only as the second half of a
757/// `quoted-pair`, which is what the escape below produces. A control character is in NEITHER
758/// production: it is not `qdtext`, and it is not `VCHAR`, so a backslash in front of it produces an
759/// illegal `quoted-pair` rather than a legal escape. There is no spelling of CR, LF or DEL inside a
760/// `quoted-string`, so the only conformant thing to do with one is to not emit it. See
761/// [`step_up_challenge`] for why the value can contain one in the first place.
762///
763/// [`char::is_control`] is not the test used here: it is true for the Unicode `Cc` category, which
764/// includes `%x80..=%x9F`, and those are `obs-text` and therefore legal. The ranges are written out
765/// instead, and every scalar value above `%x7F` is kept.
766fn push_quoted(out: &mut String, value: &str) {
767 for c in value.chars() {
768 match c {
769 '"' | '\\' => {
770 out.push('\\');
771 out.push(c);
772 }
773 '\t' | ' ' | '\u{21}'..='\u{7e}' | '\u{80}'.. => out.push(c),
774 // Everything else is %x00-%x08, %x0A-%x1F or %x7F, none of which a `quoted-string` can
775 // carry escaped or otherwise.
776 _ => {}
777 }
778 }
779}
780
781/// RFC 9110 section 5.6.2 `tchar`, the character set an auth scheme (section 11.1 `auth-scheme`,
782/// which is a `token`) is made of.
783///
784/// Used to filter [`step_up_challenge`]'s `scheme`, which is written outside every quoted string in
785/// the challenge and so has no escape available to it at all: one space in it and everything after
786/// it reads as the auth parameters of a different scheme, one `"` and the quoting is off by one for
787/// the rest of the header.
788fn is_tchar(c: char) -> bool {
789 c.is_ascii_alphanumeric() || "!#$%&'*+-.^_`|~".contains(c)
790}
791
792#[cfg(test)]
793#[path = "tests/consent.rs"]
794mod tests;