1use thiserror::Error;
2
3#[derive(Error, Debug)]
9pub enum AuthError {
10 #[error("{0}")]
11 BadRequest(String),
12
13 #[error("Invalid request: {0}")]
14 InvalidRequest(String),
15
16 #[error("Validation error: {0}")]
17 Validation(String),
18
19 #[error("Invalid email or password")]
20 InvalidCredentials,
21
22 #[error("Authentication required")]
23 Unauthenticated,
24
25 #[error("{0}")]
26 AuthenticationFailed(String),
27
28 #[error("Session not found or expired")]
29 SessionNotFound,
30
31 #[error("{0}")]
32 Forbidden(String),
33
34 #[error("{0}")]
35 BannedUser(String),
36
37 #[error("Insufficient permissions")]
38 Unauthorized,
39
40 #[error("User not found")]
41 UserNotFound,
42
43 #[error("{0}")]
44 NotFound(String),
45
46 #[error("{0}")]
47 Conflict(String),
48
49 #[error("{0}")]
50 PayloadTooLarge(String),
51
52 #[error("{0}")]
53 UnprocessableEntity(String),
54
55 #[error("Too many requests")]
56 RateLimited,
57
58 #[error("{0}")]
59 NotImplemented(String),
60
61 #[error("Configuration error: {0}")]
62 Config(String),
63
64 #[error("Database error: {0}")]
65 Database(#[from] DatabaseError),
66
67 #[error("Serialization error: {0}")]
68 Serialization(#[from] serde_json::Error),
69
70 #[error("Plugin error: {plugin} - {message}")]
71 Plugin { plugin: String, message: String },
72
73 #[error("Internal server error: {0}")]
74 Internal(String),
75
76 #[error("Password hashing error: {0}")]
77 PasswordHash(String),
78
79 #[error("JWT error: {0}")]
80 Jwt(#[from] jsonwebtoken::errors::Error),
81}
82
83impl AuthError {
84 pub fn status_code(&self) -> u16 {
86 match self {
87 Self::BadRequest(_) | Self::InvalidRequest(_) | Self::Validation(_) => 400,
89 Self::InvalidCredentials
91 | Self::Unauthenticated
92 | Self::AuthenticationFailed(_)
93 | Self::SessionNotFound => 401,
94 Self::Forbidden(_) | Self::BannedUser(_) | Self::Unauthorized => 403,
96 Self::UserNotFound | Self::NotFound(_) => 404,
98 Self::Conflict(_) => 409,
100 Self::PayloadTooLarge(_) => 413,
102 Self::UnprocessableEntity(_) => 422,
104 Self::RateLimited => 429,
106 Self::NotImplemented(_) => 501,
108 Self::Config(_)
110 | Self::Database(_)
111 | Self::Serialization(_)
112 | Self::Plugin { .. }
113 | Self::Internal(_)
114 | Self::PasswordHash(_)
115 | Self::Jwt(_) => 500,
116 }
117 }
118
119 pub fn code_from_message(message: &str) -> String {
122 message
123 .to_uppercase()
124 .chars()
125 .map(|c| if c == ' ' { '_' } else { c })
126 .filter(|c| c.is_ascii_alphanumeric() || *c == '_')
127 .collect()
128 }
129
130 pub fn error_payload(&self) -> (u16, String, String) {
135 let status = self.status_code();
136 let (code, message) = match self {
137 Self::BannedUser(message) => ("BANNED_USER".to_string(), message.clone()),
138 _ => {
139 let message = match status {
140 500 => {
141 tracing::error!(error = %self, "Internal server error");
142 "Internal server error".to_string()
143 }
144 _ => self.to_string(),
145 };
146 let code = Self::code_from_message(&message);
147 (code, message)
148 }
149 };
150 (status, code, message)
151 }
152
153 pub fn to_auth_response(self) -> crate::types::AuthResponse {
159 let (status, code, message) = self.error_payload();
160 crate::types::AuthResponse::json(
161 status,
162 &crate::types::ErrorCodeMessageResponse {
163 code,
164 message: message.clone(),
165 },
166 )
167 .unwrap_or_else(|_| crate::types::AuthResponse::text(status, &message))
168 }
169
170 pub fn bad_request(message: impl Into<String>) -> Self {
171 Self::BadRequest(message.into())
172 }
173
174 pub fn forbidden(message: impl Into<String>) -> Self {
175 Self::Forbidden(message.into())
176 }
177
178 pub fn banned_user(message: impl Into<String>) -> Self {
179 Self::BannedUser(message.into())
180 }
181
182 pub fn not_found(message: impl Into<String>) -> Self {
183 Self::NotFound(message.into())
184 }
185
186 pub fn conflict(message: impl Into<String>) -> Self {
187 Self::Conflict(message.into())
188 }
189
190 pub fn payload_too_large(message: impl Into<String>) -> Self {
191 Self::PayloadTooLarge(message.into())
192 }
193
194 pub fn not_implemented(message: impl Into<String>) -> Self {
195 Self::NotImplemented(message.into())
196 }
197
198 pub fn plugin(plugin: &str, message: impl Into<String>) -> Self {
199 Self::Plugin {
200 plugin: plugin.to_string(),
201 message: message.into(),
202 }
203 }
204
205 pub fn config(message: impl Into<String>) -> Self {
206 Self::Config(message.into())
207 }
208
209 pub fn internal(message: impl Into<String>) -> Self {
210 Self::Internal(message.into())
211 }
212
213 pub fn validation(message: impl Into<String>) -> Self {
214 Self::Validation(message.into())
215 }
216
217 pub fn authentication_failed(message: impl Into<String>) -> Self {
218 Self::AuthenticationFailed(message.into())
219 }
220}
221
222#[derive(Error, Debug)]
223pub enum DatabaseError {
224 #[error("Connection error: {0}")]
225 Connection(String),
226
227 #[error("Query error: {0}")]
228 Query(String),
229
230 #[error("Migration error: {0}")]
231 Migration(String),
232
233 #[error("Constraint violation: {0}")]
234 Constraint(String),
235
236 #[error("Transaction error: {0}")]
237 Transaction(String),
238}
239
240pub type AuthResult<T> = Result<T, AuthError>;
241
242#[cfg(feature = "axum")]
243impl axum::response::IntoResponse for AuthError {
244 fn into_response(self) -> axum::response::Response {
245 let (status_u16, code, message) = self.error_payload();
246 let status = axum::http::StatusCode::from_u16(status_u16)
247 .unwrap_or(axum::http::StatusCode::INTERNAL_SERVER_ERROR);
248 (
249 status,
250 axum::Json(crate::types::ErrorCodeMessageResponse { code, message }),
251 )
252 .into_response()
253 }
254}
255
256pub fn validation_error_response(
261 errors: &validator::ValidationErrors,
262) -> crate::types::AuthResponse {
263 let messages: Vec<String> = errors
265 .field_errors()
266 .into_iter()
267 .flat_map(|(field, errs)| {
268 errs.iter().map(move |e| {
269 let msg = e
270 .message
271 .as_ref()
272 .map(|m| m.to_string())
273 .unwrap_or_else(|| format!("Invalid value for {}", field));
274 format!("[body.{}] {}", field, msg)
275 })
276 })
277 .collect();
278 let message = messages.join("; ");
279
280 let body = crate::types::ErrorCodeMessageResponse {
281 code: "VALIDATION_ERROR".to_string(),
282 message,
283 };
284
285 crate::types::AuthResponse::json(400, &body)
287 .unwrap_or_else(|_| crate::types::AuthResponse::text(400, "Validation failed"))
288}
289
290pub fn validate_request_body<T>(
292 req: &crate::types::AuthRequest,
293) -> Result<T, crate::types::AuthResponse>
294where
295 T: serde::de::DeserializeOwned + validator::Validate,
296{
297 let value: T = req.body_as_json().map_err(|e| {
298 let message = format!("Invalid JSON: {}", e);
299 let code = AuthError::code_from_message(&message);
300 crate::types::AuthResponse::json(
301 400,
302 &crate::types::ErrorCodeMessageResponse { code, message },
303 )
304 .unwrap_or_else(|_| crate::types::AuthResponse::text(400, "Invalid JSON"))
305 })?;
306
307 value
308 .validate()
309 .map_err(|e| validation_error_response(&e))?;
310
311 Ok(value)
312}
313
314#[cfg(test)]
315mod tests {
316 use super::*;
317
318 #[test]
322 fn bad_request_is_400() {
323 assert_eq!(AuthError::bad_request("oops").status_code(), 400);
324 }
325
326 #[test]
328 fn invalid_request_is_400() {
329 assert_eq!(AuthError::InvalidRequest("x".into()).status_code(), 400);
330 }
331
332 #[test]
334 fn validation_is_400() {
335 assert_eq!(AuthError::validation("x").status_code(), 400);
336 }
337
338 #[test]
340 fn invalid_credentials_is_401() {
341 assert_eq!(AuthError::InvalidCredentials.status_code(), 401);
342 }
343
344 #[test]
346 fn unauthenticated_is_401() {
347 assert_eq!(AuthError::Unauthenticated.status_code(), 401);
348 }
349
350 #[test]
352 fn session_not_found_is_401() {
353 assert_eq!(AuthError::SessionNotFound.status_code(), 401);
354 }
355
356 #[test]
358 fn forbidden_is_403() {
359 assert_eq!(AuthError::forbidden("nope").status_code(), 403);
360 }
361
362 #[test]
364 fn unauthorized_is_403() {
365 assert_eq!(AuthError::Unauthorized.status_code(), 403);
366 }
367
368 #[test]
370 fn not_found_is_404() {
371 assert_eq!(AuthError::not_found("gone").status_code(), 404);
372 assert_eq!(AuthError::UserNotFound.status_code(), 404);
373 }
374
375 #[test]
377 fn conflict_is_409() {
378 assert_eq!(AuthError::conflict("dup").status_code(), 409);
379 }
380
381 #[test]
383 fn unprocessable_entity_is_422() {
384 assert_eq!(
385 AuthError::UnprocessableEntity("x".into()).status_code(),
386 422
387 );
388 }
389
390 #[test]
392 fn rate_limited_is_429() {
393 assert_eq!(AuthError::RateLimited.status_code(), 429);
394 }
395
396 #[test]
398 fn not_implemented_is_501() {
399 assert_eq!(AuthError::not_implemented("todo").status_code(), 501);
400 }
401
402 #[test]
404 fn internal_errors_are_500() {
405 assert_eq!(AuthError::config("bad").status_code(), 500);
406 assert_eq!(AuthError::internal("fail").status_code(), 500);
407 assert_eq!(AuthError::plugin("p", "m").status_code(), 500);
408 assert_eq!(AuthError::PasswordHash("h".into()).status_code(), 500);
409 assert_eq!(
410 AuthError::Database(DatabaseError::Connection("c".into())).status_code(),
411 500
412 );
413 }
414
415 #[test]
419 fn code_from_message_uppercases_and_replaces_spaces() {
420 assert_eq!(
421 AuthError::code_from_message("User not found"),
422 "USER_NOT_FOUND"
423 );
424 }
425
426 #[test]
428 fn code_from_message_strips_special_chars() {
429 assert_eq!(
430 AuthError::code_from_message("invalid email!"),
431 "INVALID_EMAIL"
432 );
433 }
434
435 #[test]
437 fn code_from_message_empty() {
438 assert_eq!(AuthError::code_from_message(""), "");
439 }
440
441 #[test]
445 fn error_payload_for_client_error() {
446 let (status, code, message) = AuthError::bad_request("Missing field").error_payload();
447 assert_eq!(status, 400);
448 assert_eq!(code, "MISSING_FIELD");
449 assert_eq!(message, "Missing field");
450 }
451
452 #[test]
454 fn error_payload_for_internal_error_hides_details() {
455 let (status, _code, message) = AuthError::internal("secret detail").error_payload();
456 assert_eq!(status, 500);
457 assert_eq!(message, "Internal server error");
458 }
459
460 #[test]
464 fn to_auth_response_returns_correct_status() {
465 let resp = AuthError::bad_request("oops").to_auth_response();
466 assert_eq!(resp.status, 400);
467 }
468
469 #[test]
471 fn to_auth_response_body_contains_code_and_message() {
472 let resp = AuthError::UserNotFound.to_auth_response();
473 assert_eq!(resp.status, 404);
474 let body: serde_json::Value =
475 serde_json::from_slice(&resp.body).expect("response body should be valid JSON");
476 assert_eq!(body["code"], "USER_NOT_FOUND");
477 assert_eq!(body["message"], "User not found");
478 }
479
480 #[test]
484 fn constructor_helpers_produce_correct_variants() {
485 assert_eq!(AuthError::bad_request("x").to_string(), "x");
487 assert_eq!(AuthError::forbidden("x").to_string(), "x");
488 assert_eq!(AuthError::not_found("x").to_string(), "x");
489 assert_eq!(AuthError::conflict("x").to_string(), "x");
490 assert_eq!(AuthError::not_implemented("x").to_string(), "x");
491 assert_eq!(AuthError::config("x").to_string(), "Configuration error: x");
492 assert_eq!(
493 AuthError::internal("x").to_string(),
494 "Internal server error: x"
495 );
496 assert_eq!(
497 AuthError::validation("x").to_string(),
498 "Validation error: x"
499 );
500 assert_eq!(
501 AuthError::plugin("p", "m").to_string(),
502 "Plugin error: p - m"
503 );
504 }
505
506 #[test]
510 fn database_error_display() {
511 let e = DatabaseError::Connection("timeout".into());
512 assert_eq!(e.to_string(), "Connection error: timeout");
513 }
514
515 #[test]
517 fn database_error_converts_to_auth_error() {
518 let db_err = DatabaseError::Query("bad sql".into());
519 let auth_err: AuthError = db_err.into();
520 assert_eq!(auth_err.status_code(), 500);
521 }
522
523 #[test]
527 fn fixed_message_variants_display() {
528 assert_eq!(
529 AuthError::InvalidCredentials.to_string(),
530 "Invalid email or password"
531 );
532 assert_eq!(
533 AuthError::Unauthenticated.to_string(),
534 "Authentication required"
535 );
536 assert_eq!(
537 AuthError::SessionNotFound.to_string(),
538 "Session not found or expired"
539 );
540 assert_eq!(
541 AuthError::Unauthorized.to_string(),
542 "Insufficient permissions"
543 );
544 assert_eq!(AuthError::UserNotFound.to_string(), "User not found");
545 assert_eq!(AuthError::RateLimited.to_string(), "Too many requests");
546 }
547}