Skip to main content

volga_oauth_core/
error.rs

1//! OAuth 2.0/2.1 error models
2//!
3//! See [RFC 6749 Section 5.2](https://www.rfc-editor.org/rfc/rfc6749#section-5.2),
4//! [RFC 6750 Section 3.1](https://www.rfc-editor.org/rfc/rfc6750#section-3.1) and
5//! [RFC 8707 Section 2](https://www.rfc-editor.org/rfc/rfc8707#section-2).
6
7use http::StatusCode;
8use serde::{Deserialize, Serialize};
9use std::fmt::{Display, Formatter};
10
11/// Machine-readable OAuth 2.0 error code
12///
13/// Covers the registered codes from RFC 6749 (authorization and token
14/// endpoints), RFC 6750 (bearer token usage), RFC 8707 (resource
15/// indicators) and RFC 7591 (dynamic client registration). Unregistered
16/// extension codes are preserved as [`OAuthErrorCode::Other`].
17///
18/// Serializes to/from its `snake_case` wire form:
19/// ```
20/// use volga_oauth_core::OAuthErrorCode;
21///
22/// let code = OAuthErrorCode::InvalidToken;
23/// assert_eq!(code.as_str(), "invalid_token");
24/// assert_eq!(OAuthErrorCode::from("invalid_token"), code);
25/// ```
26#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
27#[serde(from = "String", into = "String")]
28#[non_exhaustive]
29pub enum OAuthErrorCode {
30    /// The request is missing a required parameter, includes an unsupported
31    /// parameter value, repeats a parameter or is otherwise malformed
32    InvalidRequest,
33    /// Client authentication failed
34    InvalidClient,
35    /// The provided authorization grant or refresh token is invalid, expired or revoked
36    InvalidGrant,
37    /// The authenticated client is not authorized to use this authorization grant type
38    UnauthorizedClient,
39    /// The authorization grant type is not supported by the authorization server
40    UnsupportedGrantType,
41    /// The requested scope is invalid, unknown or malformed
42    InvalidScope,
43    /// The resource owner or authorization server denied the request
44    AccessDenied,
45    /// The authorization server does not support obtaining an authorization code using this method
46    UnsupportedResponseType,
47    /// The server encountered an unexpected condition
48    ServerError,
49    /// The server is currently unable to handle the request
50    TemporarilyUnavailable,
51    /// The access token is expired, revoked, malformed or otherwise invalid (RFC 6750)
52    InvalidToken,
53    /// The request requires higher privileges than provided by the access token (RFC 6750)
54    InsufficientScope,
55    /// The requested resource is invalid, missing, unknown or malformed (RFC 8707)
56    InvalidTarget,
57    /// The value of one or more redirection URIs is invalid (RFC 7591)
58    InvalidRedirectUri,
59    /// The value of one of the client metadata fields is invalid (RFC 7591)
60    InvalidClientMetadata,
61    /// The software statement presented is invalid (RFC 7591)
62    InvalidSoftwareStatement,
63    /// The software statement is not approved for use by this
64    /// authorization server (RFC 7591)
65    UnapprovedSoftwareStatement,
66    /// An unregistered extension error code
67    Other(String),
68}
69
70impl OAuthErrorCode {
71    /// Returns the `snake_case` wire form of this error code
72    pub fn as_str(&self) -> &str {
73        match self {
74            OAuthErrorCode::InvalidRequest => "invalid_request",
75            OAuthErrorCode::InvalidClient => "invalid_client",
76            OAuthErrorCode::InvalidGrant => "invalid_grant",
77            OAuthErrorCode::UnauthorizedClient => "unauthorized_client",
78            OAuthErrorCode::UnsupportedGrantType => "unsupported_grant_type",
79            OAuthErrorCode::InvalidScope => "invalid_scope",
80            OAuthErrorCode::AccessDenied => "access_denied",
81            OAuthErrorCode::UnsupportedResponseType => "unsupported_response_type",
82            OAuthErrorCode::ServerError => "server_error",
83            OAuthErrorCode::TemporarilyUnavailable => "temporarily_unavailable",
84            OAuthErrorCode::InvalidToken => "invalid_token",
85            OAuthErrorCode::InsufficientScope => "insufficient_scope",
86            OAuthErrorCode::InvalidTarget => "invalid_target",
87            OAuthErrorCode::InvalidRedirectUri => "invalid_redirect_uri",
88            OAuthErrorCode::InvalidClientMetadata => "invalid_client_metadata",
89            OAuthErrorCode::InvalidSoftwareStatement => "invalid_software_statement",
90            OAuthErrorCode::UnapprovedSoftwareStatement => "unapproved_software_statement",
91            OAuthErrorCode::Other(code) => code,
92        }
93    }
94
95    /// Returns the HTTP status code conventionally paired with this error code
96    ///
97    /// Bearer-usage codes follow RFC 6750 Section 3.1 (`invalid_token` -> 401,
98    /// `insufficient_scope` -> 403); `invalid_client` maps to 401 and the
99    /// remaining token/authorization endpoint codes to 400 per RFC 6749 Section 5.2,
100    /// except `server_error` (500) and `temporarily_unavailable` (503).
101    /// Extension codes default to 400.
102    pub fn status(&self) -> StatusCode {
103        match self {
104            OAuthErrorCode::InvalidToken | OAuthErrorCode::InvalidClient => {
105                StatusCode::UNAUTHORIZED
106            }
107            OAuthErrorCode::InsufficientScope | OAuthErrorCode::AccessDenied => {
108                StatusCode::FORBIDDEN
109            }
110            OAuthErrorCode::ServerError => StatusCode::INTERNAL_SERVER_ERROR,
111            OAuthErrorCode::TemporarilyUnavailable => StatusCode::SERVICE_UNAVAILABLE,
112            _ => StatusCode::BAD_REQUEST,
113        }
114    }
115
116    /// Maps a wire-form code to a known variant, if any
117    fn from_known(code: &str) -> Option<Self> {
118        let known = match code {
119            "invalid_request" => OAuthErrorCode::InvalidRequest,
120            "invalid_client" => OAuthErrorCode::InvalidClient,
121            "invalid_grant" => OAuthErrorCode::InvalidGrant,
122            "unauthorized_client" => OAuthErrorCode::UnauthorizedClient,
123            "unsupported_grant_type" => OAuthErrorCode::UnsupportedGrantType,
124            "invalid_scope" => OAuthErrorCode::InvalidScope,
125            "access_denied" => OAuthErrorCode::AccessDenied,
126            "unsupported_response_type" => OAuthErrorCode::UnsupportedResponseType,
127            "server_error" => OAuthErrorCode::ServerError,
128            "temporarily_unavailable" => OAuthErrorCode::TemporarilyUnavailable,
129            "invalid_token" => OAuthErrorCode::InvalidToken,
130            "insufficient_scope" => OAuthErrorCode::InsufficientScope,
131            "invalid_target" => OAuthErrorCode::InvalidTarget,
132            "invalid_redirect_uri" => OAuthErrorCode::InvalidRedirectUri,
133            "invalid_client_metadata" => OAuthErrorCode::InvalidClientMetadata,
134            "invalid_software_statement" => OAuthErrorCode::InvalidSoftwareStatement,
135            "unapproved_software_statement" => OAuthErrorCode::UnapprovedSoftwareStatement,
136            _ => return None,
137        };
138        Some(known)
139    }
140}
141
142impl From<&str> for OAuthErrorCode {
143    #[inline]
144    fn from(code: &str) -> Self {
145        Self::from_known(code).unwrap_or_else(|| OAuthErrorCode::Other(code.into()))
146    }
147}
148
149impl From<String> for OAuthErrorCode {
150    #[inline]
151    fn from(code: String) -> Self {
152        Self::from_known(&code).unwrap_or(OAuthErrorCode::Other(code))
153    }
154}
155
156impl From<OAuthErrorCode> for String {
157    #[inline]
158    fn from(code: OAuthErrorCode) -> Self {
159        match code {
160            OAuthErrorCode::Other(code) => code,
161            known => known.as_str().into(),
162        }
163    }
164}
165
166impl Display for OAuthErrorCode {
167    #[inline]
168    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
169        f.write_str(self.as_str())
170    }
171}
172
173/// OAuth 2.0 error response per RFC 6749 Section 5.2
174///
175/// Serializes to the standard JSON error body returned by token and other
176/// OAuth endpoints:
177///
178/// ```json
179/// { "error": "invalid_grant", "error_description": "..." }
180/// ```
181#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
182pub struct OAuthError {
183    /// Machine-readable error code
184    pub error: OAuthErrorCode,
185
186    /// Human-readable ASCII text providing additional information
187    #[serde(default, skip_serializing_if = "Option::is_none")]
188    pub error_description: Option<String>,
189
190    /// URI of a web page with information about the error
191    #[serde(default, skip_serializing_if = "Option::is_none")]
192    pub error_uri: Option<String>,
193}
194
195impl OAuthError {
196    /// Creates a new error with the given code and no description
197    pub fn new(error: OAuthErrorCode) -> Self {
198        Self {
199            error,
200            error_description: None,
201            error_uri: None,
202        }
203    }
204
205    /// Sets the human-readable `error_description`
206    pub fn with_description(mut self, description: impl Into<String>) -> Self {
207        self.error_description = Some(description.into());
208        self
209    }
210
211    /// Sets the `error_uri` pointing to a web page with details about the error
212    pub fn with_error_uri(mut self, uri: impl Into<String>) -> Self {
213        self.error_uri = Some(uri.into());
214        self
215    }
216}
217
218impl From<OAuthErrorCode> for OAuthError {
219    #[inline]
220    fn from(error: OAuthErrorCode) -> Self {
221        Self::new(error)
222    }
223}
224
225impl Display for OAuthError {
226    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
227        match &self.error_description {
228            Some(desc) => write!(f, "{}: {desc}", self.error),
229            None => Display::fmt(&self.error, f),
230        }
231    }
232}
233
234impl std::error::Error for OAuthError {}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239
240    #[test]
241    fn it_maps_known_codes_to_wire_form() {
242        let cases = [
243            (OAuthErrorCode::InvalidRequest, "invalid_request"),
244            (OAuthErrorCode::InvalidClient, "invalid_client"),
245            (OAuthErrorCode::InvalidGrant, "invalid_grant"),
246            (OAuthErrorCode::UnauthorizedClient, "unauthorized_client"),
247            (
248                OAuthErrorCode::UnsupportedGrantType,
249                "unsupported_grant_type",
250            ),
251            (OAuthErrorCode::InvalidScope, "invalid_scope"),
252            (OAuthErrorCode::AccessDenied, "access_denied"),
253            (
254                OAuthErrorCode::UnsupportedResponseType,
255                "unsupported_response_type",
256            ),
257            (OAuthErrorCode::ServerError, "server_error"),
258            (
259                OAuthErrorCode::TemporarilyUnavailable,
260                "temporarily_unavailable",
261            ),
262            (OAuthErrorCode::InvalidToken, "invalid_token"),
263            (OAuthErrorCode::InsufficientScope, "insufficient_scope"),
264            (OAuthErrorCode::InvalidTarget, "invalid_target"),
265            (OAuthErrorCode::InvalidRedirectUri, "invalid_redirect_uri"),
266            (
267                OAuthErrorCode::InvalidClientMetadata,
268                "invalid_client_metadata",
269            ),
270            (
271                OAuthErrorCode::InvalidSoftwareStatement,
272                "invalid_software_statement",
273            ),
274            (
275                OAuthErrorCode::UnapprovedSoftwareStatement,
276                "unapproved_software_statement",
277            ),
278        ];
279        for (code, wire) in cases {
280            assert_eq!(code.as_str(), wire);
281            assert_eq!(OAuthErrorCode::from(wire), code);
282            assert_eq!(OAuthErrorCode::from(wire.to_string()), code);
283        }
284    }
285
286    #[test]
287    fn it_preserves_unknown_codes() {
288        let code = OAuthErrorCode::from("use_dpop_nonce");
289        assert_eq!(code, OAuthErrorCode::Other("use_dpop_nonce".into()));
290        assert_eq!(code.as_str(), "use_dpop_nonce");
291        assert_eq!(String::from(code), "use_dpop_nonce");
292    }
293
294    #[test]
295    fn it_serializes_code_as_string() {
296        let json = serde_json::to_string(&OAuthErrorCode::InvalidToken).unwrap();
297        assert_eq!(json, r#""invalid_token""#);
298    }
299
300    #[test]
301    fn it_deserializes_code_from_string() {
302        let code: OAuthErrorCode = serde_json::from_str(r#""insufficient_scope""#).unwrap();
303        assert_eq!(code, OAuthErrorCode::InsufficientScope);
304
305        let code: OAuthErrorCode = serde_json::from_str(r#""something_custom""#).unwrap();
306        assert_eq!(code, OAuthErrorCode::Other("something_custom".into()));
307    }
308
309    #[test]
310    fn it_displays_code() {
311        assert_eq!(
312            OAuthErrorCode::TemporarilyUnavailable.to_string(),
313            "temporarily_unavailable"
314        );
315    }
316
317    #[test]
318    fn it_serializes_error_without_optional_fields() {
319        let err = OAuthError::new(OAuthErrorCode::InvalidGrant);
320        let json = serde_json::to_string(&err).unwrap();
321        assert_eq!(json, r#"{"error":"invalid_grant"}"#);
322    }
323
324    #[test]
325    fn it_serializes_error_with_all_fields() {
326        let err = OAuthError::new(OAuthErrorCode::InvalidRequest)
327            .with_description("Missing code_verifier")
328            .with_error_uri("https://example.com/errors/invalid_request");
329        let json = serde_json::to_string(&err).unwrap();
330        assert_eq!(
331            json,
332            r#"{"error":"invalid_request","error_description":"Missing code_verifier","error_uri":"https://example.com/errors/invalid_request"}"#
333        );
334    }
335
336    #[test]
337    fn it_deserializes_error_response() {
338        let err: OAuthError = serde_json::from_str(
339            r#"{"error":"invalid_token","error_description":"Token has expired"}"#,
340        )
341        .unwrap();
342        assert_eq!(err.error, OAuthErrorCode::InvalidToken);
343        assert_eq!(err.error_description.as_deref(), Some("Token has expired"));
344        assert!(err.error_uri.is_none());
345    }
346
347    #[test]
348    fn it_displays_error_with_and_without_description() {
349        let err = OAuthError::new(OAuthErrorCode::InvalidToken);
350        assert_eq!(err.to_string(), "invalid_token");
351
352        let err = err.with_description("Token has expired");
353        assert_eq!(err.to_string(), "invalid_token: Token has expired");
354    }
355
356    #[test]
357    fn it_converts_code_into_error() {
358        let err: OAuthError = OAuthErrorCode::AccessDenied.into();
359        assert_eq!(err.error, OAuthErrorCode::AccessDenied);
360        assert!(err.error_description.is_none());
361    }
362
363    #[test]
364    fn it_maps_codes_to_status() {
365        let cases = [
366            (OAuthErrorCode::InvalidToken, StatusCode::UNAUTHORIZED),
367            (OAuthErrorCode::InvalidClient, StatusCode::UNAUTHORIZED),
368            (OAuthErrorCode::InsufficientScope, StatusCode::FORBIDDEN),
369            (OAuthErrorCode::AccessDenied, StatusCode::FORBIDDEN),
370            (
371                OAuthErrorCode::ServerError,
372                StatusCode::INTERNAL_SERVER_ERROR,
373            ),
374            (
375                OAuthErrorCode::TemporarilyUnavailable,
376                StatusCode::SERVICE_UNAVAILABLE,
377            ),
378            (OAuthErrorCode::InvalidRequest, StatusCode::BAD_REQUEST),
379            (OAuthErrorCode::InvalidGrant, StatusCode::BAD_REQUEST),
380            (OAuthErrorCode::InvalidTarget, StatusCode::BAD_REQUEST),
381            (
382                OAuthErrorCode::Other("custom".into()),
383                StatusCode::BAD_REQUEST,
384            ),
385        ];
386        for (code, status) in cases {
387            assert_eq!(code.status(), status, "code: {code}");
388        }
389    }
390}