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::into_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 credentials")]
20    InvalidCredentials,
21
22    #[error("Authentication required")]
23    Unauthenticated,
24
25    #[error("Session not found or expired")]
26    SessionNotFound,
27
28    #[error("{0}")]
29    Forbidden(String),
30
31    #[error("Insufficient permissions")]
32    Unauthorized,
33
34    #[error("User not found")]
35    UserNotFound,
36
37    #[error("{0}")]
38    NotFound(String),
39
40    #[error("{0}")]
41    Conflict(String),
42
43    #[error("Too many requests")]
44    RateLimited,
45
46    #[error("{0}")]
47    NotImplemented(String),
48
49    #[error("Configuration error: {0}")]
50    Config(String),
51
52    #[error("Database error: {0}")]
53    Database(#[from] DatabaseError),
54
55    #[error("Serialization error: {0}")]
56    Serialization(#[from] serde_json::Error),
57
58    #[error("Plugin error: {plugin} - {message}")]
59    Plugin { plugin: String, message: String },
60
61    #[error("Internal server error: {0}")]
62    Internal(String),
63
64    #[error("Password hashing error: {0}")]
65    PasswordHash(String),
66
67    #[error("JWT error: {0}")]
68    Jwt(#[from] jsonwebtoken::errors::Error),
69}
70
71impl AuthError {
72    /// HTTP status code for this error.
73    pub fn status_code(&self) -> u16 {
74        match self {
75            // 400
76            Self::BadRequest(_) | Self::InvalidRequest(_) | Self::Validation(_) => 400,
77            // 401
78            Self::InvalidCredentials | Self::Unauthenticated | Self::SessionNotFound => 401,
79            // 403
80            Self::Forbidden(_) | Self::Unauthorized => 403,
81            // 404
82            Self::UserNotFound | Self::NotFound(_) => 404,
83            // 409
84            Self::Conflict(_) => 409,
85            // 429
86            Self::RateLimited => 429,
87            // 501
88            Self::NotImplemented(_) => 501,
89            // 500
90            Self::Config(_)
91            | Self::Database(_)
92            | Self::Serialization(_)
93            | Self::Plugin { .. }
94            | Self::Internal(_)
95            | Self::PasswordHash(_)
96            | Self::Jwt(_) => 500,
97        }
98    }
99
100    /// Convert this error into a standardized [`AuthResponse`] matching the
101    /// better-auth OpenAPI spec: `{ "message": "..." }`.
102    ///
103    /// Internal errors (500) use a generic message to avoid leaking details.
104    pub fn into_response(self) -> crate::types::AuthResponse {
105        let status = self.status_code();
106        let message = match status {
107            500 => {
108                tracing::error!(error = %self, "Internal server error");
109                "Internal server error".to_string()
110            }
111            _ => self.to_string(),
112        };
113
114        crate::types::AuthResponse::json(
115            status,
116            &crate::types::ErrorMessageResponse {
117                message: message.clone(),
118            },
119        )
120        .unwrap_or_else(|_| crate::types::AuthResponse::text(status, &message))
121    }
122
123    pub fn bad_request(message: impl Into<String>) -> Self {
124        Self::BadRequest(message.into())
125    }
126
127    pub fn forbidden(message: impl Into<String>) -> Self {
128        Self::Forbidden(message.into())
129    }
130
131    pub fn not_found(message: impl Into<String>) -> Self {
132        Self::NotFound(message.into())
133    }
134
135    pub fn conflict(message: impl Into<String>) -> Self {
136        Self::Conflict(message.into())
137    }
138
139    pub fn not_implemented(message: impl Into<String>) -> Self {
140        Self::NotImplemented(message.into())
141    }
142
143    pub fn plugin(plugin: &str, message: impl Into<String>) -> Self {
144        Self::Plugin {
145            plugin: plugin.to_string(),
146            message: message.into(),
147        }
148    }
149
150    pub fn config(message: impl Into<String>) -> Self {
151        Self::Config(message.into())
152    }
153
154    pub fn internal(message: impl Into<String>) -> Self {
155        Self::Internal(message.into())
156    }
157
158    pub fn validation(message: impl Into<String>) -> Self {
159        Self::Validation(message.into())
160    }
161}
162
163#[derive(Error, Debug)]
164pub enum DatabaseError {
165    #[error("Connection error: {0}")]
166    Connection(String),
167
168    #[error("Query error: {0}")]
169    Query(String),
170
171    #[error("Migration error: {0}")]
172    Migration(String),
173
174    #[error("Constraint violation: {0}")]
175    Constraint(String),
176
177    #[error("Transaction error: {0}")]
178    Transaction(String),
179}
180
181#[cfg(feature = "sqlx-postgres")]
182impl From<sqlx::Error> for DatabaseError {
183    fn from(err: sqlx::Error) -> Self {
184        match err {
185            sqlx::Error::Database(db_err) => {
186                if db_err.is_unique_violation() {
187                    DatabaseError::Constraint(db_err.to_string())
188                } else {
189                    DatabaseError::Query(db_err.to_string())
190                }
191            }
192            sqlx::Error::PoolClosed => DatabaseError::Connection("Pool closed".to_string()),
193            sqlx::Error::PoolTimedOut => DatabaseError::Connection("Pool timed out".to_string()),
194            _ => DatabaseError::Query(err.to_string()),
195        }
196    }
197}
198
199#[cfg(feature = "sqlx-postgres")]
200impl From<sqlx::Error> for AuthError {
201    fn from(err: sqlx::Error) -> Self {
202        AuthError::Database(DatabaseError::from(err))
203    }
204}
205
206pub type AuthResult<T> = Result<T, AuthError>;
207
208#[cfg(feature = "axum")]
209impl axum::response::IntoResponse for AuthError {
210    fn into_response(self) -> axum::response::Response {
211        let status = axum::http::StatusCode::from_u16(self.status_code())
212            .unwrap_or(axum::http::StatusCode::INTERNAL_SERVER_ERROR);
213        let message = match self.status_code() {
214            500 => {
215                tracing::error!(error = %self, "Internal server error");
216                "Internal server error".to_string()
217            }
218            _ => self.to_string(),
219        };
220        (
221            status,
222            axum::Json(crate::types::ErrorMessageResponse { message }),
223        )
224            .into_response()
225    }
226}
227
228/// Convert `validator::ValidationErrors` into a standardized error response body.
229///
230/// Returns a 422 response with `{ "code": "VALIDATION_ERROR", "message": "...", "errors": {...} }`.
231pub fn validation_error_response(
232    errors: &validator::ValidationErrors,
233) -> crate::types::AuthResponse {
234    let field_errors: std::collections::HashMap<&str, Vec<String>> = errors
235        .field_errors()
236        .into_iter()
237        .map(|(field, errs)| {
238            let messages: Vec<String> = errs
239                .iter()
240                .map(|e| {
241                    e.message
242                        .as_ref()
243                        .map(|m| m.to_string())
244                        .unwrap_or_else(|| format!("Invalid value for {}", field))
245                })
246                .collect();
247            (field, messages)
248        })
249        .collect();
250
251    let body = crate::types::ValidationErrorResponse {
252        code: "VALIDATION_ERROR",
253        message: "Validation failed",
254        errors: field_errors,
255    };
256
257    crate::types::AuthResponse::json(422, &body)
258        .unwrap_or_else(|_| crate::types::AuthResponse::text(422, "Validation failed"))
259}
260
261/// Validate a request body, returning a parsed + validated value or an error response.
262pub fn validate_request_body<T>(
263    req: &crate::types::AuthRequest,
264) -> Result<T, crate::types::AuthResponse>
265where
266    T: serde::de::DeserializeOwned + validator::Validate,
267{
268    let value: T = req.body_as_json().map_err(|e| {
269        crate::types::AuthResponse::json(
270            400,
271            &crate::types::ErrorMessageResponse {
272                message: format!("Invalid JSON: {}", e),
273            },
274        )
275        .unwrap_or_else(|_| crate::types::AuthResponse::text(400, "Invalid JSON"))
276    })?;
277
278    value
279        .validate()
280        .map_err(|e| validation_error_response(&e))?;
281
282    Ok(value)
283}