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 a
115	/// string or UUID user ID exists in the individual entries, `None` otherwise.
116	/// Legacy identity entries deny authentication and active status unless
117	/// the corresponding explicit status wrappers are present.
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(false);
131		let is_admin = extensions.get::<IsAdmin>().map(|v| v.0).unwrap_or(false);
132		let is_active = extensions.get::<IsActive>().map(|v| v.0).unwrap_or(false);
133		Some(Self {
134			user_id,
135			is_authenticated,
136			is_admin,
137			is_active,
138			_marker: AuthStateMarker,
139		})
140	}
141
142	/// Get the authenticated user's ID.
143	pub fn user_id(&self) -> &str {
144		&self.user_id
145	}
146
147	/// Check if the user is authenticated.
148	pub fn is_authenticated(&self) -> bool {
149		self.is_authenticated
150	}
151
152	/// Check if the user has admin privileges.
153	pub fn is_admin(&self) -> bool {
154		self.is_admin
155	}
156
157	/// Check if the user's account is active.
158	pub fn is_active(&self) -> bool {
159		self.is_active
160	}
161
162	/// Check if user is anonymous (not authenticated).
163	pub fn is_anonymous(&self) -> bool {
164		!self.is_authenticated
165	}
166}
167
168#[cfg(test)]
169mod tests {
170	use super::*;
171	use rstest::rstest;
172
173	#[test]
174	fn test_authenticated() {
175		let state = AuthState::authenticated("user-123", true, true);
176
177		assert_eq!(state.user_id(), "user-123");
178		assert!(state.is_authenticated());
179		assert!(state.is_admin());
180		assert!(state.is_active());
181	}
182
183	#[test]
184	fn test_anonymous() {
185		let state = AuthState::anonymous();
186
187		assert!(state.user_id().is_empty());
188		assert!(!state.is_authenticated());
189		assert!(!state.is_admin());
190		assert!(!state.is_active());
191	}
192
193	#[rstest]
194	fn test_from_extensions_with_authstate_object() {
195		// Arrange
196		let extensions = Extensions::new();
197		let state = AuthState::authenticated("user-456", true, true);
198		extensions.insert(state.clone());
199
200		// Act
201		let result = AuthState::from_extensions(&extensions);
202
203		// Assert
204		assert_eq!(result, Some(state));
205		let retrieved = result.unwrap();
206		assert_eq!(retrieved.user_id(), "user-456");
207		assert!(retrieved.is_authenticated());
208		assert!(retrieved.is_admin());
209		assert!(retrieved.is_active());
210	}
211
212	#[rstest]
213	fn test_from_extensions_with_legacy_identity_defaults_to_unauthenticated_inactive() {
214		// Arrange
215		let extensions = Extensions::new();
216		extensions.insert("user-789".to_string());
217
218		// Act
219		let result = AuthState::from_extensions(&extensions);
220
221		// Assert
222		assert!(result.is_some());
223		let retrieved = result.unwrap();
224		assert_eq!(retrieved.user_id(), "user-789");
225		assert!(!retrieved.is_authenticated());
226		assert!(!retrieved.is_admin());
227		assert!(!retrieved.is_active());
228	}
229
230	#[rstest]
231	fn test_from_extensions_preserves_explicit_inactive_status() {
232		// Arrange
233		let extensions = Extensions::new();
234		extensions.insert("user-789".to_string());
235		extensions.insert(IsAuthenticated(true));
236		extensions.insert(IsActive(false));
237
238		// Act
239		let result = AuthState::from_extensions(&extensions);
240
241		// Assert
242		let retrieved = result.expect("legacy identity should produce an auth state");
243		assert!(retrieved.is_authenticated());
244		assert!(!retrieved.is_active());
245	}
246
247	#[rstest]
248	fn test_from_extensions_with_uuid_user_id() {
249		// Arrange
250		let extensions = Extensions::new();
251		let user_id = uuid::Uuid::now_v7();
252		extensions.insert(user_id);
253		extensions.insert(IsAuthenticated(true));
254		extensions.insert(IsActive(true));
255
256		// Act
257		let result = AuthState::from_extensions(&extensions);
258
259		// Assert
260		let retrieved = result.expect("UUID identity should produce an auth state");
261		assert_eq!(retrieved.user_id(), user_id.to_string());
262		assert!(retrieved.is_authenticated());
263		assert!(retrieved.is_active());
264	}
265
266	#[rstest]
267	fn test_from_extensions_empty() {
268		// Arrange
269		let extensions = Extensions::new();
270
271		// Act
272		let result = AuthState::from_extensions(&extensions);
273
274		// Assert
275		assert_eq!(result, None);
276	}
277
278	#[rstest]
279	fn test_from_extensions_preserves_admin_and_active() {
280		// Arrange
281		let extensions = Extensions::new();
282		let state = AuthState::authenticated("admin-user", true, true);
283		extensions.insert(state);
284
285		// Act
286		let result = AuthState::from_extensions(&extensions);
287
288		// Assert
289		let retrieved = result.unwrap();
290		assert_eq!(retrieved.user_id(), "admin-user");
291		assert!(retrieved.is_authenticated());
292		assert!(retrieved.is_admin());
293		assert!(retrieved.is_active());
294	}
295}