reinhardt_http/auth_state.rs
1//! Authentication state stored in request extensions.
2//!
3//! This module provides [`AuthState`], a helper struct that stores
4//! authentication information in request extensions.
5//!
6//! `AuthState` uses a private validation marker to prevent external construction
7//! via struct literal syntax. Only the provided constructors
8//! ([`AuthState::authenticated`], [`AuthState::anonymous`], [`AuthState::from_extensions`])
9//! can create valid instances, preventing type collision attacks where
10//! malicious code could insert a spoofed auth state into request extensions.
11
12use crate::Extensions;
13
14/// Private marker to validate that an `AuthState` was created through
15/// official constructors, not through external struct literal construction.
16#[derive(Clone, Debug, PartialEq, Eq)]
17struct AuthStateMarker;
18
19/// Helper struct to store authentication state in request extensions.
20///
21/// This struct is used by authentication middleware to communicate
22/// the authenticated user's information to downstream handlers.
23///
24/// The struct contains a private field to prevent external construction
25/// via struct literal syntax. Use the provided constructors instead.
26///
27/// # Security Note
28///
29/// If this state is serialized and sent to client-side code (e.g., in
30/// a WASM SPA), the permission checks (`is_authenticated()`,
31/// `is_admin()`, `is_active()`) should only be used for **UI display
32/// purposes** (showing/hiding elements). An attacker can modify
33/// client-side state, so all authorization decisions must be enforced
34/// server-side through authentication middleware and permission
35/// classes (see `reinhardt-auth`).
36///
37/// # Example
38///
39/// ```rust,no_run
40/// # use reinhardt_http::AuthState;
41/// # struct Request { extensions: Extensions }
42/// # struct Extensions;
43/// # impl Extensions {
44/// # fn insert<T>(&mut self, _value: T) {}
45/// # fn get<T>(&self) -> Option<T> { None }
46/// # }
47/// # let mut request = Request { extensions: Extensions };
48/// // In middleware (after authentication)
49/// request.extensions.insert(AuthState::authenticated("123", false, true));
50///
51/// // In handler (via CurrentUser or directly)
52/// let auth_state: Option<AuthState> = request.extensions.get();
53/// ```
54#[derive(Clone, Debug, PartialEq, Eq)]
55pub struct AuthState {
56 /// The authenticated user's ID as a string.
57 ///
58 /// This is typically a UUID or database primary key serialized to string.
59 user_id: String,
60
61 /// Whether the user is authenticated.
62 is_authenticated: bool,
63
64 /// Whether the user has admin/superuser privileges.
65 is_admin: bool,
66
67 /// Whether the user's account is active.
68 is_active: bool,
69
70 /// Private validation marker to prevent external construction.
71 _marker: AuthStateMarker,
72}
73
74impl AuthState {
75 /// Creates a new authenticated state.
76 ///
77 /// # Arguments
78 ///
79 /// * `user_id` - The authenticated user's ID
80 /// * `is_admin` - Whether the user has admin privileges
81 /// * `is_active` - Whether the user's account is active
82 pub fn authenticated(user_id: impl Into<String>, is_admin: bool, is_active: bool) -> Self {
83 Self {
84 user_id: user_id.into(),
85 is_authenticated: true,
86 is_admin,
87 is_active,
88 _marker: AuthStateMarker,
89 }
90 }
91
92 /// Creates an anonymous (unauthenticated) state.
93 pub fn anonymous() -> Self {
94 Self {
95 user_id: String::new(),
96 is_authenticated: false,
97 is_admin: false,
98 is_active: false,
99 _marker: AuthStateMarker,
100 }
101 }
102
103 /// Create auth state from request extensions.
104 ///
105 /// This method first attempts to retrieve an `AuthState` object that was
106 /// inserted directly into extensions (e.g., by custom middleware). If no
107 /// `AuthState` object is found, it falls back to reconstructing one from
108 /// individual `String` (user_id) and `bool` (is_authenticated) entries
109 /// stored in extensions by legacy middleware. Note that the fallback path
110 /// sets `is_admin` and `is_active` to `false` since those values are not
111 /// available as individual extension entries.
112 ///
113 /// # Returns
114 ///
115 /// Returns `Some(AuthState)` if an `AuthState` object is found or if both
116 /// user_id and is_authenticated individual entries exist, `None` otherwise.
117 pub fn from_extensions(extensions: &Extensions) -> Option<Self> {
118 // Primary: try to get AuthState object directly
119 if let Some(state) = extensions.get::<AuthState>() {
120 return Some(state);
121 }
122 // Fallback: reconstruct from individual extension entries (backward compatibility)
123 Some(Self {
124 user_id: extensions.get::<String>()?,
125 is_authenticated: extensions.get::<bool>()?,
126 is_admin: false,
127 is_active: false,
128 _marker: AuthStateMarker,
129 })
130 }
131
132 /// Get the authenticated user's ID.
133 pub fn user_id(&self) -> &str {
134 &self.user_id
135 }
136
137 /// Check if the user is authenticated.
138 pub fn is_authenticated(&self) -> bool {
139 self.is_authenticated
140 }
141
142 /// Check if the user has admin privileges.
143 pub fn is_admin(&self) -> bool {
144 self.is_admin
145 }
146
147 /// Check if the user's account is active.
148 pub fn is_active(&self) -> bool {
149 self.is_active
150 }
151
152 /// Check if user is anonymous (not authenticated).
153 pub fn is_anonymous(&self) -> bool {
154 !self.is_authenticated
155 }
156}
157
158#[cfg(test)]
159mod tests {
160 use super::*;
161 use rstest::rstest;
162
163 #[test]
164 fn test_authenticated() {
165 let state = AuthState::authenticated("user-123", true, true);
166
167 assert_eq!(state.user_id(), "user-123");
168 assert!(state.is_authenticated());
169 assert!(state.is_admin());
170 assert!(state.is_active());
171 }
172
173 #[test]
174 fn test_anonymous() {
175 let state = AuthState::anonymous();
176
177 assert!(state.user_id().is_empty());
178 assert!(!state.is_authenticated());
179 assert!(!state.is_admin());
180 assert!(!state.is_active());
181 }
182
183 #[rstest]
184 fn test_from_extensions_with_authstate_object() {
185 // Arrange
186 let extensions = Extensions::new();
187 let state = AuthState::authenticated("user-456", true, true);
188 extensions.insert(state.clone());
189
190 // Act
191 let result = AuthState::from_extensions(&extensions);
192
193 // Assert
194 assert_eq!(result, Some(state));
195 let retrieved = result.unwrap();
196 assert_eq!(retrieved.user_id(), "user-456");
197 assert!(retrieved.is_authenticated());
198 assert!(retrieved.is_admin());
199 assert!(retrieved.is_active());
200 }
201
202 #[rstest]
203 fn test_from_extensions_with_individual_values() {
204 // Arrange
205 let extensions = Extensions::new();
206 extensions.insert("user-789".to_string());
207 extensions.insert(true);
208
209 // Act
210 let result = AuthState::from_extensions(&extensions);
211
212 // Assert
213 assert!(result.is_some());
214 let retrieved = result.unwrap();
215 assert_eq!(retrieved.user_id(), "user-789");
216 assert!(retrieved.is_authenticated());
217 assert!(!retrieved.is_admin());
218 assert!(!retrieved.is_active());
219 }
220
221 #[rstest]
222 fn test_from_extensions_empty() {
223 // Arrange
224 let extensions = Extensions::new();
225
226 // Act
227 let result = AuthState::from_extensions(&extensions);
228
229 // Assert
230 assert_eq!(result, None);
231 }
232
233 #[rstest]
234 fn test_from_extensions_preserves_admin_and_active() {
235 // Arrange
236 let extensions = Extensions::new();
237 let state = AuthState::authenticated("admin-user", true, true);
238 extensions.insert(state);
239
240 // Act
241 let result = AuthState::from_extensions(&extensions);
242
243 // Assert
244 let retrieved = result.unwrap();
245 assert_eq!(retrieved.user_id(), "admin-user");
246 assert!(retrieved.is_authenticated());
247 assert!(retrieved.is_admin());
248 assert!(retrieved.is_active());
249 }
250}