1use thiserror::Error;
2
3pub struct ApiErrorResponse(Box<crate::types::AuthResponse>, bool);
5
6impl std::fmt::Debug for ApiErrorResponse {
7 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
8 f.debug_struct("ApiErrorResponse")
9 .field("status", &self.0.status)
10 .finish()
11 }
12}
13
14impl std::fmt::Display for ApiErrorResponse {
15 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
16 write!(f, "Endpoint rejected request (HTTP {})", self.0.status)
17 }
18}
19
20#[derive(Error, Debug)]
26pub enum AuthError {
27 #[error(transparent)]
29 SchemaCheck(std::sync::Arc<crate::store::schema::SchemaCheckError>),
30 #[error("{0}")]
32 Response(ApiErrorResponse),
33 #[error("{message}")]
35 FieldInput {
36 code: &'static str,
38 message: String,
40 },
41 #[error("{message}")]
43 Upstream {
44 status: u16,
46 code: &'static str,
48 message: &'static str,
50 },
51
52 #[error("{0}")]
53 BadRequest(String),
54
55 #[error("Invalid request: {0}")]
56 InvalidRequest(String),
57
58 #[error("Validation error: {0}")]
59 Validation(String),
60
61 #[error("Invalid email or password")]
62 InvalidCredentials,
63
64 #[error("Unauthorized")]
65 Unauthenticated,
66
67 #[error("{0}")]
68 AuthenticationFailed(String),
69
70 #[error("Session not found or expired")]
71 SessionNotFound,
72
73 #[error("{0}")]
74 Forbidden(String),
75
76 #[error("{0}")]
77 BannedUser(String),
78
79 #[error("Insufficient permissions")]
80 Unauthorized,
81
82 #[error("User not found")]
83 UserNotFound,
84
85 #[error("{0}")]
86 NotFound(String),
87
88 #[error("{0}")]
89 Conflict(String),
90
91 #[error("{0}")]
92 MethodNotAllowed(String),
93
94 #[error("{0}")]
95 PayloadTooLarge(String),
96
97 #[error("{0}")]
98 UnprocessableEntity(String),
99
100 #[error("Too many requests")]
101 RateLimited,
102
103 #[error("{0}")]
104 NotImplemented(String),
105
106 #[error("Configuration error: {0}")]
107 Config(String),
108
109 #[error("Database error: {0}")]
110 Database(#[from] DatabaseError),
111
112 #[cfg(feature = "redis-cache")]
114 #[error("Redis cache error: {0}")]
115 Redis(#[from] redis::RedisError),
116
117 #[error("Serialization error: {0}")]
118 Serialization(#[from] serde_json::Error),
119
120 #[error("Plugin error: {plugin} - {message}")]
121 Plugin { plugin: String, message: String },
122
123 #[error("Internal server error: {0}")]
124 Internal(String),
125
126 #[error("Password hashing error: {0}")]
127 PasswordHash(String),
128
129 #[error("JWT error: {0}")]
130 Jwt(#[from] jsonwebtoken::errors::Error),
131}
132
133impl AuthError {
134 pub fn instrumentation_message(&self) -> String {
136 match self {
137 Self::Response(response) => response
138 .0
139 .body
140 .json()
141 .ok()
142 .flatten()
143 .and_then(|body| {
144 body.get("message")
145 .and_then(serde_json::Value::as_str)
146 .map(str::to_owned)
147 })
148 .unwrap_or_else(|| self.to_string()),
149 Self::Internal(message) | Self::Config(message) | Self::PasswordHash(message) => {
150 message.clone()
151 }
152 _ => self.to_string(),
153 }
154 }
155
156 pub fn redirect(location: impl Into<String>) -> Self {
159 Self::Response(ApiErrorResponse(
160 Box::new(
161 crate::AuthResponse::new(302)
162 .with_header("location", location)
163 .with_header("content-type", "application/json"),
164 ),
165 true,
166 ))
167 }
168
169 pub fn is_found_redirect(&self) -> bool {
171 matches!(self, Self::Response(ApiErrorResponse(_, true)))
172 }
173
174 pub fn capture_endpoint_headers(self, headers: crate::Headers) -> Self {
176 match self {
177 Self::Response(mut response) => {
178 response.0.capture_error_headers(headers);
179 Self::Response(response)
180 }
181 other => {
182 let mut response = other.to_auth_response();
183 response.capture_error_headers(headers);
184 response.into()
185 }
186 }
187 }
188
189 pub fn is_api_error(&self) -> bool {
191 match self {
192 Self::Response(_)
193 | Self::FieldInput { .. }
194 | Self::Upstream { .. }
195 | Self::BadRequest(_)
196 | Self::InvalidRequest(_)
197 | Self::Validation(_)
198 | Self::InvalidCredentials
199 | Self::Unauthenticated
200 | Self::AuthenticationFailed(_)
201 | Self::SessionNotFound
202 | Self::Forbidden(_)
203 | Self::BannedUser(_)
204 | Self::Unauthorized
205 | Self::UserNotFound
206 | Self::NotFound(_)
207 | Self::Conflict(_)
208 | Self::MethodNotAllowed(_)
209 | Self::PayloadTooLarge(_)
210 | Self::UnprocessableEntity(_)
211 | Self::RateLimited
212 | Self::NotImplemented(_) => true,
213 Self::SchemaCheck(_)
214 | Self::Config(_)
215 | Self::Database(_)
216 | Self::Serialization(_)
217 | Self::Plugin { .. }
218 | Self::Internal(_)
219 | Self::PasswordHash(_)
220 | Self::Jwt(_) => false,
221 #[cfg(feature = "redis-cache")]
222 Self::Redis(_) => false,
223 }
224 }
225
226 pub fn to_http_response(self) -> crate::AuthResponse {
228 if self.is_api_error() {
229 self.to_auth_response()
230 } else {
231 crate::observability::logger::current().error(
232 "Authentication request failed",
233 &[crate::observability::LogArgument::Error(&self)],
234 );
235 crate::AuthResponse::new(500)
236 }
237 }
238
239 pub fn status_code(&self) -> u16 {
241 match self {
242 Self::Response(response) => response.0.status,
243 Self::FieldInput { .. } => 400,
244 Self::Upstream { status, .. } => *status,
245 Self::BadRequest(_) | Self::InvalidRequest(_) | Self::Validation(_) => 400,
247 Self::InvalidCredentials
249 | Self::Unauthenticated
250 | Self::AuthenticationFailed(_)
251 | Self::SessionNotFound => 401,
252 Self::Forbidden(_) | Self::BannedUser(_) | Self::Unauthorized => 403,
254 Self::UserNotFound | Self::NotFound(_) => 404,
256 Self::Conflict(_) => 409,
258 Self::MethodNotAllowed(_) => 405,
260 Self::PayloadTooLarge(_) => 413,
262 Self::UnprocessableEntity(_) => 422,
264 Self::RateLimited => 429,
266 Self::NotImplemented(_) => 501,
268 #[cfg(feature = "redis-cache")]
270 Self::Redis(_) => 500,
271 Self::SchemaCheck(_)
272 | Self::Config(_)
273 | Self::Database(_)
274 | Self::Serialization(_)
275 | Self::Plugin { .. }
276 | Self::Internal(_)
277 | Self::PasswordHash(_)
278 | Self::Jwt(_) => 500,
279 }
280 }
281
282 pub fn code_from_message(message: &str) -> Option<String> {
290 crate::error_codes::upstream_code(message).map(str::to_string)
291 }
292
293 pub fn error_payload(&self) -> (u16, Option<String>, String) {
301 let status = self.status_code();
302 let (code, message) = match self {
303 Self::FieldInput { code, message } => (Some((*code).to_owned()), message.clone()),
304 Self::Upstream { code, message, .. } => {
305 (Some((*code).to_owned()), (*message).to_owned())
306 }
307 Self::BannedUser(message) => (Some("BANNED_USER".to_string()), message.clone()),
308 _ => {
309 let message = match status {
310 500 => {
311 crate::observability::logger::current().error(
312 "Internal server error",
313 &[crate::observability::LogArgument::Error(&self)],
314 );
315 "Internal server error".to_string()
316 }
317 _ => self.to_string(),
318 };
319 let code = Self::code_from_message(&message);
320 (code, message)
321 }
322 };
323 (status, code, message)
324 }
325
326 pub fn to_auth_response(self) -> crate::types::AuthResponse {
332 if let Self::Response(response) = self {
333 return (*response.0).into_api_error();
334 }
335 if let Self::PasswordHash(error) = self {
336 crate::observability::logger::current().error(
337 "Password hashing failed",
338 &[crate::observability::LogArgument::Value(
339 &serde_json::json!(error),
340 )],
341 );
342 return crate::types::AuthResponse::new(500);
343 }
344 let is_api_error = self.is_api_error();
345 let (status, code, message) = self.error_payload();
346 let response = crate::types::AuthResponse::json(
347 status,
348 &crate::types::ErrorCodeMessageResponse {
349 code,
350 message: message.clone(),
351 },
352 )
353 .unwrap_or_else(|_| crate::types::AuthResponse::text(status, &message));
354 if is_api_error {
355 response.into_api_error()
356 } else {
357 response
358 }
359 }
360
361 pub fn bad_request(message: impl Into<String>) -> Self {
362 Self::BadRequest(message.into())
363 }
364
365 pub fn forbidden(message: impl Into<String>) -> Self {
366 Self::Forbidden(message.into())
367 }
368
369 pub fn banned_user(message: impl Into<String>) -> Self {
370 Self::BannedUser(message.into())
371 }
372
373 pub fn not_found(message: impl Into<String>) -> Self {
374 Self::NotFound(message.into())
375 }
376
377 pub fn conflict(message: impl Into<String>) -> Self {
378 Self::Conflict(message.into())
379 }
380
381 pub fn method_not_allowed(message: impl Into<String>) -> Self {
382 Self::MethodNotAllowed(message.into())
383 }
384
385 pub fn payload_too_large(message: impl Into<String>) -> Self {
386 Self::PayloadTooLarge(message.into())
387 }
388
389 pub fn not_implemented(message: impl Into<String>) -> Self {
390 Self::NotImplemented(message.into())
391 }
392
393 pub fn plugin(plugin: &str, message: impl Into<String>) -> Self {
394 Self::Plugin {
395 plugin: plugin.to_string(),
396 message: message.into(),
397 }
398 }
399
400 pub fn config(message: impl Into<String>) -> Self {
401 Self::Config(message.into())
402 }
403
404 pub fn internal(message: impl Into<String>) -> Self {
405 Self::Internal(message.into())
406 }
407
408 pub fn validation(message: impl Into<String>) -> Self {
409 Self::Validation(message.into())
410 }
411
412 pub fn authentication_failed(message: impl Into<String>) -> Self {
413 Self::AuthenticationFailed(message.into())
414 }
415}
416
417#[derive(Error, Debug)]
418pub enum DatabaseError {
419 #[error("Connection error: {0}")]
420 Connection(String),
421
422 #[error("Query error: {0}")]
423 Query(String),
424
425 #[error("Migration error: {0}")]
426 Migration(String),
427
428 #[error("Constraint violation: {0}")]
429 Constraint(String),
430
431 #[error("Unique constraint violation: {0}")]
433 UniqueConstraint(String),
434
435 #[error("Transaction error: {0}")]
436 Transaction(String),
437}
438
439pub type AuthResult<T> = Result<T, AuthError>;
440
441impl From<crate::types::AuthResponse> for AuthError {
442 fn from(response: crate::types::AuthResponse) -> Self {
443 Self::Response(ApiErrorResponse(Box::new(response), false))
444 }
445}
446
447#[cfg(feature = "axum")]
448impl axum::response::IntoResponse for AuthError {
449 fn into_response(self) -> axum::response::Response {
450 let response = self.to_auth_response();
451 let body = match response.body.into_bytes() {
452 Ok(body) => body,
453 Err(error) => return error.into_response(),
454 };
455 let status = axum::http::StatusCode::from_u16(response.status)
456 .unwrap_or(axum::http::StatusCode::INTERNAL_SERVER_ERROR);
457 let mut output = axum::response::Response::new(axum::body::Body::from(body));
458 *output.status_mut() = status;
459 for (name, value) in response.headers {
460 if let (Ok(name), Ok(value)) = (
461 axum::http::HeaderName::from_bytes(name.as_bytes()),
462 axum::http::HeaderValue::from_str(&value),
463 ) {
464 let _ = output.headers_mut().append(name, value);
465 }
466 }
467 output
468 }
469}
470
471pub fn validation_error_response(
476 errors: &validator::ValidationErrors,
477) -> crate::types::AuthResponse {
478 let messages: Vec<String> = errors
480 .field_errors()
481 .into_iter()
482 .flat_map(|(field, errs)| {
483 errs.iter().map(move |e| {
484 let msg = e
485 .message
486 .as_ref()
487 .map(|m| m.to_string())
488 .unwrap_or_else(|| format!("Invalid value for {}", field));
489 format!("[body.{}] {}", field, msg)
490 })
491 })
492 .collect();
493 let message = messages.join("; ");
494
495 let body = crate::types::ErrorCodeMessageResponse {
496 code: Some("VALIDATION_ERROR".to_string()),
497 message,
498 };
499
500 crate::types::AuthResponse::json(400, &body)
502 .unwrap_or_else(|_| crate::types::AuthResponse::text(400, "Validation failed"))
503}
504
505pub fn validate_request_body<T>(
507 req: &crate::types::AuthRequest,
508) -> Result<T, crate::types::AuthResponse>
509where
510 T: serde::de::DeserializeOwned + validator::Validate,
511{
512 let value: T = req.body_as_json().map_err(|e| {
513 let message = format!("Invalid JSON: {}", e);
514 let code = AuthError::code_from_message(&message);
515 crate::types::AuthResponse::json(
516 400,
517 &crate::types::ErrorCodeMessageResponse { code, message },
518 )
519 .unwrap_or_else(|_| crate::types::AuthResponse::text(400, "Invalid JSON"))
520 })?;
521
522 value
523 .validate()
524 .map_err(|e| validation_error_response(&e))?;
525
526 Ok(value)
527}
528
529#[cfg(test)]
530mod tests {
531 use super::*;
532
533 #[test]
534 fn endpoint_error_preserves_body_headers_and_redacts_diagnostics() {
535 let body = serde_json::json!({"code":"RATE_LIMITED", "message":"Rate limit exceeded.", "details":{"tryAgainIn":1234}});
536 let mut response = crate::types::AuthResponse::json(429, &body).unwrap();
537 response.headers.append("Set-Cookie", "sensitive=first");
538 response.headers.append("Set-Cookie", "sensitive=second");
539 let error = AuthError::from(response);
540 assert_eq!(error.status_code(), 429);
541 assert!(!format!("{error:?} {error}").contains("sensitive"));
542 assert!(!format!("{error:?} {error}").contains("tryAgainIn"));
543 let response = error.to_auth_response();
544 assert_eq!(
545 serde_json::from_slice::<serde_json::Value>(&response.body.bytes().unwrap()).unwrap(),
546 body
547 );
548 assert_eq!(
549 response.headers.get_all("set-cookie").collect::<Vec<_>>(),
550 ["sensitive=first", "sensitive=second"]
551 );
552 }
553
554 #[cfg(feature = "axum")]
555 #[tokio::test]
556 async fn endpoint_error_into_axum_preserves_json_and_repeated_headers() {
557 use axum::response::IntoResponse;
558 let mut response = crate::types::AuthResponse::json(
559 429,
560 &serde_json::json!({"details":{"tryAgainIn":1234}}),
561 )
562 .unwrap();
563 response.headers.append("Set-Cookie", "first=1");
564 response.headers.append("Set-Cookie", "second=2");
565 let output = AuthError::from(response).into_response();
566 assert_eq!(output.status(), 429);
567 assert_eq!(output.headers().get_all("set-cookie").iter().count(), 2);
568 assert_eq!(output.headers()["content-type"], "application/json");
569 let body = axum::body::to_bytes(output.into_body(), usize::MAX)
570 .await
571 .unwrap();
572 assert_eq!(
573 serde_json::from_slice::<serde_json::Value>(&body).unwrap()["details"]["tryAgainIn"],
574 1234
575 );
576 }
577
578 #[test]
582 fn bad_request_is_400() {
583 assert_eq!(AuthError::bad_request("oops").status_code(), 400);
584 }
585
586 #[test]
588 fn invalid_request_is_400() {
589 assert_eq!(AuthError::InvalidRequest("x".into()).status_code(), 400);
590 }
591
592 #[test]
594 fn validation_is_400() {
595 assert_eq!(AuthError::validation("x").status_code(), 400);
596 }
597
598 #[test]
600 fn invalid_credentials_is_401() {
601 assert_eq!(AuthError::InvalidCredentials.status_code(), 401);
602 }
603
604 #[test]
606 fn unauthenticated_is_401() {
607 assert_eq!(AuthError::Unauthenticated.status_code(), 401);
608 }
609
610 #[test]
612 fn session_not_found_is_401() {
613 assert_eq!(AuthError::SessionNotFound.status_code(), 401);
614 }
615
616 #[test]
618 fn forbidden_is_403() {
619 assert_eq!(AuthError::forbidden("nope").status_code(), 403);
620 }
621
622 #[test]
624 fn unauthorized_is_403() {
625 assert_eq!(AuthError::Unauthorized.status_code(), 403);
626 }
627
628 #[test]
630 fn not_found_is_404() {
631 assert_eq!(AuthError::not_found("gone").status_code(), 404);
632 assert_eq!(AuthError::UserNotFound.status_code(), 404);
633 }
634
635 #[test]
637 fn conflict_is_409() {
638 assert_eq!(AuthError::conflict("dup").status_code(), 409);
639 }
640
641 #[test]
643 fn unprocessable_entity_is_422() {
644 assert_eq!(
645 AuthError::UnprocessableEntity("x".into()).status_code(),
646 422
647 );
648 }
649
650 #[test]
652 fn rate_limited_is_429() {
653 assert_eq!(AuthError::RateLimited.status_code(), 429);
654 }
655
656 #[test]
658 fn not_implemented_is_501() {
659 assert_eq!(AuthError::not_implemented("todo").status_code(), 501);
660 }
661
662 #[test]
664 fn internal_errors_are_500() {
665 assert_eq!(AuthError::config("bad").status_code(), 500);
666 assert_eq!(AuthError::internal("fail").status_code(), 500);
667 assert_eq!(AuthError::plugin("p", "m").status_code(), 500);
668 assert_eq!(AuthError::PasswordHash("h".into()).status_code(), 500);
669 assert_eq!(
670 AuthError::Database(DatabaseError::Connection("c".into())).status_code(),
671 500
672 );
673 }
674
675 #[test]
680 fn code_from_message_returns_the_upstream_constant() {
681 assert_eq!(
682 AuthError::code_from_message("User not found").as_deref(),
683 Some("USER_NOT_FOUND")
684 );
685 }
686
687 #[test]
691 fn code_from_message_is_none_for_unknown_messages() {
692 assert_eq!(AuthError::code_from_message("invalid email!"), None);
693 assert_eq!(AuthError::code_from_message("Email is the same"), None);
694 assert_eq!(AuthError::code_from_message(""), None);
695 }
696
697 #[test]
700 fn code_from_message_covers_plugin_tables() {
701 assert_eq!(
702 AuthError::code_from_message("Username is already taken. Please try another.")
703 .as_deref(),
704 Some("USERNAME_IS_ALREADY_TAKEN")
705 );
706 }
707
708 #[test]
712 fn base_error_codes_are_not_derived_from_the_message() {
713 for (message, expected) in [
714 ("Invalid callbackURL", "INVALID_CALLBACK_URL"),
715 ("Invalid redirectURL", "INVALID_REDIRECT_URL"),
716 ("Invalid errorCallbackURL", "INVALID_ERROR_CALLBACK_URL"),
717 (
718 "Invalid newUserCallbackURL",
719 "INVALID_NEW_USER_CALLBACK_URL",
720 ),
721 ("Email is already verified", "EMAIL_ALREADY_VERIFIED"),
722 (
723 "Verification email isn't enabled",
724 "VERIFICATION_EMAIL_NOT_ENABLED",
725 ),
726 (
727 "Session expired. Re-authenticate to perform this action.",
728 "SESSION_EXPIRED",
729 ),
730 (
731 "Cross-site navigation login blocked. This request appears to be a CSRF attack.",
732 "CROSS_SITE_NAVIGATION_LOGIN_BLOCKED",
733 ),
734 ] {
735 assert_eq!(
736 AuthError::code_from_message(message).as_deref(),
737 Some(expected),
738 "message {message:?} must carry the upstream constant"
739 );
740 }
741 }
742
743 #[test]
747 fn error_payload_for_client_error() {
748 let (status, code, message) = AuthError::bad_request("Missing field").error_payload();
750 assert_eq!(status, 400);
751 assert_eq!(code, None);
752 assert_eq!(message, "Missing field");
753
754 let (status, code, message) = AuthError::bad_request("Field is required").error_payload();
755 assert_eq!(status, 400);
756 assert_eq!(code.as_deref(), Some("MISSING_FIELD"));
757 assert_eq!(message, "Field is required");
758 }
759
760 #[test]
762 fn error_payload_for_internal_error_hides_details() {
763 let (status, _code, message) = AuthError::internal("secret detail").error_payload();
764 assert_eq!(status, 500);
765 assert_eq!(message, "Internal server error");
766 }
767
768 #[test]
772 fn to_auth_response_returns_correct_status() {
773 let resp = AuthError::bad_request("oops").to_auth_response();
774 assert_eq!(resp.status, 400);
775 }
776
777 #[test]
779 fn to_auth_response_body_contains_code_and_message() {
780 let resp = AuthError::UserNotFound.to_auth_response();
781 assert_eq!(resp.status, 404);
782 let body: serde_json::Value = serde_json::from_slice(&resp.body.bytes().unwrap())
783 .expect("response body should be valid JSON");
784 assert_eq!(body["code"], "USER_NOT_FOUND");
785 assert_eq!(body["message"], "User not found");
786 }
787
788 #[test]
792 fn constructor_helpers_produce_correct_variants() {
793 assert_eq!(AuthError::bad_request("x").to_string(), "x");
795 assert_eq!(AuthError::forbidden("x").to_string(), "x");
796 assert_eq!(AuthError::not_found("x").to_string(), "x");
797 assert_eq!(AuthError::conflict("x").to_string(), "x");
798 assert_eq!(AuthError::not_implemented("x").to_string(), "x");
799 assert_eq!(AuthError::config("x").to_string(), "Configuration error: x");
800 assert_eq!(
801 AuthError::internal("x").to_string(),
802 "Internal server error: x"
803 );
804 assert_eq!(
805 AuthError::validation("x").to_string(),
806 "Validation error: x"
807 );
808 assert_eq!(
809 AuthError::plugin("p", "m").to_string(),
810 "Plugin error: p - m"
811 );
812 }
813
814 #[test]
818 fn database_error_display() {
819 let e = DatabaseError::Connection("timeout".into());
820 assert_eq!(e.to_string(), "Connection error: timeout");
821 }
822
823 #[test]
825 fn database_error_converts_to_auth_error() {
826 let db_err = DatabaseError::Query("bad sql".into());
827 let auth_err: AuthError = db_err.into();
828 assert_eq!(auth_err.status_code(), 500);
829 }
830
831 #[test]
835 fn validation_error_response_serializes_field_errors() {
836 let mut errors = validator::ValidationErrors::new();
837 let mut error = validator::ValidationError::new("email");
838 error.message = Some("Email is invalid".into());
839 errors.add("email", error);
840
841 let resp = validation_error_response(&errors);
842 assert_eq!(resp.status, 400);
843 let body: serde_json::Value = serde_json::from_slice(&resp.body.bytes().unwrap())
844 .expect("response body should be valid JSON");
845 assert_eq!(body["code"], "VALIDATION_ERROR");
846 assert_eq!(body["message"], "[body.email] Email is invalid");
847 }
848
849 #[test]
853 fn fixed_message_variants_display() {
854 assert_eq!(
855 AuthError::InvalidCredentials.to_string(),
856 "Invalid email or password"
857 );
858 assert_eq!(AuthError::Unauthenticated.to_string(), "Unauthorized");
859 assert_eq!(
860 AuthError::SessionNotFound.to_string(),
861 "Session not found or expired"
862 );
863 assert_eq!(
864 AuthError::Unauthorized.to_string(),
865 "Insufficient permissions"
866 );
867 assert_eq!(AuthError::UserNotFound.to_string(), "User not found");
868 assert_eq!(AuthError::RateLimited.to_string(), "Too many requests");
869 }
870}