1pub mod codes;
2
3pub mod page;
4
5use crate::types::{AuthRequest, AuthResponse, ErrorCodeMessageResponse};
6use codes::upstream_code;
7use thiserror::Error;
8
9#[derive(Error, Debug)]
15pub enum AuthError {
16 #[error("{message}")]
21 Api {
22 status: u16,
23 code: Option<String>,
24 message: String,
25 },
26
27 #[error("{message}")]
29 Upstream {
30 status: u16,
32 code: &'static str,
34 message: &'static str,
36 },
37
38 #[error("{0}")]
39 BadRequest(String),
40
41 #[error("Invalid request: {0}")]
42 InvalidRequest(String),
43
44 #[error("Validation error: {0}")]
45 Validation(String),
46
47 #[error("Invalid email or password")]
48 InvalidCredentials,
49
50 #[error("Authentication required")]
51 Unauthenticated,
52
53 #[error("{0}")]
54 AuthenticationFailed(String),
55
56 #[error("Session not found or expired")]
57 SessionNotFound,
58
59 #[error("{0}")]
60 Forbidden(String),
61
62 #[error("session creation cancelled by database hook")]
66 SessionCreationCancelled,
67
68 #[error("user creation cancelled by database hook")]
72 UserCreationCancelled,
73
74 #[error("{0}")]
75 BannedUser(String),
76
77 #[error("Insufficient permissions")]
78 Unauthorized,
79
80 #[error("User not found")]
81 UserNotFound,
82
83 #[error("{0}")]
84 NotFound(String),
85
86 #[error("{0}")]
87 Conflict(String),
88
89 #[error("{0}")]
90 MethodNotAllowed(String),
91
92 #[error("{0}")]
93 PayloadTooLarge(String),
94
95 #[error("{0}")]
96 UnprocessableEntity(String),
97
98 #[error("Too many requests")]
101 RateLimited { retry_after: Option<f64> },
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 #[error("Serialization error: {0}")]
113 Serialization(#[from] serde_json::Error),
114
115 #[error("Plugin error: {plugin} - {message}")]
116 Plugin { plugin: String, message: String },
117
118 #[error("Internal server error: {0}")]
119 Internal(String),
120
121 #[error("Encrypted authentication data failed: {0}")]
124 Encryption(String),
125
126 #[error("Application callback failed: {0}")]
130 CallbackFailure(Box<Self>),
131
132 #[error("Password hashing error: {0}")]
133 PasswordHash(String),
134
135 #[error("JWT error: {0}")]
136 Jwt(#[from] jsonwebtoken::errors::Error),
137}
138
139impl AuthError {
140 #[must_use]
142 pub const fn status_code(&self) -> u16 {
143 match self {
144 Self::Api { status, .. } | Self::Upstream { status, .. } => *status,
145 Self::BadRequest(_) | Self::InvalidRequest(_) | Self::Validation(_) => 400,
146 Self::InvalidCredentials
147 | Self::Unauthenticated
148 | Self::AuthenticationFailed(_)
149 | Self::SessionNotFound => 401,
150 Self::Forbidden(_)
151 | Self::SessionCreationCancelled
152 | Self::UserCreationCancelled
153 | Self::BannedUser(_)
154 | Self::Unauthorized => 403,
155 Self::UserNotFound | Self::NotFound(_) => 404,
156 Self::Conflict(_) => 409,
157 Self::MethodNotAllowed(_) => 405,
158 Self::PayloadTooLarge(_) => 413,
159 Self::UnprocessableEntity(_) => 422,
160 Self::RateLimited { .. } => 429,
161 Self::NotImplemented(_) => 501,
162 Self::Config(_)
163 | Self::Database(_)
164 | Self::Serialization(_)
165 | Self::Plugin { .. }
166 | Self::Internal(_)
167 | Self::Encryption(_)
168 | Self::CallbackFailure(_)
169 | Self::PasswordHash(_)
170 | Self::Jwt(_) => 500,
171 }
172 }
173
174 pub(crate) fn into_callback_failure(self) -> Self {
177 match self {
178 Self::Api { .. } | Self::Upstream { .. } | Self::CallbackFailure(_) => self,
179 other => Self::CallbackFailure(Box::new(other)),
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(codes::BANNED_USER.to_owned()), message.clone()),
209 _ => {
210 let message = match status {
211 500 => {
212 tracing::error!(error = %self, "Internal server error");
213 "Internal server error".to_owned()
214 }
215 _ => self.to_string(),
216 };
217 let code = Self::code_from_message(&message);
218 (code, message)
219 }
220 };
221 (status, code, message)
222 }
223
224 #[must_use]
230 pub fn to_auth_response(self) -> AuthResponse {
231 if matches!(&self, Self::CallbackFailure(_) | Self::Encryption(_)) {
232 tracing::error!(error = %self, "Authentication operation failed");
233 return AuthResponse::new(500);
234 }
235 let ordinary = self.status_code() == 500 && !crate::endpoint::is_endpoint_api_error(&self);
236 let retry_after = self.retry_after_header();
237 let (status, code, message) = self.error_payload();
238 let response = AuthResponse::json(
239 status,
240 &ErrorCodeMessageResponse {
241 code,
242 message: message.clone(),
243 },
244 )
245 .unwrap_or_else(|_| AuthResponse::text(status, &message));
246 let response = if ordinary {
247 response.with_header("Content-Type", "application/json;charset=utf-8")
248 } else {
249 response
250 };
251 match retry_after {
252 Some(seconds) => response.with_header("X-Retry-After", seconds),
253 None => response,
254 }
255 }
256
257 fn retry_after_header(&self) -> Option<String> {
260 match self {
261 Self::RateLimited {
262 retry_after: Some(seconds),
263 } => Some(ryu_js::Buffer::new().format(*seconds).to_owned()),
264 _ => None,
265 }
266 }
267
268 #[must_use]
271 pub const fn rate_limited(retry_after: f64) -> Self {
272 Self::RateLimited {
273 retry_after: Some(retry_after),
274 }
275 }
276
277 #[must_use]
278 pub fn bad_request(message: impl Into<String>) -> Self {
279 Self::BadRequest(message.into())
280 }
281
282 #[must_use]
283 pub fn forbidden(message: impl Into<String>) -> Self {
284 Self::Forbidden(message.into())
285 }
286
287 #[must_use]
288 pub fn banned_user(message: impl Into<String>) -> Self {
289 Self::BannedUser(message.into())
290 }
291
292 #[must_use]
293 pub fn not_found(message: impl Into<String>) -> Self {
294 Self::NotFound(message.into())
295 }
296
297 #[must_use]
298 pub fn conflict(message: impl Into<String>) -> Self {
299 Self::Conflict(message.into())
300 }
301
302 #[must_use]
303 pub fn method_not_allowed(message: impl Into<String>) -> Self {
304 Self::MethodNotAllowed(message.into())
305 }
306
307 #[must_use]
308 pub fn payload_too_large(message: impl Into<String>) -> Self {
309 Self::PayloadTooLarge(message.into())
310 }
311
312 #[must_use]
313 pub fn not_implemented(message: impl Into<String>) -> Self {
314 Self::NotImplemented(message.into())
315 }
316
317 #[must_use]
318 pub fn plugin(plugin: &str, message: impl Into<String>) -> Self {
319 Self::Plugin {
320 plugin: plugin.to_owned(),
321 message: message.into(),
322 }
323 }
324
325 #[must_use]
326 pub fn config(message: impl Into<String>) -> Self {
327 Self::Config(message.into())
328 }
329
330 #[must_use]
331 pub fn internal(message: impl Into<String>) -> Self {
332 Self::Internal(message.into())
333 }
334
335 #[must_use]
336 pub fn validation(message: impl Into<String>) -> Self {
337 Self::Validation(message.into())
338 }
339
340 #[must_use]
341 pub fn authentication_failed(message: impl Into<String>) -> Self {
342 Self::AuthenticationFailed(message.into())
343 }
344}
345
346#[derive(Error, Debug)]
347pub enum DatabaseError {
348 #[error(
350 "Multiple accounts match the same accountId for provider {provider:?}. Resolve duplicate account identities before continuing."
351 )]
352 AmbiguousAccount { provider: String },
353
354 #[error("Connection error: {0}")]
355 Connection(String),
356
357 #[error("Query error: {0}")]
358 Query(String),
359
360 #[error("Migration error: {0}")]
361 Migration(String),
362
363 #[error("Constraint violation: {0}")]
364 Constraint(String),
365
366 #[error("Transaction error: {0}")]
367 Transaction(String),
368}
369
370pub type AuthResult<T> = Result<T, AuthError>;
371
372#[cfg(feature = "axum")]
373impl axum::response::IntoResponse for AuthError {
374 fn into_response(self) -> axum::response::Response {
375 self.to_auth_response().into_response()
376 }
377}
378
379#[must_use]
384pub fn validation_error_response(errors: &validator::ValidationErrors) -> AuthResponse {
385 let messages: Vec<String> = errors
386 .field_errors()
387 .into_iter()
388 .flat_map(|(field, errs)| {
389 errs.iter().map(move |e| {
390 let msg = e
391 .message
392 .as_ref()
393 .map_or_else(|| format!("Invalid value for {field}"), ToString::to_string);
394 format!("[body.{field}] {msg}")
395 })
396 })
397 .collect();
398 let message = messages.join("; ");
399 let body = ErrorCodeMessageResponse {
400 code: Some(codes::VALIDATION_ERROR.to_owned()),
401 message,
402 };
403
404 AuthResponse::json(400, &body).unwrap_or_else(|_| AuthResponse::text(400, "Validation failed"))
406}
407
408pub fn validate_request_body<T>(req: &AuthRequest) -> Result<T, AuthResponse>
414where
415 T: serde::de::DeserializeOwned + validator::Validate + 'static,
416{
417 let value: T = req.body_as_json().map_err(|e| {
418 let message = format!("Invalid JSON: {e}");
419 let code = AuthError::code_from_message(&message);
420 AuthResponse::json(400, &ErrorCodeMessageResponse { code, message })
421 .unwrap_or_else(|_| AuthResponse::text(400, "Invalid JSON"))
422 })?;
423
424 value
425 .validate()
426 .map_err(|e| validation_error_response(&e))?;
427 Ok(value)
428}
429
430#[cfg(test)]
432mod tests {
433 use super::*;
434
435 #[test]
437 fn bad_request_is_400() {
438 assert_eq!(AuthError::bad_request("oops").status_code(), 400);
439 }
440
441 #[test]
443 fn invalid_request_is_400() {
444 assert_eq!(AuthError::InvalidRequest("x".into()).status_code(), 400);
445 }
446
447 #[test]
449 fn validation_is_400() {
450 assert_eq!(AuthError::validation("x").status_code(), 400);
451 }
452
453 #[test]
455 fn invalid_credentials_is_401() {
456 assert_eq!(AuthError::InvalidCredentials.status_code(), 401);
457 }
458
459 #[test]
461 fn unauthenticated_is_401() {
462 assert_eq!(AuthError::Unauthenticated.status_code(), 401);
463 }
464
465 #[test]
467 fn session_not_found_is_401() {
468 assert_eq!(AuthError::SessionNotFound.status_code(), 401);
469 }
470
471 #[test]
473 fn forbidden_is_403() {
474 assert_eq!(AuthError::forbidden("nope").status_code(), 403);
475 }
476
477 #[test]
479 fn unauthorized_is_403() {
480 assert_eq!(AuthError::Unauthorized.status_code(), 403);
481 }
482
483 #[test]
485 fn not_found_is_404() {
486 assert_eq!(AuthError::not_found("gone").status_code(), 404);
487 assert_eq!(AuthError::UserNotFound.status_code(), 404);
488 }
489
490 #[test]
492 fn conflict_is_409() {
493 assert_eq!(AuthError::conflict("dup").status_code(), 409);
494 }
495
496 #[test]
498 fn unprocessable_entity_is_422() {
499 assert_eq!(
500 AuthError::UnprocessableEntity("x".into()).status_code(),
501 422
502 );
503 }
504
505 #[test]
507 fn rate_limited_is_429() {
508 assert_eq!(
509 AuthError::RateLimited { retry_after: None }.status_code(),
510 429
511 );
512 }
513
514 #[test]
516 fn rate_limited_responses_publish_retry_after() {
517 for (seconds, header) in [(9.0, "9"), (2.5, "2.5")] {
518 let response = AuthError::rate_limited(seconds).to_auth_response();
519 assert_eq!(response.status, 429);
520 assert_eq!(
521 response.headers.get("X-Retry-After").map(String::as_str),
522 Some(header)
523 );
524 #[cfg(feature = "axum")]
525 {
526 let response =
527 axum::response::IntoResponse::into_response(AuthError::rate_limited(seconds));
528 assert_eq!(response.status(), 429);
529 assert_eq!(response.headers()["x-retry-after"], header);
530 }
531 }
532 }
533
534 #[test]
536 fn not_implemented_is_501() {
537 assert_eq!(AuthError::not_implemented("todo").status_code(), 501);
538 }
539
540 #[test]
542 fn internal_errors_are_500() {
543 assert_eq!(AuthError::config("bad").status_code(), 500);
544 assert_eq!(AuthError::internal("fail").status_code(), 500);
545 assert_eq!(AuthError::plugin("p", "m").status_code(), 500);
546 assert_eq!(AuthError::PasswordHash("h".into()).status_code(), 500);
547 assert_eq!(
548 AuthError::Database(DatabaseError::Connection("c".into())).status_code(),
549 500
550 );
551 }
552
553 #[test]
556 fn code_from_message_returns_the_upstream_constant() {
557 assert_eq!(
558 AuthError::code_from_message("User not found").as_deref(),
559 Some("USER_NOT_FOUND")
560 );
561 }
562
563 #[test]
567 fn code_from_message_is_none_for_unknown_messages() {
568 assert_eq!(AuthError::code_from_message("invalid email!"), None);
569 assert_eq!(AuthError::code_from_message("Email is the same"), None);
570 assert_eq!(AuthError::code_from_message(""), None);
571 }
572
573 #[test]
576 fn code_from_message_covers_plugin_tables() {
577 assert_eq!(
578 AuthError::code_from_message("Username is already taken. Please try another.")
579 .as_deref(),
580 Some("USERNAME_IS_ALREADY_TAKEN")
581 );
582 }
583
584 #[test]
588 fn base_error_codes_are_not_derived_from_the_message() {
589 for (message, expected) in [
590 ("Invalid callbackURL", "INVALID_CALLBACK_URL"),
591 ("Invalid redirectURL", "INVALID_REDIRECT_URL"),
592 ("Invalid errorCallbackURL", "INVALID_ERROR_CALLBACK_URL"),
593 (
594 "Invalid newUserCallbackURL",
595 "INVALID_NEW_USER_CALLBACK_URL",
596 ),
597 ("Email is already verified", "EMAIL_ALREADY_VERIFIED"),
598 (
599 "Verification email isn't enabled",
600 "VERIFICATION_EMAIL_NOT_ENABLED",
601 ),
602 (
603 "Session expired. Re-authenticate to perform this action.",
604 "SESSION_EXPIRED",
605 ),
606 (
607 "Cross-site navigation login blocked. This request appears to be a CSRF attack.",
608 "CROSS_SITE_NAVIGATION_LOGIN_BLOCKED",
609 ),
610 ] {
611 assert_eq!(
612 AuthError::code_from_message(message).as_deref(),
613 Some(expected),
614 "message {message:?} must carry the upstream constant"
615 );
616 }
617 }
618
619 #[test]
621 fn error_payload_for_client_error() {
622 let (status, code, message) = AuthError::bad_request("Missing field").error_payload();
624 assert_eq!(status, 400);
625 assert_eq!(code, None);
626 assert_eq!(message, "Missing field");
627
628 let (status_2, code_2, message_2) =
629 AuthError::bad_request("Field is required").error_payload();
630 assert_eq!(status_2, 400);
631 assert_eq!(code_2.as_deref(), Some("MISSING_FIELD"));
632 assert_eq!(message_2, "Field is required");
633 }
634
635 #[test]
637 fn error_payload_for_internal_error_hides_details() {
638 let (status, _code, message) = AuthError::internal("secret detail").error_payload();
639 assert_eq!(status, 500);
640 assert_eq!(message, "Internal server error");
641 }
642
643 #[test]
645 fn to_auth_response_returns_correct_status() {
646 let resp = AuthError::bad_request("oops").to_auth_response();
647 assert_eq!(resp.status, 400);
648 }
649
650 #[test]
652 fn to_auth_response_body_contains_code_and_message() {
653 let resp = AuthError::UserNotFound.to_auth_response();
654 assert_eq!(resp.status, 404);
655 let body: serde_json::Value =
656 serde_json::from_slice(&resp.body).expect("response body should be valid JSON");
657 assert_eq!(
658 (*(body).get("code").unwrap_or(&serde_json::Value::Null)),
659 "USER_NOT_FOUND"
660 );
661 assert_eq!(
662 (*(body).get("message").unwrap_or(&serde_json::Value::Null)),
663 "User not found"
664 );
665 }
666
667 #[test]
669 fn constructor_helpers_produce_correct_variants() {
670 assert_eq!(AuthError::bad_request("x").to_string(), "x");
672 assert_eq!(AuthError::forbidden("x").to_string(), "x");
673 assert_eq!(AuthError::not_found("x").to_string(), "x");
674 assert_eq!(AuthError::conflict("x").to_string(), "x");
675 assert_eq!(AuthError::not_implemented("x").to_string(), "x");
676 assert_eq!(AuthError::config("x").to_string(), "Configuration error: x");
677 assert_eq!(
678 AuthError::internal("x").to_string(),
679 "Internal server error: x"
680 );
681 assert_eq!(
682 AuthError::validation("x").to_string(),
683 "Validation error: x"
684 );
685 assert_eq!(
686 AuthError::plugin("p", "m").to_string(),
687 "Plugin error: p - m"
688 );
689 }
690
691 #[test]
693 fn database_error_display() {
694 let e = DatabaseError::Connection("timeout".into());
695 assert_eq!(e.to_string(), "Connection error: timeout");
696 }
697
698 #[test]
700 fn database_error_converts_to_auth_error() {
701 let db_err = DatabaseError::Query("bad sql".into());
702 let auth_err: AuthError = db_err.into();
703 assert_eq!(auth_err.status_code(), 500);
704 }
705
706 #[test]
708 fn validation_error_response_serializes_field_errors() {
709 let mut errors = validator::ValidationErrors::new();
710 let mut error = validator::ValidationError::new("email");
711 error.message = Some("Email is invalid".into());
712 errors.add("email", error);
713
714 let resp = validation_error_response(&errors);
715 assert_eq!(resp.status, 400);
716 let body: serde_json::Value =
717 serde_json::from_slice(&resp.body).expect("response body should be valid JSON");
718 assert_eq!(
719 (*(body).get("code").unwrap_or(&serde_json::Value::Null)),
720 "VALIDATION_ERROR"
721 );
722 assert_eq!(
723 (*(body).get("message").unwrap_or(&serde_json::Value::Null)),
724 "[body.email] Email is invalid"
725 );
726 }
727
728 #[test]
730 fn fixed_message_variants_display() {
731 assert_eq!(
732 AuthError::InvalidCredentials.to_string(),
733 "Invalid email or password"
734 );
735 assert_eq!(
736 AuthError::Unauthenticated.to_string(),
737 "Authentication required"
738 );
739 assert_eq!(
740 AuthError::SessionNotFound.to_string(),
741 "Session not found or expired"
742 );
743 assert_eq!(
744 AuthError::Unauthorized.to_string(),
745 "Insufficient permissions"
746 );
747 assert_eq!(AuthError::UserNotFound.to_string(), "User not found");
748 assert_eq!(
749 AuthError::RateLimited { retry_after: None }.to_string(),
750 "Too many requests"
751 );
752 }
753
754 #[test]
756 fn callback_failure_transport_preserves_empty_body_and_public_api_errors() {
757 let response = AuthError::CallbackFailure(Box::new(AuthError::internal("private cause")))
758 .to_auth_response();
759 assert_eq!(response.status, 500);
760 assert_eq!(response.body, Vec::<u8>::new());
761 assert!(response.headers.get("content-type").is_none());
762
763 let internal = AuthError::internal("private cause").to_auth_response();
764 assert_eq!(internal.status, 500);
765 assert_eq!(
766 serde_json::from_slice::<serde_json::Value>(&internal.body).unwrap(),
767 serde_json::json!({"message":"Internal server error"})
768 );
769 let explicit = AuthError::Api {
770 status: 500,
771 code: Some("APPLICATION_ERROR".into()),
772 message: "public error".into(),
773 }
774 .to_auth_response();
775 assert_eq!(explicit.status, 500);
776 assert_eq!(
777 serde_json::from_slice::<serde_json::Value>(&explicit.body).unwrap(),
778 serde_json::json!({"code":"APPLICATION_ERROR","message":"public error"})
779 );
780 }
781}
782