Skip to main content

better_auth_core/
error.rs

1use thiserror::Error;
2
3/// Authentication framework error types.
4///
5/// Each variant maps to an HTTP status code via [`AuthError::status_code`].
6/// Use [`AuthError::to_auth_response`] to produce a standardized JSON response
7/// matching the better-auth OpenAPI spec: `{ "message": "..." }`.
8#[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    /// HTTP status code for this error.
85    pub fn status_code(&self) -> u16 {
86        match self {
87            // 400
88            Self::BadRequest(_) | Self::InvalidRequest(_) | Self::Validation(_) => 400,
89            // 401
90            Self::InvalidCredentials
91            | Self::Unauthenticated
92            | Self::AuthenticationFailed(_)
93            | Self::SessionNotFound => 401,
94            // 403
95            Self::Forbidden(_) | Self::BannedUser(_) | Self::Unauthorized => 403,
96            // 404
97            Self::UserNotFound | Self::NotFound(_) => 404,
98            // 409
99            Self::Conflict(_) => 409,
100            // 413
101            Self::PayloadTooLarge(_) => 413,
102            // 422
103            Self::UnprocessableEntity(_) => 422,
104            // 429
105            Self::RateLimited => 429,
106            // 501
107            Self::NotImplemented(_) => 501,
108            // 500
109            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    /// Derive the error code from the message, matching the TS better-auth
120    /// behavior: `message.toUpperCase().replace(/ /g, "_").replace(/[^A-Z0-9_]/g, "")`.
121    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    /// Compute the HTTP status, error code, and user-facing message.
131    ///
132    /// Internal errors (500) are logged and replaced with a generic message
133    /// to avoid leaking details.
134    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    /// Convert this error into a standardized [`AuthResponse`](crate::types::AuthResponse) matching the
154    /// better-auth spec: `{ "code": "...", "message": "..." }`.
155    ///
156    /// Named `to_auth_response` to avoid collision with Axum's
157    /// `IntoResponse::into_response` when the `axum` feature is enabled.
158    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
256/// Convert `validator::ValidationErrors` into a standardized error response body.
257///
258/// Returns a 400 response with `{ "code": "VALIDATION_ERROR", "message": "[body.field] ..." }`
259/// matching the TS better-auth error shape.
260pub fn validation_error_response(
261    errors: &validator::ValidationErrors,
262) -> crate::types::AuthResponse {
263    // Build a TS-compatible message: "[body.field] message; [body.field2] message2"
264    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    // Validation errors return 400 (not 422) per the TS spec
286    crate::types::AuthResponse::json(400, &body)
287        .unwrap_or_else(|_| crate::types::AuthResponse::text(400, "Validation failed"))
288}
289
290/// Validate a request body, returning a parsed + validated value or an error response.
291pub 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    // ── status_code ─────────────────────────────────────────────────────
319
320    // Rust-specific surface: `AuthError` and Rust-side response/error conversion behavior are public Rust library APIs with no direct TS analogue.
321    #[test]
322    fn bad_request_is_400() {
323        assert_eq!(AuthError::bad_request("oops").status_code(), 400);
324    }
325
326    // Rust-specific surface: `AuthError` and Rust-side response/error conversion behavior are public Rust library APIs with no direct TS analogue.
327    #[test]
328    fn invalid_request_is_400() {
329        assert_eq!(AuthError::InvalidRequest("x".into()).status_code(), 400);
330    }
331
332    // Rust-specific surface: `AuthError` and Rust-side response/error conversion behavior are public Rust library APIs with no direct TS analogue.
333    #[test]
334    fn validation_is_400() {
335        assert_eq!(AuthError::validation("x").status_code(), 400);
336    }
337
338    // Rust-specific surface: `AuthError` and Rust-side response/error conversion behavior are public Rust library APIs with no direct TS analogue.
339    #[test]
340    fn invalid_credentials_is_401() {
341        assert_eq!(AuthError::InvalidCredentials.status_code(), 401);
342    }
343
344    // Rust-specific surface: `AuthError` and Rust-side response/error conversion behavior are public Rust library APIs with no direct TS analogue.
345    #[test]
346    fn unauthenticated_is_401() {
347        assert_eq!(AuthError::Unauthenticated.status_code(), 401);
348    }
349
350    // Rust-specific surface: `AuthError` and Rust-side response/error conversion behavior are public Rust library APIs with no direct TS analogue.
351    #[test]
352    fn session_not_found_is_401() {
353        assert_eq!(AuthError::SessionNotFound.status_code(), 401);
354    }
355
356    // Rust-specific surface: `AuthError` and Rust-side response/error conversion behavior are public Rust library APIs with no direct TS analogue.
357    #[test]
358    fn forbidden_is_403() {
359        assert_eq!(AuthError::forbidden("nope").status_code(), 403);
360    }
361
362    // Rust-specific surface: `AuthError` and Rust-side response/error conversion behavior are public Rust library APIs with no direct TS analogue.
363    #[test]
364    fn unauthorized_is_403() {
365        assert_eq!(AuthError::Unauthorized.status_code(), 403);
366    }
367
368    // Rust-specific surface: `AuthError` and Rust-side response/error conversion behavior are public Rust library APIs with no direct TS analogue.
369    #[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    // Rust-specific surface: `AuthError` and Rust-side response/error conversion behavior are public Rust library APIs with no direct TS analogue.
376    #[test]
377    fn conflict_is_409() {
378        assert_eq!(AuthError::conflict("dup").status_code(), 409);
379    }
380
381    // Rust-specific surface: `AuthError` and Rust-side response/error conversion behavior are public Rust library APIs with no direct TS analogue.
382    #[test]
383    fn unprocessable_entity_is_422() {
384        assert_eq!(
385            AuthError::UnprocessableEntity("x".into()).status_code(),
386            422
387        );
388    }
389
390    // Rust-specific surface: `AuthError` and Rust-side response/error conversion behavior are public Rust library APIs with no direct TS analogue.
391    #[test]
392    fn rate_limited_is_429() {
393        assert_eq!(AuthError::RateLimited.status_code(), 429);
394    }
395
396    // Rust-specific surface: `AuthError` and Rust-side response/error conversion behavior are public Rust library APIs with no direct TS analogue.
397    #[test]
398    fn not_implemented_is_501() {
399        assert_eq!(AuthError::not_implemented("todo").status_code(), 501);
400    }
401
402    // Rust-specific surface: `AuthError` and Rust-side response/error conversion behavior are public Rust library APIs with no direct TS analogue.
403    #[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    // ── code_from_message ───────────────────────────────────────────────
416
417    // Rust-specific surface: `AuthError` and Rust-side response/error conversion behavior are public Rust library APIs with no direct TS analogue.
418    #[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    // Rust-specific surface: `AuthError` and Rust-side response/error conversion behavior are public Rust library APIs with no direct TS analogue.
427    #[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    // Rust-specific surface: `AuthError` and Rust-side response/error conversion behavior are public Rust library APIs with no direct TS analogue.
436    #[test]
437    fn code_from_message_empty() {
438        assert_eq!(AuthError::code_from_message(""), "");
439    }
440
441    // ── error_payload ───────────────────────────────────────────────────
442
443    // Rust-specific surface: `AuthError` and Rust-side response/error conversion behavior are public Rust library APIs with no direct TS analogue.
444    #[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    // Rust-specific surface: `AuthError` and Rust-side response/error conversion behavior are public Rust library APIs with no direct TS analogue.
453    #[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    // ── to_auth_response ────────────────────────────────────────────────
461
462    // Rust-specific surface: `AuthError` and Rust-side response/error conversion behavior are public Rust library APIs with no direct TS analogue.
463    #[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    // Rust-specific surface: `AuthError` and Rust-side response/error conversion behavior are public Rust library APIs with no direct TS analogue.
470    #[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    // ── constructor helpers ──────────────────────────────────────────────
481
482    // Rust-specific surface: `AuthError` and Rust-side response/error conversion behavior are public Rust library APIs with no direct TS analogue.
483    #[test]
484    fn constructor_helpers_produce_correct_variants() {
485        // Each helper should produce the expected Display output
486        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    // ── DatabaseError ───────────────────────────────────────────────────
507
508    // Rust-specific surface: `AuthError` and Rust-side response/error conversion behavior are public Rust library APIs with no direct TS analogue.
509    #[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    // Rust-specific surface: `AuthError` and Rust-side response/error conversion behavior are public Rust library APIs with no direct TS analogue.
516    #[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    // ── Display for fixed-message variants ──────────────────────────────
524
525    // Rust-specific surface: `AuthError` and Rust-side response/error conversion behavior are public Rust library APIs with no direct TS analogue.
526    #[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}