use ntex::{http::StatusCode, web};
use thiserror::Error;
#[cfg(feature = "json")]
use serde::{Deserialize, Serialize};
#[derive(Debug, Error)]
pub enum AuthError {
#[error("Missing Authorization header")]
MissingHeader,
#[error("Invalid Authorization header format")]
InvalidFormat,
#[error("Invalid Base64 encoding")]
InvalidBase64,
#[error("Invalid user credentials")]
InvalidCredentials,
#[error("User validation failed: {0}")]
ValidationFailed(String),
#[error("Cache operation failed: {0}")]
CacheError(String),
#[error("Configuration error: {0}")]
ConfigError(String),
#[error("Internal server error: {0}")]
InternalError(String),
}
pub type AuthResult<T> = Result<T, AuthError>;
#[derive(Debug)]
#[cfg_attr(feature = "json", derive(Serialize, Deserialize))]
struct AuthErrorResponse {
code: u16,
message: &'static str,
error: String,
#[cfg_attr(feature = "json", serde(skip_serializing_if = "Option::is_none"))]
details: Option<String>,
#[cfg_attr(feature = "json", serde(skip_serializing_if = "Option::is_none"))]
error_id: Option<String>,
}
impl AuthError {
pub fn to_response(&self, realm: &str) -> web::HttpResponse {
self.to_response_with_details(realm, None)
}
pub fn to_response_with_details(
&self,
realm: &str,
details: Option<String>,
) -> web::HttpResponse {
let (status_code, message) = match self {
AuthError::MissingHeader | AuthError::InvalidFormat | AuthError::InvalidBase64 => {
(StatusCode::UNAUTHORIZED, "Authentication required")
}
AuthError::InvalidCredentials => (StatusCode::UNAUTHORIZED, "Invalid credentials"),
AuthError::ValidationFailed(_) => (StatusCode::UNAUTHORIZED, "Validation failed"),
AuthError::ConfigError(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Configuration error"),
AuthError::CacheError(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Cache error"),
AuthError::InternalError(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Internal error"),
};
let error_response = AuthErrorResponse {
code: status_code.as_u16(),
message,
error: self.to_string(),
details,
error_id: Some(self.error_id()),
};
#[cfg(feature = "json")]
let body = serde_json::to_string(&error_response)
.unwrap_or_else(|_| self.fallback_json_response());
#[cfg(not(feature = "json"))]
let body = format!(
r#"{{"code":{},"message":"{}","error":"{}"}}"#,
error_response.code,
error_response.message,
self.escape_json(&error_response.error)
);
let mut binding = web::HttpResponse::build(status_code);
let mut response = binding
.set_header("content-type", "application/json")
.set_header("cache-control", "no-store");
if status_code == StatusCode::UNAUTHORIZED {
let www_authenticate = format!("Basic realm=\"{}\"", self.escape_header_value(realm));
response = response.set_header("www-authenticate", www_authenticate);
}
response.body(body)
}
fn error_id(&self) -> String {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
std::mem::discriminant(self).hash(&mut hasher);
format!("AUTH_{:x}", hasher.finish())
}
#[cfg(not(feature = "json"))]
fn escape_json(&self, s: &str) -> String {
s.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('\n', "\\n")
.replace('\r', "\\r")
.replace('\t', "\\t")
}
fn escape_header_value(&self, s: &str) -> String {
s.replace('"', "\\\"")
}
fn fallback_json_response(&self) -> String {
r#"{"code":500,"message":"Internal error","error":"Response serialization failed"}"#
.to_string()
}
pub fn is_client_error(&self) -> bool {
matches!(
self,
AuthError::MissingHeader
| AuthError::InvalidFormat
| AuthError::InvalidBase64
| AuthError::InvalidCredentials
)
}
pub fn is_server_error(&self) -> bool {
!self.is_client_error()
}
pub fn log_level(&self) -> &'static str {
match self {
AuthError::MissingHeader | AuthError::InvalidCredentials => "info",
AuthError::InvalidFormat | AuthError::InvalidBase64 => "warn",
AuthError::ValidationFailed(_) => "warn",
AuthError::ConfigError(_) | AuthError::InternalError(_) => "error",
AuthError::CacheError(_) => "warn",
}
}
}
impl web::error::WebResponseError for AuthError {
fn status_code(&self) -> StatusCode {
match self {
AuthError::MissingHeader
| AuthError::InvalidFormat
| AuthError::InvalidBase64
| AuthError::InvalidCredentials
| AuthError::ValidationFailed(_) => StatusCode::UNAUTHORIZED,
AuthError::ConfigError(_) | AuthError::CacheError(_) | AuthError::InternalError(_) => {
StatusCode::INTERNAL_SERVER_ERROR
}
}
}
fn error_response(&self, _req: &ntex::web::HttpRequest) -> web::HttpResponse {
self.to_response("Restricted Area")
}
}
#[macro_export]
macro_rules! auth_error {
(missing_header) => {
$crate::AuthError::MissingHeader
};
(invalid_format) => {
$crate::AuthError::InvalidFormat
};
(invalid_base64) => {
$crate::AuthError::InvalidBase64
};
(invalid_credentials) => {
$crate::AuthError::InvalidCredentials
};
(validation_failed, $msg:expr) => {
$crate::AuthError::ValidationFailed($msg.to_string())
};
(cache_error, $msg:expr) => {
$crate::AuthError::CacheError($msg.to_string())
};
(config_error, $msg:expr) => {
$crate::AuthError::ConfigError($msg.to_string())
};
(internal_error, $msg:expr) => {
$crate::AuthError::InternalError($msg.to_string())
};
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_error_classification() {
assert!(AuthError::MissingHeader.is_client_error());
assert!(AuthError::InvalidCredentials.is_client_error());
assert!(AuthError::ConfigError("test".to_string()).is_server_error());
assert!(AuthError::InternalError("test".to_string()).is_server_error());
}
#[test]
fn test_error_id_consistency() {
let error1 = AuthError::MissingHeader;
let error2 = AuthError::MissingHeader;
assert_eq!(error1.error_id(), error2.error_id());
let error3 = AuthError::InvalidCredentials;
assert_ne!(error1.error_id(), error3.error_id());
}
#[cfg(not(feature = "json"))]
#[test]
fn test_json_escaping() {
let error =
AuthError::ValidationFailed("Message with \"quotes\" and\nnew line".to_string());
let escaped = error.escape_json(&error.to_string());
assert!(!escaped.contains('\n'));
assert!(escaped.contains("\\\""));
}
#[test]
fn test_macro_usage() {
let _error1 = auth_error!(missing_header);
let _error2 = auth_error!(validation_failed, "Custom message");
let _error3 = auth_error!(config_error, "Configuration issue");
}
}