1use ntex::{http::StatusCode, web};
4use thiserror::Error;
5
6#[cfg(feature = "json")]
7use serde::{Deserialize, Serialize};
8
9#[derive(Debug, Error)]
11pub enum AuthError {
12 #[error("Missing Authorization header")]
13 MissingHeader,
15
16 #[error("Invalid Authorization header format")]
17 InvalidFormat,
19
20 #[error("Invalid Base64 encoding")]
21 InvalidBase64,
23
24 #[error("Invalid user credentials")]
25 InvalidCredentials,
27
28 #[error("User validation failed: {0}")]
29 ValidationFailed(String),
31
32 #[error("Cache operation failed: {0}")]
33 CacheError(String),
35
36 #[error("Configuration error: {0}")]
37 ConfigError(String),
39
40 #[error("Internal server error: {0}")]
41 InternalError(String),
43
44 #[error("Rate limit exceeded")]
45 RateLimited,
47}
48
49pub type AuthResult<T> = Result<T, AuthError>;
51
52#[derive(Debug)]
54#[cfg_attr(feature = "json", derive(Serialize, Deserialize))]
55#[cfg_attr(not(feature = "json"), allow(dead_code))]
56struct AuthErrorResponse {
57 code: u16,
58 message: &'static str,
59 error: String,
60 #[cfg_attr(feature = "json", serde(skip_serializing_if = "Option::is_none"))]
61 details: Option<String>,
62 #[cfg_attr(feature = "json", serde(skip_serializing_if = "Option::is_none"))]
63 error_id: Option<String>,
64}
65
66impl AuthError {
67 pub fn to_response(&self, realm: &str) -> web::HttpResponse {
69 self.to_response_with_details(realm, None)
70 }
71
72 pub fn to_response_with_details(
74 &self,
75 realm: &str,
76 details: Option<String>,
77 ) -> web::HttpResponse {
78 let (status_code, message) = match self {
79 AuthError::MissingHeader | AuthError::InvalidFormat | AuthError::InvalidBase64 => {
80 (StatusCode::UNAUTHORIZED, "Authentication required")
81 }
82 AuthError::InvalidCredentials => (StatusCode::UNAUTHORIZED, "Invalid credentials"),
83 AuthError::ValidationFailed(_) => (StatusCode::UNAUTHORIZED, "Validation failed"),
84 AuthError::ConfigError(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Configuration error"),
85 AuthError::CacheError(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Cache error"),
86 AuthError::InternalError(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Internal error"),
87 AuthError::RateLimited => (StatusCode::TOO_MANY_REQUESTS, "Rate limit exceeded"),
88 };
89
90 let error_response = AuthErrorResponse {
91 code: status_code.as_u16(),
92 message,
93 error: self.to_string(),
94 details,
95 error_id: Some(self.error_id()),
96 };
97
98 #[cfg(feature = "json")]
99 let body = serde_json::to_string(&error_response)
100 .unwrap_or_else(|_| self.fallback_json_response());
101
102 #[cfg(not(feature = "json"))]
103 let body = format!(
104 r#"{{"code":{},"message":"{}","error":"{}"}}"#,
105 error_response.code,
106 error_response.message,
107 self.escape_json(&error_response.error)
108 );
109
110 let mut binding = web::HttpResponse::build(status_code);
111 let mut response = binding
112 .set_header("content-type", "application/json")
113 .set_header("cache-control", "no-store");
114
115 if status_code == StatusCode::UNAUTHORIZED {
117 let www_authenticate = format!(
118 "Basic realm=\"{}\", charset=\"UTF-8\"",
119 self.escape_header_value(realm)
120 );
121 response = response.set_header("www-authenticate", www_authenticate);
122 }
123
124 response.body(body)
125 }
126
127 fn error_id(&self) -> String {
129 use std::collections::hash_map::DefaultHasher;
130 use std::hash::{Hash, Hasher};
131
132 let mut hasher = DefaultHasher::new();
133 std::mem::discriminant(self).hash(&mut hasher);
134 format!("AUTH_{:x}", hasher.finish())
135 }
136
137 #[cfg(not(feature = "json"))]
138 fn escape_json(&self, s: &str) -> String {
140 let mut out = String::with_capacity(s.len() + 2);
141 for c in s.chars() {
142 match c {
143 '\\' => out.push_str("\\\\"),
144 '"' => out.push_str("\\\""),
145 '\n' => out.push_str("\\n"),
146 '\r' => out.push_str("\\r"),
147 '\t' => out.push_str("\\t"),
148 c if c.is_control() => out.push_str(&format!("\\u{:04x}", c as u32)),
149 c => out.push(c),
150 }
151 }
152 out
153 }
154
155 fn escape_header_value(&self, s: &str) -> String {
158 let mut out = String::with_capacity(s.len());
159 for c in s.chars() {
160 match c {
161 '"' => out.push_str("\\\""),
162 '\\' => out.push_str("\\\\"),
163 c if c.is_control() => {}
164 c => out.push(c),
165 }
166 }
167 out
168 }
169
170 #[cfg(feature = "json")]
172 fn fallback_json_response(&self) -> String {
173 r#"{"code":500,"message":"Internal error","error":"Response serialization failed"}"#
174 .to_string()
175 }
176
177 pub fn is_client_error(&self) -> bool {
179 matches!(
180 self,
181 AuthError::MissingHeader
182 | AuthError::InvalidFormat
183 | AuthError::InvalidBase64
184 | AuthError::InvalidCredentials
185 | AuthError::RateLimited
186 )
187 }
188
189 pub fn is_server_error(&self) -> bool {
191 !self.is_client_error()
192 }
193
194 pub fn log_level(&self) -> &'static str {
196 match self {
197 AuthError::MissingHeader | AuthError::InvalidCredentials => "info",
198 AuthError::InvalidFormat | AuthError::InvalidBase64 => "warn",
199 AuthError::ValidationFailed(_) => "warn",
200 AuthError::ConfigError(_) | AuthError::InternalError(_) => "error",
201 AuthError::CacheError(_) | AuthError::RateLimited => "warn",
202 }
203 }
204}
205
206impl web::error::WebResponseError for AuthError {
207 fn status_code(&self) -> StatusCode {
208 match self {
209 AuthError::MissingHeader
210 | AuthError::InvalidFormat
211 | AuthError::InvalidBase64
212 | AuthError::InvalidCredentials
213 | AuthError::ValidationFailed(_) => StatusCode::UNAUTHORIZED,
214
215 AuthError::ConfigError(_) | AuthError::CacheError(_) | AuthError::InternalError(_) => {
216 StatusCode::INTERNAL_SERVER_ERROR
217 }
218
219 AuthError::RateLimited => StatusCode::TOO_MANY_REQUESTS,
220 }
221 }
222
223 fn error_response(&self, _req: &ntex::web::HttpRequest) -> web::HttpResponse {
224 self.to_response("Restricted Area")
225 }
226}
227
228#[macro_export]
230macro_rules! auth_error {
231 (missing_header) => {
232 $crate::AuthError::MissingHeader
233 };
234 (invalid_format) => {
235 $crate::AuthError::InvalidFormat
236 };
237 (invalid_base64) => {
238 $crate::AuthError::InvalidBase64
239 };
240 (invalid_credentials) => {
241 $crate::AuthError::InvalidCredentials
242 };
243 (validation_failed, $msg:expr) => {
244 $crate::AuthError::ValidationFailed($msg.to_string())
245 };
246 (cache_error, $msg:expr) => {
247 $crate::AuthError::CacheError($msg.to_string())
248 };
249 (config_error, $msg:expr) => {
250 $crate::AuthError::ConfigError($msg.to_string())
251 };
252 (internal_error, $msg:expr) => {
253 $crate::AuthError::InternalError($msg.to_string())
254 };
255}
256
257#[cfg(test)]
258mod tests {
259 use super::*;
260
261 #[test]
262 fn test_error_classification() {
263 assert!(AuthError::MissingHeader.is_client_error());
264 assert!(AuthError::InvalidCredentials.is_client_error());
265 assert!(AuthError::ConfigError("test".to_string()).is_server_error());
266 assert!(AuthError::InternalError("test".to_string()).is_server_error());
267 }
268
269 #[test]
270 fn test_error_id_consistency() {
271 let error1 = AuthError::MissingHeader;
272 let error2 = AuthError::MissingHeader;
273 assert_eq!(error1.error_id(), error2.error_id());
274
275 let error3 = AuthError::InvalidCredentials;
276 assert_ne!(error1.error_id(), error3.error_id());
277 }
278
279 #[test]
280 fn test_escape_header_value_strips_crlf() {
281 let error = AuthError::MissingHeader;
282 let escaped = error.escape_header_value("safe\r\nX-Injected: evil");
285 assert!(!escaped.contains('\r'));
286 assert!(!escaped.contains('\n'));
287 assert!(escaped.contains("X-Injected"));
290 }
291
292 #[cfg(not(feature = "json"))]
293 #[test]
294 fn test_json_escaping() {
295 let error =
296 AuthError::ValidationFailed("Message with \"quotes\" and\nnew line".to_string());
297 let escaped = error.escape_json(&error.to_string());
298 assert!(!escaped.contains('\n'));
299 assert!(escaped.contains("\\\""));
300 }
301
302 #[test]
303 fn test_macro_usage() {
304 let _error1 = auth_error!(missing_header);
305 let _error2 = auth_error!(validation_failed, "Custom message");
306 let _error3 = auth_error!(config_error, "Configuration issue");
307 }
308}