oauth_as/authorization.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2// Copyright (C) 2026 Matthew Jackson
3
4//! The authorization endpoint (RFC 6749 section 4.1) under the OAuth 2.1 constraints: `code` is
5//! the only response type (the implicit grant is removed), PKCE is REQUIRED, only `S256` is
6//! offered, and registered redirect URIs match exactly.
7//!
8//! # Why the request type is lenient
9//!
10//! [`AuthorizationRequest`] holds raw, optional text rather than parsed enums. A server cannot
11//! reject what it cannot represent, and rejecting correctly is most of what this endpoint does:
12//! a request with no `code_challenge`, or with `response_type=token`, has to reach the state
13//! machine so the machine can answer it the way the RFC prescribes. Parsing into
14//! [`ValidatedAuthorizationRequest`] is what validation MEANS here, and only the validated form
15//! can mint a code. [`ValidatedAuthorizationRequest`] carries a private witness field so that,
16//! within this crate, `ValidatedAuthorizationRequest::new` (private) is the only way to produce one; a
17//! host consuming this crate cannot construct one for itself. See that type's doc comment for
18//! exactly what is and is not proven by this.
19//!
20//! # Why the two error shapes are different
21//!
22//! RFC 6749 section 4.1.2.1 splits authorization errors in two, and the split is a security
23//! boundary rather than a stylistic one. If the client or the redirect URI cannot be validated,
24//! the AS MUST NOT redirect: the redirect target is precisely what an attacker would be trying to
25//! choose, so reporting the error to it would hand over the thing being protected. Every other
26//! error goes back to the (already validated) redirect URI as query parameters.
27//!
28//! # Allocation
29//!
30//! Request fields are [`Cow`], so a host parsing a query string that needs no percent-decoding
31//! borrows it and allocates nothing. Only the validated form, which outlives the request while a
32//! consent screen is shown, owns its data.
33
34use std::borrow::Cow;
35use std::fmt;
36use std::time::SystemTime;
37
38use serde::{Deserialize, Serialize};
39
40use crate::client::ClientId;
41use crate::error::{ErrorCode, ErrorResponse};
42use crate::scope::ScopeSet;
43
44/// `response_type` values this server will ever accept. OAuth 2.1 removes `token` (implicit).
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
46pub enum ResponseType {
47 /// The authorization-code response type.
48 #[serde(rename = "code")]
49 Code,
50}
51
52/// PKCE challenge methods this server offers (RFC 7636). OAuth 2.1 requires `S256`; `plain` is
53/// deliberately not implemented, and is not advertised, because accepting it is a downgrade.
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
55pub enum CodeChallengeMethod {
56 /// `code_challenge = BASE64URL(SHA256(ASCII(code_verifier)))`, no padding.
57 S256,
58}
59
60/// The raw authorization request as it arrives on the wire (RFC 6749 section 4.1.1 plus the RFC
61/// 7636 PKCE parameters). The host parses its query string into this; every member is optional
62/// because every member can be absent in a real (invalid) request.
63#[derive(Debug, Clone, PartialEq, Eq, Default)]
64/// `#[non_exhaustive]`: `rar` adds `authorization_details` and `consent` adds two more, and the
65/// doc above explains why they live here rather than being read off the query separately, which
66/// means this type is where every future authorization parameter lands as well.
67///
68/// [`AuthorizationRequest::from_pairs`] is the path a host actually wants: it takes the decoded
69/// query pairs and applies the section 3.1 rules about unknown and repeated parameters, which a
70/// struct literal assembled by hand does not. For a request built in code rather than parsed, start
71/// from `Default::default()` and assign; every field is public and every field is legitimately
72/// absent, so there is nothing a literal could express that this cannot.
73#[non_exhaustive]
74pub struct AuthorizationRequest<'a> {
75 /// Must be `code`.
76 pub response_type: Option<Cow<'a, str>>,
77 /// The requesting client.
78 pub client_id: Option<Cow<'a, str>>,
79 /// Requested redirect target; must exact-match a registered URI when present.
80 pub redirect_uri: Option<Cow<'a, str>>,
81 /// Requested scope, space delimited; absent means the client's registered default.
82 pub scope: Option<Cow<'a, str>>,
83 /// Opaque client state, echoed back verbatim.
84 pub state: Option<Cow<'a, str>>,
85 /// The PKCE challenge (REQUIRED in OAuth 2.1 for the authorization code grant).
86 pub code_challenge: Option<Cow<'a, str>>,
87 /// The PKCE method; only `S256`.
88 pub code_challenge_method: Option<Cow<'a, str>>,
89 /// RFC 8707 resource indicators: the resource server(s) the client intends the issued token to
90 /// be used at.
91 ///
92 /// A `Vec` rather than an `Option<Cow>` because RFC 8707 section 2 says the parameter MAY be
93 /// repeated, and a client naming two resource servers means both, not the last one. An empty
94 /// `Vec` allocates nothing, so a request that carries no `resource` still costs what it did
95 /// before this parameter existed.
96 pub resource: Vec<Cow<'a, str>>,
97 /// RFC 9396 section 2 `authorization_details`: a JSON array of objects, each naming a
98 /// `type` that defines the rest of it.
99 ///
100 /// RAW TEXT, not a parsed structure, for the reason the rest of this type is raw text:
101 /// a server cannot reject what it cannot represent, and this parameter's failure modes
102 /// (unparseable, oversized, an unknown type) all have to reach the state machine so it
103 /// can answer them the way RFC 9396 section 5 prescribes. Parsing happens in
104 /// [`crate::rar::AuthorizationDetails::parse`], under this crate's bounds, and only the
105 /// validated form carries the result.
106 ///
107 /// NOT FEATURE GATED, which is the same decision [`crate::ErrorCode::InvalidAuthorizationDetails`]
108 /// records for the error code and taken for the same reason. Without `rar` this crate supports
109 /// no authorization detail type at all, so RFC 9396 section 5's condition is met by EVERY
110 /// request that carries the parameter and every one of them has to be refused. A field that
111 /// disappeared with the feature left the parameter nowhere to land, and a parameter that lands
112 /// nowhere is a parameter accepted and ignored, which is the one outcome section 5 forbids.
113 /// So the field exists in every build; what changes with the feature is whether the value is
114 /// honoured or refused, and that is decided during validation, not during parsing.
115 pub authorization_details: Option<Cow<'a, str>>,
116 /// RFC 9470 section 4 / OpenID Connect Core section 3.1.2.1 `acr_values`: the authentication
117 /// context classes the client will accept, space delimited, in order of preference.
118 ///
119 /// ON THIS TYPE rather than parsed straight off the query, and that is the whole of the fix
120 /// for a real gap. Every way into the authorization endpoint (a plain query, an RFC 9126
121 /// pushed request, an RFC 9101 signed request object) funnels through this struct, so a
122 /// parameter that lives here is a parameter every path must carry; a parameter parsed
123 /// separately from the query is a parameter the other two paths silently drop. That is exactly
124 /// what happened to these two: PAR and JAR requests disabled step-up entirely, and for JAR the
125 /// server was reading intermediary-rewritable query text on a request whose only purpose is
126 /// that it cannot be rewritten (RFC 9101 section 6.3).
127 ///
128 /// RAW TEXT for the same reason as the rest of this type, and parsed into
129 /// [`crate::consent::AuthenticationRequirement`] by validation.
130 #[cfg(feature = "consent")]
131 pub acr_values: Option<Cow<'a, str>>,
132 /// RFC 9470 section 4 / OpenID Connect Core section 3.1.2.1 `max_age`: how old the user's
133 /// authentication may be, in seconds. See [`AuthorizationRequest::acr_values`] for why it is
134 /// a field here rather than something read off the query.
135 #[cfg(feature = "consent")]
136 pub max_age: Option<Cow<'a, str>>,
137}
138
139impl<'a> AuthorizationRequest<'a> {
140 /// Collect a request from already-decoded `(name, value)` query pairs.
141 ///
142 /// Unknown parameters are ignored, which RFC 6749 section 3.1 requires. A repeated parameter
143 /// keeps the FIRST occurrence: section 3.1 says a parameter MUST NOT appear more than once,
144 /// and last-wins is the smuggling-friendly choice when two intermediaries disagree about
145 /// which copy counts.
146 pub fn from_pairs<I, K, V>(pairs: I) -> Self
147 where
148 I: IntoIterator<Item = (K, V)>,
149 K: AsRef<str>,
150 V: Into<Cow<'a, str>>,
151 {
152 let mut req = AuthorizationRequest::default();
153 for (k, v) in pairs {
154 // RFC 8707 section 2 is the one exception to the first-wins rule below: `resource` MAY
155 // legitimately appear more than once, and every occurrence is part of the request.
156 if k.as_ref() == "resource" {
157 req.resource.push(v.into());
158 continue;
159 }
160 let slot = match k.as_ref() {
161 "response_type" => &mut req.response_type,
162 "client_id" => &mut req.client_id,
163 "redirect_uri" => &mut req.redirect_uri,
164 "scope" => &mut req.scope,
165 "state" => &mut req.state,
166 "code_challenge" => &mut req.code_challenge,
167 "code_challenge_method" => &mut req.code_challenge_method,
168 // Ungated, with the field: a build without `rar` has to KNOW the parameter was
169 // sent in order to refuse it (RFC 9396 s5). Falling through to `_ => continue`
170 // here is what made this the accept-and-ignore path for `GET /authorize` and, via
171 // `crate::par`, for the RFC 9126 push as well.
172 "authorization_details" => &mut req.authorization_details,
173 #[cfg(feature = "consent")]
174 "acr_values" => &mut req.acr_values,
175 #[cfg(feature = "consent")]
176 "max_age" => &mut req.max_age,
177 _ => continue,
178 };
179 if slot.is_none() {
180 *slot = Some(v.into());
181 }
182 }
183 req
184 }
185}
186
187/// Whether one RFC 8707 `resource` value is a resource indicator this server will accept.
188///
189/// RFC 8707 section 2 states the rule in three parts, and all three are checked here:
190///
191/// 1. the value MUST be an absolute URI as defined by RFC 3986 section 4.3, so it carries a scheme
192/// (`scheme = ALPHA *( ALPHA / DIGIT / "+" / "-" / "." )`) followed by `:`. A relative reference
193/// names nothing an audience restriction could be built from, since what it resolves against is
194/// the client's business and not the server's;
195/// 2. it MAY include a query component, so `?` is explicitly NOT a reason to refuse. That is worth
196/// stating because the obvious "strip everything after the first delimiter" implementation gets
197/// it wrong;
198/// 3. it MUST NOT include a fragment. A fragment is never transmitted to a server (RFC 3986
199/// section 3.5), so two indicators differing only in their fragment name the SAME resource while
200/// comparing unequal, which turns any later audience check into a string game.
201///
202/// Anything outside printable ASCII is refused as well: RFC 3986 section 2 requires characters
203/// outside its own grammar to be percent-encoded, so a raw space or control byte here is not a URI
204/// at all, and accepting one would let a value that cannot round-trip through a query string reach
205/// the token's audience.
206pub(crate) fn is_valid_resource_indicator(value: &str) -> bool {
207 let bytes = value.as_bytes();
208 // Scheme: at least one character, the first alphabetic, terminated by the first `:`.
209 let colon = match value.find(':') {
210 Some(0) | None => return false,
211 Some(i) => i,
212 };
213 if !bytes[0].is_ascii_alphabetic() {
214 return false;
215 }
216 if !bytes[1..colon]
217 .iter()
218 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'-' | b'.'))
219 {
220 return false;
221 }
222 // No fragment, and nothing that is not a legal URI character in the first place.
223 bytes
224 .iter()
225 .all(|&b| b != b'#' && (0x21..=0x7e).contains(&b))
226}
227
228/// A request that has passed validation: the client exists, is allowed this grant, the redirect
229/// URI is one of its registrations, and the PKCE parameters are well formed.
230///
231/// The data fields stay `pub` for read access (state, ergonomics, and the smallest diff over the
232/// existing call sites in `tests/authorization_code.rs`, none of which construct this type by
233/// hand). What actually enforces "only a validated request can mint a code" is the private
234/// `_sealed` field below: because it is not `pub`, no struct-literal expression written outside
235/// this module (that includes every downstream host, since this module is the only one with
236/// access to `Sealed`) can name every field of this struct, so a `ValidatedAuthorizationRequest`
237/// can only come from the crate-private `ValidatedAuthorizationRequest::new`, which only
238/// `AuthorizationServer::validate_authorization_request` (in `server.rs`, within this crate)
239/// calls. That is what "cannot be spelled" now actually means: not "the fields are private" (they
240/// are not), but "the value cannot be produced without going through validation first."
241#[derive(Debug, Clone, PartialEq, Eq)]
242pub struct ValidatedAuthorizationRequest {
243 /// The validated client.
244 pub client_id: ClientId,
245 /// The exact registered redirect URI this request resolved to.
246 pub redirect_uri: String,
247 /// Whether the authorization REQUEST named the redirect URI itself, as opposed to omitting it
248 /// and being filled in from the client's single registration (RFC 6749 section 3.1.2.3).
249 ///
250 /// Carried onto [`AuthorizationCodeRecord::redirect_uri_was_explicit`], because RFC 6749
251 /// section 4.1.3 makes the token endpoint's `redirect_uri` parameter REQUIRED "if the
252 /// `redirect_uri` parameter was included in the authorization request" and not otherwise. The
253 /// resolved URI above cannot answer that: it is filled in either way, so by the time the token
254 /// endpoint sees the record the two cases are indistinguishable without this.
255 pub redirect_uri_was_explicit: bool,
256 /// The scope that will be granted on approval.
257 pub scope: ScopeSet,
258 /// The request's `state`, to be echoed on either outcome.
259 pub state: Option<String>,
260 /// The PKCE challenge to record against the issued code.
261 pub code_challenge: String,
262 /// The PKCE method (only `S256`).
263 pub code_challenge_method: CodeChallengeMethod,
264 /// This server's issuer identifier, carried so that the authorization response can name its
265 /// author (RFC 9207 section 2).
266 ///
267 /// It is on the VALIDATED request rather than looked up at response time because
268 /// [`ValidatedAuthorizationRequest::denied`] is also an authorization response and has no
269 /// access to the server's configuration: a refusal that could not say who refused would be
270 /// exactly the gap RFC 9207 section 2.2 closes.
271 pub issuer: String,
272 /// The RFC 8707 resource indicators this request asked for, already validated. Empty when the
273 /// client named none, which means the issued token carries no audience restriction from this
274 /// mechanism.
275 pub resource: Vec<String>,
276 /// The RFC 9396 authorization details this request asked for, already parsed and
277 /// already checked against the server's supported types (section 5). Empty when the
278 /// client named none.
279 ///
280 /// A host's consent screen MAY replace this before the code is issued: RFC 9396 section
281 /// 7.1 is explicit that the details attached to the token may differ from the request,
282 /// which is how an AS records the account the user actually picked.
283 #[cfg(feature = "rar")]
284 pub authorization_details: crate::rar::AuthorizationDetails,
285 /// The RFC 9470 step-up requirement this request carried, already parsed.
286 ///
287 /// It rides on the VALIDATED request so that there is exactly ONE source of it per path, and
288 /// that source is the request that was actually resolved: the pushed record for a PAR request,
289 /// the signed claim set for a JAR request, the query for a plain one. Anything that reads the
290 /// query separately is reading the wrong thing for two of the three (RFC 9126 section 4, RFC
291 /// 9101 section 6.3).
292 ///
293 /// Empty means the client asked for no step-up, which is what an ordinary request carries.
294 #[cfg(feature = "consent")]
295 pub authentication_requirement: crate::consent::AuthenticationRequirement,
296 /// Zero-sized witness, private to this module. Its only purpose is that it cannot be named
297 /// (let alone constructed) outside `authorization.rs`, so a struct-literal expression cannot
298 /// build a whole `ValidatedAuthorizationRequest` from anywhere else, in this crate or out of
299 /// it. See the struct doc comment.
300 _sealed: Sealed,
301}
302
303/// The witness type behind [`ValidatedAuthorizationRequest`]'s sealed field. Deliberately
304/// private and zero-sized: it carries no data and exists only to make the containing struct
305/// unconstructible by struct-literal syntax from outside this module.
306#[derive(Debug, Clone, Copy, PartialEq, Eq)]
307struct Sealed;
308
309impl ValidatedAuthorizationRequest {
310 /// The only constructor. `pub(crate)` rather than private: `AuthorizationServer::
311 /// validate_authorization_request` lives in `server.rs`, a sibling module, and is the sole
312 /// intended caller. `pub(crate)` is strictly narrower than the old fully-`pub` struct
313 /// literal: no code outside this crate can reach this function, so no host can hand
314 /// [`crate::server::AuthorizationServer::issue_authorization_code`] a request it invented
315 /// itself. (Within the crate, `pub(crate)` cannot stop a different in-crate module from also
316 /// calling this constructor honestly; the guarantee this seals is against a host of the
317 /// library, not against other code inside it.)
318 #[allow(clippy::too_many_arguments)]
319 pub(crate) fn new(
320 client_id: ClientId,
321 redirect_uri: String,
322 // An ARGUMENT rather than a setter with a default, unlike the two `set_*` methods below.
323 // Those record something the request may simply not have carried; this one is a fact about
324 // every authorization request there is, and the token endpoint's refusal turns on it. A
325 // default would be a value the caller could forget to correct, in the one place where
326 // getting it wrong either refuses a conforming client or waives a check RFC 6749 section
327 // 4.1.3 requires.
328 redirect_uri_was_explicit: bool,
329 scope: ScopeSet,
330 state: Option<String>,
331 code_challenge: String,
332 code_challenge_method: CodeChallengeMethod,
333 issuer: String,
334 resource: Vec<String>,
335 ) -> Self {
336 ValidatedAuthorizationRequest {
337 client_id,
338 redirect_uri,
339 redirect_uri_was_explicit,
340 scope,
341 state,
342 code_challenge,
343 code_challenge_method,
344 issuer,
345 resource,
346 #[cfg(feature = "rar")]
347 authorization_details: crate::rar::AuthorizationDetails::none(),
348 #[cfg(feature = "consent")]
349 authentication_requirement: crate::consent::AuthenticationRequirement::none(),
350 _sealed: Sealed,
351 }
352 }
353
354 /// Record the RFC 9470 step-up requirement this request was validated as carrying.
355 ///
356 /// `pub(crate)` for the reason [`ValidatedAuthorizationRequest::set_authorization_details`]
357 /// is: a public setter for the field that decides whether a code may be minted at all would
358 /// hand back exactly what the sealed field exists to keep.
359 #[cfg(feature = "consent")]
360 pub(crate) fn set_authentication_requirement(
361 &mut self,
362 requirement: crate::consent::AuthenticationRequirement,
363 ) {
364 self.authentication_requirement = requirement;
365 }
366
367 /// Record the RFC 9396 authorization details this request was validated as carrying.
368 ///
369 /// `pub(crate)`, like [`ValidatedAuthorizationRequest::new`] itself: the sealed field on
370 /// this type exists so that only validation can produce one, and a public setter for a
371 /// field that decides what a token authorizes would hand that back.
372 #[cfg(feature = "rar")]
373 pub(crate) fn set_authorization_details(&mut self, details: crate::rar::AuthorizationDetails) {
374 self.authorization_details = details;
375 }
376
377 /// The redirect describing the user refusing consent (RFC 6749 section 4.1.2.1
378 /// `access_denied`). A refusal is an answer the client is entitled to receive, not an error
379 /// page.
380 pub fn denied(&self) -> AuthorizationErrorRedirect {
381 AuthorizationErrorRedirect {
382 redirect_uri: self.redirect_uri.clone(),
383 error: ErrorResponse::new(ErrorCode::AccessDenied),
384 state: self.state.clone(),
385 // RFC 9207 section 2: the parameter belongs on the authorization response whatever the
386 // outcome, and a refusal is an outcome the client is entitled to attribute.
387 iss: self.issuer.clone(),
388 }
389 }
390}
391
392/// Hand-written so the one-time `code` never prints. `state` and `iss` print in full: `state` is
393/// the client's own opaque value echoed back and `iss` is this server's public identifier.
394impl fmt::Debug for AuthorizationResponse {
395 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
396 f.debug_struct("AuthorizationResponse")
397 .field("code", &"[redacted]")
398 .field("state", &self.state)
399 .field("iss", &self.iss)
400 .finish()
401 }
402}
403
404/// The success redirect parameters (RFC 6749 section 4.1.2).
405///
406/// `Debug` is HAND-WRITTEN (above) and does not print the `code`. The RECORD form of the same
407/// value, [`AuthorizationCodeRecord`], has been hand-redacted since it was written, for the reason
408/// stated there -- RFC 6749 section 4.1.2 makes a code a credential in its own right -- and this
409/// type, which carries the same string to the client, was left deriving until 0.9.2. A host that
410/// logs the response it is about to redirect with would have written a live, unredeemed code into
411/// its logs; PKCE binds the code to a DIFFERENT client, not to a reader who has the log and the
412/// verifier, and for a confidential client it does not bind at all.
413#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
414pub struct AuthorizationResponse {
415 /// The single-use authorization code.
416 pub code: String,
417 /// The request's `state`, echoed verbatim; REQUIRED iff the request carried one.
418 #[serde(skip_serializing_if = "Option::is_none")]
419 pub state: Option<String>,
420 /// RFC 9207 section 2: this server's issuer identifier, so a client talking to several
421 /// authorization servers can tell WHICH one answered.
422 ///
423 /// Not an `Option`: this server always supports RFC 9207 and its metadata says so
424 /// (`authorization_response_iss_parameter_supported`), and section 2 makes the parameter
425 /// mandatory for a server that does. A response that could omit it would be a response a
426 /// client cannot rely on, which defeats the mix-up countermeasure RFC 9700 section 4.4 is
427 /// asking for.
428 pub iss: String,
429}
430
431impl AuthorizationResponse {
432 /// The `Location` header value for the success redirect.
433 ///
434 /// Parameter ORDER is not constrained by any RFC (RFC 6749 section 4.1.2 and RFC 9207
435 /// section 2 both describe an unordered set of query parameters), so `iss` is appended last
436 /// purely to keep the existing prefix of the URL unchanged.
437 pub fn location(&self, redirect_uri: &str) -> String {
438 let mut out = String::with_capacity(redirect_uri.len() + self.encoded_len());
439 out.push_str(redirect_uri);
440 let mut sep = query_separator(redirect_uri);
441 append_param(&mut out, &mut sep, "code", &self.code);
442 if let Some(state) = &self.state {
443 append_param(&mut out, &mut sep, "state", state);
444 }
445 append_param(&mut out, &mut sep, "iss", &self.iss);
446 out
447 }
448
449 /// A worst-case size for the appended query, so `location` allocates exactly once.
450 fn encoded_len(&self) -> usize {
451 6 + self.code.len() * 3
452 + self.state.as_ref().map_or(0, |s| 7 + s.len() * 3)
453 // "&iss=" is 5 characters, and the issuer is percent-encoded like everything else.
454 + 5
455 + self.iss.len() * 3
456 }
457}
458
459/// An authorization error delivered by redirecting the user agent back to the client (RFC 6749
460/// section 4.1.2.1).
461#[derive(Debug, Clone, PartialEq, Eq)]
462pub struct AuthorizationErrorRedirect {
463 /// The VALIDATED redirect URI. Never a URI supplied by an unvalidated request.
464 pub redirect_uri: String,
465 /// The error to report.
466 pub error: ErrorResponse,
467 /// The request's `state`, echoed so the client can correlate the failure.
468 pub state: Option<String>,
469 /// RFC 9207 section 2: the issuer identifier, present on error responses exactly as on
470 /// successful ones. A client that cannot attribute a failure cannot tell a genuine refusal by
471 /// its own AS from one manufactured by an attacker's.
472 pub iss: String,
473}
474
475impl AuthorizationErrorRedirect {
476 /// The `Location` header value for the error redirect.
477 pub fn location(&self) -> String {
478 // 96 covers the error code, the separators, and a short description; the issuer is sized
479 // properly because a host may run under a long issuer identifier and RFC 9207 section 2
480 // puts it on every one of these.
481 let mut out = String::with_capacity(self.redirect_uri.len() + 96 + self.iss.len() * 3);
482 out.push_str(&self.redirect_uri);
483 let mut sep = query_separator(&self.redirect_uri);
484 append_param(&mut out, &mut sep, "error", self.error.error.as_str());
485 if let Some(d) = &self.error.error_description {
486 append_param(&mut out, &mut sep, "error_description", d);
487 }
488 if let Some(u) = &self.error.error_uri {
489 append_param(&mut out, &mut sep, "error_uri", u);
490 }
491 if let Some(state) = &self.state {
492 append_param(&mut out, &mut sep, "state", state);
493 }
494 append_param(&mut out, &mut sep, "iss", &self.iss);
495 out
496 }
497}
498
499/// How an authorization request failed.
500#[derive(Debug, Clone, PartialEq, Eq)]
501pub enum AuthorizationError {
502 /// RFC 6749 section 4.1.2.1: the client or the redirect URI could not be validated, so the AS
503 /// MUST NOT redirect. The host renders this to the user directly; 400 is the conventional
504 /// status.
505 Direct(ErrorResponse),
506 /// The error is reported to the client by redirecting to its validated redirect URI.
507 Redirect(AuthorizationErrorRedirect),
508}
509
510impl AuthorizationError {
511 /// The HTTP status the host should use: 302 for the redirect form, otherwise a direct status.
512 pub fn http_status(&self) -> u16 {
513 match self {
514 // invalid_client's 401 belongs to the token endpoint's WWW-Authenticate exchange; at
515 // the authorization endpoint there is no client authentication to challenge, so a
516 // refused request is a plain 400.
517 AuthorizationError::Direct(e) if e.error == ErrorCode::ServerError => 500,
518 AuthorizationError::Direct(_) => 400,
519 AuthorizationError::Redirect(_) => 302,
520 }
521 }
522}
523
524impl std::fmt::Display for AuthorizationError {
525 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
526 match self {
527 AuthorizationError::Direct(e) => write!(f, "{e}"),
528 AuthorizationError::Redirect(r) => write!(f, "{}", r.error),
529 }
530 }
531}
532
533impl std::error::Error for AuthorizationError {}
534
535/// What an issued authorization code became, once redeemed.
536///
537/// Consumed codes are RETAINED until their expiry rather than deleted, because RFC 6749 section
538/// 4.1.2 and RFC 9700 section 4.1.1 want a replayed code to revoke the tokens it already minted.
539/// Deleting the record on redemption would make a replay indistinguishable from a typo, and the
540/// stolen access token would stay live.
541/// `Debug` is hand-written (see below), for the same reason as [`crate::client::ClientAuth`]: the
542/// `Consumed` variant carries the access and refresh tokens this code minted, and those are bearer
543/// credentials that a host's `tracing::debug!(?record)` must not write to a log in plaintext.
544#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
545pub enum AuthorizationCodeState {
546 /// Issued and not yet redeemed.
547 Issued,
548 /// Already redeemed, recording what it minted so a replay can revoke it.
549 Consumed {
550 /// The access token issued, if the issuance got as far as producing one.
551 ///
552 /// `None` means the code was marked consumed and the issuance that followed did not
553 /// complete. That is deliberate and it is not a lost write: the record is written BEFORE
554 /// issuance precisely so that a store failure halfway through a redemption cannot take the
555 /// replay alarm offline with it (see `AuthorizationServer::authorization_code_token`).
556 /// There is genuinely nothing to revoke in that case, because nothing was issued, and a
557 /// replay of the code is still recognised as a replay.
558 access_token: Option<String>,
559 /// The refresh token issued, if any.
560 refresh_token: Option<String>,
561 },
562 /// Consumed, AND presented again afterwards. A detected replay, recorded DURABLY.
563 ///
564 /// # Why this is a state and not a boolean on the side
565 ///
566 /// It exists to be read by a redemption that is still running. The interleaving it closes:
567 /// redeemer A takes the code, writes `Consumed { access_token: None, .. }` before issuing (so
568 /// that a store failure cannot disarm the alarm), and then SUSPENDS on the host's
569 /// [`crate::jwt::Es256Signer`], which is a network round trip when that signer fronts a KMS.
570 /// Replayer B arrives in that window, finds `Consumed { access_token: None }`, and correctly
571 /// concludes there is nothing to revoke, because nothing has been issued YET. B refuses the
572 /// replay and puts the record back.
573 ///
574 /// If what B puts back is `Consumed`, it is byte for byte what A wrote, so when A wakes and
575 /// records what it minted, A cannot tell that anything happened. The replay was detected, the
576 /// audit event fired, and A's freshly minted access token and refresh chain are live. The
577 /// alarm rang and nothing was contained.
578 ///
579 /// `Replayed` is the trace A can see. A's second write is a compare-and-swap against the
580 /// `Consumed` it wrote itself (see [`crate::store::Storage::compare_and_swap_authorization_code`]),
581 /// so this state makes it fail, and A undoes its own issuance.
582 ///
583 /// It carries the same two fields because a THIRD presentation is still a replay and must
584 /// still revoke whatever is by then known to have been minted.
585 Replayed {
586 /// The access token issued, if the redemption that consumed this code got as far as
587 /// producing one before the replay was detected.
588 access_token: Option<String>,
589 /// The refresh token issued, if any.
590 refresh_token: Option<String>,
591 },
592}
593
594impl AuthorizationCodeState {
595 /// What this code minted, for the two states that can name it.
596 ///
597 /// One accessor rather than two matches at each call site: the replay path treats `Consumed`
598 /// and `Replayed` identically when deciding what to revoke, and the only difference between
599 /// them is which one a concurrent redemption is allowed to overwrite.
600 pub fn minted(&self) -> Option<(Option<&str>, Option<&str>)> {
601 match self {
602 AuthorizationCodeState::Issued => None,
603 AuthorizationCodeState::Consumed {
604 access_token,
605 refresh_token,
606 }
607 | AuthorizationCodeState::Replayed {
608 access_token,
609 refresh_token,
610 } => Some((access_token.as_deref(), refresh_token.as_deref())),
611 }
612 }
613}
614
615impl fmt::Debug for AuthorizationCodeState {
616 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
617 match self {
618 AuthorizationCodeState::Issued => f.write_str("Issued"),
619 // Both carry the same two credentials and redact them the same way. The VARIANT NAME
620 // is the part an operator reading a debug dump needs, because it is the difference
621 // between a redemption and a detected replay.
622 AuthorizationCodeState::Consumed {
623 access_token,
624 refresh_token,
625 }
626 | AuthorizationCodeState::Replayed {
627 access_token,
628 refresh_token,
629 } => f
630 .debug_struct(match self {
631 AuthorizationCodeState::Replayed { .. } => "Replayed",
632 _ => "Consumed",
633 })
634 // Presence/absence stays visible for the same reason it does for the refresh token
635 // below, and here it carries more: `None` is how a redemption whose issuance failed
636 // is told apart from one that completed.
637 .field("access_token", &access_token.as_ref().map(|_| "[redacted]"))
638 .field(
639 "refresh_token",
640 // Presence/absence is worth keeping visible (it distinguishes an
641 // authorization-code-only grant from one that also minted a refresh token);
642 // the value itself is not.
643 &refresh_token.as_ref().map(|_| "[redacted]"),
644 )
645 .finish(),
646 }
647 }
648}
649
650/// The serde default for [`AuthorizationCodeRecord::redirect_uri_was_explicit`]: `true`, which is
651/// the fail-closed reading of a record written before the field existed. See the field.
652fn redirect_uri_was_explicit_default() -> bool {
653 true
654}
655
656/// The serde default for [`AuthorizationCodeRecord::issued_at`]: the epoch, because it is the
657/// fail-closed answer. Every barrier is recorded after it, so a code with no stated decision
658/// instant is REFUSED by a standing revocation rather than admitted by one. See the field.
659fn grant_instant_default() -> SystemTime {
660 SystemTime::UNIX_EPOCH
661}
662
663/// A persisted authorization code (RFC 6749 section 4.1.2).
664///
665/// `Debug` is hand-written (see below): `code` is itself a bearer credential (RFC 6749 section
666/// 4.1.2 treats a leaked code as equivalent to a leaked token for as long as it is live, which is
667/// why replay revokes what it minted, see [`AuthorizationCodeState`]'s doc comment), so it must
668/// not appear in a debug format either.
669#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
670/// `#[non_exhaustive]`: `rar` and `consent` each add a field. Like the two token records this is a
671/// value a [`crate::store::Storage`] implementor is HANDED and gives back, through the derived
672/// serde impls, which are generated in this crate and keep working from outside it; nothing in
673/// `oauth-as-postgres` names a field of it. [`AuthorizationCodeRecord::new`] is for anyone building
674/// one directly.
675#[non_exhaustive]
676pub struct AuthorizationCodeRecord {
677 /// The code string (the storage key).
678 pub code: String,
679 /// The client the code was issued to; presentation by any other client is `invalid_grant`.
680 pub client_id: ClientId,
681 /// The redirect URI the authorization request used; a token request that presents one must
682 /// present this one.
683 pub redirect_uri: String,
684 /// Whether the authorization request NAMED that URI, rather than omitting it and being filled
685 /// in from the client's single registration (RFC 6749 section 3.1.2.3).
686 ///
687 /// RFC 6749 section 4.1.3 makes the token endpoint's `redirect_uri` parameter REQUIRED "if the
688 /// `redirect_uri` parameter was included in the authorization request", and conditional means
689 /// conditional in both directions: through 0.9.1 the token endpoint required it always, so a
690 /// client entitled by section 3.1.2.3 to omit it at the authorization endpoint — the ordinary
691 /// shape for a client with exactly one registered URI — was refused at the token endpoint, and
692 /// refused with a message blaming a mismatch that had not happened. `redirect_uri` above cannot
693 /// stand in for this, because it is filled in either way.
694 ///
695 /// `#[serde(default)]` with a `true` default, so a record persisted by 0.9.0 still
696 /// deserializes. TRUE is the fail-closed direction: it keeps the check that release performed
697 /// (the parameter is required) for records minted before this field existed, rather than
698 /// silently waiving section 4.1.3's requirement for every grant that survived the upgrade.
699 #[serde(default = "redirect_uri_was_explicit_default")]
700 pub redirect_uri_was_explicit: bool,
701 /// The scope the user approved.
702 pub scope: ScopeSet,
703 /// The authenticated resource owner.
704 pub subject: String,
705 /// The recorded PKCE challenge (RFC 7636 section 4.4).
706 pub code_challenge: String,
707 /// The recorded PKCE method.
708 pub code_challenge_method: CodeChallengeMethod,
709 /// The RFC 8707 resource indicators the authorization request named.
710 ///
711 /// Recorded on the code because the token request that redeems it MAY narrow this set but MUST
712 /// NOT widen it (RFC 8707 section 2), and "what was granted" is not knowable at the token
713 /// endpoint any other way. Empty means the client asked for no audience restriction.
714 pub resource: Vec<String>,
715 /// The RFC 9396 authorization details the user approved.
716 ///
717 /// Recorded on the code for exactly the reason `resource` above is: section 6 lets the
718 /// token request that redeems it NARROW this set and never widen it, and "what was
719 /// granted" is not knowable at the token endpoint any other way. Empty means the client
720 /// asked for no rich authorization detail.
721 ///
722 /// `#[serde(default)]`, which [`crate::token::IssuedToken::authorization_details`] states in
723 /// full: a code written by a build without `rar` carries no such key, this is not an `Option`
724 /// so serde supplies no default of its own, and without one every code in flight becomes
725 /// unreadable the moment anything in the host's dependency graph turns the feature on.
726 #[cfg(feature = "rar")]
727 #[serde(default)]
728 pub authorization_details: crate::rar::AuthorizationDetails,
729 /// The instant this code was MINTED, which is the instant the user's authorization decision
730 /// was made. Carried into [`crate::token::IssuedToken::grant_established_at`] on redemption so
731 /// that a revocation can tell a code that predates it from one minted afterwards.
732 ///
733 /// `expires_at` cannot stand in for this: a code minted a minute before a withdrawal expires
734 /// minutes AFTER it, so comparing the deadline would let exactly the in-flight redemption a
735 /// barrier exists to refuse through.
736 ///
737 /// `#[serde(default)]`, and the default is the epoch, which is the FAIL-CLOSED direction.
738 /// This field is new in 0.9.1, so a code a 0.9.0 node wrote — or is still writing, during a
739 /// rolling upgrade — carries no such key, and without a default the read fails outright and
740 /// every code that release minted becomes unredeemable the moment this one starts. With it,
741 /// the record deserializes and dates from before every barrier that could ever be recorded, so
742 /// a standing revocation REFUSES it rather than admitting it. A far-future default would
743 /// deserialize just as happily and ADMIT every code 0.9.0 wrote, which is exactly the
744 /// resurrection this field exists to close, reintroduced through the upgrade path. The
745 /// There is deliberately NO backfill migration: a backfill cannot reach a 0.9.0 node still
746 /// writing field-less payloads during a rolling upgrade, which is the window that matters, so
747 /// the serde default covers strictly more than one would.
748 #[serde(default = "grant_instant_default")]
749 pub issued_at: SystemTime,
750 /// Expiry instant; the code is dead at and after this instant.
751 pub expires_at: SystemTime,
752 /// Whether the code has been redeemed, and what it produced.
753 pub state: AuthorizationCodeState,
754 /// What the host reported about the resource owner's authentication when this code was
755 /// approved (the consent-0.8.0 slice; see [`crate::consent::Authentication`]).
756 ///
757 /// Recorded on the CODE because that is the only path by which the authentication the user
758 /// actually performed can reach the token the code mints: the token endpoint has no user in
759 /// front of it and cannot ask. Without it, RFC 9470 section 6's `auth_time` and `acr` could
760 /// only ever be guessed at.
761 #[cfg(feature = "consent")]
762 pub authentication: Option<Box<crate::consent::Authentication>>,
763}
764
765impl AuthorizationCodeRecord {
766 /// A freshly minted, unredeemed code: `state` is [`AuthorizationCodeState::Issued`], because
767 /// the `Consumed` form records what a redemption produced and there is nothing to record until
768 /// one happens.
769 ///
770 /// Every argument is a value the record is worthless without, and each is one the RFC names as
771 /// the thing a later token request is checked against: the `redirect_uri` it must present again
772 /// (RFC 6749 section 4.1.3), the `code_challenge` it must produce a verifier for (RFC 7636
773 /// section 4.6), the subject and scope it is redeeming on behalf of, and the instant after
774 /// which none of that is true any more. The method is `S256` and not an argument, because
775 /// [`CodeChallengeMethod`] has one variant and it is one for a reason.
776 #[allow(clippy::too_many_arguments)]
777 pub fn new(
778 code: impl Into<String>,
779 client_id: ClientId,
780 redirect_uri: impl Into<String>,
781 scope: ScopeSet,
782 subject: impl Into<String>,
783 code_challenge: impl Into<String>,
784 expires_at: SystemTime,
785 ) -> Self {
786 AuthorizationCodeRecord {
787 // FAIL-CLOSED, as `IssuedToken::new` and `RefreshTokenRecord::new` are: a record built
788 // by hand has not said when its decision was made, and the epoch predates every
789 // revocation, so a standing barrier refuses what it redeems into.
790 issued_at: SystemTime::UNIX_EPOCH,
791 code: code.into(),
792 client_id,
793 redirect_uri: redirect_uri.into(),
794 // FAIL-CLOSED, like `issued_at` above: a record built by hand has not said whether the
795 // authorization request named its redirect URI, and `true` keeps RFC 6749 section
796 // 4.1.3's requirement rather than waiving it on a guess.
797 redirect_uri_was_explicit: true,
798 scope,
799 subject: subject.into(),
800 code_challenge: code_challenge.into(),
801 code_challenge_method: CodeChallengeMethod::S256,
802 resource: Vec::new(),
803 #[cfg(feature = "rar")]
804 authorization_details: crate::rar::AuthorizationDetails::none(),
805 expires_at,
806 state: AuthorizationCodeState::Issued,
807 #[cfg(feature = "consent")]
808 authentication: None,
809 }
810 }
811}
812
813/// Hand-written so the one-time `code` never prints (RFC 6749 section 4.1.2 makes it a credential
814/// in its own right). EVERY other field prints, on the rule
815/// [`crate::token::IssuedToken`]'s `Debug` states in full. `issued_at` in particular: it is what
816/// a [`crate::store::RevocationBarrier`] is compared against on redemption, its fail-closed default
817/// is the epoch, and without it printing an operator cannot tell a code refused by a standing
818/// barrier from one refused for any other reason.
819impl fmt::Debug for AuthorizationCodeRecord {
820 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
821 let mut out = f.debug_struct("AuthorizationCodeRecord");
822 out.field("code", &"[redacted]")
823 .field("client_id", &self.client_id)
824 .field("redirect_uri", &self.redirect_uri)
825 .field("redirect_uri_was_explicit", &self.redirect_uri_was_explicit)
826 .field("scope", &self.scope)
827 .field("subject", &self.subject)
828 .field("code_challenge", &self.code_challenge)
829 .field("code_challenge_method", &self.code_challenge_method)
830 .field("resource", &self.resource);
831 // Not a credential: it describes what was authorized, which is precisely what an
832 // operator investigating a grant needs to see.
833 #[cfg(feature = "rar")]
834 out.field("authorization_details", &self.authorization_details);
835 out.field("issued_at", &self.issued_at)
836 .field("expires_at", &self.expires_at)
837 .field("state", &self.state);
838 #[cfg(feature = "consent")]
839 out.field("authentication", &self.authentication);
840 out.finish()
841 }
842}
843
844/// `?` if the URI has no query yet, `&` if it does.
845pub(crate) fn query_separator(uri: &str) -> char {
846 if uri.contains('?') {
847 '&'
848 } else {
849 '?'
850 }
851}
852
853/// Append `name=value` with `value` percent-encoded, advancing the separator after first use.
854fn append_param(out: &mut String, sep: &mut char, name: &str, value: &str) {
855 out.push(*sep);
856 *sep = '&';
857 out.push_str(name);
858 out.push('=');
859 percent_encode_into(out, value);
860}
861
862/// Percent-encode everything outside the RFC 3986 unreserved set.
863///
864/// Deliberately conservative: encoding a character that did not strictly require it is harmless,
865/// while failing to encode `&`, `=` or `#` lets a value forge or truncate the query. That is a
866/// parameter-injection bug in the one place it must never happen.
867fn percent_encode_into(out: &mut String, value: &str) {
868 const HEX: &[u8; 16] = b"0123456789ABCDEF";
869 for &b in value.as_bytes() {
870 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
871 out.push(b as char);
872 } else {
873 out.push('%');
874 out.push(HEX[(b >> 4) as usize] as char);
875 out.push(HEX[(b & 0x0f) as usize] as char);
876 }
877 }
878}
879
880#[cfg(test)]
881#[path = "tests/authorization.rs"]
882mod tests;