Skip to main content

rustlavel_oauth/
error.rs

1//! The error vocabulary both halves of OAuth share.
2//!
3//! RFC 6749 fixes a small set of error codes and a JSON body to carry them.
4//! Getting these right matters more than it looks: a client library decides
5//! whether to retry, to re-authenticate, or to give up based on the code alone,
6//! so returning `invalid_request` where the spec says `invalid_grant` sends
7//! well-behaved clients into a retry loop.
8
9use rustlavel_core::Json;
10use rustlavel_http::{IntoResponse, Response, Status};
11
12/// The error codes defined by RFC 6749 §4.1.2.1 (authorisation endpoint) and
13/// §5.2 (token endpoint), plus the two RFC 6750 codes a resource server needs.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum OAuthErrorCode {
16    // The token endpoint, RFC 6749 §5.2.
17    InvalidRequest,
18    InvalidClient,
19    InvalidGrant,
20    UnauthorizedClient,
21    UnsupportedGrantType,
22    InvalidScope,
23    // The authorisation endpoint, RFC 6749 §4.1.2.1.
24    AccessDenied,
25    UnsupportedResponseType,
26    ServerError,
27    TemporarilyUnavailable,
28    // A resource server rejecting a bearer token, RFC 6750 §3.1.
29    InvalidToken,
30    InsufficientScope,
31}
32
33impl OAuthErrorCode {
34    /// The wire spelling. These strings are part of the protocol.
35    pub fn as_str(self) -> &'static str {
36        match self {
37            OAuthErrorCode::InvalidRequest => "invalid_request",
38            OAuthErrorCode::InvalidClient => "invalid_client",
39            OAuthErrorCode::InvalidGrant => "invalid_grant",
40            OAuthErrorCode::UnauthorizedClient => "unauthorized_client",
41            OAuthErrorCode::UnsupportedGrantType => "unsupported_grant_type",
42            OAuthErrorCode::InvalidScope => "invalid_scope",
43            OAuthErrorCode::AccessDenied => "access_denied",
44            OAuthErrorCode::UnsupportedResponseType => "unsupported_response_type",
45            OAuthErrorCode::ServerError => "server_error",
46            OAuthErrorCode::TemporarilyUnavailable => "temporarily_unavailable",
47            OAuthErrorCode::InvalidToken => "invalid_token",
48            OAuthErrorCode::InsufficientScope => "insufficient_scope",
49        }
50    }
51
52    pub fn parse(raw: &str) -> Option<OAuthErrorCode> {
53        Some(match raw {
54            "invalid_request" => OAuthErrorCode::InvalidRequest,
55            "invalid_client" => OAuthErrorCode::InvalidClient,
56            "invalid_grant" => OAuthErrorCode::InvalidGrant,
57            "unauthorized_client" => OAuthErrorCode::UnauthorizedClient,
58            "unsupported_grant_type" => OAuthErrorCode::UnsupportedGrantType,
59            "invalid_scope" => OAuthErrorCode::InvalidScope,
60            "access_denied" => OAuthErrorCode::AccessDenied,
61            "unsupported_response_type" => OAuthErrorCode::UnsupportedResponseType,
62            "server_error" => OAuthErrorCode::ServerError,
63            "temporarily_unavailable" => OAuthErrorCode::TemporarilyUnavailable,
64            "invalid_token" => OAuthErrorCode::InvalidToken,
65            "insufficient_scope" => OAuthErrorCode::InsufficientScope,
66            _ => return None,
67        })
68    }
69
70    /// The HTTP status this code is returned with.
71    ///
72    /// RFC 6749 §5.2 is specific: everything is 400 except `invalid_client`,
73    /// which is 401 so a client knows to fix its credentials rather than its
74    /// request.
75    pub fn status(self) -> Status {
76        match self {
77            OAuthErrorCode::InvalidClient | OAuthErrorCode::InvalidToken => Status::UNAUTHORIZED,
78            OAuthErrorCode::InsufficientScope | OAuthErrorCode::AccessDenied => Status::FORBIDDEN,
79            OAuthErrorCode::ServerError => Status::INTERNAL_ERROR,
80            OAuthErrorCode::TemporarilyUnavailable => Status::SERVICE_UNAVAILABLE,
81            _ => Status::BAD_REQUEST,
82        }
83    }
84}
85
86impl std::fmt::Display for OAuthErrorCode {
87    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88        f.write_str(self.as_str())
89    }
90}
91
92/// An OAuth error: a code, and an optional description for a human.
93#[derive(Debug, Clone)]
94pub struct OAuthError {
95    pub code: OAuthErrorCode,
96    pub description: Option<String>,
97    pub uri: Option<String>,
98    /// Echoed back on a redirect, so the client can match the response to the
99    /// request it made.
100    pub state: Option<String>,
101}
102
103impl OAuthError {
104    pub fn new(code: OAuthErrorCode) -> Self {
105        OAuthError { code, description: None, uri: None, state: None }
106    }
107
108    /// The description is shown to developers, so say what is actually wrong.
109    ///
110    /// It must never quote the offending secret back — an error body ends up in
111    /// logs, browser history and bug reports.
112    pub fn because(code: OAuthErrorCode, description: impl Into<String>) -> Self {
113        OAuthError { description: Some(description.into()), ..OAuthError::new(code) }
114    }
115
116    pub fn invalid_request(description: impl Into<String>) -> Self {
117        OAuthError::because(OAuthErrorCode::InvalidRequest, description)
118    }
119
120    pub fn invalid_client(description: impl Into<String>) -> Self {
121        OAuthError::because(OAuthErrorCode::InvalidClient, description)
122    }
123
124    pub fn invalid_grant(description: impl Into<String>) -> Self {
125        OAuthError::because(OAuthErrorCode::InvalidGrant, description)
126    }
127
128    pub fn invalid_scope(description: impl Into<String>) -> Self {
129        OAuthError::because(OAuthErrorCode::InvalidScope, description)
130    }
131
132    pub fn server_error(description: impl Into<String>) -> Self {
133        OAuthError::because(OAuthErrorCode::ServerError, description)
134    }
135
136    pub fn with_state(mut self, state: Option<String>) -> Self {
137        self.state = state;
138        self
139    }
140
141    pub fn with_uri(mut self, uri: impl Into<String>) -> Self {
142        self.uri = Some(uri.into());
143        self
144    }
145
146    /// Read an error out of a provider's JSON response.
147    ///
148    /// Providers are not uniform here: some send a bare string, some an object,
149    /// and some send 200 OK with an error body. The client checks for the field
150    /// regardless of status, which is why this is lenient.
151    pub fn from_json(body: &Json) -> Option<OAuthError> {
152        let code = body.get("error")?;
153        let code = match code {
154            Json::String(text) => text.as_str(),
155            // Google's older endpoints nest it: {"error": {"message": ...}}.
156            Json::Object(_) => code.get("status").and_then(Json::as_str).unwrap_or("server_error"),
157            _ => return None,
158        };
159
160        Some(OAuthError {
161            code: OAuthErrorCode::parse(code).unwrap_or(OAuthErrorCode::ServerError),
162            description: body
163                .get("error_description")
164                .and_then(Json::as_str)
165                .or_else(|| body.get("error.message").and_then(Json::as_str))
166                .map(str::to_string)
167                // Keep the unrecognised code visible rather than swallowing it.
168                .or_else(|| {
169                    OAuthErrorCode::parse(code).is_none().then(|| format!("provider said: {code}"))
170                }),
171            uri: body.get("error_uri").and_then(Json::as_str).map(str::to_string),
172            state: body.get("state").and_then(Json::as_str).map(str::to_string),
173        })
174    }
175
176    pub fn to_json(&self) -> Json {
177        let mut pairs = vec![("error".to_string(), Json::String(self.code.to_string()))];
178        if let Some(description) = &self.description {
179            pairs.push(("error_description".into(), Json::String(description.clone())));
180        }
181        if let Some(uri) = &self.uri {
182            pairs.push(("error_uri".into(), Json::String(uri.clone())));
183        }
184        if let Some(state) = &self.state {
185            pairs.push(("state".into(), Json::String(state.clone())));
186        }
187        Json::object(pairs)
188    }
189
190    /// The `error=...` query string appended to a redirect URI, per §4.1.2.1.
191    pub fn to_query(&self) -> String {
192        let mut query = format!("error={}", crate::url::encode(self.code.as_str()));
193        if let Some(description) = &self.description {
194            query.push_str(&format!("&error_description={}", crate::url::encode(description)));
195        }
196        if let Some(uri) = &self.uri {
197            query.push_str(&format!("&error_uri={}", crate::url::encode(uri)));
198        }
199        if let Some(state) = &self.state {
200            query.push_str(&format!("&state={}", crate::url::encode(state)));
201        }
202        query
203    }
204}
205
206impl std::fmt::Display for OAuthError {
207    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
208        match &self.description {
209            Some(description) => write!(f, "{}: {description}", self.code),
210            None => write!(f, "{}", self.code),
211        }
212    }
213}
214
215impl std::error::Error for OAuthError {}
216
217impl IntoResponse for OAuthError {
218    fn into_response(self) -> Response {
219        let response = Response::json(self.to_json()).with_status(self.code.status());
220
221        // RFC 6750 §3: a 401 from a resource server has to say how to
222        // authenticate, or the client cannot tell this apart from any other 401.
223        match self.code {
224            OAuthErrorCode::InvalidToken | OAuthErrorCode::InsufficientScope => {
225                let mut challenge = format!(r#"Bearer error="{}""#, self.code);
226                if let Some(description) = &self.description {
227                    // Quoted-string: a stray quote would split the header.
228                    let escaped = description.replace('\\', r"\\").replace('"', r#"\""#);
229                    challenge.push_str(&format!(r#", error_description="{escaped}""#));
230                }
231                response.with_header("WWW-Authenticate", challenge)
232            }
233            OAuthErrorCode::InvalidClient => {
234                response.with_header("WWW-Authenticate", r#"Basic realm="oauth""#)
235            }
236            _ => response,
237        }
238    }
239}
240
241impl From<OAuthError> for rustlavel_core::Error {
242    fn from(error: OAuthError) -> rustlavel_core::Error {
243        rustlavel_core::Error::msg(error.to_string())
244    }
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250
251    #[test]
252    fn codes_round_trip_through_their_wire_spelling() {
253        for code in [
254            OAuthErrorCode::InvalidRequest,
255            OAuthErrorCode::InvalidClient,
256            OAuthErrorCode::InvalidGrant,
257            OAuthErrorCode::UnauthorizedClient,
258            OAuthErrorCode::UnsupportedGrantType,
259            OAuthErrorCode::InvalidScope,
260            OAuthErrorCode::AccessDenied,
261            OAuthErrorCode::UnsupportedResponseType,
262            OAuthErrorCode::ServerError,
263            OAuthErrorCode::TemporarilyUnavailable,
264            OAuthErrorCode::InvalidToken,
265            OAuthErrorCode::InsufficientScope,
266        ] {
267            assert_eq!(OAuthErrorCode::parse(code.as_str()), Some(code));
268        }
269    }
270
271    #[test]
272    fn bad_client_credentials_are_a_401_not_a_400() {
273        // The distinction RFC 6749 §5.2 draws: fix your credentials, not your
274        // request. Clients branch on it.
275        assert_eq!(OAuthErrorCode::InvalidClient.status(), Status::UNAUTHORIZED);
276        assert_eq!(OAuthErrorCode::InvalidGrant.status(), Status::BAD_REQUEST);
277    }
278
279    #[test]
280    fn a_rejected_bearer_token_says_how_to_authenticate() {
281        let response = OAuthError::because(OAuthErrorCode::InvalidToken, "expired").into_response();
282
283        assert_eq!(response.status, Status::UNAUTHORIZED);
284        let challenge = response.headers.get("WWW-Authenticate").unwrap();
285        assert!(challenge.contains(r#"error="invalid_token""#));
286        assert!(challenge.contains(r#"error_description="expired""#));
287    }
288
289    #[test]
290    fn a_quote_in_the_description_cannot_split_the_header() {
291        let response =
292            OAuthError::because(OAuthErrorCode::InvalidToken, r#"say "hi""#).into_response();
293
294        let challenge = response.headers.get("WWW-Authenticate").unwrap();
295        assert!(challenge.contains(r#"say \"hi\""#), "got {challenge}");
296        assert_eq!(challenge.matches('"').count() - challenge.matches(r#"\""#).count(), 4);
297    }
298
299    #[test]
300    fn parses_a_providers_error_body() {
301        let body = Json::parse(
302            r#"{"error":"invalid_grant","error_description":"code already used"}"#,
303        )
304        .unwrap();
305
306        let error = OAuthError::from_json(&body).unwrap();
307        assert_eq!(error.code, OAuthErrorCode::InvalidGrant);
308        assert_eq!(error.description.as_deref(), Some("code already used"));
309    }
310
311    #[test]
312    fn an_unrecognised_code_is_kept_in_the_description() {
313        // Otherwise a provider-specific code vanishes and the developer is left
314        // with a bare "server_error" and nothing to search for.
315        let body = Json::parse(r#"{"error":"rate_limited"}"#).unwrap();
316
317        let error = OAuthError::from_json(&body).unwrap();
318        assert_eq!(error.code, OAuthErrorCode::ServerError);
319        assert!(error.description.unwrap().contains("rate_limited"));
320    }
321
322    #[test]
323    fn a_body_with_no_error_field_is_not_an_error() {
324        let body = Json::parse(r#"{"access_token":"abc"}"#).unwrap();
325        assert!(OAuthError::from_json(&body).is_none());
326    }
327
328    #[test]
329    fn the_redirect_form_escapes_its_values() {
330        let error = OAuthError::because(OAuthErrorCode::AccessDenied, "user said no")
331            .with_state(Some("a b&c".into()));
332
333        let query = error.to_query();
334        assert!(query.contains("error=access_denied"));
335        assert!(query.contains("error_description=user%20said%20no"));
336        assert!(query.contains("state=a%20b%26c"), "got {query}");
337    }
338}