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 => "Internal server error".to_string(),
108            _ => self.to_string(),
109        };
110
111        crate::types::AuthResponse::json(
112            status,
113            &serde_json::json!({
114                "message": message
115            }),
116        )
117        .unwrap_or_else(|_| crate::types::AuthResponse::text(status, &message))
118    }
119
120    pub fn bad_request(message: impl Into<String>) -> Self {
121        Self::BadRequest(message.into())
122    }
123
124    pub fn forbidden(message: impl Into<String>) -> Self {
125        Self::Forbidden(message.into())
126    }
127
128    pub fn not_found(message: impl Into<String>) -> Self {
129        Self::NotFound(message.into())
130    }
131
132    pub fn conflict(message: impl Into<String>) -> Self {
133        Self::Conflict(message.into())
134    }
135
136    pub fn not_implemented(message: impl Into<String>) -> Self {
137        Self::NotImplemented(message.into())
138    }
139
140    pub fn plugin(plugin: &str, message: impl Into<String>) -> Self {
141        Self::Plugin {
142            plugin: plugin.to_string(),
143            message: message.into(),
144        }
145    }
146
147    pub fn config(message: impl Into<String>) -> Self {
148        Self::Config(message.into())
149    }
150
151    pub fn internal(message: impl Into<String>) -> Self {
152        Self::Internal(message.into())
153    }
154
155    pub fn validation(message: impl Into<String>) -> Self {
156        Self::Validation(message.into())
157    }
158}
159
160#[derive(Error, Debug)]
161pub enum DatabaseError {
162    #[error("Connection error: {0}")]
163    Connection(String),
164
165    #[error("Query error: {0}")]
166    Query(String),
167
168    #[error("Migration error: {0}")]
169    Migration(String),
170
171    #[error("Constraint violation: {0}")]
172    Constraint(String),
173
174    #[error("Transaction error: {0}")]
175    Transaction(String),
176}
177
178#[cfg(feature = "sqlx-postgres")]
179impl From<sqlx::Error> for DatabaseError {
180    fn from(err: sqlx::Error) -> Self {
181        match err {
182            sqlx::Error::Database(db_err) => {
183                if db_err.is_unique_violation() {
184                    DatabaseError::Constraint(db_err.to_string())
185                } else {
186                    DatabaseError::Query(db_err.to_string())
187                }
188            }
189            sqlx::Error::PoolClosed => DatabaseError::Connection("Pool closed".to_string()),
190            sqlx::Error::PoolTimedOut => DatabaseError::Connection("Pool timed out".to_string()),
191            _ => DatabaseError::Query(err.to_string()),
192        }
193    }
194}
195
196#[cfg(feature = "sqlx-postgres")]
197impl From<sqlx::Error> for AuthError {
198    fn from(err: sqlx::Error) -> Self {
199        AuthError::Database(DatabaseError::from(err))
200    }
201}
202
203pub type AuthResult<T> = Result<T, AuthError>;
204
205/// Convert `validator::ValidationErrors` into a standardized error response body.
206///
207/// Returns a 422 response with `{ "code": "VALIDATION_ERROR", "message": "...", "errors": {...} }`.
208pub fn validation_error_response(
209    errors: &validator::ValidationErrors,
210) -> crate::types::AuthResponse {
211    let field_errors: std::collections::HashMap<&str, Vec<String>> = errors
212        .field_errors()
213        .into_iter()
214        .map(|(field, errs)| {
215            let messages: Vec<String> = errs
216                .iter()
217                .map(|e| {
218                    e.message
219                        .as_ref()
220                        .map(|m| m.to_string())
221                        .unwrap_or_else(|| format!("Invalid value for {}", field))
222                })
223                .collect();
224            (field, messages)
225        })
226        .collect();
227
228    let body = serde_json::json!({
229        "code": "VALIDATION_ERROR",
230        "message": "Validation failed",
231        "errors": field_errors,
232    });
233
234    crate::types::AuthResponse::json(422, &body)
235        .unwrap_or_else(|_| crate::types::AuthResponse::text(422, "Validation failed"))
236}
237
238/// Validate a request body, returning a parsed + validated value or an error response.
239pub fn validate_request_body<T>(
240    req: &crate::types::AuthRequest,
241) -> Result<T, crate::types::AuthResponse>
242where
243    T: serde::de::DeserializeOwned + validator::Validate,
244{
245    let value: T = req.body_as_json().map_err(|e| {
246        crate::types::AuthResponse::json(
247            400,
248            &serde_json::json!({
249                "message": format!("Invalid JSON: {}", e),
250            }),
251        )
252        .unwrap_or_else(|_| crate::types::AuthResponse::text(400, "Invalid JSON"))
253    })?;
254
255    value
256        .validate()
257        .map_err(|e| validation_error_response(&e))?;
258
259    Ok(value)
260}