Skip to main content

fraiseql_auth/
error_sanitizer.rs

1//! Error sanitization layer — separates user-facing messages from internal details.
2//!
3//! Authentication errors often contain internal information that must never reach API
4//! clients (database query details, key material references, stack traces). This module
5//! provides [`SanitizedError`] and [`AuthErrorSanitizer`] to ensure only safe, generic
6//! messages are returned in API responses while full details are retained for server-side
7//! logging.
8
9use std::fmt;
10
11/// A sanitizable error that separates user-facing and internal messages
12#[derive(Debug, Clone)]
13pub struct SanitizedError {
14    /// User-facing message (safe for API responses)
15    user_message:     String,
16    /// Internal message (for logging only)
17    internal_message: String,
18}
19
20impl SanitizedError {
21    /// Create a new sanitized error
22    pub fn new(user_message: impl Into<String>, internal_message: impl Into<String>) -> Self {
23        Self {
24            user_message:     user_message.into(),
25            internal_message: internal_message.into(),
26        }
27    }
28
29    /// Get the user-facing message (safe for API responses)
30    pub fn user_facing(&self) -> &str {
31        &self.user_message
32    }
33
34    /// Get the internal message (for logging only)
35    pub fn internal(&self) -> &str {
36        &self.internal_message
37    }
38}
39
40impl fmt::Display for SanitizedError {
41    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
42        // Display uses user-facing message (safe for logs)
43        write!(f, "{}", self.user_message)
44    }
45}
46
47impl std::error::Error for SanitizedError {}
48
49/// Helper trait for creating sanitized errors from standard error types.
50///
51/// The method `.sanitized(msg)` converts any `Display` type into a [`SanitizedError`],
52/// keeping the original message for server-side logging while returning a safe message
53/// in the API response.
54pub trait Sanitize {
55    /// Convert to a sanitized error
56    fn sanitized(self, user_message: &str) -> SanitizedError;
57}
58
59impl<E: fmt::Display> Sanitize for E {
60    fn sanitized(self, user_message: &str) -> SanitizedError {
61        SanitizedError::new(user_message, self.to_string())
62    }
63}
64
65/// Pre-defined error messages for common authentication scenarios
66pub mod messages {
67    /// Generic authentication failure message
68    pub const AUTH_FAILED: &str = "Authentication failed";
69
70    /// Generic permission denied message
71    pub const PERMISSION_DENIED: &str = "Permission denied";
72
73    /// Generic service error message
74    pub const SERVICE_UNAVAILABLE: &str = "Service temporarily unavailable";
75
76    /// Generic request error message
77    pub const REQUEST_FAILED: &str = "Request failed";
78
79    /// Invalid state (CSRF token)
80    pub const INVALID_STATE: &str = "Authentication failed";
81
82    /// Token expired
83    pub const TOKEN_EXPIRED: &str = "Authentication failed";
84
85    /// Invalid signature
86    pub const INVALID_SIGNATURE: &str = "Authentication failed";
87
88    /// Session expired
89    pub const SESSION_EXPIRED: &str = "Authentication failed";
90
91    /// Session revoked
92    pub const SESSION_REVOKED: &str = "Authentication failed";
93}
94
95/// Error sanitization for authentication errors
96pub struct AuthErrorSanitizer;
97
98impl AuthErrorSanitizer {
99    /// Sanitize JWT validation error
100    pub fn jwt_validation_error(internal_error: &str) -> SanitizedError {
101        SanitizedError::new(messages::AUTH_FAILED, internal_error)
102    }
103
104    /// Sanitize OIDC provider error
105    pub fn oidc_provider_error(internal_error: &str) -> SanitizedError {
106        SanitizedError::new(messages::AUTH_FAILED, internal_error)
107    }
108
109    /// Sanitize session token error
110    pub fn session_token_error(internal_error: &str) -> SanitizedError {
111        SanitizedError::new(messages::AUTH_FAILED, internal_error)
112    }
113
114    /// Sanitize CSRF state error
115    pub fn csrf_state_error(internal_error: &str) -> SanitizedError {
116        SanitizedError::new(messages::INVALID_STATE, internal_error)
117    }
118
119    /// Sanitize permission/authorization error
120    pub fn permission_error(internal_error: &str) -> SanitizedError {
121        SanitizedError::new(messages::PERMISSION_DENIED, internal_error)
122    }
123
124    /// Sanitize database error
125    pub fn database_error(internal_error: &str) -> SanitizedError {
126        SanitizedError::new(messages::SERVICE_UNAVAILABLE, internal_error)
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    #[allow(clippy::wildcard_imports)]
133    // Reason: test module — wildcard keeps test boilerplate minimal
134    use super::*;
135
136    #[test]
137    fn test_sanitized_error_creation() {
138        let error = SanitizedError::new(
139            "Authentication failed",
140            "JWT signature validation failed at cryptographic boundary",
141        );
142
143        assert_eq!(error.user_facing(), "Authentication failed");
144        assert!(error.internal().contains("cryptographic"));
145    }
146
147    #[test]
148    fn test_sanitized_error_display() {
149        let error = SanitizedError::new(
150            "Authentication failed",
151            "Internal database error: constraint violation",
152        );
153
154        // Display should show user message
155        assert_eq!(format!("{}", error), "Authentication failed");
156    }
157
158    #[test]
159    fn test_auth_error_sanitizer_jwt() {
160        let error =
161            AuthErrorSanitizer::jwt_validation_error("RS256 signature mismatch at offset 512");
162
163        assert_eq!(error.user_facing(), messages::AUTH_FAILED);
164        assert!(error.internal().contains("RS256"));
165    }
166
167    #[test]
168    fn test_auth_error_sanitizer_permission() {
169        let error = AuthErrorSanitizer::permission_error(
170            "User lacks role=admin for operation write:config",
171        );
172
173        assert_eq!(error.user_facing(), messages::PERMISSION_DENIED);
174        assert!(error.internal().contains("role=admin"));
175    }
176
177    #[test]
178    fn test_sanitizable_trait() {
179        let std_error = "Socket error: Connection refused".to_string();
180        let sanitized = std_error.sanitized("Service temporarily unavailable");
181
182        assert_eq!(sanitized.user_facing(), "Service temporarily unavailable");
183        assert_eq!(sanitized.internal(), "Socket error: Connection refused");
184    }
185}