1use std::fmt;
12use std::time::Duration;
13
14use secrecy::{ExposeSecret, SecretSlice};
15use tower_sessions::cookie::Key;
16use tower_sessions::{Expiry, SessionManagerLayer};
17
18use crate::auth::{SessionBuildError, SessionConfigError, SigningKeyReason};
19
20pub type SessionLayer<Store> = SessionManagerLayer<Store, tower_sessions::service::SignedCookie>;
27
28#[derive(Clone, Copy, Debug, PartialEq, Eq)]
35pub enum SameSite {
36 Strict,
38 Lax,
40 None,
42}
43
44impl SameSite {
45 pub(crate) fn as_tower(&self) -> tower_sessions::cookie::SameSite {
46 match self {
47 Self::Strict => tower_sessions::cookie::SameSite::Strict,
48 Self::Lax => tower_sessions::cookie::SameSite::Lax,
49 Self::None => tower_sessions::cookie::SameSite::None,
50 }
51 }
52}
53
54#[derive(Clone)]
81pub struct SessionConfig {
82 cookie_name: String,
83 same_site: SameSite,
84 secure: bool,
85 http_only: bool,
86 path: String,
87 domain: Option<String>,
88 max_age: Duration,
89 absolute_max_age: Duration,
90 signing_key: SecretSlice<u8>,
91}
92
93impl SessionConfig {
94 pub fn new(signing_key: &[u8]) -> Result<Self, SessionConfigError> {
115 if signing_key.len() != 64 {
116 return Err(SessionConfigError::InvalidSigningKey {
117 reason: SigningKeyReason::WrongLength,
118 });
119 }
120 Ok(Self {
121 cookie_name: "__Host-id".to_string(),
122 same_site: SameSite::Strict,
123 secure: true,
124 http_only: true,
125 path: "/".to_string(),
126 domain: None,
127 max_age: Duration::from_secs(60 * 60 * 24 * 14),
128 absolute_max_age: Duration::from_secs(60 * 60 * 24 * 30),
129 signing_key: SecretSlice::from(signing_key.to_vec()),
130 })
131 }
132
133 pub fn dev(signing_key: &[u8]) -> Result<Self, SessionConfigError> {
149 if signing_key.len() != 64 {
150 return Err(SessionConfigError::InvalidSigningKey {
151 reason: SigningKeyReason::WrongLength,
152 });
153 }
154 Ok(Self {
155 cookie_name: "arcature-id".to_string(),
156 same_site: SameSite::Strict,
157 secure: false,
158 http_only: true,
159 path: "/".to_string(),
160 domain: None,
161 max_age: Duration::from_secs(60 * 60 * 24 * 14),
162 absolute_max_age: Duration::from_secs(60 * 60 * 24 * 30),
163 signing_key: SecretSlice::from(signing_key.to_vec()),
164 })
165 }
166
167 #[must_use]
169 pub fn with_cookie_name(mut self, name: impl Into<String>) -> Self {
170 self.cookie_name = name.into();
171 self
172 }
173
174 #[must_use]
176 pub fn with_same_site(mut self, same_site: SameSite) -> Self {
177 self.same_site = same_site;
178 self
179 }
180
181 #[must_use]
183 pub fn with_secure(mut self, secure: bool) -> Self {
184 self.secure = secure;
185 self
186 }
187
188 #[must_use]
190 pub fn with_http_only(mut self, http_only: bool) -> Self {
191 self.http_only = http_only;
192 self
193 }
194
195 #[must_use]
197 pub fn with_path(mut self, path: impl Into<String>) -> Self {
198 self.path = path.into();
199 self
200 }
201
202 #[must_use]
204 pub fn with_domain(mut self, domain: impl Into<String>) -> Self {
205 self.domain = Some(domain.into());
206 self
207 }
208
209 #[must_use]
216 pub fn with_max_age(mut self, max_age: Duration) -> Self {
217 self.max_age = max_age;
218 self
219 }
220
221 #[must_use]
224 pub fn with_absolute_max_age(mut self, absolute_max_age: Duration) -> Self {
225 self.absolute_max_age = absolute_max_age;
226 self
227 }
228
229 #[must_use]
231 pub fn absolute_max_age(&self) -> Duration {
232 self.absolute_max_age
233 }
234
235 pub(crate) fn cookie_name(&self) -> &str {
236 &self.cookie_name
237 }
238
239 pub(crate) fn same_site(&self) -> SameSite {
240 self.same_site
241 }
242
243 pub(crate) fn secure(&self) -> bool {
244 self.secure
245 }
246
247 pub(crate) fn http_only(&self) -> bool {
248 self.http_only
249 }
250
251 pub(crate) fn path(&self) -> &str {
252 &self.path
253 }
254
255 pub(crate) fn domain(&self) -> Option<&str> {
256 self.domain.as_deref()
257 }
258
259 pub(crate) fn max_age(&self) -> Duration {
260 self.max_age
261 }
262
263 pub(crate) fn signing_key(&self) -> &[u8] {
264 self.signing_key.expose_secret()
265 }
266
267 pub(crate) fn validate(&self) -> Result<(), SessionConfigError> {
268 if self.cookie_name.is_empty() {
269 return Err(SessionConfigError::EmptyCookieAttribute { attribute: "name" });
270 }
271 if self.path.is_empty() {
272 return Err(SessionConfigError::EmptyCookieAttribute { attribute: "path" });
273 }
274 if self.max_age.is_zero() {
275 return Err(SessionConfigError::ZeroDuration { field: "max_age" });
276 }
277 if self.absolute_max_age.is_zero() {
278 return Err(SessionConfigError::ZeroDuration {
279 field: "absolute_max_age",
280 });
281 }
282 if self.signing_key().len() != 64 {
283 return Err(SessionConfigError::InvalidSigningKey {
284 reason: SigningKeyReason::WrongLength,
285 });
286 }
287 if !self.secure && self.cookie_name.starts_with("__Host-") {
291 return Err(SessionConfigError::InsecureHostPrefixedCookie {
292 cookie_name: self.cookie_name.clone(),
293 });
294 }
295 Ok(())
296 }
297
298 pub fn into_layer<Store>(self, store: Store) -> Result<SessionLayer<Store>, SessionBuildError>
307 where
308 Store: tower_sessions::SessionStore,
309 {
310 self.validate().map_err(SessionBuildError::new)?;
311 Ok(assemble_layer(self, store))
312 }
313}
314
315impl fmt::Debug for SessionConfig {
317 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
318 formatter
319 .debug_struct("SessionConfig")
320 .field("cookie_name", &self.cookie_name)
321 .field("same_site", &self.same_site)
322 .field("secure", &self.secure)
323 .field("http_only", &self.http_only)
324 .field("path", &self.path)
325 .field("domain", &self.domain)
326 .field("max_age_secs", &self.max_age.as_secs())
327 .field("absolute_max_secs", &self.absolute_max_age.as_secs())
328 .field("signing_key", &"<redacted 64-byte secret>")
329 .finish()
330 }
331}
332
333fn assemble_layer<Store: tower_sessions::SessionStore>(
334 config: SessionConfig,
335 store: Store,
336) -> SessionLayer<Store> {
337 let key = Key::from(config.signing_key());
338 let max_age_secs: i64 = config.max_age().as_secs().try_into().unwrap_or(i64::MAX);
339 let expiry = Expiry::OnInactivity(time::Duration::seconds(max_age_secs));
340 let layer = SessionManagerLayer::new(store)
341 .with_name(config.cookie_name().to_string())
342 .with_same_site(config.same_site().as_tower())
343 .with_secure(config.secure())
344 .with_http_only(config.http_only())
345 .with_path(config.path().to_string())
346 .with_expiry(expiry)
347 .with_signed(key);
348 match config.domain() {
349 Some(domain) => layer.with_domain(domain.to_string()),
350 None => layer,
351 }
352}
353
354#[derive(Clone)]
362pub struct SessionKey {
363 inner: SecretSlice<u8>,
364}
365
366impl SessionKey {
367 pub fn generate() -> Result<Self, SessionConfigError> {
374 let mut bytes = vec![0u8; 64];
375 getrandom::fill(&mut bytes).map_err(|_| SessionConfigError::InvalidSigningKey {
376 reason: SigningKeyReason::WrongLength,
377 })?;
378 Ok(Self {
379 inner: SecretSlice::from(bytes),
380 })
381 }
382
383 pub fn from_bytes(bytes: &[u8]) -> Result<Self, SessionConfigError> {
390 if bytes.len() != 64 {
391 return Err(SessionConfigError::InvalidSigningKey {
392 reason: SigningKeyReason::WrongLength,
393 });
394 }
395 Ok(Self {
396 inner: SecretSlice::from(bytes.to_vec()),
397 })
398 }
399
400 #[must_use]
403 pub fn as_bytes(&self) -> &[u8] {
404 self.inner.expose_secret()
405 }
406}
407
408impl fmt::Debug for SessionKey {
409 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
410 write!(formatter, "SessionKey(<redacted 64-byte key>)")
411 }
412}
413
414#[cfg(test)]
415mod tests {
416 use super::*;
417 use tower_sessions_memory_store::MemoryStore;
418
419 fn fresh_config() -> SessionConfig {
420 SessionConfig::new(&[0u8; 64]).expect("valid key")
421 }
422
423 #[test]
424 fn defaults_are_secure() {
425 let config = fresh_config();
426 assert_eq!(config.cookie_name(), "__Host-id");
427 assert_eq!(config.same_site(), SameSite::Strict);
428 assert!(config.secure());
429 assert!(config.http_only());
430 assert_eq!(config.path(), "/");
431 assert!(config.domain().is_none());
432 assert_eq!(config.max_age(), Duration::from_secs(60 * 60 * 24 * 14));
433 assert_eq!(
434 config.absolute_max_age(),
435 Duration::from_secs(60 * 60 * 24 * 30)
436 );
437 assert!(config.absolute_max_age() > config.max_age());
438 }
439
440 #[test]
441 fn with_absolute_max_age_overrides_default() {
442 let config = fresh_config().with_absolute_max_age(Duration::from_secs(60));
443 assert_eq!(config.absolute_max_age(), Duration::from_secs(60));
444 }
445
446 #[test]
447 fn dev_defaults_are_for_plain_http() {
448 let key = SessionKey::generate().expect("rng");
449 let config = SessionConfig::dev(key.as_bytes()).expect("valid key");
450 assert_eq!(config.cookie_name(), "arcature-id");
451 assert!(!config.secure(), "dev Secure defaults to false");
452 assert!(config.http_only());
453 assert_eq!(config.path(), "/");
454 assert!(config.domain().is_none());
455 }
456
457 #[test]
458 fn debug_redacts_signing_key() {
459 let config = SessionConfig::new(&[0xAB; 64]).expect("valid key");
460 let debug = format!("{config:?}");
461 assert!(debug.contains("<redacted"));
462 assert!(
463 !debug.contains("abab"),
464 "hex key bytes must not leak: {debug}"
465 );
466 assert!(
467 !debug.contains("171"),
468 "decimal key bytes must not leak: {debug}"
469 );
470 }
471
472 #[test]
473 fn rejects_wrong_key_length() {
474 assert!(matches!(
475 SessionConfig::new(&[0u8; 32]),
476 Err(SessionConfigError::InvalidSigningKey { .. })
477 ));
478 }
479
480 #[test]
481 fn rejects_empty_name() {
482 let config = fresh_config().with_cookie_name("");
483 assert!(config.into_layer(MemoryStore::default()).is_err());
484 }
485
486 #[test]
487 fn rejects_zero_max_age() {
488 let config = fresh_config().with_max_age(Duration::ZERO);
489 assert!(config.into_layer(MemoryStore::default()).is_err());
490 }
491
492 #[test]
493 fn rejects_zero_absolute_max_age() {
494 let config = fresh_config().with_absolute_max_age(Duration::ZERO);
495 assert!(config.into_layer(MemoryStore::default()).is_err());
496 }
497
498 #[test]
499 fn production_cookie_name_is_host_prefixed() {
500 let config = fresh_config();
501 assert_eq!(config.cookie_name(), "__Host-id");
502 assert!(config.cookie_name().starts_with("__Host-"));
503 assert!(config.secure());
504 }
505
506 #[test]
507 fn rejects_host_prefixed_cookie_with_secure_false() {
508 let config = fresh_config().with_secure(false);
509 let result = config.into_layer(MemoryStore::default());
510 assert!(result.is_err(), "__Host-id + Secure=false must be rejected");
511 }
512
513 #[test]
514 fn accepts_non_host_cookie_with_secure_false() {
515 let config = fresh_config().with_cookie_name("sid").with_secure(false);
516 assert!(config.into_layer(MemoryStore::default()).is_ok());
517 }
518
519 #[test]
520 fn key_generate_produces_64_bytes() {
521 let key = SessionKey::generate().expect("rng");
522 assert_eq!(key.as_bytes().len(), 64);
523 }
524
525 #[test]
526 fn key_from_bytes_rejects_wrong_length() {
527 assert!(matches!(
528 SessionKey::from_bytes(&[0u8; 32]),
529 Err(SessionConfigError::InvalidSigningKey { .. })
530 ));
531 assert!(SessionKey::from_bytes(&[0u8; 64]).is_ok());
532 }
533
534 #[test]
535 fn key_debug_redacts() {
536 let key = SessionKey::from_bytes(&[0xf0; 64]).expect("64 bytes");
537 let debug = format!("{key:?}");
538 assert!(debug.contains("redacted"));
539 assert!(!debug.contains("f0"), "Debug leaked individual key byte");
540 }
541
542 #[test]
543 fn key_clone_preserves_bytes() {
544 let key = SessionKey::generate().expect("rng");
545 let clone = key.clone();
546 assert_eq!(key.as_bytes(), clone.as_bytes());
547 }
548}