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),
#[error("Rate limit exceeded")]
RateLimited,
}
pub type AuthResult<T> = Result<T, AuthError>;
#[derive(Debug)]
#[cfg_attr(feature = "json", derive(Serialize, Deserialize))]
#[cfg_attr(not(feature = "json"), allow(dead_code))]
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"),
AuthError::RateLimited => (StatusCode::TOO_MANY_REQUESTS, "Rate limit exceeded"),
};
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=\"{}\", charset=\"UTF-8\"",
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 {
let mut out = String::with_capacity(s.len() + 2);
for c in s.chars() {
match c {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if c.is_control() => out.push_str(&format!("\\u{:04x}", c as u32)),
c => out.push(c),
}
}
out
}
fn escape_header_value(&self, s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
c if c.is_control() => {}
c => out.push(c),
}
}
out
}
#[cfg(feature = "json")]
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
| AuthError::RateLimited
)
}
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(_) | AuthError::RateLimited => "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
}
AuthError::RateLimited => StatusCode::TOO_MANY_REQUESTS,
}
}
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());
}
#[test]
fn test_escape_header_value_strips_crlf() {
let error = AuthError::MissingHeader;
let escaped = error.escape_header_value("safe\r\nX-Injected: evil");
assert!(!escaped.contains('\r'));
assert!(!escaped.contains('\n'));
assert!(escaped.contains("X-Injected"));
}
#[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");
}
}