Skip to main content

volga_oauth_client/
error.rs

1//! Client-side error model
2//!
3//! [`ClientError`] separates transport failures from protocol-level OAuth
4//! errors: an OAuth error response body (RFC 6749 Section 5.2) surfaces as
5//! [`ClientError::Protocol`] with the parsed [`OAuthError`], everything
6//! below it (connection, TLS, timeout, malformed body) as the other
7//! variants.
8
9use http::StatusCode;
10use std::fmt::{Display, Formatter};
11use volga_oauth_core::OAuthError;
12
13/// Error returned by OAuth client operations
14#[derive(Debug)]
15#[non_exhaustive]
16pub enum ClientError {
17    /// The server returned an OAuth 2.0 error response (RFC 6749 Section 5.2)
18    Protocol(OAuthError),
19
20    /// The server returned an unexpected HTTP status without a parseable
21    /// OAuth error body
22    Http(StatusCode),
23
24    /// The request could not be completed (connection, TLS or timeout failure)
25    Transport(Box<dyn std::error::Error + Send + Sync>),
26
27    /// The response body could not be deserialized
28    Decode(serde_json::Error),
29
30    /// A plain `http://` URL was rejected because HTTPS is enforced
31    /// (see [`ClientConfig::require_https`](crate::ClientConfig::require_https))
32    InsecureUrl(String),
33
34    /// The response failed semantic validation required by the spec
35    /// (e.g. the `issuer` in a discovered document does not match the
36    /// requested issuer, RFC 8414 Section 3.3)
37    Validation(String),
38}
39
40impl ClientError {
41    /// Creates a [`ClientError::Transport`] from any error source
42    pub fn transport(err: impl Into<Box<dyn std::error::Error + Send + Sync>>) -> Self {
43        Self::Transport(err.into())
44    }
45
46    /// Creates a [`ClientError::Validation`] with the given reason
47    pub fn validation(reason: impl Into<String>) -> Self {
48        Self::Validation(reason.into())
49    }
50}
51
52impl Display for ClientError {
53    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
54        match self {
55            Self::Protocol(err) => Display::fmt(err, f),
56            Self::Http(status) => write!(f, "unexpected HTTP status: {status}"),
57            Self::Transport(err) => write!(f, "transport error: {err}"),
58            Self::Decode(err) => write!(f, "malformed response body: {err}"),
59            Self::InsecureUrl(url) => write!(f, "insecure URL rejected (HTTPS is enforced): {url}"),
60            Self::Validation(reason) => write!(f, "response validation failed: {reason}"),
61        }
62    }
63}
64
65impl std::error::Error for ClientError {
66    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
67        match self {
68            Self::Protocol(err) => Some(err),
69            Self::Transport(err) => Some(err.as_ref()),
70            Self::Decode(err) => Some(err),
71            _ => None,
72        }
73    }
74}
75
76impl From<OAuthError> for ClientError {
77    #[inline]
78    fn from(err: OAuthError) -> Self {
79        Self::Protocol(err)
80    }
81}
82
83impl From<serde_json::Error> for ClientError {
84    #[inline]
85    fn from(err: serde_json::Error) -> Self {
86        Self::Decode(err)
87    }
88}
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93    use volga_oauth_core::OAuthErrorCode;
94
95    #[test]
96    fn it_displays_all_variants() {
97        let cases: [(ClientError, &str); 6] = [
98            (
99                OAuthError::new(OAuthErrorCode::InvalidGrant)
100                    .with_description("expired")
101                    .into(),
102                "invalid_grant: expired",
103            ),
104            (
105                ClientError::Http(StatusCode::BAD_GATEWAY),
106                "unexpected HTTP status: 502 Bad Gateway",
107            ),
108            (
109                ClientError::transport(std::io::Error::other("connection reset")),
110                "transport error: connection reset",
111            ),
112            (
113                serde_json::from_str::<serde_json::Value>("{")
114                    .unwrap_err()
115                    .into(),
116                "malformed response body: EOF while parsing an object at line 1 column 1",
117            ),
118            (
119                ClientError::InsecureUrl("http://auth.example.com".into()),
120                "insecure URL rejected (HTTPS is enforced): http://auth.example.com",
121            ),
122            (
123                ClientError::validation("issuer mismatch"),
124                "response validation failed: issuer mismatch",
125            ),
126        ];
127        for (err, expected) in cases {
128            assert_eq!(err.to_string(), expected);
129        }
130    }
131
132    #[test]
133    fn it_exposes_error_sources() {
134        let err: ClientError = OAuthError::new(OAuthErrorCode::InvalidGrant).into();
135        assert!(std::error::Error::source(&err).is_some());
136
137        let err = ClientError::transport(std::io::Error::other("reset"));
138        assert!(std::error::Error::source(&err).is_some());
139
140        let err = ClientError::Http(StatusCode::BAD_GATEWAY);
141        assert!(std::error::Error::source(&err).is_none());
142    }
143}