Skip to main content

oauth_as/
error.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2// Copyright (C) 2026 Matthew Jackson
3
4//! The OAuth error response object, mirrored from RFC 6749 section 5.2 (token endpoint), section
5//! 4.1.2.1 (authorization endpoint), and the RFC 8628 section 3.5 device-grant extension codes.
6//! One enum, one struct, owned here: never a third party's generated types.
7
8use std::borrow::Cow;
9use std::fmt;
10
11use serde::{Deserialize, Serialize};
12
13/// Registered `error` codes this server can emit.
14///
15/// The wire spelling is the exact registered token (`snake_case`), which the `serde` rename below
16/// pins; `tests/conformance_schema.rs` locks the full emitted set against a schema transcribed
17/// from the RFCs.
18///
19/// `#[non_exhaustive]`, and for this enum that is not the usual forward-compatibility hedge. The
20/// VARIANT SET here depends on cargo features: `consent`, `dpop`, `par` and `jar` each add one
21/// (`rar` used to, and no longer does: see `InvalidAuthorizationDetails`). Without the attribute,
22/// a host's exhaustive `match` compiles or fails depending on which features something ELSE in
23/// its dependency graph turned on, which is a build break with no release behind it. This is also the most widely matched type this crate publishes, so a host
24/// that wants a total match should write one with a `_` arm and decide what an unknown code means
25/// to it (`ErrorCode::as_str` still gives it the wire spelling).
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(rename_all = "snake_case")]
28#[non_exhaustive]
29pub enum ErrorCode {
30    // RFC 6749 section 5.2 (token endpoint).
31    /// RFC 6749 section 5.2 `invalid_request`: the request is missing a required parameter,
32    /// repeats one, or is otherwise malformed. It says "you sent the wrong bytes", so a client
33    /// that retries the identical request cannot succeed.
34    InvalidRequest,
35    /// RFC 6749 section 5.2 `invalid_client`: client authentication failed. Every reason collapses
36    /// into this one code deliberately, unknown client and wrong secret alike, because
37    /// distinguishing them tells an attacker which client ids exist. Carries HTTP 401 when the
38    /// client authenticated with a scheme that requires a `WWW-Authenticate` challenge.
39    InvalidClient,
40    /// RFC 6749 section 5.2 `invalid_grant`: the authorization code, device code or refresh token
41    /// is invalid, expired, revoked, was issued to another client, or does not match the
42    /// redirect URI. Also the answer to a failed PKCE verification (RFC 7636 section 4.6), and to
43    /// a REPLAY, which additionally revokes the whole issued family.
44    InvalidGrant,
45    /// RFC 6749 section 5.2 `unauthorized_client`: the client is authenticated but is not
46    /// registered for this grant type. Distinct from `invalid_client`, which is about identity,
47    /// and from `access_denied`, which is about the user.
48    UnauthorizedClient,
49    /// RFC 6749 section 5.2 `unsupported_grant_type`: this server does not implement the requested
50    /// `grant_type` at all, as opposed to declining it for this client.
51    UnsupportedGrantType,
52    /// RFC 6749 section 5.2 `invalid_scope`: the requested scope is unknown, malformed, or exceeds
53    /// what the grant being presented was issued with. A refresh that widens scope lands here
54    /// (section 6), because narrowing is allowed and widening never is.
55    InvalidScope,
56    // RFC 6749 section 4.1.2.1 (authorization endpoint; `access_denied` is also an RFC 8628
57    // section 3.5 device-grant terminal code).
58    /// RFC 6749 section 4.1.2.1 `access_denied`: the resource owner, or this server's own policy,
59    /// refused the request. Also the RFC 8628 section 3.5 terminal answer for a device grant the
60    /// user rejected at the verification page.
61    AccessDenied,
62    /// RFC 6749 section 4.1.2.1 `unsupported_response_type`: this server will not issue an
63    /// authorization code by this method. `response_type=token`, the implicit grant, is refused
64    /// with this code: OAuth 2.1 removes it.
65    UnsupportedResponseType,
66    /// RFC 6749 section 4.1.2.1 `server_error`: the server hit a condition it could not recover
67    /// from and that is nobody's fault but its own. It is what a storage failure becomes, so it
68    /// never carries a detail that would describe the host's internals to a caller.
69    ServerError,
70    /// RFC 6749 section 4.1.2.1 `temporarily_unavailable`: the server is overloaded or under
71    /// maintenance. Distinct from `server_error` because it tells the client that retrying LATER
72    /// is the right response, where `server_error` does not.
73    TemporarilyUnavailable,
74    // RFC 8628 section 3.5 (device access token request).
75    /// RFC 8628 section 3.5 `authorization_pending`: the device grant exists and the user has not
76    /// finished with it yet. The client keeps polling at the interval it was given. Not an error
77    /// in any useful sense: it is the normal answer for most of a device flow's life.
78    AuthorizationPending,
79    /// RFC 8628 section 3.5 `slow_down`: the client polled faster than the interval it was given.
80    /// Emitting this obliges the server to increase that interval by 5 seconds, which this crate
81    /// does; a server that emitted the code without raising the interval would be asking the
82    /// client to guess by how much.
83    SlowDown,
84    /// RFC 8628 section 3.5 `expired_token`: the `device_code` has passed its lifetime. Terminal.
85    /// The client must start a new device authorization request rather than keep polling.
86    ExpiredToken,
87    /// RFC 9396 section 5: the `authorization_details` parameter is unparseable, exceeds
88    /// what this server will accept, names a `type` this server does not support, or asks
89    /// for more than the underlying grant allows (section 6). Section 5 makes refusing a
90    /// MUST rather than a choice: an AS that ignored an authorization detail it did not
91    /// understand would issue a token that says nothing about a permission the client
92    /// believes it obtained, and the client cannot tell the difference.
93    ///
94    /// Distinct from `invalid_request` for the reason `invalid_target` is: the parameter was
95    /// well formed AS A PARAMETER, so a client conflating the two would retry unchanged.
96    ///
97    /// NOT FEATURE GATED, for the same reason `invalid_target` is not: the build that has the
98    /// most to refuse is the build WITHOUT `rar`, which supports no authorization detail type
99    /// whatsoever and therefore meets section 5's condition on every request that carries the
100    /// parameter. Gating the code on `rar` left exactly that build with nothing to answer with,
101    /// so the parameter was accepted and ignored, which is the one outcome section 5 forbids.
102    InvalidAuthorizationDetails,
103    /// RFC 8707 section 2: the `resource` parameter names a target this server will not issue a
104    /// token for, because the value is malformed, is not an absolute URI, or was never granted.
105    /// The code itself is registered by RFC 8693 section 2.2.2 and RFC 8707 section 2 is what
106    /// directs an authorization server to use it for resource indicators specifically. It is a
107    /// distinct code from `invalid_request` on purpose: the parameter was well formed AS A
108    /// PARAMETER, so a client that conflated the two would retry the same request.
109    InvalidTarget,
110    /// RFC 9470 section 3: the authentication the user performed is not enough for what is
111    /// being asked. Registered by RFC 9470 for the RESOURCE server's challenge; this server
112    /// emits it from the AUTHORIZATION endpoint when the host's reported authentication
113    /// cannot satisfy the request's `acr_values` or `max_age`.
114    ///
115    /// Reusing the resource server's code is deliberate. It is the code the client was just
116    /// handed, so re-sending it says the true thing: the authentication is STILL not
117    /// sufficient. `invalid_request` would say the parameters were malformed and invite the
118    /// client to retry the identical request, which is the one thing that cannot help.
119    #[cfg(feature = "consent")]
120    InsufficientUserAuthentication,
121    /// RFC 9449 section 5: the DPoP proof on this request is missing, malformed, does not bind to
122    /// this request, or has already been used. Registered by RFC 9449 section 12.3.
123    ///
124    /// A DISTINCT code from `invalid_client` on purpose, and the distinction is actionable: the
125    /// client's credential may be perfectly good and only its proof wrong, and a client told
126    /// `invalid_client` would go and check the wrong thing. Feature gated, so a build without
127    /// `dpop` has exactly the code set it had before.
128    #[cfg(feature = "dpop")]
129    InvalidDpopProof,
130    /// RFC 9101 section 7: the `request_uri` in the authorization request returns an error or
131    /// contains invalid data. This server mints its own `request_uri` values at its RFC 9126
132    /// endpoint and fetches nothing, so "invalid data" here means unknown, already used, expired,
133    /// or issued to a different client.
134    #[cfg(feature = "par")]
135    InvalidRequestUri,
136    /// RFC 9101 section 7: the `request` parameter contains an invalid Request Object. Sections
137    /// 6.1 and 6.2 make this the REQUIRED answer for a request object that fails to decrypt, fails
138    /// signature validation, or is signed with a key that is not the client's.
139    #[cfg(feature = "jar")]
140    InvalidRequestObject,
141    /// RFC 9101 section 7: this server does not support the `request` parameter. Emitted when the
142    /// host has not enabled signed request objects at all, which is distinct from a request object
143    /// that was offered and refused.
144    #[cfg(feature = "jar")]
145    RequestNotSupported,
146}
147
148impl ErrorCode {
149    /// The registered wire spelling.
150    pub fn as_str(self) -> &'static str {
151        match self {
152            ErrorCode::InvalidRequest => "invalid_request",
153            ErrorCode::InvalidClient => "invalid_client",
154            ErrorCode::InvalidGrant => "invalid_grant",
155            ErrorCode::UnauthorizedClient => "unauthorized_client",
156            ErrorCode::UnsupportedGrantType => "unsupported_grant_type",
157            ErrorCode::InvalidScope => "invalid_scope",
158            ErrorCode::AccessDenied => "access_denied",
159            ErrorCode::UnsupportedResponseType => "unsupported_response_type",
160            ErrorCode::ServerError => "server_error",
161            ErrorCode::TemporarilyUnavailable => "temporarily_unavailable",
162            ErrorCode::AuthorizationPending => "authorization_pending",
163            ErrorCode::SlowDown => "slow_down",
164            ErrorCode::ExpiredToken => "expired_token",
165            ErrorCode::InvalidAuthorizationDetails => "invalid_authorization_details",
166            ErrorCode::InvalidTarget => "invalid_target",
167            #[cfg(feature = "consent")]
168            ErrorCode::InsufficientUserAuthentication => "insufficient_user_authentication",
169            #[cfg(feature = "dpop")]
170            ErrorCode::InvalidDpopProof => "invalid_dpop_proof",
171            #[cfg(feature = "par")]
172            ErrorCode::InvalidRequestUri => "invalid_request_uri",
173            #[cfg(feature = "jar")]
174            ErrorCode::InvalidRequestObject => "invalid_request_object",
175            #[cfg(feature = "jar")]
176            ErrorCode::RequestNotSupported => "request_not_supported",
177        }
178    }
179
180    /// The HTTP status a token-endpoint response carrying this code takes, per RFC 6749
181    /// section 5.2: 400 unless the code is `invalid_client` (401, and the host should attach a
182    /// `WWW-Authenticate` header when the client attempted header-based authentication), plus the
183    /// conventional 500/503 for the two server-side codes.
184    ///
185    /// EVERY variant is listed, exactly as in [`ErrorCode::as_str`] above, and there is no
186    /// catch-all. A `_ => 400` arm compiles for a variant nobody thought about, and 400 is a
187    /// plausible enough answer that nothing would ever notice: the status is part of the wire
188    /// contract, so adding a code should require choosing one rather than inheriting one.
189    pub fn http_status(self) -> u16 {
190        match self {
191            // 401, because RFC 6749 section 5.2 says so: client authentication failed, and the
192            // response participates in the `WWW-Authenticate` challenge exchange.
193            ErrorCode::InvalidClient => 401,
194            // The two server-side codes take the conventional statuses for what they describe.
195            ErrorCode::ServerError => 500,
196            ErrorCode::TemporarilyUnavailable => 503,
197            // Everything below is 400: RFC 6749 section 5.2's default for a request this server
198            // will not act on, and RFC 8628 section 3.5 keeps the device grant's three polling
199            // codes there too (they are answers about the REQUEST, not about the server).
200            ErrorCode::InvalidRequest => 400,
201            ErrorCode::InvalidGrant => 400,
202            ErrorCode::UnauthorizedClient => 400,
203            ErrorCode::UnsupportedGrantType => 400,
204            ErrorCode::InvalidScope => 400,
205            ErrorCode::AccessDenied => 400,
206            ErrorCode::UnsupportedResponseType => 400,
207            ErrorCode::AuthorizationPending => 400,
208            ErrorCode::SlowDown => 400,
209            ErrorCode::ExpiredToken => 400,
210            ErrorCode::InvalidTarget => 400,
211            ErrorCode::InvalidAuthorizationDetails => 400,
212            // RFC 9470 section 3 gives 401 to the RESOURCE server's challenge; this is the
213            // AUTHORIZATION server's token-endpoint refusal of a grant whose authentication was
214            // too old or too weak, which is an RFC 6749 section 5.2 error response like the rest.
215            #[cfg(feature = "consent")]
216            ErrorCode::InsufficientUserAuthentication => 400,
217            // RFC 9449 section 5: the token endpoint answers a bad proof with 400 and this code,
218            // not with 401, because the client's CREDENTIAL was fine and its proof was not.
219            #[cfg(feature = "dpop")]
220            ErrorCode::InvalidDpopProof => 400,
221            #[cfg(feature = "par")]
222            ErrorCode::InvalidRequestUri => 400,
223            #[cfg(feature = "jar")]
224            ErrorCode::InvalidRequestObject => 400,
225            #[cfg(feature = "jar")]
226            ErrorCode::RequestNotSupported => 400,
227        }
228    }
229}
230
231impl fmt::Display for ErrorCode {
232    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
233        f.write_str(self.as_str())
234    }
235}
236
237/// The RFC 6749 section 5.2 error response body: `error` required, `error_description` and
238/// `error_uri` optional and omitted (never `null`) when absent.
239///
240/// # Why the two optional fields are `Cow<'static, str>` and not `String`
241///
242/// This is the type every REFUSAL in this crate is built out of, and a refusal is the one response
243/// an attacker chooses the rate of: an unauthenticated caller can ask for as many `invalid_request`
244/// bodies as it can open sockets for, and asks for none of the successful ones. Roughly 50 of the
245/// crate's 57 description sites pass a string constant, so an owned `String` meant one heap copy
246/// of a `&'static str` per refused request, bought for nothing.
247///
248/// It is free in memory as well as in allocations: `Option<Cow<'static, str>>` is 24 bytes, the
249/// SAME as `Option<String>`, because `Cow`'s discriminant lives in the niche the pointer already
250/// has. MEASURED, not assumed: `ErrorResponse` is 56 bytes before and after, and
251/// `tests/allocation.rs` pins both that size and the zero-allocation claim.
252///
253/// A host that needs a description it computed still passes a `String`: `Cow` owns that case, and
254/// [`ErrorResponse::with_description`] takes `impl Into<Cow<'static, str>>` so both spellings
255/// compile unchanged.
256#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
257pub struct ErrorResponse {
258    /// The registered error code.
259    pub error: ErrorCode,
260    /// Human-readable ASCII detail for the developer (not the end user), per section 5.2.
261    #[serde(skip_serializing_if = "Option::is_none")]
262    pub error_description: Option<Cow<'static, str>>,
263    /// A URI identifying a human-readable page with more information.
264    #[serde(skip_serializing_if = "Option::is_none")]
265    pub error_uri: Option<Cow<'static, str>>,
266}
267
268impl ErrorResponse {
269    /// A bare error with no description.
270    pub fn new(error: ErrorCode) -> Self {
271        ErrorResponse {
272            error,
273            error_description: None,
274            error_uri: None,
275        }
276    }
277
278    /// Attach a developer-facing description.
279    ///
280    /// `impl Into<Cow<'static, str>>`, so a `&'static str` borrows and a `String` moves: the
281    /// overwhelmingly common caller passes a literal and pays nothing, and a caller that genuinely
282    /// computed the text keeps working with no change at the call site.
283    pub fn with_description(mut self, description: impl Into<Cow<'static, str>>) -> Self {
284        self.error_description = Some(description.into());
285        self
286    }
287
288    /// Attach an `error_uri` (RFC 6749 section 5.2), the same way and for the same reason.
289    pub fn with_uri(mut self, uri: impl Into<Cow<'static, str>>) -> Self {
290        self.error_uri = Some(uri.into());
291        self
292    }
293
294    /// The HTTP status for this response; see [`ErrorCode::http_status`].
295    pub fn http_status(&self) -> u16 {
296        self.error.http_status()
297    }
298}
299
300impl fmt::Display for ErrorResponse {
301    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
302        match &self.error_description {
303            Some(d) => write!(f, "{}: {}", self.error, d),
304            None => f.write_str(self.error.as_str()),
305        }
306    }
307}
308
309impl std::error::Error for ErrorResponse {}
310
311#[cfg(test)]
312#[path = "tests/error.rs"]
313mod tests;