Skip to main content

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;
13use crate::extensions::{IsActive, IsAdmin, IsAuthenticated};
14
15/// Private marker to validate that an `AuthState` was created through
16/// official constructors, not through external struct literal construction.
17#[derive(Clone, Debug, PartialEq, Eq)]
18struct AuthStateMarker;
19
20/// Helper struct to store authentication state in request extensions.
21///
22/// This struct is used by authentication middleware to communicate
23/// the authenticated user's information to downstream handlers.
24///
25/// The struct contains a private field to prevent external construction
26/// via struct literal syntax. Use the provided constructors instead.
27///
28/// # Security Note
29///
30/// If this state is serialized and sent to client-side code (e.g., in
31/// a WASM SPA), the permission checks (`is_authenticated()`,
32/// `is_admin()`, `is_active()`) should only be used for **UI display
33/// purposes** (showing/hiding elements). An attacker can modify
34/// client-side state, so all authorization decisions must be enforced
35/// server-side through authentication middleware and permission
36/// classes (see `reinhardt-auth`).
37///
38/// # Example
39///
40/// ```rust,no_run
41/// # use reinhardt_http::AuthState;
42/// # struct Request { extensions: Extensions }
43/// # struct Extensions;
44/// # impl Extensions {
45/// #     fn insert<T>(&mut self, _value: T) {}
46/// #     fn get<T>(&self) -> Option<T> { None }
47/// # }
48/// # let mut request = Request { extensions: Extensions };
49/// // In middleware (after authentication)
50/// request.extensions.insert(AuthState::authenticated("123", false, true));
51///
52/// // In handler (via CurrentUser or directly)
53/// let auth_state: Option<AuthState> = request.extensions.get();
54/// ```
55#[derive(Clone, Debug, PartialEq, Eq)]
56pub struct AuthState {
57	/// The authenticated user's ID as a string.
58	///
59	/// This is typically a UUID or database primary key serialized to string.
60	user_id: String,
61
62	/// Whether the user is authenticated.
63	is_authenticated: bool,
64
65	/// Whether the user has admin/superuser privileges.
66	is_admin: bool,
67
68	/// Whether the user's account is active.
69	is_active: bool,
70
71	/// Private validation marker to prevent external construction.
72	_marker: AuthStateMarker,
73}
74
75impl AuthState {
76	/// Creates a new authenticated state.
77	///
78	/// # Arguments
79	///
80	/// * `user_id` - The authenticated user's ID
81	/// * `is_admin` - Whether the user has admin privileges
82	/// * `is_active` - Whether the user's account is active
83	pub fn authenticated(user_id: impl Into<String>, is_admin: bool, is_active: bool) -> Self {
84		Self {
85			user_id: user_id.into(),
86			is_authenticated: true,
87			is_admin,
88			is_active,
89			_marker: AuthStateMarker,
90		}
91	}
92
93	/// Creates an anonymous (unauthenticated) state.
94	pub fn anonymous() -> Self {
95		Self {
96			user_id: String::new(),
97			is_authenticated: false,
98			is_admin: false,
99			is_active: false,
100			_marker: AuthStateMarker,
101		}
102	}
103
104	/// Create auth state from request extensions.
105	///
106	/// This method first attempts to retrieve an `AuthState` object that was
107	/// inserted directly into extensions (e.g., by custom middleware). If no
108	/// `AuthState` object is found, it falls back to reconstructing one from
109	/// individual newtype-wrapped entries (`IsAuthenticated`, `IsAdmin`,
110	/// `IsActive`) stored in extensions by legacy middleware.
111	///
112	/// # Returns
113	///
114	/// Returns `Some(AuthState)` if an `AuthState` object is found or if both
115	/// a string or UUID user ID exists in the individual entries, `None` otherwise.
116	/// Legacy identity-only entries are treated as authenticated and active unless
117	/// an explicit status wrapper overrides either value.
118	pub fn from_extensions(extensions: &Extensions) -> Option<Self> {
119		// Primary: try to get AuthState object directly
120		if let Some(state) = extensions.get::<AuthState>() {
121			return Some(state);
122		}
123		// Fallback: reconstruct from individual extension entries (backward compatibility)
124		let user_id = extensions
125			.get::<String>()
126			.or_else(|| extensions.get::<uuid::Uuid>().map(|id| id.to_string()))?;
127		let is_authenticated = extensions
128			.get::<IsAuthenticated>()
129			.map(|v| v.0)
130			.unwrap_or(true);
131		let is_admin = extensions.get::<IsAdmin>().map(|v| v.0).unwrap_or(false);
132		let is_active = extensions
133			.get::<IsActive>()
134			.map(|v| v.0)
135			.unwrap_or(is_authenticated);
136		Some(Self {
137			user_id,
138			is_authenticated,
139			is_admin,
140			is_active,
141			_marker: AuthStateMarker,
142		})
143	}
144
145	/// Get the authenticated user's ID.
146	pub fn user_id(&self) -> &str {
147		&self.user_id
148	}
149
150	/// Check if the user is authenticated.
151	pub fn is_authenticated(&self) -> bool {
152		self.is_authenticated
153	}
154
155	/// Check if the user has admin privileges.
156	pub fn is_admin(&self) -> bool {
157		self.is_admin
158	}
159
160	/// Check if the user's account is active.
161	pub fn is_active(&self) -> bool {
162		self.is_active
163	}
164
165	/// Check if user is anonymous (not authenticated).
166	pub fn is_anonymous(&self) -> bool {
167		!self.is_authenticated
168	}
169}
170
171#[cfg(test)]
172mod tests {
173	use super::*;
174	use rstest::rstest;
175
176	#[test]
177	fn test_authenticated() {
178		let state = AuthState::authenticated("user-123", true, true);
179
180		assert_eq!(state.user_id(), "user-123");
181		assert!(state.is_authenticated());
182		assert!(state.is_admin());
183		assert!(state.is_active());
184	}
185
186	#[test]
187	fn test_anonymous() {
188		let state = AuthState::anonymous();
189
190		assert!(state.user_id().is_empty());
191		assert!(!state.is_authenticated());
192		assert!(!state.is_admin());
193		assert!(!state.is_active());
194	}
195
196	#[rstest]
197	fn test_from_extensions_with_authstate_object() {
198		// Arrange
199		let extensions = Extensions::new();
200		let state = AuthState::authenticated("user-456", true, true);
201		extensions.insert(state.clone());
202
203		// Act
204		let result = AuthState::from_extensions(&extensions);
205
206		// Assert
207		assert_eq!(result, Some(state));
208		let retrieved = result.unwrap();
209		assert_eq!(retrieved.user_id(), "user-456");
210		assert!(retrieved.is_authenticated());
211		assert!(retrieved.is_admin());
212		assert!(retrieved.is_active());
213	}
214
215	#[rstest]
216	fn test_from_extensions_with_legacy_identity_defaults_to_authenticated_active() {
217		// Arrange
218		let extensions = Extensions::new();
219		extensions.insert("user-789".to_string());
220		extensions.insert(IsAuthenticated(true));
221
222		// Act
223		let result = AuthState::from_extensions(&extensions);
224
225		// Assert
226		assert!(result.is_some());
227		let retrieved = result.unwrap();
228		assert_eq!(retrieved.user_id(), "user-789");
229		assert!(retrieved.is_authenticated());
230		assert!(!retrieved.is_admin());
231		assert!(retrieved.is_active());
232	}
233
234	#[rstest]
235	fn test_from_extensions_preserves_explicit_inactive_status() {
236		// Arrange
237		let extensions = Extensions::new();
238		extensions.insert("user-789".to_string());
239		extensions.insert(IsAuthenticated(true));
240		extensions.insert(IsActive(false));
241
242		// Act
243		let result = AuthState::from_extensions(&extensions);
244
245		// Assert
246		let retrieved = result.expect("legacy identity should produce an auth state");
247		assert!(retrieved.is_authenticated());
248		assert!(!retrieved.is_active());
249	}
250
251	#[rstest]
252	fn test_from_extensions_with_uuid_user_id() {
253		// Arrange
254		let extensions = Extensions::new();
255		let user_id = uuid::Uuid::now_v7();
256		extensions.insert(user_id);
257		extensions.insert(IsAuthenticated(true));
258		extensions.insert(IsActive(true));
259
260		// Act
261		let result = AuthState::from_extensions(&extensions);
262
263		// Assert
264		let retrieved = result.expect("UUID identity should produce an auth state");
265		assert_eq!(retrieved.user_id(), user_id.to_string());
266		assert!(retrieved.is_authenticated());
267		assert!(retrieved.is_active());
268	}
269
270	#[rstest]
271	fn test_from_extensions_empty() {
272		// Arrange
273		let extensions = Extensions::new();
274
275		// Act
276		let result = AuthState::from_extensions(&extensions);
277
278		// Assert
279		assert_eq!(result, None);
280	}
281
282	#[rstest]
283	fn test_from_extensions_preserves_admin_and_active() {
284		// Arrange
285		let extensions = Extensions::new();
286		let state = AuthState::authenticated("admin-user", true, true);
287		extensions.insert(state);
288
289		// Act
290		let result = AuthState::from_extensions(&extensions);
291
292		// Assert
293		let retrieved = result.unwrap();
294		assert_eq!(retrieved.user_id(), "admin-user");
295		assert!(retrieved.is_authenticated());
296		assert!(retrieved.is_admin());
297		assert!(retrieved.is_active());
298	}
299}