1use rustlavel_core::Json;
10use rustlavel_http::{IntoResponse, Response, Status};
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum OAuthErrorCode {
16 InvalidRequest,
18 InvalidClient,
19 InvalidGrant,
20 UnauthorizedClient,
21 UnsupportedGrantType,
22 InvalidScope,
23 AccessDenied,
25 UnsupportedResponseType,
26 ServerError,
27 TemporarilyUnavailable,
28 InvalidToken,
30 InsufficientScope,
31}
32
33impl OAuthErrorCode {
34 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 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#[derive(Debug, Clone)]
94pub struct OAuthError {
95 pub code: OAuthErrorCode,
96 pub description: Option<String>,
97 pub uri: Option<String>,
98 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 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 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 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 .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 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 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 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 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 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}