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