use axum::http::StatusCode;
use axum::response::{Html, IntoResponse, Response};
use axum::Json;
use serde_json::json;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum AuthError {
#[error("Invalid credentials")]
InvalidCredentials,
#[error("User not found")]
UserNotFound,
#[error("Not found: {0}")]
NotFound(String),
#[error("Unauthorized")]
Unauthorized,
#[error("Forbidden: {0}")]
Forbidden(String),
#[error("Missing entitlements: {0}")]
MissingEntitlements(String),
#[error("Conflict: {0}")]
Conflict(String),
#[error("Validation error: {0}")]
Validation(String),
#[error("Database error: {0}")]
Database(#[from] sqlx::Error),
#[error("JWT error: {0}")]
Jwt(#[from] jsonwebtoken::errors::Error),
#[error("Password hash error: {0}")]
PasswordHash(String),
#[error("Internal error: {0}")]
Internal(String),
#[error("Bad request: {0}")]
BadRequest(String),
#[error("MFA verification required")]
MfaRequired,
#[error("MFA is not enabled for this account")]
MfaNotEnabled,
#[error("Invalid TOTP code")]
InvalidTotpCode,
#[error("Invalid backup code")]
InvalidBackupCode,
#[error("Backup code has already been used")]
BackupCodeAlreadyUsed,
#[error("Token has expired")]
TokenExpired,
#[error("Token has already been used")]
TokenAlreadyUsed,
#[error("Rate limit exceeded")]
RateLimitExceeded,
#[error("Device not trusted")]
DeviceNotTrusted,
#[error("WebAuthn error: {0}")]
WebAuthn(String),
#[error("Encryption error: {0}")]
Encryption(String),
}
impl IntoResponse for AuthError {
fn into_response(self) -> Response {
let (status, message) = match self {
Self::InvalidCredentials => (StatusCode::UNAUTHORIZED, "Invalid credentials"),
Self::UserNotFound => (StatusCode::NOT_FOUND, "User not found"),
Self::NotFound(ref msg) => (StatusCode::NOT_FOUND, msg.as_str()),
Self::Unauthorized => (StatusCode::UNAUTHORIZED, "Unauthorized"),
Self::Forbidden(ref msg) => (StatusCode::FORBIDDEN, msg.as_str()),
Self::MissingEntitlements(_) => {
(StatusCode::FORBIDDEN, "User lacks required entitlements")
}
Self::Conflict(ref msg) => (StatusCode::CONFLICT, msg.as_str()),
Self::Validation(ref msg) | Self::BadRequest(ref msg) => {
(StatusCode::BAD_REQUEST, msg.as_str())
}
Self::Database(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Database error"),
Self::Jwt(_) => (StatusCode::UNAUTHORIZED, "Invalid token"),
Self::PasswordHash(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Password hashing error"),
Self::Internal(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error"),
Self::MfaRequired => (StatusCode::FORBIDDEN, "MFA verification required"),
Self::MfaNotEnabled => (StatusCode::BAD_REQUEST, "MFA is not enabled"),
Self::InvalidTotpCode => (StatusCode::UNAUTHORIZED, "Invalid TOTP code"),
Self::InvalidBackupCode => (StatusCode::UNAUTHORIZED, "Invalid backup code"),
Self::BackupCodeAlreadyUsed => (StatusCode::BAD_REQUEST, "Backup code already used"),
Self::TokenExpired => (StatusCode::UNAUTHORIZED, "Token has expired"),
Self::TokenAlreadyUsed => (StatusCode::BAD_REQUEST, "Token has already been used"),
Self::RateLimitExceeded => (StatusCode::TOO_MANY_REQUESTS, "Rate limit exceeded"),
Self::DeviceNotTrusted => (StatusCode::FORBIDDEN, "Device not trusted"),
Self::WebAuthn(ref msg) => (StatusCode::BAD_REQUEST, msg.as_str()),
Self::Encryption(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Encryption error"),
};
if let Self::MissingEntitlements(required) = self {
let display = if required.trim().is_empty() {
"the required entitlement from your administrator".to_string()
} else {
format!("{required} from your administrator")
};
let body = format!(
"<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><title>Access Required</title></head><body><main><h1>Access required</h1><p>Please request access for {}</p></main></body></html>",
escape_html(&display)
);
(status, Html(body)).into_response()
} else {
let body = json!({
"error": message,
"status": status.as_u16()
});
(status, Json(body)).into_response()
}
}
}
impl From<validator::ValidationErrors> for AuthError {
fn from(err: validator::ValidationErrors) -> Self {
let message = err
.field_errors()
.values()
.next()
.and_then(|errors| errors.first())
.map_or_else(
|| "Invalid input".to_string(),
|error| {
error
.message
.as_ref()
.map_or_else(|| "Invalid input".to_string(), ToString::to_string)
},
);
Self::Validation(message)
}
}
impl From<argon2::password_hash::Error> for AuthError {
fn from(err: argon2::password_hash::Error) -> Self {
Self::PasswordHash(err.to_string())
}
}
fn escape_html(input: &str) -> String {
input
.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
#[cfg(test)]
mod tests {
use super::AuthError;
use argon2::password_hash::{errors::InvalidValue, Error as PasswordHashError};
use axum::{
http::{header::CONTENT_TYPE, StatusCode},
response::IntoResponse,
};
use serde_json::Value;
use validator::{ValidationError, ValidationErrors};
async fn response_body(err: AuthError) -> (u16, Value) {
let response = err.into_response();
let status = response.status().as_u16();
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("body read");
let json: Value = serde_json::from_slice(&body).expect("json body");
(status, json)
}
#[tokio::test]
async fn into_response_maps_status_and_message() {
let cases = vec![
(AuthError::InvalidCredentials, 401, "Invalid credentials"),
(AuthError::UserNotFound, 404, "User not found"),
(AuthError::NotFound("Missing".into()), 404, "Missing"),
(AuthError::Unauthorized, 401, "Unauthorized"),
(AuthError::Forbidden("Nope".into()), 403, "Nope"),
(AuthError::Conflict("Taken".into()), 409, "Taken"),
(AuthError::Validation("Bad input".into()), 400, "Bad input"),
(
AuthError::Database(sqlx::Error::RowNotFound),
500,
"Database error",
),
(
AuthError::Jwt(jsonwebtoken::errors::Error::from(
jsonwebtoken::errors::ErrorKind::InvalidToken,
)),
401,
"Invalid token",
),
(
AuthError::PasswordHash("oops".into()),
500,
"Password hashing error",
),
(
AuthError::Internal("kaput".into()),
500,
"Internal server error",
),
(
AuthError::BadRequest("Missing field".into()),
400,
"Missing field",
),
];
for (error, expected_status, expected_message) in cases {
let (status, body) = response_body(error).await;
assert_eq!(status, expected_status);
assert_eq!(body["error"], expected_message);
assert_eq!(body["status"], expected_status);
}
}
#[test]
fn validation_error_uses_first_message() {
let mut errors = ValidationErrors::new();
let mut field_error = ValidationError::new("length");
field_error.message = Some("Too short".into());
errors.add("username", field_error);
let auth_error = AuthError::from(errors);
assert!(matches!(auth_error, AuthError::Validation(msg) if msg == "Too short"));
}
#[test]
fn validation_error_falls_back_to_default() {
let mut errors = ValidationErrors::new();
errors.add("email", ValidationError::new("email"));
let auth_error = AuthError::from(errors);
assert!(matches!(auth_error, AuthError::Validation(msg) if msg == "Invalid input"));
}
#[test]
fn password_hash_error_is_wrapped() {
let err = PasswordHashError::SaltInvalid(InvalidValue::InvalidFormat);
let auth_error = AuthError::from(err);
assert!(matches!(auth_error, AuthError::PasswordHash(msg) if msg.contains("salt")));
}
#[tokio::test]
async fn missing_entitlements_renders_html_page() {
let response = AuthError::MissingEntitlements("admin".into()).into_response();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
let (parts, body) = response.into_parts();
let content_type = parts
.headers
.get(CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.unwrap_or("");
assert!(content_type.starts_with("text/html"));
let bytes = axum::body::to_bytes(body, usize::MAX)
.await
.expect("body read");
let body = String::from_utf8_lossy(&bytes);
assert!(body.contains("Access required"));
assert!(body.contains("admin"));
}
}