Skip to main content

reinhardt_auth/
settings.rs

1//! Settings fragments for authentication backends.
2//!
3//! These fragments map authentication configuration onto the Reinhardt
4//! `#[settings]` macro. Each fragment owns a globally unique section (prefixed
5//! with `auth_`) and converts into the matching legacy `XxxConfig` value during
6//! the 0.2 compatibility window. New code should prefer the fragments and the
7//! `create_*_from_settings` constructors.
8
9#![allow(deprecated)] // Conversions target legacy config types during the compatibility window.
10
11#[cfg(any(feature = "sessions", feature = "jwt", feature = "token"))]
12use serde::{Deserialize, Serialize};
13
14// --- session --------------------------------------------------------------
15
16#[cfg(feature = "sessions")]
17mod session_settings {
18	use super::*;
19	use crate::sessions::config::{SameSite, SessionConfig};
20	use reinhardt_core::macros::settings;
21	use std::time::Duration;
22
23	fn default_cookie_name() -> String {
24		"sessionid".to_string()
25	}
26
27	fn default_cookie_path() -> String {
28		"/".to_string()
29	}
30
31	fn default_cookie_secure() -> bool {
32		true
33	}
34
35	fn default_cookie_httponly() -> bool {
36		true
37	}
38
39	fn default_cookie_samesite() -> String {
40		"lax".to_string()
41	}
42
43	/// Parse a SameSite policy string into the legacy [`SameSite`] enum.
44	///
45	/// Unknown values fall back to the secure default (`Lax`).
46	fn parse_samesite(value: &str) -> SameSite {
47		match value.to_ascii_lowercase().as_str() {
48			"strict" => SameSite::Strict,
49			"none" => SameSite::None,
50			_ => SameSite::Lax,
51		}
52	}
53
54	/// Session configuration fragment.
55	///
56	/// Maps to the `[auth_session]` section and composes with the `#[settings]`
57	/// macro. Convert into the deprecated [`SessionConfig`] via [`to_config`].
58	///
59	/// The session cookie max age is expressed as `cookie_age_secs` (an integer
60	/// number of seconds). `None` means a browser-session cookie.
61	///
62	/// [`to_config`]: SessionSettings::to_config
63	#[settings(fragment = true, section = "auth_session")]
64	#[derive(Clone, Debug, Serialize, Deserialize)]
65	pub struct SessionSettings {
66		/// Name of the session cookie.
67		#[serde(default = "default_cookie_name")]
68		pub cookie_name: String,
69		/// Maximum cookie age, in seconds. `None` yields a session cookie.
70		#[serde(default)]
71		pub cookie_age_secs: Option<u64>,
72		/// Cookie path.
73		#[serde(default = "default_cookie_path")]
74		pub cookie_path: String,
75		/// Cookie domain. `None` uses the current domain.
76		#[serde(default)]
77		pub cookie_domain: Option<String>,
78		/// Whether the cookie sets the `Secure` flag (HTTPS only).
79		#[serde(default = "default_cookie_secure")]
80		pub cookie_secure: bool,
81		/// Whether the cookie sets the `HttpOnly` flag.
82		#[serde(default = "default_cookie_httponly")]
83		pub cookie_httponly: bool,
84		/// SameSite policy: `"strict"`, `"lax"`, or `"none"`.
85		#[serde(default = "default_cookie_samesite")]
86		pub cookie_samesite: String,
87		/// Whether to save the session on every request.
88		#[serde(default)]
89		pub save_every_request: bool,
90	}
91
92	impl Default for SessionSettings {
93		fn default() -> Self {
94			Self {
95				cookie_name: default_cookie_name(),
96				cookie_age_secs: None,
97				cookie_path: default_cookie_path(),
98				cookie_domain: None,
99				cookie_secure: default_cookie_secure(),
100				cookie_httponly: default_cookie_httponly(),
101				cookie_samesite: default_cookie_samesite(),
102				save_every_request: false,
103			}
104		}
105	}
106
107	impl SessionSettings {
108		/// Convert these settings into the deprecated compatibility config.
109		///
110		/// The legacy [`SessionConfig`] uses private fields, so the conversion
111		/// rebuilds it through [`SessionConfig::builder`].
112		pub fn to_config(&self) -> SessionConfig {
113			let mut builder = SessionConfig::builder()
114				.cookie_name(self.cookie_name.clone())
115				.cookie_path(self.cookie_path.clone())
116				.cookie_secure(self.cookie_secure)
117				.cookie_httponly(self.cookie_httponly)
118				.cookie_samesite(parse_samesite(&self.cookie_samesite))
119				.save_every_request(self.save_every_request);
120			if let Some(secs) = self.cookie_age_secs {
121				builder = builder.cookie_age(Duration::from_secs(secs));
122			}
123			if let Some(domain) = &self.cookie_domain {
124				builder = builder.cookie_domain(domain.clone());
125			}
126			builder.build()
127		}
128	}
129
130	impl From<&SessionSettings> for SessionConfig {
131		fn from(settings: &SessionSettings) -> Self {
132			settings.to_config()
133		}
134	}
135}
136
137#[cfg(feature = "sessions")]
138pub use session_settings::SessionSettings;
139
140// --- jwt session backend --------------------------------------------------
141
142#[cfg(feature = "jwt")]
143mod jwt_session_settings {
144	use super::*;
145	use crate::sessions::backends::jwt::{JwtConfig, JwtSessionBackend, JwtSessionError};
146	use jsonwebtoken::Algorithm;
147	use reinhardt_core::macros::settings;
148	use serde::ser::SerializeStruct;
149	use std::fmt;
150
151	fn default_algorithm() -> String {
152		"HS256".to_string()
153	}
154
155	fn default_expiration() -> u64 {
156		3600 // 1 hour
157	}
158
159	/// Parse a JWT algorithm string into the [`Algorithm`] enum.
160	///
161	/// `jsonwebtoken::Algorithm` is not `serde`-serializable in this version,
162	/// so the fragment stores the algorithm as a string and maps it here.
163	/// Unknown values fall back to the default `HS256`.
164	fn parse_algorithm(value: &str) -> Algorithm {
165		match value.to_ascii_uppercase().as_str() {
166			"HS384" => Algorithm::HS384,
167			"HS512" => Algorithm::HS512,
168			_ => Algorithm::HS256,
169		}
170	}
171
172	/// JWT session backend configuration fragment.
173	///
174	/// Maps to the `[auth_jwt_session]` section and composes with the
175	/// `#[settings]` macro. Convert into the deprecated [`JwtConfig`] via
176	/// [`to_config`].
177	///
178	/// The HMAC secret length is validated when the backend is constructed
179	/// (see [`create_jwt_session_backend_from_settings`]), not when the
180	/// fragment is parsed, matching the legacy [`JwtSessionBackend::new`]
181	/// behavior.
182	///
183	/// [`to_config`]: JwtSessionSettings::to_config
184	#[settings(fragment = true, section = "auth_jwt_session")]
185	#[derive(Clone, Deserialize)]
186	pub struct JwtSessionSettings {
187		/// Secret key used for HMAC signing.
188		#[serde(default)]
189		pub secret: String,
190		/// JWT signing algorithm: `"HS256"`, `"HS384"`, or `"HS512"`.
191		#[serde(default = "default_algorithm")]
192		pub algorithm: String,
193		/// Default token expiration, in seconds.
194		#[serde(default = "default_expiration")]
195		pub expiration: u64,
196		/// Token issuer.
197		#[serde(default)]
198		pub issuer: Option<String>,
199		/// Token audience.
200		#[serde(default)]
201		pub audience: Option<String>,
202	}
203
204	impl Default for JwtSessionSettings {
205		fn default() -> Self {
206			Self {
207				secret: String::new(),
208				algorithm: default_algorithm(),
209				expiration: default_expiration(),
210				issuer: None,
211				audience: None,
212			}
213		}
214	}
215
216	impl JwtSessionSettings {
217		/// Convert these settings into the deprecated compatibility config.
218		pub fn to_config(&self) -> JwtConfig {
219			JwtConfig {
220				secret: self.secret.clone(),
221				algorithm: parse_algorithm(&self.algorithm),
222				expiration: self.expiration,
223				issuer: self.issuer.clone(),
224				audience: self.audience.clone(),
225			}
226		}
227	}
228
229	impl fmt::Debug for JwtSessionSettings {
230		fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
231			formatter
232				.debug_struct("JwtSessionSettings")
233				.field("secret", &"[REDACTED]")
234				.field("algorithm", &self.algorithm)
235				.field("expiration", &self.expiration)
236				.field("issuer", &self.issuer)
237				.field("audience", &self.audience)
238				.finish()
239		}
240	}
241
242	impl Serialize for JwtSessionSettings {
243		fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
244		where
245			S: serde::Serializer,
246		{
247			let mut state = serializer.serialize_struct("JwtSessionSettings", 5)?;
248			state.serialize_field("secret", "[REDACTED]")?;
249			state.serialize_field("algorithm", &self.algorithm)?;
250			state.serialize_field("expiration", &self.expiration)?;
251			state.serialize_field("issuer", &self.issuer)?;
252			state.serialize_field("audience", &self.audience)?;
253			state.end()
254		}
255	}
256
257	impl From<&JwtSessionSettings> for JwtConfig {
258		fn from(settings: &JwtSessionSettings) -> Self {
259			settings.to_config()
260		}
261	}
262
263	/// Build a [`JwtSessionBackend`] from a [`JwtSessionSettings`] fragment.
264	///
265	/// Returns an error if the configured HMAC secret is too short for the
266	/// selected algorithm, preserving the legacy validation performed by
267	/// [`JwtSessionBackend::new`].
268	pub fn create_jwt_session_backend_from_settings(
269		settings: &JwtSessionSettings,
270	) -> Result<JwtSessionBackend, JwtSessionError> {
271		JwtSessionBackend::new(settings.to_config())
272	}
273}
274
275#[cfg(feature = "jwt")]
276pub use jwt_session_settings::{JwtSessionSettings, create_jwt_session_backend_from_settings};
277
278// --- token rotation -------------------------------------------------------
279
280#[cfg(feature = "token")]
281mod token_rotation_settings {
282	use super::*;
283	use crate::token_rotation::{AutoTokenRotationManager, TokenRotationConfig};
284	use reinhardt_core::macros::settings;
285
286	fn default_rotation_interval() -> i64 {
287		3600 // 1 hour
288	}
289
290	fn default_grace_period() -> i64 {
291		300 // 5 minutes
292	}
293
294	fn default_max_active_tokens() -> usize {
295		5
296	}
297
298	/// Token rotation configuration fragment.
299	///
300	/// Maps to the `[auth_token_rotation]` section and composes with the
301	/// `#[settings]` macro. Convert into the deprecated [`TokenRotationConfig`]
302	/// via [`to_config`].
303	///
304	/// [`to_config`]: TokenRotationSettings::to_config
305	#[settings(fragment = true, section = "auth_token_rotation")]
306	#[derive(Clone, Debug, Serialize, Deserialize)]
307	pub struct TokenRotationSettings {
308		/// Interval, in seconds, between rotations.
309		#[serde(default = "default_rotation_interval")]
310		pub rotation_interval: i64,
311		/// Grace period, in seconds, during which the old token stays valid.
312		#[serde(default = "default_grace_period")]
313		pub grace_period: i64,
314		/// Maximum number of active tokens per user.
315		#[serde(default = "default_max_active_tokens")]
316		pub max_active_tokens: usize,
317		/// Whether to rotate the token on every use.
318		#[serde(default)]
319		pub rotate_on_use: bool,
320	}
321
322	impl Default for TokenRotationSettings {
323		fn default() -> Self {
324			Self {
325				rotation_interval: default_rotation_interval(),
326				grace_period: default_grace_period(),
327				max_active_tokens: default_max_active_tokens(),
328				rotate_on_use: false,
329			}
330		}
331	}
332
333	impl TokenRotationSettings {
334		/// Convert these settings into the deprecated compatibility config.
335		pub fn to_config(&self) -> TokenRotationConfig {
336			TokenRotationConfig {
337				rotation_interval: self.rotation_interval,
338				grace_period: self.grace_period,
339				max_active_tokens: self.max_active_tokens,
340				rotate_on_use: self.rotate_on_use,
341			}
342		}
343	}
344
345	impl From<&TokenRotationSettings> for TokenRotationConfig {
346		fn from(settings: &TokenRotationSettings) -> Self {
347			settings.to_config()
348		}
349	}
350
351	/// Build an [`AutoTokenRotationManager`] from a [`TokenRotationSettings`]
352	/// fragment.
353	pub fn create_token_rotation_manager_from_settings(
354		settings: &TokenRotationSettings,
355	) -> AutoTokenRotationManager {
356		AutoTokenRotationManager::new(settings.to_config())
357	}
358}
359
360#[cfg(feature = "token")]
361pub use token_rotation_settings::{
362	TokenRotationSettings, create_token_rotation_manager_from_settings,
363};
364
365#[cfg(test)]
366mod tests {
367	#[cfg(feature = "sessions")]
368	#[test]
369	fn session_settings_default_matches_legacy_config() {
370		use super::SessionSettings;
371		use crate::sessions::config::SessionConfig;
372
373		let from_settings = SessionSettings::default().to_config();
374		let legacy = SessionConfig::default();
375
376		assert_eq!(from_settings.cookie_name(), legacy.cookie_name());
377		assert_eq!(from_settings.cookie_age(), legacy.cookie_age());
378		assert_eq!(from_settings.cookie_path(), legacy.cookie_path());
379		assert_eq!(from_settings.cookie_domain(), legacy.cookie_domain());
380		assert_eq!(from_settings.cookie_secure(), legacy.cookie_secure());
381		assert_eq!(from_settings.cookie_httponly(), legacy.cookie_httponly());
382		assert_eq!(from_settings.cookie_samesite(), legacy.cookie_samesite());
383		assert_eq!(
384			from_settings.save_every_request(),
385			legacy.save_every_request()
386		);
387	}
388
389	#[cfg(feature = "sessions")]
390	#[test]
391	fn session_settings_round_trips_samesite_and_age() {
392		use super::SessionSettings;
393		use crate::sessions::config::SameSite;
394		use std::time::Duration;
395
396		let settings = SessionSettings {
397			cookie_samesite: "strict".to_string(),
398			cookie_age_secs: Some(7200),
399			cookie_domain: Some("example.com".to_string()),
400			..SessionSettings::default()
401		};
402
403		let config = settings.to_config();
404		assert_eq!(config.cookie_samesite(), SameSite::Strict);
405		assert_eq!(config.cookie_age(), Some(Duration::from_secs(7200)));
406		assert_eq!(config.cookie_domain(), Some("example.com"));
407	}
408
409	#[cfg(feature = "jwt")]
410	#[test]
411	fn jwt_session_settings_default_matches_legacy_config() {
412		use super::JwtSessionSettings;
413		use crate::sessions::backends::jwt::JwtConfig;
414		use jsonwebtoken::Algorithm;
415
416		let settings = JwtSessionSettings::default();
417		let config = settings.to_config();
418		// The legacy `JwtConfig::new` default carries the same algorithm,
419		// expiration, and empty optional fields.
420		let legacy = JwtConfig::new(String::new());
421
422		assert_eq!(config.secret, settings.secret);
423		assert_eq!(config.algorithm, Algorithm::HS256);
424		assert_eq!(config.algorithm, legacy.algorithm);
425		assert_eq!(config.expiration, legacy.expiration);
426		assert_eq!(config.issuer, legacy.issuer);
427		assert_eq!(config.audience, legacy.audience);
428	}
429
430	#[cfg(feature = "jwt")]
431	#[test]
432	fn jwt_session_settings_redacts_secret_in_debug_and_json() {
433		use super::JwtSessionSettings;
434
435		let raw_secret = "super-secret-signing-key";
436		let settings = JwtSessionSettings {
437			secret: raw_secret.to_string(),
438			..JwtSessionSettings::default()
439		};
440
441		let debug = format!("{settings:?}");
442		let json = serde_json::to_string(&settings).expect("settings should serialize");
443
444		assert_eq!(settings.to_config().secret, raw_secret);
445		assert!(!debug.contains(raw_secret));
446		assert!(debug.contains("[REDACTED]"));
447		assert!(!json.contains(raw_secret));
448		assert!(json.contains("[REDACTED]"));
449	}
450
451	#[cfg(feature = "token")]
452	#[test]
453	fn token_rotation_settings_default_matches_legacy_config() {
454		use super::TokenRotationSettings;
455		use crate::token_rotation::TokenRotationConfig;
456
457		let from_settings = TokenRotationSettings::default().to_config();
458		let legacy = TokenRotationConfig::default();
459
460		assert_eq!(from_settings.rotation_interval, legacy.rotation_interval);
461		assert_eq!(from_settings.grace_period, legacy.grace_period);
462		assert_eq!(from_settings.max_active_tokens, legacy.max_active_tokens);
463		assert_eq!(from_settings.rotate_on_use, legacy.rotate_on_use);
464	}
465}