1mod codes;
2
3use codes::upstream_code;
4pub mod page;
5use thiserror::Error;
6
7#[derive(Error, Debug)]
13pub enum AuthError {
14 #[error("{message}")]
19 Api {
20 status: u16,
21 code: Option<String>,
22 message: String,
23 },
24
25 #[error("{message}")]
27 Upstream {
28 status: u16,
30 code: &'static str,
32 message: &'static str,
34 },
35
36 #[error("{0}")]
37 BadRequest(String),
38
39 #[error("Invalid request: {0}")]
40 InvalidRequest(String),
41
42 #[error("Validation error: {0}")]
43 Validation(String),
44
45 #[error("Invalid email or password")]
46 InvalidCredentials,
47
48 #[error("Authentication required")]
49 Unauthenticated,
50
51 #[error("{0}")]
52 AuthenticationFailed(String),
53
54 #[error("Session not found or expired")]
55 SessionNotFound,
56
57 #[error("{0}")]
58 Forbidden(String),
59
60 #[error("session creation cancelled by database hook")]
64 SessionCreationCancelled,
65
66 #[error("user creation cancelled by database hook")]
70 UserCreationCancelled,
71
72 #[error("{0}")]
73 BannedUser(String),
74
75 #[error("Insufficient permissions")]
76 Unauthorized,
77
78 #[error("User not found")]
79 UserNotFound,
80
81 #[error("{0}")]
82 NotFound(String),
83
84 #[error("{0}")]
85 Conflict(String),
86
87 #[error("{0}")]
88 MethodNotAllowed(String),
89
90 #[error("{0}")]
91 PayloadTooLarge(String),
92
93 #[error("{0}")]
94 UnprocessableEntity(String),
95
96 #[error("Too many requests")]
99 RateLimited { retry_after: Option<f64> },
100
101 #[error("{0}")]
102 NotImplemented(String),
103
104 #[error("Configuration error: {0}")]
105 Config(String),
106
107 #[error("Database error: {0}")]
108 Database(#[from] DatabaseError),
109
110 #[error("Serialization error: {0}")]
111 Serialization(#[from] serde_json::Error),
112
113 #[error("Plugin error: {plugin} - {message}")]
114 Plugin { plugin: String, message: String },
115
116 #[error("Internal server error: {0}")]
117 Internal(String),
118
119 #[error("Encrypted authentication data failed: {0}")]
122 Encryption(String),
123
124 #[error("Application callback failed: {0}")]
128 CallbackFailure(Box<Self>),
129
130 #[error("Password hashing error: {0}")]
131 PasswordHash(String),
132
133 #[error("JWT error: {0}")]
134 Jwt(#[from] jsonwebtoken::errors::Error),
135}
136
137impl AuthError {
138 #[must_use]
140 pub const fn status_code(&self) -> u16 {
141 match self {
142 Self::Api { status, .. } | Self::Upstream { status, .. } => *status,
143 Self::BadRequest(_) | Self::InvalidRequest(_) | Self::Validation(_) => 400,
145 Self::InvalidCredentials
147 | Self::Unauthenticated
148 | Self::AuthenticationFailed(_)
149 | Self::SessionNotFound => 401,
150 Self::Forbidden(_)
152 | Self::SessionCreationCancelled
153 | Self::UserCreationCancelled
154 | Self::BannedUser(_)
155 | Self::Unauthorized => 403,
156 Self::UserNotFound | Self::NotFound(_) => 404,
158 Self::Conflict(_) => 409,
160 Self::MethodNotAllowed(_) => 405,
162 Self::PayloadTooLarge(_) => 413,
164 Self::UnprocessableEntity(_) => 422,
166 Self::RateLimited { .. } => 429,
168 Self::NotImplemented(_) => 501,
170 Self::Config(_)
172 | Self::Database(_)
173 | Self::Serialization(_)
174 | Self::Plugin { .. }
175 | Self::Internal(_)
176 | Self::Encryption(_)
177 | Self::CallbackFailure(_)
178 | Self::PasswordHash(_)
179 | Self::Jwt(_) => 500,
180 }
181 }
182
183 pub fn code_from_message(message: &str) -> Option<String> {
191 upstream_code(message).map(str::to_owned)
192 }
193
194 pub fn error_payload(&self) -> (u16, Option<String>, String) {
202 let status = self.status_code();
203 let (code, message) = match self {
204 Self::Api { code, message, .. } => (code.clone(), message.clone()),
205 Self::Upstream { code, message, .. } => {
206 (Some((*code).to_owned()), (*message).to_owned())
207 }
208 Self::BannedUser(message) => (Some("BANNED_USER".to_owned()), message.clone()),
209 Self::BadRequest(_)
210 | Self::InvalidRequest(_)
211 | Self::Validation(_)
212 | Self::InvalidCredentials
213 | Self::Unauthenticated
214 | Self::AuthenticationFailed(_)
215 | Self::SessionNotFound
216 | Self::Forbidden(_)
217 | Self::SessionCreationCancelled
218 | Self::UserCreationCancelled
219 | Self::Unauthorized
220 | Self::UserNotFound
221 | Self::NotFound(_)
222 | Self::Conflict(_)
223 | Self::MethodNotAllowed(_)
224 | Self::PayloadTooLarge(_)
225 | Self::UnprocessableEntity(_)
226 | Self::RateLimited { .. }
227 | Self::NotImplemented(_)
228 | Self::Config(_)
229 | Self::Database(_)
230 | Self::Serialization(_)
231 | Self::Plugin { .. }
232 | Self::Internal(_)
233 | Self::Encryption(_)
234 | Self::CallbackFailure(_)
235 | Self::PasswordHash(_)
236 | Self::Jwt(_) => {
237 let message = match status {
238 500 => {
239 tracing::error!(error = %self, "Internal server error");
240 "Internal server error".to_owned()
241 }
242 _ => self.to_string(),
243 };
244 let code = Self::code_from_message(&message);
245 (code, message)
246 }
247 };
248 (status, code, message)
249 }
250
251 #[must_use]
257 pub fn to_auth_response(self) -> crate::types::AuthResponse {
258 if matches!(&self, Self::CallbackFailure(_) | Self::Encryption(_)) {
259 tracing::error!(error = %self, "Authentication operation failed");
260 return crate::types::AuthResponse::new(500);
261 }
262 let retry_after = self.retry_after_header();
263 let (status, code, message) = self.error_payload();
264 let response = crate::types::AuthResponse::json(
265 status,
266 &crate::types::ErrorCodeMessageResponse {
267 code,
268 message: message.clone(),
269 },
270 )
271 .unwrap_or_else(|_| crate::types::AuthResponse::text(status, &message));
272 match retry_after {
273 Some(seconds) => response.with_header("X-Retry-After", seconds),
274 None => response,
275 }
276 }
277
278 fn retry_after_header(&self) -> Option<String> {
281 match self {
282 Self::RateLimited {
283 retry_after: Some(seconds),
284 } => Some(ryu_js::Buffer::new().format(*seconds).to_owned()),
285 _ => None,
286 }
287 }
288
289 #[must_use]
292 pub const fn rate_limited(retry_after: f64) -> Self {
293 Self::RateLimited {
294 retry_after: Some(retry_after),
295 }
296 }
297
298 #[must_use]
299 pub fn bad_request(message: impl Into<String>) -> Self {
300 Self::BadRequest(message.into())
301 }
302
303 #[must_use]
304 pub fn forbidden(message: impl Into<String>) -> Self {
305 Self::Forbidden(message.into())
306 }
307
308 #[must_use]
309 pub fn banned_user(message: impl Into<String>) -> Self {
310 Self::BannedUser(message.into())
311 }
312
313 #[must_use]
314 pub fn not_found(message: impl Into<String>) -> Self {
315 Self::NotFound(message.into())
316 }
317
318 #[must_use]
319 pub fn conflict(message: impl Into<String>) -> Self {
320 Self::Conflict(message.into())
321 }
322
323 #[must_use]
324 pub fn method_not_allowed(message: impl Into<String>) -> Self {
325 Self::MethodNotAllowed(message.into())
326 }
327
328 #[must_use]
329 pub fn payload_too_large(message: impl Into<String>) -> Self {
330 Self::PayloadTooLarge(message.into())
331 }
332
333 #[must_use]
334 pub fn not_implemented(message: impl Into<String>) -> Self {
335 Self::NotImplemented(message.into())
336 }
337
338 #[must_use]
339 pub fn plugin(plugin: &str, message: impl Into<String>) -> Self {
340 Self::Plugin {
341 plugin: plugin.to_owned(),
342 message: message.into(),
343 }
344 }
345
346 #[must_use]
347 pub fn config(message: impl Into<String>) -> Self {
348 Self::Config(message.into())
349 }
350
351 #[must_use]
352 pub fn internal(message: impl Into<String>) -> Self {
353 Self::Internal(message.into())
354 }
355
356 #[must_use]
357 pub fn validation(message: impl Into<String>) -> Self {
358 Self::Validation(message.into())
359 }
360
361 #[must_use]
362 pub fn authentication_failed(message: impl Into<String>) -> Self {
363 Self::AuthenticationFailed(message.into())
364 }
365}
366
367#[derive(Error, Debug)]
368pub enum DatabaseError {
369 #[error(
371 "Multiple accounts match the same accountId for provider {provider:?}. Resolve duplicate account identities before continuing."
372 )]
373 AmbiguousAccount { provider: String },
374
375 #[error("Connection error: {0}")]
376 Connection(String),
377
378 #[error("Query error: {0}")]
379 Query(String),
380
381 #[error("Migration error: {0}")]
382 Migration(String),
383
384 #[error("Constraint violation: {0}")]
385 Constraint(String),
386
387 #[error("Transaction error: {0}")]
388 Transaction(String),
389}
390
391pub type AuthResult<T> = Result<T, AuthError>;
392
393#[cfg(feature = "axum")]
394impl axum::response::IntoResponse for AuthError {
395 fn into_response(self) -> axum::response::Response {
396 if matches!(&self, Self::CallbackFailure(_) | Self::Encryption(_)) {
397 tracing::error!(error = %self, "Authentication operation failed");
398 return axum::http::StatusCode::INTERNAL_SERVER_ERROR.into_response();
399 }
400 let retry_after = self.retry_after_header();
401 let (status_u16, code, message) = self.error_payload();
402 let status = axum::http::StatusCode::from_u16(status_u16)
403 .unwrap_or(axum::http::StatusCode::INTERNAL_SERVER_ERROR);
404 let mut response = (
405 status,
406 axum::Json(crate::types::ErrorCodeMessageResponse { code, message }),
407 )
408 .into_response();
409 if let Some(value) = retry_after.and_then(|value| value.parse().ok()) {
410 drop(response.headers_mut().insert("x-retry-after", value));
411 }
412 response
413 }
414}
415
416#[must_use]
421pub fn validation_error_response(
422 errors: &validator::ValidationErrors,
423) -> crate::types::AuthResponse {
424 let messages: Vec<String> = errors
426 .field_errors()
427 .into_iter()
428 .flat_map(|(field, errs)| {
429 errs.iter().map(move |e| {
430 let msg = e
431 .message
432 .as_ref()
433 .map_or_else(|| format!("Invalid value for {field}"), ToString::to_string);
434 format!("[body.{field}] {msg}")
435 })
436 })
437 .collect();
438 let message = messages.join("; ");
439
440 let body = crate::types::ErrorCodeMessageResponse {
441 code: Some("VALIDATION_ERROR".to_owned()),
442 message,
443 };
444
445 crate::types::AuthResponse::json(400, &body)
447 .unwrap_or_else(|_| crate::types::AuthResponse::text(400, "Validation failed"))
448}
449
450pub fn validate_request_body<T>(
456 req: &crate::types::AuthRequest,
457) -> Result<T, crate::types::AuthResponse>
458where
459 T: serde::de::DeserializeOwned + validator::Validate + 'static,
460{
461 let value: T = req.body_as_json().map_err(|e| {
462 let message = format!("Invalid JSON: {e}");
463 let code = AuthError::code_from_message(&message);
464 crate::types::AuthResponse::json(
465 400,
466 &crate::types::ErrorCodeMessageResponse { code, message },
467 )
468 .unwrap_or_else(|_| crate::types::AuthResponse::text(400, "Invalid JSON"))
469 })?;
470
471 value
472 .validate()
473 .map_err(|e| validation_error_response(&e))?;
474
475 Ok(value)
476}
477
478#[cfg(test)]
480mod tests {
481 use super::*;
482
483 #[test]
487 fn bad_request_is_400() {
488 assert_eq!(AuthError::bad_request("oops").status_code(), 400);
489 }
490
491 #[test]
493 fn invalid_request_is_400() {
494 assert_eq!(AuthError::InvalidRequest("x".into()).status_code(), 400);
495 }
496
497 #[test]
499 fn validation_is_400() {
500 assert_eq!(AuthError::validation("x").status_code(), 400);
501 }
502
503 #[test]
505 fn invalid_credentials_is_401() {
506 assert_eq!(AuthError::InvalidCredentials.status_code(), 401);
507 }
508
509 #[test]
511 fn unauthenticated_is_401() {
512 assert_eq!(AuthError::Unauthenticated.status_code(), 401);
513 }
514
515 #[test]
517 fn session_not_found_is_401() {
518 assert_eq!(AuthError::SessionNotFound.status_code(), 401);
519 }
520
521 #[test]
523 fn forbidden_is_403() {
524 assert_eq!(AuthError::forbidden("nope").status_code(), 403);
525 }
526
527 #[test]
529 fn unauthorized_is_403() {
530 assert_eq!(AuthError::Unauthorized.status_code(), 403);
531 }
532
533 #[test]
535 fn not_found_is_404() {
536 assert_eq!(AuthError::not_found("gone").status_code(), 404);
537 assert_eq!(AuthError::UserNotFound.status_code(), 404);
538 }
539
540 #[test]
542 fn conflict_is_409() {
543 assert_eq!(AuthError::conflict("dup").status_code(), 409);
544 }
545
546 #[test]
548 fn unprocessable_entity_is_422() {
549 assert_eq!(
550 AuthError::UnprocessableEntity("x".into()).status_code(),
551 422
552 );
553 }
554
555 #[test]
557 fn rate_limited_is_429() {
558 assert_eq!(
559 AuthError::RateLimited { retry_after: None }.status_code(),
560 429
561 );
562 }
563
564 #[test]
566 fn rate_limited_responses_publish_retry_after() {
567 for (seconds, header) in [(9.0, "9"), (2.5, "2.5")] {
568 let response = AuthError::rate_limited(seconds).to_auth_response();
569 assert_eq!(response.status, 429);
570 assert_eq!(
571 response.headers.get("X-Retry-After").map(String::as_str),
572 Some(header)
573 );
574 #[cfg(feature = "axum")]
575 {
576 let response =
577 axum::response::IntoResponse::into_response(AuthError::rate_limited(seconds));
578 assert_eq!(response.status(), 429);
579 assert_eq!(response.headers()["x-retry-after"], header);
580 }
581 }
582 }
583
584 #[test]
586 fn not_implemented_is_501() {
587 assert_eq!(AuthError::not_implemented("todo").status_code(), 501);
588 }
589
590 #[test]
592 fn internal_errors_are_500() {
593 assert_eq!(AuthError::config("bad").status_code(), 500);
594 assert_eq!(AuthError::internal("fail").status_code(), 500);
595 assert_eq!(AuthError::plugin("p", "m").status_code(), 500);
596 assert_eq!(AuthError::PasswordHash("h".into()).status_code(), 500);
597 assert_eq!(
598 AuthError::Database(DatabaseError::Connection("c".into())).status_code(),
599 500
600 );
601 }
602
603 #[test]
608 fn code_from_message_returns_the_upstream_constant() {
609 assert_eq!(
610 AuthError::code_from_message("User not found").as_deref(),
611 Some("USER_NOT_FOUND")
612 );
613 }
614
615 #[test]
619 fn code_from_message_is_none_for_unknown_messages() {
620 assert_eq!(AuthError::code_from_message("invalid email!"), None);
621 assert_eq!(AuthError::code_from_message("Email is the same"), None);
622 assert_eq!(AuthError::code_from_message(""), None);
623 }
624
625 #[test]
628 fn code_from_message_covers_plugin_tables() {
629 assert_eq!(
630 AuthError::code_from_message("Username is already taken. Please try another.")
631 .as_deref(),
632 Some("USERNAME_IS_ALREADY_TAKEN")
633 );
634 }
635
636 #[test]
640 fn base_error_codes_are_not_derived_from_the_message() {
641 for (message, expected) in [
642 ("Invalid callbackURL", "INVALID_CALLBACK_URL"),
643 ("Invalid redirectURL", "INVALID_REDIRECT_URL"),
644 ("Invalid errorCallbackURL", "INVALID_ERROR_CALLBACK_URL"),
645 (
646 "Invalid newUserCallbackURL",
647 "INVALID_NEW_USER_CALLBACK_URL",
648 ),
649 ("Email is already verified", "EMAIL_ALREADY_VERIFIED"),
650 (
651 "Verification email isn't enabled",
652 "VERIFICATION_EMAIL_NOT_ENABLED",
653 ),
654 (
655 "Session expired. Re-authenticate to perform this action.",
656 "SESSION_EXPIRED",
657 ),
658 (
659 "Cross-site navigation login blocked. This request appears to be a CSRF attack.",
660 "CROSS_SITE_NAVIGATION_LOGIN_BLOCKED",
661 ),
662 ] {
663 assert_eq!(
664 AuthError::code_from_message(message).as_deref(),
665 Some(expected),
666 "message {message:?} must carry the upstream constant"
667 );
668 }
669 }
670
671 #[test]
675 fn error_payload_for_client_error() {
676 let (status, code, message) = AuthError::bad_request("Missing field").error_payload();
678 assert_eq!(status, 400);
679 assert_eq!(code, None);
680 assert_eq!(message, "Missing field");
681
682 let (status_2, code_2, message_2) =
683 AuthError::bad_request("Field is required").error_payload();
684 assert_eq!(status_2, 400);
685 assert_eq!(code_2.as_deref(), Some("MISSING_FIELD"));
686 assert_eq!(message_2, "Field is required");
687 }
688
689 #[test]
691 fn error_payload_for_internal_error_hides_details() {
692 let (status, _code, message) = AuthError::internal("secret detail").error_payload();
693 assert_eq!(status, 500);
694 assert_eq!(message, "Internal server error");
695 }
696
697 #[test]
701 fn to_auth_response_returns_correct_status() {
702 let resp = AuthError::bad_request("oops").to_auth_response();
703 assert_eq!(resp.status, 400);
704 }
705
706 #[test]
708 fn to_auth_response_body_contains_code_and_message() {
709 let resp = AuthError::UserNotFound.to_auth_response();
710 assert_eq!(resp.status, 404);
711 let body: serde_json::Value =
712 serde_json::from_slice(&resp.body).expect("response body should be valid JSON");
713 assert_eq!(
714 (*(body).get("code").unwrap_or(&serde_json::Value::Null)),
715 "USER_NOT_FOUND"
716 );
717 assert_eq!(
718 (*(body).get("message").unwrap_or(&serde_json::Value::Null)),
719 "User not found"
720 );
721 }
722
723 #[test]
727 fn constructor_helpers_produce_correct_variants() {
728 assert_eq!(AuthError::bad_request("x").to_string(), "x");
730 assert_eq!(AuthError::forbidden("x").to_string(), "x");
731 assert_eq!(AuthError::not_found("x").to_string(), "x");
732 assert_eq!(AuthError::conflict("x").to_string(), "x");
733 assert_eq!(AuthError::not_implemented("x").to_string(), "x");
734 assert_eq!(AuthError::config("x").to_string(), "Configuration error: x");
735 assert_eq!(
736 AuthError::internal("x").to_string(),
737 "Internal server error: x"
738 );
739 assert_eq!(
740 AuthError::validation("x").to_string(),
741 "Validation error: x"
742 );
743 assert_eq!(
744 AuthError::plugin("p", "m").to_string(),
745 "Plugin error: p - m"
746 );
747 }
748
749 #[test]
753 fn database_error_display() {
754 let e = DatabaseError::Connection("timeout".into());
755 assert_eq!(e.to_string(), "Connection error: timeout");
756 }
757
758 #[test]
760 fn database_error_converts_to_auth_error() {
761 let db_err = DatabaseError::Query("bad sql".into());
762 let auth_err: AuthError = db_err.into();
763 assert_eq!(auth_err.status_code(), 500);
764 }
765
766 #[test]
770 fn validation_error_response_serializes_field_errors() {
771 let mut errors = validator::ValidationErrors::new();
772 let mut error = validator::ValidationError::new("email");
773 error.message = Some("Email is invalid".into());
774 errors.add("email", error);
775
776 let resp = validation_error_response(&errors);
777 assert_eq!(resp.status, 400);
778 let body: serde_json::Value =
779 serde_json::from_slice(&resp.body).expect("response body should be valid JSON");
780 assert_eq!(
781 (*(body).get("code").unwrap_or(&serde_json::Value::Null)),
782 "VALIDATION_ERROR"
783 );
784 assert_eq!(
785 (*(body).get("message").unwrap_or(&serde_json::Value::Null)),
786 "[body.email] Email is invalid"
787 );
788 }
789
790 #[test]
794 fn fixed_message_variants_display() {
795 assert_eq!(
796 AuthError::InvalidCredentials.to_string(),
797 "Invalid email or password"
798 );
799 assert_eq!(
800 AuthError::Unauthenticated.to_string(),
801 "Authentication required"
802 );
803 assert_eq!(
804 AuthError::SessionNotFound.to_string(),
805 "Session not found or expired"
806 );
807 assert_eq!(
808 AuthError::Unauthorized.to_string(),
809 "Insufficient permissions"
810 );
811 assert_eq!(AuthError::UserNotFound.to_string(), "User not found");
812 assert_eq!(
813 AuthError::RateLimited { retry_after: None }.to_string(),
814 "Too many requests"
815 );
816 }
817
818 #[test]
820 fn callback_failure_transport_preserves_empty_body_and_public_api_errors() {
821 let response = AuthError::CallbackFailure(Box::new(AuthError::internal("private cause")))
822 .to_auth_response();
823 assert_eq!(response.status, 500);
824 assert!(response.body.is_empty());
825 assert!(response.headers.get("content-type").is_none());
826
827 let internal = AuthError::internal("private cause").to_auth_response();
828 assert_eq!(internal.status, 500);
829 assert_eq!(
830 serde_json::from_slice::<serde_json::Value>(&internal.body).unwrap(),
831 serde_json::json!({"message":"Internal server error"})
832 );
833 let explicit = AuthError::Api {
834 status: 500,
835 code: Some("APPLICATION_ERROR".into()),
836 message: "public error".into(),
837 }
838 .to_auth_response();
839 assert_eq!(explicit.status, 500);
840 assert_eq!(
841 serde_json::from_slice::<serde_json::Value>(&explicit.body).unwrap(),
842 serde_json::json!({"code":"APPLICATION_ERROR","message":"public error"})
843 );
844 }
845}
846