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;