Skip to main content

reinhardt_middleware/session/
value.rs

1//! Typed session-value extractors usable directly in handler signatures.
2//!
3//! Four flavours mirror the rest of the Reinhardt extractor surface:
4//!
5//! - [`SessionValue<T>`] reads the identity reference at `session["user_id"]`
6//!   and deserialises it as `T`; 401 when the session or key is missing.
7//! - [`OptionalSessionValue<T>`] is the optional variant: any failure
8//!   collapses to `OptionalSessionValue(None)` rather than propagating.
9//! - [`SessionValueNamed<K, T>`] reads a custom session key chosen at
10//!   compile time via a marker type implementing [`SessionKey`].
11//! - [`OptionalSessionValueNamed<K, T>`] is the optional variant of
12//!   [`SessionValueNamed<K, T>`]: a missing/unreadable value collapses to
13//!   `None` instead of failing extraction.
14//!
15//! Each extractor is wired through both `Injectable` (for `#[inject]`
16//! parameters) **and** `FromRequest` (for `Path(...)`-style auto-extraction
17//! without the `#[inject]` attribute). Pick whichever ergonomics you
18//! prefer:
19//!
20//! ```rust,ignore
21//! use reinhardt::middleware::session::{OptionalSessionValue, SessionValue};
22//!
23//! // Auto-extraction (no `#[inject]`, matches `Path(...)` ergonomics).
24//! #[server_fn]
25//! pub async fn session_identity(
26//!     SessionValue(user_id): SessionValue<i64>,
27//! ) -> Result<i64, ServerFnError> { Ok(user_id) }
28//!
29//! // Equivalent legacy form with `#[inject]`.
30//! #[server_fn]
31//! pub async fn session_identity(
32//!     #[inject] SessionValue(user_id): SessionValue<i64>,
33//! ) -> Result<i64, ServerFnError> { Ok(user_id) }
34//! ```
35//!
36//! A stored identity is not proof of current authentication or authorization.
37//! Before granting access, resolve the account and validate its current active
38//! and privilege state, or consume an [`reinhardt_http::AuthState`] populated by
39//! middleware that performs that validation.
40//!
41//! See issue #4446 for the motivating discussion.
42
43use async_trait::async_trait;
44use reinhardt_di::params::{ParamContext, ParamError, ParamResult, extract::FromRequest};
45use reinhardt_di::{DiError, DiResult, Injectable, InjectionContext};
46use reinhardt_http::Request;
47use serde::de::DeserializeOwned;
48use std::fmt::{self, Debug};
49use std::marker::PhantomData;
50use std::ops::Deref;
51
52use super::data::{SessionData, USER_ID_SESSION_KEY};
53
54/// Marker trait identifying a session-storage key at the type level.
55///
56/// Implementors are zero-sized marker types similar to
57/// `reinhardt_di::params::CookieName` — define one type per logical key
58/// and reuse it across handlers:
59///
60/// ```rust,ignore
61/// use reinhardt::middleware::session::{SessionKey, SessionValueNamed};
62///
63/// pub struct TenantIdKey;
64/// impl SessionKey for TenantIdKey {
65///     const KEY: &'static str = "tenant_id";
66/// }
67///
68/// #[server_fn]
69/// pub async fn current_tenant(
70///     SessionValueNamed::<TenantIdKey, i64>(tenant_id): SessionValueNamed<TenantIdKey, i64>,
71/// ) -> Result<TenantInfo, ServerFnError> { /* ... */ }
72/// ```
73pub trait SessionKey: Send + Sync + 'static {
74	/// The session-store key whose value this marker maps to.
75	const KEY: &'static str;
76}
77
78/// Default marker pointing at [`USER_ID_SESSION_KEY`], which stores an identity
79/// reference and does not by itself establish current authentication.
80#[derive(Debug, Clone, Copy)]
81pub struct UserIdKey;
82
83impl SessionKey for UserIdKey {
84	const KEY: &'static str = USER_ID_SESSION_KEY;
85}
86
87/// Required typed session-value extractor.
88///
89/// Resolves the [`USER_ID_SESSION_KEY`] entry from the active
90/// [`SessionData`], deserialises it as `T`, and fails extraction when the
91/// key is missing or the value cannot be deserialised. Use this extractor
92/// to retrieve an identity reference. The absent case surfaces as HTTP 401 via
93/// `CoreError::Authentication`. This extractor does not load or validate the
94/// referenced account; authorization requires a separate current-account
95/// check or validated [`reinhardt_http::AuthState`].
96///
97/// # Usage
98///
99/// ```rust,ignore
100/// use reinhardt::middleware::session::SessionValue;
101///
102/// #[server_fn]
103/// pub async fn session_identity(
104///     SessionValue(user_id): SessionValue<i64>,
105/// ) -> Result<i64, ServerFnError> {
106///     // Resolve and validate the account before using this identity for access.
107///     Ok(user_id)
108/// }
109/// ```
110///
111/// Adding `#[inject]` continues to work for code that prefers explicit
112/// dependency markers (see the module-level docs).
113#[derive(Debug, Clone)]
114pub struct SessionValue<T>(pub T);
115
116/// Optional typed session-value extractor.
117///
118/// Identical to [`SessionValue<T>`] except extraction never fails: when
119/// the session is missing, expired, or carries no value at
120/// [`USER_ID_SESSION_KEY`], the extractor yields
121/// `OptionalSessionValue(None)`. Use this on handlers that may serve
122/// both anonymous and authenticated callers (a public "/current_user"
123/// endpoint, for instance).
124#[derive(Debug, Clone)]
125pub struct OptionalSessionValue<T>(pub Option<T>);
126
127/// Typed session-value extractor parameterised by a [`SessionKey`].
128///
129/// Generalises [`SessionValue<T>`] to keys other than
130/// [`USER_ID_SESSION_KEY`]. Construct one marker per logical key (see
131/// the [`SessionKey`] trait docs) and use the marker as the first type
132/// parameter:
133///
134/// ```rust,ignore
135/// use reinhardt::middleware::session::{SessionKey, SessionValueNamed};
136///
137/// pub struct TenantIdKey;
138/// impl SessionKey for TenantIdKey {
139///     const KEY: &'static str = "tenant_id";
140/// }
141///
142/// #[server_fn]
143/// pub async fn current_tenant(
144///     SessionValueNamed::<TenantIdKey, i64>(tenant_id): SessionValueNamed<TenantIdKey, i64>,
145/// ) -> Result<TenantInfo, ServerFnError> { /* ... */ }
146/// ```
147pub struct SessionValueNamed<K: SessionKey, T> {
148	value: T,
149	_phantom: PhantomData<fn() -> K>,
150}
151
152impl<K: SessionKey, T> SessionValueNamed<K, T> {
153	/// Construct a `SessionValueNamed` directly from a value. Primarily
154	/// useful in tests where extraction is bypassed.
155	pub fn new(value: T) -> Self {
156		Self {
157			value,
158			_phantom: PhantomData,
159		}
160	}
161
162	/// Unwrap the extractor and return the inner value.
163	pub fn into_inner(self) -> T {
164		self.value
165	}
166}
167
168impl<K: SessionKey, T> Deref for SessionValueNamed<K, T> {
169	type Target = T;
170
171	fn deref(&self) -> &Self::Target {
172		&self.value
173	}
174}
175
176impl<K: SessionKey, T: Debug> Debug for SessionValueNamed<K, T> {
177	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
178		f.debug_struct("SessionValueNamed")
179			.field("key", &K::KEY)
180			.field("value", &self.value)
181			.finish()
182	}
183}
184
185impl<K: SessionKey, T: Clone> Clone for SessionValueNamed<K, T> {
186	fn clone(&self) -> Self {
187		Self {
188			value: self.value.clone(),
189			_phantom: PhantomData,
190		}
191	}
192}
193
194/// Optional typed session-value extractor parameterised by a [`SessionKey`].
195///
196/// Generalises [`OptionalSessionValue<T>`] to keys other than
197/// [`USER_ID_SESSION_KEY`], mirroring the relationship between
198/// [`SessionValue<T>`] and [`SessionValueNamed<K, T>`]. Extraction never
199/// fails: when the session is missing, expired, or carries no value at
200/// `K::KEY`, the extractor yields `None` rather than propagating the
201/// underlying error. Use this on handlers that accept a custom session key
202/// and may serve both anonymous and authenticated callers.
203///
204/// ```rust,ignore
205/// use reinhardt::middleware::session::{OptionalSessionValueNamed, SessionKey};
206///
207/// pub struct TenantIdKey;
208/// impl SessionKey for TenantIdKey {
209///     const KEY: &'static str = "tenant_id";
210/// }
211///
212/// #[server_fn]
213/// pub async fn current_tenant_opt(
214///     extractor: OptionalSessionValueNamed<TenantIdKey, i64>,
215/// ) -> Result<Option<TenantInfo>, ServerFnError> {
216///     let tenant_id: Option<i64> = extractor.into_inner();
217///     /* ... */
218/// }
219/// ```
220pub struct OptionalSessionValueNamed<K: SessionKey, T> {
221	value: Option<T>,
222	_phantom: PhantomData<fn() -> K>,
223}
224
225impl<K: SessionKey, T> OptionalSessionValueNamed<K, T> {
226	/// Construct an `OptionalSessionValueNamed` directly from an
227	/// `Option<T>`. Primarily useful in tests where extraction is
228	/// bypassed.
229	pub fn new(value: Option<T>) -> Self {
230		Self {
231			value,
232			_phantom: PhantomData,
233		}
234	}
235
236	/// Unwrap the extractor and return the inner `Option<T>`.
237	pub fn into_inner(self) -> Option<T> {
238		self.value
239	}
240}
241
242impl<K: SessionKey, T> Deref for OptionalSessionValueNamed<K, T> {
243	type Target = Option<T>;
244
245	fn deref(&self) -> &Self::Target {
246		&self.value
247	}
248}
249
250impl<K: SessionKey, T: Debug> Debug for OptionalSessionValueNamed<K, T> {
251	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
252		f.debug_struct("OptionalSessionValueNamed")
253			.field("key", &K::KEY)
254			.field("value", &self.value)
255			.finish()
256	}
257}
258
259impl<K: SessionKey, T: Clone> Clone for OptionalSessionValueNamed<K, T> {
260	fn clone(&self) -> Self {
261		Self {
262			value: self.value.clone(),
263			_phantom: PhantomData,
264		}
265	}
266}
267
268// ---------------------------------------------------------------------------
269// Internal helpers shared between `Injectable` and `FromRequest` impls.
270// ---------------------------------------------------------------------------
271
272/// Load the active `SessionData` via the standard `Injectable` path,
273/// then extract the value at `key` and deserialise it as `T`.
274async fn load_session_value_via_di<T>(ctx: &InjectionContext, key: &str) -> DiResult<T>
275where
276	T: DeserializeOwned + Send + Sync + 'static,
277{
278	let session = SessionData::inject(ctx).await?;
279	session.get::<T>(key).ok_or_else(|| {
280		DiError::Authentication(format!(
281			"SessionValue<{}>: no value stored under session key '{}'",
282			std::any::type_name::<T>(),
283			key,
284		))
285	})
286}
287
288/// Reach the request-scoped `InjectionContext` and delegate to
289/// [`load_session_value_via_di`]. Wraps the resulting `DiError` into a
290/// `ParamError` so the handler macro can surface the right HTTP status.
291async fn load_session_value_via_request<T>(req: &Request, key: &str) -> ParamResult<T>
292where
293	T: DeserializeOwned + Send + Sync + 'static,
294{
295	let di_ctx = req.get_di_context::<InjectionContext>().ok_or_else(|| {
296		// Missing DI context is a server-side misconfiguration (the router
297		// was not wired with `.with_di_context()` or `SessionMiddleware`),
298		// not an unauthenticated request. Surface it as `Internal` so the
299		// handler returns HTTP 500 rather than masking it as a 401.
300		ParamError::Internal(
301			"SessionValue: DI context not available on the request. \
302			 Ensure the router is configured with `.with_di_context()` and \
303			 `SessionMiddleware` is installed in the middleware chain."
304				.to_string(),
305		)
306	})?;
307	load_session_value_via_di::<T>(&di_ctx, key)
308		.await
309		.map_err(di_error_to_param_error)
310}
311
312/// Project `DiError` into the matching `ParamError` variant. Only the
313/// variants that genuinely represent a missing or unauthenticated identity
314/// (`Authentication`, `NotFound`) collapse into `ParamError::Authentication`
315/// so they reach the response as HTTP 401 (see #4446 + `ParamError::Authentication`
316/// in `reinhardt-di`). Other variants describe infrastructure-level failures
317/// (DI scope corruption, provider errors, type mismatches, etc.) and are
318/// surfaced as `ParamError::Internal` so the handler returns HTTP 500 rather
319/// than masking a misconfiguration as a 401.
320fn di_error_to_param_error(err: DiError) -> ParamError {
321	match err {
322		DiError::Authentication(msg) | DiError::NotFound(msg) => ParamError::Authentication(msg),
323		other => ParamError::Internal(other.to_string()),
324	}
325}
326
327// ---------------------------------------------------------------------------
328// Injectable impls (back-compat with `#[inject]` parameters).
329// ---------------------------------------------------------------------------
330
331#[async_trait]
332impl<T> Injectable for SessionValue<T>
333where
334	T: DeserializeOwned + Send + Sync + 'static,
335{
336	async fn inject(ctx: &InjectionContext) -> DiResult<Self> {
337		load_session_value_via_di::<T>(ctx, USER_ID_SESSION_KEY)
338			.await
339			.map(SessionValue)
340	}
341}
342
343#[async_trait]
344impl<T> Injectable for OptionalSessionValue<T>
345where
346	T: DeserializeOwned + Send + Sync + 'static,
347{
348	async fn inject(ctx: &InjectionContext) -> DiResult<Self> {
349		// Mirror `SessionValue`, but collapse "no session"/"no value" into
350		// `None` rather than propagating an injection error. Any other
351		// error (such as a corrupted singleton scope) still bubbles up so
352		// genuine misconfigurations remain visible.
353		match SessionData::inject(ctx).await {
354			Ok(session) => Ok(OptionalSessionValue(session.get::<T>(USER_ID_SESSION_KEY))),
355			Err(DiError::NotFound(_)) => Ok(OptionalSessionValue(None)),
356			Err(e) => Err(e),
357		}
358	}
359}
360
361#[async_trait]
362impl<K, T> Injectable for SessionValueNamed<K, T>
363where
364	K: SessionKey,
365	T: DeserializeOwned + Send + Sync + 'static,
366{
367	async fn inject(ctx: &InjectionContext) -> DiResult<Self> {
368		load_session_value_via_di::<T>(ctx, K::KEY)
369			.await
370			.map(Self::new)
371	}
372}
373
374#[async_trait]
375impl<K, T> Injectable for OptionalSessionValueNamed<K, T>
376where
377	K: SessionKey,
378	T: DeserializeOwned + Send + Sync + 'static,
379{
380	async fn inject(ctx: &InjectionContext) -> DiResult<Self> {
381		// Mirror `OptionalSessionValue`, but parameterise the key over
382		// `K::KEY`. Collapse "no session"/"no value" into `None`; any
383		// other error (e.g. corrupted singleton scope) still bubbles up.
384		match SessionData::inject(ctx).await {
385			Ok(session) => Ok(Self::new(session.get::<T>(K::KEY))),
386			Err(DiError::NotFound(_)) => Ok(Self::new(None)),
387			Err(e) => Err(e),
388		}
389	}
390}
391
392// ---------------------------------------------------------------------------
393// FromRequest impls (auto-extraction without `#[inject]`).
394// ---------------------------------------------------------------------------
395
396#[async_trait]
397impl<T> FromRequest for SessionValue<T>
398where
399	T: DeserializeOwned + Send + Sync + 'static,
400{
401	async fn from_request(req: &Request, _ctx: &ParamContext) -> ParamResult<Self> {
402		load_session_value_via_request::<T>(req, USER_ID_SESSION_KEY)
403			.await
404			.map(SessionValue)
405	}
406}
407
408#[async_trait]
409impl<T> FromRequest for OptionalSessionValue<T>
410where
411	T: DeserializeOwned + Send + Sync + 'static,
412{
413	async fn from_request(req: &Request, _ctx: &ParamContext) -> ParamResult<Self> {
414		// Mirror the `Injectable` semantics: any failure to reach a live
415		// session collapses to `None`. Successful session lookups still
416		// honour the `session.get::<T>(...) -> Option<T>` semantics for
417		// missing keys and deserialisation failures.
418		let di_ctx = match req.get_di_context::<InjectionContext>() {
419			Some(c) => c,
420			None => return Ok(OptionalSessionValue(None)),
421		};
422		match SessionData::inject(&di_ctx).await {
423			Ok(session) => Ok(OptionalSessionValue(session.get::<T>(USER_ID_SESSION_KEY))),
424			Err(_) => Ok(OptionalSessionValue(None)),
425		}
426	}
427}
428
429#[async_trait]
430impl<K, T> FromRequest for SessionValueNamed<K, T>
431where
432	K: SessionKey,
433	T: DeserializeOwned + Send + Sync + 'static,
434{
435	async fn from_request(req: &Request, _ctx: &ParamContext) -> ParamResult<Self> {
436		load_session_value_via_request::<T>(req, K::KEY)
437			.await
438			.map(Self::new)
439	}
440}
441
442#[async_trait]
443impl<K, T> FromRequest for OptionalSessionValueNamed<K, T>
444where
445	K: SessionKey,
446	T: DeserializeOwned + Send + Sync + 'static,
447{
448	async fn from_request(req: &Request, _ctx: &ParamContext) -> ParamResult<Self> {
449		// Mirror `OptionalSessionValue::from_request`, parameterised on
450		// `K::KEY`: any failure to reach a live session collapses to
451		// `None` rather than 401/500, so this extractor never blocks the
452		// handler from running.
453		let di_ctx = match req.get_di_context::<InjectionContext>() {
454			Some(c) => c,
455			None => return Ok(Self::new(None)),
456		};
457		match SessionData::inject(&di_ctx).await {
458			Ok(session) => Ok(Self::new(session.get::<T>(K::KEY))),
459			Err(_) => Ok(Self::new(None)),
460		}
461	}
462}
463
464#[cfg(test)]
465mod tests {
466	use super::super::test_support::TenantIdKey;
467	use super::*;
468	use rstest::rstest;
469
470	#[rstest]
471	fn user_id_key_resolves_to_canonical_session_key() {
472		// Arrange + Act
473		let key = UserIdKey::KEY;
474
475		// Assert
476		assert_eq!(key, USER_ID_SESSION_KEY);
477	}
478
479	#[rstest]
480	fn session_value_named_constructor_and_deref_roundtrip() {
481		// Arrange
482		let extractor = SessionValueNamed::<TenantIdKey, i64>::new(42);
483
484		// Act
485		let via_deref: i64 = *extractor;
486		let via_into_inner = extractor.into_inner();
487
488		// Assert
489		assert_eq!(via_deref, 42);
490		assert_eq!(via_into_inner, 42);
491	}
492
493	#[rstest]
494	fn optional_session_value_named_constructor_and_deref_roundtrip_some() {
495		// Arrange
496		let extractor = OptionalSessionValueNamed::<TenantIdKey, i64>::new(Some(7));
497
498		// Act
499		let via_deref: Option<i64> = *extractor;
500		let via_into_inner = extractor.into_inner();
501
502		// Assert
503		assert_eq!(via_deref, Some(7));
504		assert_eq!(via_into_inner, Some(7));
505	}
506
507	#[rstest]
508	fn optional_session_value_named_constructor_and_deref_roundtrip_none() {
509		// Arrange
510		let extractor = OptionalSessionValueNamed::<TenantIdKey, i64>::new(None);
511
512		// Act
513		let via_deref: Option<i64> = *extractor;
514		let via_into_inner = extractor.into_inner();
515
516		// Assert
517		assert_eq!(via_deref, None);
518		assert_eq!(via_into_inner, None);
519	}
520
521	#[rstest]
522	fn optional_session_value_named_debug_includes_key_name() {
523		// Arrange
524		let extractor = OptionalSessionValueNamed::<TenantIdKey, i64>::new(Some(99));
525
526		// Act
527		let rendered = format!("{extractor:?}");
528
529		// Assert: the Debug impl should surface the `K::KEY` constant so
530		// failure diagnostics in handler logs identify which session key the
531		// extractor targeted. Mirror the contract verified for
532		// `SessionValueNamed` Debug output.
533		assert!(
534			rendered.contains("OptionalSessionValueNamed"),
535			"Debug output should name the struct, got {rendered:?}"
536		);
537		assert!(
538			rendered.contains("tenant_id"),
539			"Debug output should include the session key name, got {rendered:?}"
540		);
541	}
542
543	#[rstest]
544	fn optional_session_value_named_clone_preserves_inner_some() {
545		// Arrange
546		let original = OptionalSessionValueNamed::<TenantIdKey, i64>::new(Some(123));
547
548		// Act
549		let cloned = original.clone();
550
551		// Assert
552		assert_eq!(*cloned, Some(123));
553		assert_eq!(*original, Some(123));
554	}
555
556	#[rstest]
557	fn di_error_authentication_maps_to_param_authentication() {
558		// Arrange
559		let di_err = DiError::Authentication("nope".to_string());
560
561		// Act
562		let param_err = di_error_to_param_error(di_err);
563
564		// Assert
565		match param_err {
566			ParamError::Authentication(msg) => assert_eq!(msg, "nope"),
567			other => panic!("expected ParamError::Authentication, got {other:?}"),
568		}
569	}
570
571	#[rstest]
572	fn di_error_not_found_maps_to_param_authentication() {
573		// Arrange
574		let di_err = DiError::NotFound("missing session".to_string());
575
576		// Act
577		let param_err = di_error_to_param_error(di_err);
578
579		// Assert: missing session collapses to 401 (Authentication) so the
580		// handler macro returns the right status. See #4446.
581		assert!(matches!(param_err, ParamError::Authentication(_)));
582	}
583}