Skip to main content

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;