mod codes;
use codes::upstream_code;
pub mod page;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum AuthError {
#[error("{message}")]
Api {
status: u16,
code: Option<String>,
message: String,
},
#[error("{message}")]
Upstream {
status: u16,
code: &'static str,
message: &'static str,
},
#[error("{0}")]
BadRequest(String),
#[error("Invalid request: {0}")]
InvalidRequest(String),
#[error("Validation error: {0}")]
Validation(String),
#[error("Invalid email or password")]
InvalidCredentials,
#[error("Authentication required")]
Unauthenticated,
#[error("{0}")]
AuthenticationFailed(String),
#[error("Session not found or expired")]
SessionNotFound,
#[error("{0}")]
Forbidden(String),
#[error("session creation cancelled by database hook")]
SessionCreationCancelled,
#[error("user creation cancelled by database hook")]
UserCreationCancelled,
#[error("{0}")]
BannedUser(String),
#[error("Insufficient permissions")]
Unauthorized,
#[error("User not found")]
UserNotFound,
#[error("{0}")]
NotFound(String),
#[error("{0}")]
Conflict(String),
#[error("{0}")]
MethodNotAllowed(String),
#[error("{0}")]
PayloadTooLarge(String),
#[error("{0}")]
UnprocessableEntity(String),
#[error("Too many requests")]
RateLimited { retry_after: Option<f64> },
#[error("{0}")]
NotImplemented(String),
#[error("Configuration error: {0}")]
Config(String),
#[error("Database error: {0}")]
Database(#[from] DatabaseError),
#[error("Serialization error: {0}")]
Serialization(#[from] serde_json::Error),
#[error("Plugin error: {plugin} - {message}")]
Plugin { plugin: String, message: String },
#[error("Internal server error: {0}")]
Internal(String),
#[error("Encrypted authentication data failed: {0}")]
Encryption(String),
#[error("Application callback failed: {0}")]
CallbackFailure(Box<Self>),
#[error("Password hashing error: {0}")]
PasswordHash(String),
#[error("JWT error: {0}")]
Jwt(#[from] jsonwebtoken::errors::Error),
}
impl AuthError {
#[must_use]
pub const fn status_code(&self) -> u16 {
match self {
Self::Api { status, .. } | Self::Upstream { status, .. } => *status,
Self::BadRequest(_) | Self::InvalidRequest(_) | Self::Validation(_) => 400,
Self::InvalidCredentials
| Self::Unauthenticated
| Self::AuthenticationFailed(_)
| Self::SessionNotFound => 401,
Self::Forbidden(_)
| Self::SessionCreationCancelled
| Self::UserCreationCancelled
| Self::BannedUser(_)
| Self::Unauthorized => 403,
Self::UserNotFound | Self::NotFound(_) => 404,
Self::Conflict(_) => 409,
Self::MethodNotAllowed(_) => 405,
Self::PayloadTooLarge(_) => 413,
Self::UnprocessableEntity(_) => 422,
Self::RateLimited { .. } => 429,
Self::NotImplemented(_) => 501,
Self::Config(_)
| Self::Database(_)
| Self::Serialization(_)
| Self::Plugin { .. }
| Self::Internal(_)
| Self::Encryption(_)
| Self::CallbackFailure(_)
| Self::PasswordHash(_)
| Self::Jwt(_) => 500,
}
}
pub fn code_from_message(message: &str) -> Option<String> {
upstream_code(message).map(str::to_owned)
}
pub fn error_payload(&self) -> (u16, Option<String>, String) {
let status = self.status_code();
let (code, message) = match self {
Self::Api { code, message, .. } => (code.clone(), message.clone()),
Self::Upstream { code, message, .. } => {
(Some((*code).to_owned()), (*message).to_owned())
}
Self::BannedUser(message) => (Some("BANNED_USER".to_owned()), message.clone()),
Self::BadRequest(_)
| Self::InvalidRequest(_)
| Self::Validation(_)
| Self::InvalidCredentials
| Self::Unauthenticated
| Self::AuthenticationFailed(_)
| Self::SessionNotFound
| Self::Forbidden(_)
| Self::SessionCreationCancelled
| Self::UserCreationCancelled
| Self::Unauthorized
| Self::UserNotFound
| Self::NotFound(_)
| Self::Conflict(_)
| Self::MethodNotAllowed(_)
| Self::PayloadTooLarge(_)
| Self::UnprocessableEntity(_)
| Self::RateLimited { .. }
| Self::NotImplemented(_)
| Self::Config(_)
| Self::Database(_)
| Self::Serialization(_)
| Self::Plugin { .. }
| Self::Internal(_)
| Self::Encryption(_)
| Self::CallbackFailure(_)
| Self::PasswordHash(_)
| Self::Jwt(_) => {
let message = match status {
500 => {
tracing::error!(error = %self, "Internal server error");
"Internal server error".to_owned()
}
_ => self.to_string(),
};
let code = Self::code_from_message(&message);
(code, message)
}
};
(status, code, message)
}
#[must_use]
pub fn to_auth_response(self) -> crate::types::AuthResponse {
if matches!(&self, Self::CallbackFailure(_) | Self::Encryption(_)) {
tracing::error!(error = %self, "Authentication operation failed");
return crate::types::AuthResponse::new(500);
}
let retry_after = self.retry_after_header();
let (status, code, message) = self.error_payload();
let response = crate::types::AuthResponse::json(
status,
&crate::types::ErrorCodeMessageResponse {
code,
message: message.clone(),
},
)
.unwrap_or_else(|_| crate::types::AuthResponse::text(status, &message));
match retry_after {
Some(seconds) => response.with_header("X-Retry-After", seconds),
None => response,
}
}
fn retry_after_header(&self) -> Option<String> {
match self {
Self::RateLimited {
retry_after: Some(seconds),
} => Some(ryu_js::Buffer::new().format(*seconds).to_owned()),
_ => None,
}
}
#[must_use]
pub const fn rate_limited(retry_after: f64) -> Self {
Self::RateLimited {
retry_after: Some(retry_after),
}
}
#[must_use]
pub fn bad_request(message: impl Into<String>) -> Self {
Self::BadRequest(message.into())
}
#[must_use]
pub fn forbidden(message: impl Into<String>) -> Self {
Self::Forbidden(message.into())
}
#[must_use]
pub fn banned_user(message: impl Into<String>) -> Self {
Self::BannedUser(message.into())
}
#[must_use]
pub fn not_found(message: impl Into<String>) -> Self {
Self::NotFound(message.into())
}
#[must_use]
pub fn conflict(message: impl Into<String>) -> Self {
Self::Conflict(message.into())
}
#[must_use]
pub fn method_not_allowed(message: impl Into<String>) -> Self {
Self::MethodNotAllowed(message.into())
}
#[must_use]
pub fn payload_too_large(message: impl Into<String>) -> Self {
Self::PayloadTooLarge(message.into())
}
#[must_use]
pub fn not_implemented(message: impl Into<String>) -> Self {
Self::NotImplemented(message.into())
}
#[must_use]
pub fn plugin(plugin: &str, message: impl Into<String>) -> Self {
Self::Plugin {
plugin: plugin.to_owned(),
message: message.into(),
}
}
#[must_use]
pub fn config(message: impl Into<String>) -> Self {
Self::Config(message.into())
}
#[must_use]
pub fn internal(message: impl Into<String>) -> Self {
Self::Internal(message.into())
}
#[must_use]
pub fn validation(message: impl Into<String>) -> Self {
Self::Validation(message.into())
}
#[must_use]
pub fn authentication_failed(message: impl Into<String>) -> Self {
Self::AuthenticationFailed(message.into())
}
}
#[derive(Error, Debug)]
pub enum DatabaseError {
#[error(
"Multiple accounts match the same accountId for provider {provider:?}. Resolve duplicate account identities before continuing."
)]
AmbiguousAccount { provider: String },
#[error("Connection error: {0}")]
Connection(String),
#[error("Query error: {0}")]
Query(String),
#[error("Migration error: {0}")]
Migration(String),
#[error("Constraint violation: {0}")]
Constraint(String),
#[error("Transaction error: {0}")]
Transaction(String),
}
pub type AuthResult<T> = Result<T, AuthError>;
#[cfg(feature = "axum")]
impl axum::response::IntoResponse for AuthError {
fn into_response(self) -> axum::response::Response {
if matches!(&self, Self::CallbackFailure(_) | Self::Encryption(_)) {
tracing::error!(error = %self, "Authentication operation failed");
return axum::http::StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
let retry_after = self.retry_after_header();
let (status_u16, code, message) = self.error_payload();
let status = axum::http::StatusCode::from_u16(status_u16)
.unwrap_or(axum::http::StatusCode::INTERNAL_SERVER_ERROR);
let mut response = (
status,
axum::Json(crate::types::ErrorCodeMessageResponse { code, message }),
)
.into_response();
if let Some(value) = retry_after.and_then(|value| value.parse().ok()) {
drop(response.headers_mut().insert("x-retry-after", value));
}
response
}
}
#[must_use]
pub fn validation_error_response(
errors: &validator::ValidationErrors,
) -> crate::types::AuthResponse {
let messages: Vec<String> = errors
.field_errors()
.into_iter()
.flat_map(|(field, errs)| {
errs.iter().map(move |e| {
let msg = e
.message
.as_ref()
.map_or_else(|| format!("Invalid value for {field}"), ToString::to_string);
format!("[body.{field}] {msg}")
})
})
.collect();
let message = messages.join("; ");
let body = crate::types::ErrorCodeMessageResponse {
code: Some("VALIDATION_ERROR".to_owned()),
message,
};
crate::types::AuthResponse::json(400, &body)
.unwrap_or_else(|_| crate::types::AuthResponse::text(400, "Validation failed"))
}
pub fn validate_request_body<T>(
req: &crate::types::AuthRequest,
) -> Result<T, crate::types::AuthResponse>
where
T: serde::de::DeserializeOwned + validator::Validate + 'static,
{
let value: T = req.body_as_json().map_err(|e| {
let message = format!("Invalid JSON: {e}");
let code = AuthError::code_from_message(&message);
crate::types::AuthResponse::json(
400,
&crate::types::ErrorCodeMessageResponse { code, message },
)
.unwrap_or_else(|_| crate::types::AuthResponse::text(400, "Invalid JSON"))
})?;
value
.validate()
.map_err(|e| validation_error_response(&e))?;
Ok(value)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bad_request_is_400() {
assert_eq!(AuthError::bad_request("oops").status_code(), 400);
}
#[test]
fn invalid_request_is_400() {
assert_eq!(AuthError::InvalidRequest("x".into()).status_code(), 400);
}
#[test]
fn validation_is_400() {
assert_eq!(AuthError::validation("x").status_code(), 400);
}
#[test]
fn invalid_credentials_is_401() {
assert_eq!(AuthError::InvalidCredentials.status_code(), 401);
}
#[test]
fn unauthenticated_is_401() {
assert_eq!(AuthError::Unauthenticated.status_code(), 401);
}
#[test]
fn session_not_found_is_401() {
assert_eq!(AuthError::SessionNotFound.status_code(), 401);
}
#[test]
fn forbidden_is_403() {
assert_eq!(AuthError::forbidden("nope").status_code(), 403);
}
#[test]
fn unauthorized_is_403() {
assert_eq!(AuthError::Unauthorized.status_code(), 403);
}
#[test]
fn not_found_is_404() {
assert_eq!(AuthError::not_found("gone").status_code(), 404);
assert_eq!(AuthError::UserNotFound.status_code(), 404);
}
#[test]
fn conflict_is_409() {
assert_eq!(AuthError::conflict("dup").status_code(), 409);
}
#[test]
fn unprocessable_entity_is_422() {
assert_eq!(
AuthError::UnprocessableEntity("x".into()).status_code(),
422
);
}
#[test]
fn rate_limited_is_429() {
assert_eq!(
AuthError::RateLimited { retry_after: None }.status_code(),
429
);
}
#[test]
fn rate_limited_responses_publish_retry_after() {
for (seconds, header) in [(9.0, "9"), (2.5, "2.5")] {
let response = AuthError::rate_limited(seconds).to_auth_response();
assert_eq!(response.status, 429);
assert_eq!(
response.headers.get("X-Retry-After").map(String::as_str),
Some(header)
);
#[cfg(feature = "axum")]
{
let response =
axum::response::IntoResponse::into_response(AuthError::rate_limited(seconds));
assert_eq!(response.status(), 429);
assert_eq!(response.headers()["x-retry-after"], header);
}
}
}
#[test]
fn not_implemented_is_501() {
assert_eq!(AuthError::not_implemented("todo").status_code(), 501);
}
#[test]
fn internal_errors_are_500() {
assert_eq!(AuthError::config("bad").status_code(), 500);
assert_eq!(AuthError::internal("fail").status_code(), 500);
assert_eq!(AuthError::plugin("p", "m").status_code(), 500);
assert_eq!(AuthError::PasswordHash("h".into()).status_code(), 500);
assert_eq!(
AuthError::Database(DatabaseError::Connection("c".into())).status_code(),
500
);
}
#[test]
fn code_from_message_returns_the_upstream_constant() {
assert_eq!(
AuthError::code_from_message("User not found").as_deref(),
Some("USER_NOT_FOUND")
);
}
#[test]
fn code_from_message_is_none_for_unknown_messages() {
assert_eq!(AuthError::code_from_message("invalid email!"), None);
assert_eq!(AuthError::code_from_message("Email is the same"), None);
assert_eq!(AuthError::code_from_message(""), None);
}
#[test]
fn code_from_message_covers_plugin_tables() {
assert_eq!(
AuthError::code_from_message("Username is already taken. Please try another.")
.as_deref(),
Some("USERNAME_IS_ALREADY_TAKEN")
);
}
#[test]
fn base_error_codes_are_not_derived_from_the_message() {
for (message, expected) in [
("Invalid callbackURL", "INVALID_CALLBACK_URL"),
("Invalid redirectURL", "INVALID_REDIRECT_URL"),
("Invalid errorCallbackURL", "INVALID_ERROR_CALLBACK_URL"),
(
"Invalid newUserCallbackURL",
"INVALID_NEW_USER_CALLBACK_URL",
),
("Email is already verified", "EMAIL_ALREADY_VERIFIED"),
(
"Verification email isn't enabled",
"VERIFICATION_EMAIL_NOT_ENABLED",
),
(
"Session expired. Re-authenticate to perform this action.",
"SESSION_EXPIRED",
),
(
"Cross-site navigation login blocked. This request appears to be a CSRF attack.",
"CROSS_SITE_NAVIGATION_LOGIN_BLOCKED",
),
] {
assert_eq!(
AuthError::code_from_message(message).as_deref(),
Some(expected),
"message {message:?} must carry the upstream constant"
);
}
}
#[test]
fn error_payload_for_client_error() {
let (status, code, message) = AuthError::bad_request("Missing field").error_payload();
assert_eq!(status, 400);
assert_eq!(code, None);
assert_eq!(message, "Missing field");
let (status_2, code_2, message_2) =
AuthError::bad_request("Field is required").error_payload();
assert_eq!(status_2, 400);
assert_eq!(code_2.as_deref(), Some("MISSING_FIELD"));
assert_eq!(message_2, "Field is required");
}
#[test]
fn error_payload_for_internal_error_hides_details() {
let (status, _code, message) = AuthError::internal("secret detail").error_payload();
assert_eq!(status, 500);
assert_eq!(message, "Internal server error");
}
#[test]
fn to_auth_response_returns_correct_status() {
let resp = AuthError::bad_request("oops").to_auth_response();
assert_eq!(resp.status, 400);
}
#[test]
fn to_auth_response_body_contains_code_and_message() {
let resp = AuthError::UserNotFound.to_auth_response();
assert_eq!(resp.status, 404);
let body: serde_json::Value =
serde_json::from_slice(&resp.body).expect("response body should be valid JSON");
assert_eq!(
(*(body).get("code").unwrap_or(&serde_json::Value::Null)),
"USER_NOT_FOUND"
);
assert_eq!(
(*(body).get("message").unwrap_or(&serde_json::Value::Null)),
"User not found"
);
}
#[test]
fn constructor_helpers_produce_correct_variants() {
assert_eq!(AuthError::bad_request("x").to_string(), "x");
assert_eq!(AuthError::forbidden("x").to_string(), "x");
assert_eq!(AuthError::not_found("x").to_string(), "x");
assert_eq!(AuthError::conflict("x").to_string(), "x");
assert_eq!(AuthError::not_implemented("x").to_string(), "x");
assert_eq!(AuthError::config("x").to_string(), "Configuration error: x");
assert_eq!(
AuthError::internal("x").to_string(),
"Internal server error: x"
);
assert_eq!(
AuthError::validation("x").to_string(),
"Validation error: x"
);
assert_eq!(
AuthError::plugin("p", "m").to_string(),
"Plugin error: p - m"
);
}
#[test]
fn database_error_display() {
let e = DatabaseError::Connection("timeout".into());
assert_eq!(e.to_string(), "Connection error: timeout");
}
#[test]
fn database_error_converts_to_auth_error() {
let db_err = DatabaseError::Query("bad sql".into());
let auth_err: AuthError = db_err.into();
assert_eq!(auth_err.status_code(), 500);
}
#[test]
fn validation_error_response_serializes_field_errors() {
let mut errors = validator::ValidationErrors::new();
let mut error = validator::ValidationError::new("email");
error.message = Some("Email is invalid".into());
errors.add("email", error);
let resp = validation_error_response(&errors);
assert_eq!(resp.status, 400);
let body: serde_json::Value =
serde_json::from_slice(&resp.body).expect("response body should be valid JSON");
assert_eq!(
(*(body).get("code").unwrap_or(&serde_json::Value::Null)),
"VALIDATION_ERROR"
);
assert_eq!(
(*(body).get("message").unwrap_or(&serde_json::Value::Null)),
"[body.email] Email is invalid"
);
}
#[test]
fn fixed_message_variants_display() {
assert_eq!(
AuthError::InvalidCredentials.to_string(),
"Invalid email or password"
);
assert_eq!(
AuthError::Unauthenticated.to_string(),
"Authentication required"
);
assert_eq!(
AuthError::SessionNotFound.to_string(),
"Session not found or expired"
);
assert_eq!(
AuthError::Unauthorized.to_string(),
"Insufficient permissions"
);
assert_eq!(AuthError::UserNotFound.to_string(), "User not found");
assert_eq!(
AuthError::RateLimited { retry_after: None }.to_string(),
"Too many requests"
);
}
#[test]
fn callback_failure_transport_preserves_empty_body_and_public_api_errors() {
let response = AuthError::CallbackFailure(Box::new(AuthError::internal("private cause")))
.to_auth_response();
assert_eq!(response.status, 500);
assert!(response.body.is_empty());
assert!(response.headers.get("content-type").is_none());
let internal = AuthError::internal("private cause").to_auth_response();
assert_eq!(internal.status, 500);
assert_eq!(
serde_json::from_slice::<serde_json::Value>(&internal.body).unwrap(),
serde_json::json!({"message":"Internal server error"})
);
let explicit = AuthError::Api {
status: 500,
code: Some("APPLICATION_ERROR".into()),
message: "public error".into(),
}
.to_auth_response();
assert_eq!(explicit.status, 500);
assert_eq!(
serde_json::from_slice::<serde_json::Value>(&explicit.body).unwrap(),
serde_json::json!({"code":"APPLICATION_ERROR","message":"public error"})
);
}
}