1#![cfg_attr(not(test), deny(clippy::disallowed_methods))]
71#![cfg_attr(
75 not(test),
76 deny(
77 clippy::unwrap_used,
78 clippy::expect_used,
79 clippy::panic,
80 clippy::unreachable,
81 clippy::todo,
82 clippy::unimplemented,
83 clippy::indexing_slicing,
84 clippy::string_slice,
85 clippy::arithmetic_side_effects,
86 )
87)]
88
89use std::collections::HashMap;
90use std::future::Future;
91use std::pin::Pin;
92use std::sync::Arc;
93use std::task::{Context, Poll};
94
95use axum::extract::{FromRequestParts, Request};
96use axum::response::{IntoResponse, Response};
97use http::HeaderValue;
98use http::StatusCode;
99use http::header::{COOKIE, SET_COOKIE};
100use http::request::Parts;
101use thiserror::Error;
102use tokio::sync::RwLock;
103use tower::{Layer, Service};
104#[cfg(test)]
105use uuid::Uuid;
106
107#[derive(Clone, Debug)]
117pub struct Session {
118 inner: Arc<RwLock<SessionInner>>,
119 entropy: Arc<dyn crate::entropy::Entropy>,
123}
124
125#[derive(Debug)]
126struct SessionInner {
127 id: String,
128 old_id: Option<String>,
129 prior_ids: Vec<String>,
131 data: HashMap<String, String>,
132 cookie_backed: bool,
133 dirty: bool,
134 destroyed: bool,
135}
136
137impl Session {
138 #[doc(hidden)]
140 #[must_use]
141 pub fn new_for_test(id: String, data: HashMap<String, String>) -> Self {
142 Self::new_cookie_backed(id, data)
143 }
144
145 #[doc(hidden)]
149 #[must_use]
150 pub fn new_for_test_without_cookie(id: String, data: HashMap<String, String>) -> Self {
151 Self::new(id, data)
152 }
153
154 fn new(id: String, data: HashMap<String, String>) -> Self {
155 Self::with_cookie_state(id, data, false)
156 }
157
158 fn new_cookie_backed(id: String, data: HashMap<String, String>) -> Self {
159 Self::with_cookie_state(id, data, true)
160 }
161
162 fn with_cookie_state(id: String, data: HashMap<String, String>, cookie_backed: bool) -> Self {
163 Self {
164 inner: Arc::new(RwLock::new(SessionInner {
165 id,
166 old_id: None,
167 prior_ids: Vec::new(),
168 data,
169 cookie_backed,
170 dirty: false,
171 destroyed: false,
172 })),
173 entropy: Arc::new(crate::entropy::OsEntropy),
174 }
175 }
176
177 #[must_use]
182 fn with_entropy(mut self, entropy: Arc<dyn crate::entropy::Entropy>) -> Self {
183 self.entropy = entropy;
184 self
185 }
186
187 pub async fn id(&self) -> String {
189 self.inner.read().await.id.clone()
190 }
191
192 pub async fn is_cookie_backed(&self) -> bool {
195 self.inner.read().await.cookie_backed
196 }
197
198 pub async fn touch(&self) {
209 self.inner.write().await.dirty = true;
210 }
211
212 pub(crate) async fn has_pending_changes(&self) -> bool {
213 let inner = self.inner.read().await;
214 inner.dirty || inner.destroyed
215 }
216
217 pub async fn get(&self, key: &str) -> Option<String> {
219 self.inner.read().await.data.get(key).cloned()
220 }
221
222 pub async fn insert(&self, key: impl Into<String>, value: impl Into<String>) {
224 let mut inner = self.inner.write().await;
225 inner.data.insert(key.into(), value.into());
226 inner.dirty = true;
227 }
228
229 pub async fn remove(&self, key: &str) -> Option<String> {
231 let mut inner = self.inner.write().await;
232 let val = inner.data.remove(key);
233 if val.is_some() {
234 inner.dirty = true;
235 }
236 val
237 }
238
239 pub async fn clear(&self) {
241 let mut inner = self.inner.write().await;
242 inner.data.clear();
243 inner.dirty = true;
244 }
245
246 pub async fn rotate_id(&self) {
254 let new_id = self.entropy.uuid_v4().to_string();
255 let mut inner = self.inner.write().await;
256 if inner.old_id.is_none() {
257 inner.old_id = Some(inner.id.clone());
258 }
259 let previous = std::mem::replace(&mut inner.id, new_id);
260 inner.prior_ids.push(previous);
261 inner.dirty = true;
262 }
263
264 pub(crate) async fn previous_id(&self) -> Option<String> {
266 self.inner.read().await.prior_ids.last().cloned()
267 }
268
269 pub async fn destroy(&self) {
272 let mut inner = self.inner.write().await;
273 inner.data.clear();
274 inner.destroyed = true;
275 inner.dirty = true;
276 }
277
278 pub async fn contains_key(&self, key: &str) -> bool {
280 self.inner.read().await.data.contains_key(key)
281 }
282}
283
284impl<S> FromRequestParts<S> for Session
285where
286 S: Send + Sync,
287{
288 type Rejection = std::convert::Infallible;
289
290 fn from_request_parts(
291 parts: &mut Parts,
292 _state: &S,
293 ) -> impl Future<Output = Result<Self, Self::Rejection>> + Send {
294 #[allow(
295 clippy::expect_used,
296 reason = "misconfiguration: missing SessionLayer is a setup error, surfaced eagerly"
297 )]
298 let session = parts
299 .extensions
300 .get::<Self>()
301 .cloned()
302 .expect("SessionLayer must be installed to use the Session extractor");
303 async move { Ok(session) }
304 }
305}
306
307#[derive(Clone, Debug, PartialEq, Eq)]
314#[non_exhaustive]
315pub struct SessionRotation {
316 pub old_id: String,
318 pub new_id: String,
320 pub earlier_ids: Vec<String>,
324}
325
326pub type SessionRotationError = Box<dyn std::error::Error + Send + Sync>;
328
329type RotationHookFn = dyn Fn(SessionRotation) -> Pin<Box<dyn Future<Output = Result<(), SessionRotationError>> + Send>>
330 + Send
331 + Sync;
332
333#[derive(Clone, Default)]
346pub struct SessionRotationHooks {
347 hooks: Arc<std::sync::RwLock<Vec<Arc<RotationHookFn>>>>,
348}
349
350impl std::fmt::Debug for SessionRotationHooks {
351 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
352 f.debug_struct("SessionRotationHooks")
353 .field("len", &self.len())
354 .finish()
355 }
356}
357
358impl SessionRotationHooks {
359 pub fn register<F, Fut>(&self, hook: F)
361 where
362 F: Fn(SessionRotation) -> Fut + Send + Sync + 'static,
363 Fut: Future<Output = Result<(), SessionRotationError>> + Send + 'static,
364 {
365 self.hooks
366 .write()
367 .unwrap_or_else(std::sync::PoisonError::into_inner)
368 .push(Arc::new(move |rotation| Box::pin(hook(rotation))));
369 }
370
371 pub(crate) fn len(&self) -> usize {
372 self.hooks
373 .read()
374 .unwrap_or_else(std::sync::PoisonError::into_inner)
375 .len()
376 }
377
378 pub(crate) fn is_empty(&self) -> bool {
379 self.len() == 0
380 }
381
382 async fn run(&self, rotation: &SessionRotation) {
383 if self.is_empty() {
384 return;
385 }
386 let hooks: Vec<_> = self
387 .hooks
388 .read()
389 .unwrap_or_else(std::sync::PoisonError::into_inner)
390 .clone();
391 for hook in hooks {
392 if let Err(error) = hook(rotation.clone()).await {
393 tracing::error!(%error, "session rotation hook failed");
394 }
395 }
396 }
397}
398
399pub trait SessionStore: Send + Sync + 'static {
406 fn load(
409 &self,
410 id: &str,
411 ) -> impl Future<Output = Result<Option<HashMap<String, String>>, SessionStoreError>> + Send;
412
413 fn save(
415 &self,
416 id: &str,
417 data: HashMap<String, String>,
418 ) -> impl Future<Output = Result<(), SessionStoreError>> + Send;
419
420 fn destroy(&self, id: &str) -> impl Future<Output = Result<(), SessionStoreError>> + Send;
422}
423
424#[derive(Debug, Clone, Error, PartialEq, Eq)]
426#[error("{message}")]
427pub struct SessionStoreError {
428 message: String,
429}
430
431impl SessionStoreError {
432 #[must_use]
434 pub fn backend(operation: &'static str, error: impl std::fmt::Display) -> Self {
435 Self {
436 message: format!("{operation} failed: {error}"),
437 }
438 }
439}
440
441#[derive(Clone, Debug, Default)]
448pub struct MemoryStore {
449 sessions: Arc<RwLock<HashMap<String, HashMap<String, String>>>>,
450}
451
452impl MemoryStore {
453 #[must_use]
455 pub fn new() -> Self {
456 Self::default()
457 }
458}
459
460impl SessionStore for MemoryStore {
461 async fn load(&self, id: &str) -> Result<Option<HashMap<String, String>>, SessionStoreError> {
462 Ok(self.sessions.read().await.get(id).cloned())
463 }
464
465 async fn save(&self, id: &str, data: HashMap<String, String>) -> Result<(), SessionStoreError> {
466 self.sessions.write().await.insert(id.to_owned(), data);
467 Ok(())
468 }
469
470 async fn destroy(&self, id: &str) -> Result<(), SessionStoreError> {
471 self.sessions.write().await.remove(id);
472 Ok(())
473 }
474}
475
476pub(crate) type BoxedLoadFuture<'a> = Pin<
488 Box<
489 dyn Future<Output = Result<Option<HashMap<String, String>>, SessionStoreError>> + Send + 'a,
490 >,
491>;
492pub(crate) type BoxedUnitFuture<'a> =
493 Pin<Box<dyn Future<Output = Result<(), SessionStoreError>> + Send + 'a>>;
494
495pub(crate) trait BoxedSessionStore: Send + Sync + 'static {
496 fn boxed_load<'a>(&'a self, id: &'a str) -> BoxedLoadFuture<'a>;
497
498 fn boxed_save<'a>(&'a self, id: &'a str, data: HashMap<String, String>) -> BoxedUnitFuture<'a>;
499
500 fn boxed_destroy<'a>(&'a self, id: &'a str) -> BoxedUnitFuture<'a>;
501}
502
503impl<S: SessionStore> BoxedSessionStore for S {
504 fn boxed_load<'a>(&'a self, id: &'a str) -> BoxedLoadFuture<'a> {
505 Box::pin(SessionStore::load(self, id))
506 }
507
508 fn boxed_save<'a>(&'a self, id: &'a str, data: HashMap<String, String>) -> BoxedUnitFuture<'a> {
509 Box::pin(SessionStore::save(self, id, data))
510 }
511
512 fn boxed_destroy<'a>(&'a self, id: &'a str) -> BoxedUnitFuture<'a> {
513 Box::pin(SessionStore::destroy(self, id))
514 }
515}
516
517#[derive(Clone)]
518pub(crate) struct ArcSessionStore(pub(crate) Arc<dyn BoxedSessionStore>);
519
520impl SessionStore for ArcSessionStore {
521 async fn load(&self, id: &str) -> Result<Option<HashMap<String, String>>, SessionStoreError> {
522 self.0.boxed_load(id).await
523 }
524
525 async fn save(&self, id: &str, data: HashMap<String, String>) -> Result<(), SessionStoreError> {
526 self.0.boxed_save(id, data).await
527 }
528
529 async fn destroy(&self, id: &str) -> Result<(), SessionStoreError> {
530 self.0.boxed_destroy(id).await
531 }
532}
533
534#[derive(Debug, Clone, serde::Deserialize)]
550pub struct SessionConfig {
551 #[serde(default)]
553 pub backend: SessionBackend,
554
555 #[serde(default = "default_cookie_name")]
557 pub cookie_name: String,
558
559 #[serde(default = "default_max_age_secs")]
561 pub max_age_secs: u64,
562
563 #[serde(default = "default_true")]
565 pub secure: bool,
566
567 #[serde(default = "default_same_site")]
569 pub same_site: String,
570
571 #[serde(default = "default_true")]
573 pub http_only: bool,
574
575 #[serde(default = "default_path")]
577 pub path: String,
578
579 #[serde(default)]
581 pub allow_memory_in_production: bool,
582
583 #[serde(default)]
585 pub redis: SessionRedisConfig,
586}
587
588#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Deserialize)]
590#[serde(rename_all = "lowercase")]
591#[non_exhaustive]
592pub enum SessionBackend {
593 #[default]
595 Memory,
596 Redis,
598}
599
600impl SessionBackend {
601 pub(crate) fn from_env_value(value: &str) -> Option<Self> {
602 match value.trim().to_ascii_lowercase().as_str() {
603 "memory" => Some(Self::Memory),
604 "redis" => Some(Self::Redis),
605 _ => None,
606 }
607 }
608}
609
610#[derive(Debug, Clone, serde::Deserialize)]
612pub struct SessionRedisConfig {
613 #[serde(default)]
615 pub url: Option<String>,
616
617 #[serde(default = "default_redis_key_prefix")]
619 pub key_prefix: String,
620}
621
622impl Default for SessionRedisConfig {
623 fn default() -> Self {
624 Self {
625 url: None,
626 key_prefix: default_redis_key_prefix(),
627 }
628 }
629}
630
631fn default_redis_key_prefix() -> String {
632 "autumn:sessions".to_owned()
633}
634
635#[derive(Debug, Clone, PartialEq, Eq)]
637pub enum SessionBackendPlan {
638 Memory {
640 warn_in_production: bool,
642 },
643 Redis {
645 url: String,
647 key_prefix: String,
649 },
650}
651
652#[derive(Debug, Error, PartialEq, Eq)]
654#[non_exhaustive]
655pub enum SessionBackendConfigError {
656 #[error("session.backend=redis requires session.redis.url")]
658 MissingRedisUrl,
659 #[error("session.redis.url is not a valid Redis URL: {0}")]
661 InvalidRedisUrl(String),
662 #[error("session.backend=redis requires the `redis` feature")]
664 RedisFeatureDisabled,
665}
666
667fn default_cookie_name() -> String {
668 "autumn.sid".to_owned()
669}
670const fn default_max_age_secs() -> u64 {
671 86400
672}
673fn default_same_site() -> String {
674 "Lax".to_owned()
675}
676const fn default_true() -> bool {
677 true
678}
679fn default_path() -> String {
680 "/".to_owned()
681}
682
683impl Default for SessionConfig {
684 fn default() -> Self {
685 Self {
686 backend: SessionBackend::default(),
687 cookie_name: default_cookie_name(),
688 max_age_secs: default_max_age_secs(),
689 secure: true,
690 same_site: default_same_site(),
691 http_only: default_true(),
692 path: default_path(),
693 allow_memory_in_production: false,
694 redis: SessionRedisConfig::default(),
695 }
696 }
697}
698
699impl SessionConfig {
700 pub fn backend_plan(
707 &self,
708 profile: Option<&str>,
709 ) -> Result<SessionBackendPlan, SessionBackendConfigError> {
710 match self.backend {
711 SessionBackend::Memory => Ok(SessionBackendPlan::Memory {
712 warn_in_production: is_production_profile(profile)
713 && !self.allow_memory_in_production,
714 }),
715 SessionBackend::Redis => {
716 let Some(url) = self.redis.url.clone().filter(|url| !url.trim().is_empty()) else {
717 return Err(SessionBackendConfigError::MissingRedisUrl);
718 };
719
720 #[cfg(feature = "redis")]
721 {
722 if let Err(error) = crate::redis_tls::open_client(&url) {
723 return Err(SessionBackendConfigError::InvalidRedisUrl(
724 error.to_string(),
725 ));
726 }
727
728 Ok(SessionBackendPlan::Redis {
729 url,
730 key_prefix: self.redis.key_prefix.clone(),
731 })
732 }
733
734 #[cfg(not(feature = "redis"))]
735 {
736 let _ = url;
737 Err(SessionBackendConfigError::RedisFeatureDisabled)
738 }
739 }
740 }
741 }
742}
743
744fn is_production_profile(profile: Option<&str>) -> bool {
745 matches!(profile, Some("prod" | "production"))
746}
747
748pub(crate) fn get_cookie(headers: &http::HeaderMap, name: &str) -> Option<String> {
752 let mut found_token = None;
753
754 for cookie_header in headers.get_all(COOKIE) {
755 let Ok(cookie_str) = cookie_header.to_str() else {
756 continue;
757 };
758
759 for pair in cookie_str.split(';') {
760 let pair = pair.trim();
761 let Some((k, v)) = pair.split_once('=') else {
762 continue;
763 };
764
765 if k.trim() != name {
766 continue;
767 }
768
769 if found_token.is_some() {
770 return None;
774 }
775
776 found_token = Some(v.trim().to_owned());
777 }
778 }
779 found_token
780}
781
782pub(crate) fn verified_session_id(
786 headers: &http::HeaderMap,
787 cookie_name: &str,
788 signing_keys: &crate::security::config::ResolvedSigningKeys,
789) -> Option<String> {
790 let raw = get_cookie(headers, cookie_name)?;
791 let (id, signature) = raw.split_once('.')?;
792 signing_keys
793 .verify(id.as_bytes(), signature)
794 .then(|| id.to_owned())
795}
796
797pub(crate) fn session_id_from_headers(
799 headers: &http::HeaderMap,
800 cookie_name: &str,
801 signing_keys: Option<&crate::security::config::ResolvedSigningKeys>,
802) -> Option<String> {
803 signing_keys.map_or_else(
804 || get_cookie(headers, cookie_name),
805 |keys| verified_session_id(headers, cookie_name, keys),
806 )
807}
808
809#[cfg(fuzzing)]
816pub fn __fuzz_decode_cookie(
817 cookie_header: &[u8],
818 cookie_name: &str,
819 secret: &[u8],
820) -> Option<String> {
821 let mut headers = http::HeaderMap::new();
822 if let Ok(value) = http::HeaderValue::from_bytes(cookie_header) {
823 headers.append(COOKIE, value);
824 }
825 let raw = get_cookie(&headers, cookie_name)?;
826 let keys = crate::security::config::ResolvedSigningKeys::new(secret.to_vec(), Vec::new());
827 let (id, sig) = raw.split_once('.')?;
828 keys.verify(id.as_bytes(), sig).then(|| id.to_owned())
829}
830
831fn build_set_cookie(config: &SessionConfig, session_id: &str) -> String {
833 use std::fmt::Write;
834 let mut cookie = format!(
835 "{}={}; Path={}",
836 config.cookie_name, session_id, config.path
837 );
838 let _ = write!(cookie, "; Max-Age={}", config.max_age_secs);
839 if config.http_only {
840 cookie.push_str("; HttpOnly");
841 }
842 if config.secure {
843 cookie.push_str("; Secure");
844 }
845 let _ = write!(cookie, "; SameSite={}", config.same_site);
846 cookie
847}
848
849fn build_expire_cookie(config: &SessionConfig) -> String {
851 format!(
852 "{}=; Path={}; Max-Age=0; HttpOnly; SameSite={}",
853 config.cookie_name, config.path, config.same_site
854 )
855}
856
857#[derive(Clone)]
872pub struct SessionLayer<S: SessionStore> {
873 store: Arc<S>,
874 config: Arc<SessionConfig>,
875 signing_keys: Option<Arc<crate::security::config::ResolvedSigningKeys>>,
876 entropy: Arc<dyn crate::entropy::Entropy>,
877 tenancy_session_key: Option<Arc<str>>,
878 rotation_hooks: Option<SessionRotationHooks>,
879}
880
881impl<S: SessionStore> SessionLayer<S> {
882 pub fn new(store: S, config: SessionConfig) -> Self {
884 Self {
885 store: Arc::new(store),
886 config: Arc::new(config),
887 signing_keys: None,
888 entropy: Arc::new(crate::entropy::OsEntropy),
889 tenancy_session_key: None,
890 rotation_hooks: None,
891 }
892 }
893
894 #[must_use]
898 pub fn with_rotation_hooks(mut self, hooks: SessionRotationHooks) -> Self {
899 self.rotation_hooks = Some(hooks);
900 self
901 }
902
903 #[must_use]
909 pub fn with_entropy(mut self, entropy: Arc<dyn crate::entropy::Entropy>) -> Self {
910 self.entropy = entropy;
911 self
912 }
913
914 #[must_use]
921 pub fn with_signing_keys(
922 mut self,
923 keys: Arc<crate::security::config::ResolvedSigningKeys>,
924 ) -> Self {
925 self.signing_keys = Some(keys);
926 self
927 }
928
929 #[must_use]
949 pub fn with_tenancy_session_key(mut self, key: Option<Arc<str>>) -> Self {
950 self.tenancy_session_key = key;
951 self
952 }
953}
954
955impl<S: SessionStore + Clone, Inner> Layer<Inner> for SessionLayer<S> {
956 type Service = SessionService<S, Inner>;
957
958 fn layer(&self, inner: Inner) -> Self::Service {
959 SessionService {
960 inner,
961 store: Arc::clone(&self.store),
962 config: Arc::clone(&self.config),
963 signing_keys: self.signing_keys.clone(),
964 entropy: self.entropy.clone(),
965 tenancy_session_key: self.tenancy_session_key.clone(),
966 rotation_hooks: self.rotation_hooks.clone(),
967 }
968 }
969}
970
971#[derive(Clone)]
973pub struct SessionService<S: SessionStore, Inner> {
974 inner: Inner,
975 store: Arc<S>,
976 config: Arc<SessionConfig>,
977 signing_keys: Option<Arc<crate::security::config::ResolvedSigningKeys>>,
978 entropy: Arc<dyn crate::entropy::Entropy>,
979 tenancy_session_key: Option<Arc<str>>,
980 rotation_hooks: Option<SessionRotationHooks>,
981}
982
983fn response_was_deadline_cancelled(response: &Response) -> bool {
994 response
995 .extensions()
996 .get::<crate::router::RequestDeadlineCancelled>()
997 .is_some()
998}
999
1000impl<St, Inner> Service<Request> for SessionService<St, Inner>
1001where
1002 St: SessionStore + Clone,
1003 Inner: Service<Request, Response = Response> + Clone + Send + 'static,
1004 Inner::Future: Send + 'static,
1005 Inner::Error: Send + 'static,
1006{
1007 type Response = Response;
1008 type Error = Inner::Error;
1009 type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;
1010
1011 fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
1012 self.inner.poll_ready(cx)
1013 }
1014
1015 #[allow(clippy::too_many_lines)]
1016 fn call(&mut self, mut req: Request) -> Self::Future {
1017 let store = Arc::clone(&self.store);
1018 let config = Arc::clone(&self.config);
1019 let signing_keys = self.signing_keys.clone();
1020 let entropy = self.entropy.clone();
1021 let tenancy_session_key = self.tenancy_session_key.clone();
1022 let rotation_hooks = self.rotation_hooks.clone();
1023 let mut inner = self.inner.clone();
1024 std::mem::swap(&mut self.inner, &mut inner);
1026
1027 Box::pin(async move {
1028 let existing_id = session_id_from_headers(
1030 req.headers(),
1031 &config.cookie_name,
1032 signing_keys.as_deref(),
1033 );
1034
1035 let mut stale_cookie_session_id = None;
1036 let (session_id, data) = if let Some(ref id) = existing_id {
1037 match store.load(id).await {
1038 Ok(Some(data)) => (id.clone(), data),
1039 Ok(None) => {
1040 stale_cookie_session_id = Some(id.clone());
1041 (entropy.uuid_v4().to_string(), HashMap::new())
1042 }
1043 Err(error) => return Ok(session_store_unavailable_response(&error)),
1044 }
1045 } else {
1046 (entropy.uuid_v4().to_string(), HashMap::new())
1047 };
1048
1049 let cookie_backed = existing_id.as_ref().is_some_and(|id| id == &session_id);
1051 let session = if cookie_backed {
1052 Session::new_cookie_backed(session_id.clone(), data)
1053 } else {
1054 Session::new(session_id.clone(), data)
1055 }
1056 .with_entropy(entropy.clone());
1057 let current_session_scope = cookie_backed.then(|| session_id.clone());
1058 req.extensions_mut()
1059 .insert(crate::idempotency::IdempotencySessionScope::new(
1060 current_session_scope,
1061 stale_cookie_session_id,
1062 ));
1063 req.extensions_mut().insert(session.clone());
1064
1065 let mut response = inner.call(req).await?;
1067
1068 if response_was_deadline_cancelled(&response) {
1071 return Ok(response);
1072 }
1073
1074 let inner_guard = session.inner.read().await;
1075 if inner_guard.destroyed {
1076 if let Err(error) = store.destroy(&session_id).await {
1077 crate::idempotency::keep_deferred_session_commit_locked(&mut response);
1078 return Ok(session_store_unavailable_response(&error));
1079 }
1080 if let Ok(val) = HeaderValue::from_str(&build_expire_cookie(&config)) {
1081 response.headers_mut().append(SET_COOKIE, val);
1082 }
1083 crate::idempotency::add_deferred_session_replay_key(
1085 &response,
1086 None,
1087 inner_guard.cookie_backed,
1088 None,
1089 );
1090 } else if inner_guard.dirty {
1091 let data = inner_guard.data.clone();
1092 let sid = inner_guard.id.clone();
1093 let primary_replay_after_guard_denial =
1094 inner_guard.cookie_backed && inner_guard.old_id.is_some();
1095 let rotation = inner_guard
1097 .old_id
1098 .clone()
1099 .filter(|_| inner_guard.cookie_backed)
1100 .map(|old_id| SessionRotation {
1101 old_id,
1102 new_id: sid.clone(),
1103 earlier_ids: inner_guard.prior_ids.clone(),
1104 });
1105 let finalized_tenant = tenancy_session_key
1111 .as_deref()
1112 .and_then(|key| data.get(key).cloned());
1113 if let Some(reserve) = crate::idempotency::reserve_deferred_session_replay_key(
1117 &response,
1118 Some(&sid),
1119 primary_replay_after_guard_denial,
1120 finalized_tenant.as_deref(),
1121 ) && let Err(error) = reserve.await
1122 {
1123 drop(inner_guard);
1124 tracing::error!("session alias idempotency lock failed: {error}");
1125 crate::idempotency::keep_deferred_session_commit_locked(&mut response);
1126 return Ok(crate::idempotency::persistence_failed_response());
1127 }
1128 if let Some(ref old_id) = inner_guard.old_id
1129 && let Err(error) = store.destroy(old_id).await
1130 {
1131 drop(inner_guard);
1132 crate::idempotency::keep_deferred_session_commit_locked(&mut response);
1133 return Ok(session_store_unavailable_response(&error));
1134 }
1135 drop(inner_guard);
1136 if let Err(error) = store.save(&sid, data).await {
1137 crate::idempotency::keep_deferred_session_commit_locked(&mut response);
1138 return Ok(session_store_unavailable_response(&error));
1139 }
1140 if let (Some(hooks), Some(rotation)) = (&rotation_hooks, &rotation) {
1141 hooks.run(rotation).await;
1142 }
1143 let cookie_value = signing_keys.as_ref().map_or_else(
1145 || sid.clone(),
1146 |keys| format!("{sid}.{}", keys.sign(sid.as_bytes())),
1147 );
1148 if let Ok(val) = HeaderValue::from_str(&build_set_cookie(&config, &cookie_value)) {
1149 response.headers_mut().append(SET_COOKIE, val);
1150 }
1151 }
1152
1153 if let Some(finalize) =
1154 crate::idempotency::finalize_deferred_session_commit(&mut response)
1155 && finalize.await.is_err()
1156 {
1157 return Ok(crate::idempotency::persistence_failed_response());
1158 }
1159
1160 Ok(response)
1161 })
1162 }
1163}
1164
1165fn session_store_unavailable_response(error: &SessionStoreError) -> Response {
1166 tracing::error!("session store unavailable: {error}");
1167 (StatusCode::SERVICE_UNAVAILABLE, "Session store unavailable").into_response()
1168}
1169
1170pub(crate) fn build_session_layer(
1203 config: &SessionConfig,
1204 profile: Option<&str>,
1205 custom_store: Option<Arc<dyn BoxedSessionStore>>,
1206 signing_keys: Option<Arc<crate::security::config::ResolvedSigningKeys>>,
1207 entropy: &Arc<dyn crate::entropy::Entropy>,
1208 tenancy_session_key: Option<Arc<str>>,
1209) -> Result<SessionLayer<ArcSessionStore>, SessionBackendConfigError> {
1210 fn finish(
1213 store: ArcSessionStore,
1214 config: &SessionConfig,
1215 signing_keys: Option<Arc<crate::security::config::ResolvedSigningKeys>>,
1216 entropy: &Arc<dyn crate::entropy::Entropy>,
1217 tenancy_session_key: Option<Arc<str>>,
1218 ) -> SessionLayer<ArcSessionStore> {
1219 let mut layer = SessionLayer::new(store, config.clone())
1220 .with_entropy(Arc::clone(entropy))
1221 .with_tenancy_session_key(tenancy_session_key);
1222 if let Some(keys) = signing_keys {
1223 layer = layer.with_signing_keys(keys);
1224 }
1225 layer
1226 }
1227
1228 if let Some(store) = custom_store {
1229 tracing::debug!(
1230 "Custom session store installed via with_session_store(); skipping config-driven backend selection"
1231 );
1232 return Ok(finish(
1234 ArcSessionStore(store),
1235 config,
1236 signing_keys,
1237 entropy,
1238 tenancy_session_key,
1239 ));
1240 }
1241
1242 match config.backend_plan(profile)? {
1243 SessionBackendPlan::Memory { warn_in_production } => {
1244 if warn_in_production {
1245 tracing::warn!(
1246 "prod profile is using in-memory sessions; set session.backend=redis or \
1247 session.allow_memory_in_production=true to acknowledge the risk"
1248 );
1249 }
1250 Ok(finish(
1251 ArcSessionStore(Arc::new(MemoryStore::new())),
1252 config,
1253 signing_keys,
1254 entropy,
1255 tenancy_session_key,
1256 ))
1257 }
1258 SessionBackendPlan::Redis { .. } => {
1259 #[cfg(feature = "redis")]
1260 {
1261 let store = crate::session_redis::RedisStore::from_config(config)?;
1262 Ok(finish(
1263 ArcSessionStore(Arc::new(store)),
1264 config,
1265 signing_keys,
1266 entropy,
1267 tenancy_session_key,
1268 ))
1269 }
1270
1271 #[cfg(not(feature = "redis"))]
1272 {
1273 Err(SessionBackendConfigError::RedisFeatureDisabled)
1274 }
1275 }
1276 }
1277}
1278
1279#[cfg(test)]
1280mod tests {
1281 use super::*;
1282 use axum::Router;
1283 use axum::body::Body;
1284 use axum::routing::get;
1285 use http::Request as HttpRequest;
1286 use tower::ServiceExt;
1287
1288 #[tokio::test]
1289 async fn touch_marks_the_session_dirty_without_reading_or_writing_data() {
1290 let session = Session::new_for_test_without_cookie("fresh-id".to_owned(), HashMap::new());
1291 assert!(
1292 !session.has_pending_changes().await,
1293 "sanity: not dirty yet"
1294 );
1295
1296 session.touch().await;
1297
1298 assert!(
1299 session.has_pending_changes().await,
1300 "touch() must dirty the session so SessionLayer persists it and sets the cookie"
1301 );
1302 assert_eq!(
1303 session.id().await,
1304 "fresh-id",
1305 "touch() must not change the id"
1306 );
1307 }
1308
1309 #[tokio::test]
1313 async fn rotate_id_is_deterministic_under_seeded_entropy() {
1314 async fn rotated(seed: u64) -> String {
1315 let session = Session::new_for_test_without_cookie("orig".to_owned(), HashMap::new())
1316 .with_entropy(crate::entropy::SeededEntropy::shared(seed));
1317 session.rotate_id().await;
1318 session.id().await
1319 }
1320
1321 let first = rotated(0x5eed).await;
1322 assert_ne!(first, "orig", "rotation changes the id");
1323 assert!(Uuid::parse_str(&first).is_ok(), "rotated id is a v4 UUID");
1324 assert_eq!(
1325 first,
1326 rotated(0x5eed).await,
1327 "same seed ⇒ identical rotated session id"
1328 );
1329 assert_ne!(
1330 rotated(1).await,
1331 rotated(2).await,
1332 "a different seed ⇒ different rotated id"
1333 );
1334 }
1335
1336 #[derive(Clone, Default)]
1340 struct SentinelStore {
1341 load_calls: Arc<RwLock<u32>>,
1342 }
1343
1344 impl SessionStore for SentinelStore {
1345 async fn load(
1346 &self,
1347 _id: &str,
1348 ) -> Result<Option<HashMap<String, String>>, SessionStoreError> {
1349 *self.load_calls.write().await += 1;
1350 let mut data = HashMap::new();
1353 data.insert("from".to_owned(), "sentinel".to_owned());
1354 Ok(Some(data))
1355 }
1356
1357 async fn save(
1358 &self,
1359 _id: &str,
1360 _data: HashMap<String, String>,
1361 ) -> Result<(), SessionStoreError> {
1362 Ok(())
1363 }
1364
1365 async fn destroy(&self, _id: &str) -> Result<(), SessionStoreError> {
1366 Ok(())
1367 }
1368 }
1369
1370 #[tokio::test]
1371 async fn arc_session_store_wrapper_delegates_to_inner_session_store() {
1372 let inner = SentinelStore::default();
1373 let load_counter = inner.load_calls.clone();
1374 let arc: Arc<dyn BoxedSessionStore> = Arc::new(inner);
1375 let wrapper = ArcSessionStore(arc);
1376
1377 let result = wrapper
1378 .load("session-id")
1379 .await
1380 .expect("wrapped store should succeed");
1381
1382 assert_eq!(*load_counter.read().await, 1);
1383 assert_eq!(
1384 result
1385 .as_ref()
1386 .and_then(|m| m.get("from"))
1387 .map(String::as_str),
1388 Some("sentinel"),
1389 "wrapper must return data from the wrapped impl, not a default"
1390 );
1391 }
1392
1393 #[tokio::test]
1394 async fn boxed_session_store_blanket_impl_works_for_any_session_store() {
1395 let store = SentinelStore::default();
1397 let boxed: Arc<dyn BoxedSessionStore> = Arc::new(store);
1398 let result = boxed.boxed_load("session-id").await.unwrap();
1399 assert!(result.is_some());
1400 }
1401
1402 #[derive(Clone)]
1403 struct FailingStore {
1404 fail_on_load: bool,
1405 fail_on_save: bool,
1406 fail_on_destroy: bool,
1407 }
1408
1409 impl SessionStore for FailingStore {
1410 async fn load(
1411 &self,
1412 _id: &str,
1413 ) -> Result<Option<HashMap<String, String>>, SessionStoreError> {
1414 if self.fail_on_load {
1415 Err(SessionStoreError::backend("load", "boom"))
1416 } else {
1417 Ok(None)
1418 }
1419 }
1420
1421 async fn save(
1422 &self,
1423 _id: &str,
1424 _data: HashMap<String, String>,
1425 ) -> Result<(), SessionStoreError> {
1426 if self.fail_on_save {
1427 Err(SessionStoreError::backend("save", "boom"))
1428 } else {
1429 Ok(())
1430 }
1431 }
1432
1433 async fn destroy(&self, _id: &str) -> Result<(), SessionStoreError> {
1434 if self.fail_on_destroy {
1435 Err(SessionStoreError::backend("destroy", "boom"))
1436 } else {
1437 Ok(())
1438 }
1439 }
1440 }
1441
1442 #[tokio::test]
1443 async fn memory_store_save_and_load() {
1444 let store = MemoryStore::new();
1445 let mut data = HashMap::new();
1446 data.insert("user".into(), "alice".into());
1447 store.save("sess1", data).await.unwrap();
1448
1449 let loaded = store.load("sess1").await.unwrap();
1450 assert!(loaded.is_some());
1451 assert_eq!(loaded.unwrap().get("user").unwrap(), "alice");
1452 }
1453
1454 #[tokio::test]
1455 async fn memory_store_destroy() {
1456 let store = MemoryStore::new();
1457 store.save("sess1", HashMap::new()).await.unwrap();
1458 store.destroy("sess1").await.unwrap();
1459 assert!(store.load("sess1").await.unwrap().is_none());
1460 }
1461
1462 #[tokio::test]
1463 async fn memory_store_load_missing() {
1464 let store = MemoryStore::new();
1465 assert!(store.load("nonexistent").await.unwrap().is_none());
1466 }
1467
1468 #[tokio::test]
1469 async fn session_insert_and_get() {
1470 let session = Session::new("test".into(), HashMap::new());
1471 session.insert("key", "value").await;
1472 assert_eq!(session.get("key").await, Some("value".to_owned()));
1473 }
1474
1475 #[tokio::test]
1476 async fn session_remove() {
1477 let mut data = HashMap::new();
1478 data.insert("key".into(), "value".into());
1479 let session = Session::new("test".into(), data);
1480 let removed = session.remove("key").await;
1481 assert_eq!(removed, Some("value".to_owned()));
1482 assert!(session.get("key").await.is_none());
1483 }
1484
1485 #[tokio::test]
1486 async fn session_clear() {
1487 let mut data = HashMap::new();
1488 data.insert("a".into(), "1".into());
1489 data.insert("b".into(), "2".into());
1490 let session = Session::new("test".into(), data);
1491 session.clear().await;
1492 assert!(session.get("a").await.is_none());
1493 assert!(session.get("b").await.is_none());
1494 }
1495
1496 #[tokio::test]
1497 async fn session_contains_key() {
1498 let mut data = HashMap::new();
1499 data.insert("exists".into(), "yes".into());
1500 let session = Session::new("test".into(), data);
1501 assert!(session.contains_key("exists").await);
1502 assert!(!session.contains_key("missing").await);
1503 }
1504
1505 #[tokio::test]
1506 async fn session_destroy_marks_destroyed() {
1507 let session = Session::new("test".into(), HashMap::new());
1508 session.insert("key", "value").await;
1509 session.destroy().await;
1510 let inner = session.inner.read().await;
1511 let destroyed = inner.destroyed;
1512 let empty = inner.data.is_empty();
1513 drop(inner);
1514 assert!(destroyed);
1515 assert!(empty);
1516 }
1517
1518 #[test]
1519 fn get_cookie_extracts_value() {
1520 let mut headers = http::HeaderMap::new();
1521 headers.insert(
1522 COOKIE,
1523 HeaderValue::from_static("autumn.sid=abc123; other=xyz"),
1524 );
1525 assert_eq!(get_cookie(&headers, "autumn.sid"), Some("abc123".into()));
1526 assert_eq!(get_cookie(&headers, "other"), Some("xyz".into()));
1527 assert_eq!(get_cookie(&headers, "missing"), None);
1528 }
1529
1530 #[test]
1531 fn get_cookie_rejects_multiple_cookies() {
1532 let mut headers = http::HeaderMap::new();
1533 headers.insert(
1534 COOKIE,
1535 HeaderValue::from_static("autumn.sid=abc123; autumn.sid=xyz456"),
1536 );
1537 assert_eq!(get_cookie(&headers, "autumn.sid"), None);
1538
1539 let mut headers2 = http::HeaderMap::new();
1540 headers2.append(COOKIE, HeaderValue::from_static("autumn.sid=abc123"));
1541 headers2.append(COOKIE, HeaderValue::from_static("autumn.sid=xyz456"));
1542 assert_eq!(get_cookie(&headers2, "autumn.sid"), None);
1543 }
1544
1545 #[test]
1546 fn build_set_cookie_contains_required_parts() {
1547 let config = SessionConfig::default();
1548 let cookie = build_set_cookie(&config, "test-id");
1549 assert!(cookie.contains("autumn.sid=test-id"));
1550 assert!(cookie.contains("Path=/"));
1551 assert!(cookie.contains("HttpOnly"));
1552 assert!(cookie.contains("SameSite=Lax"));
1553 assert!(cookie.contains("Max-Age=86400"));
1554 }
1555
1556 #[test]
1557 fn build_expire_cookie_has_zero_max_age() {
1558 let config = SessionConfig::default();
1559 let cookie = build_expire_cookie(&config);
1560 assert!(cookie.contains("Max-Age=0"));
1561 }
1562
1563 #[test]
1564 fn session_config_defaults() {
1565 let config = SessionConfig::default();
1566 assert_eq!(config.backend, SessionBackend::Memory);
1567 assert_eq!(config.cookie_name, "autumn.sid");
1568 assert_eq!(config.max_age_secs, 86400);
1569 assert!(config.secure);
1570 assert_eq!(config.same_site, "Lax");
1571 assert!(config.http_only);
1572 assert_eq!(config.path, "/");
1573 assert!(!config.allow_memory_in_production);
1574 assert!(config.redis.url.is_none());
1575 assert_eq!(config.redis.key_prefix, "autumn:sessions");
1576 }
1577
1578 #[test]
1579 fn session_backend_plan_warns_for_prod_memory_without_ack() {
1580 let config = SessionConfig::default();
1581 let plan = config.backend_plan(Some("prod")).unwrap();
1582 assert_eq!(
1583 plan,
1584 SessionBackendPlan::Memory {
1585 warn_in_production: true
1586 }
1587 );
1588 }
1589
1590 #[test]
1591 fn session_backend_plan_suppresses_prod_warning_when_acknowledged() {
1592 let config = SessionConfig {
1593 allow_memory_in_production: true,
1594 ..SessionConfig::default()
1595 };
1596 let plan = config.backend_plan(Some("prod")).unwrap();
1597 assert_eq!(
1598 plan,
1599 SessionBackendPlan::Memory {
1600 warn_in_production: false
1601 }
1602 );
1603 }
1604
1605 #[test]
1606 fn session_backend_plan_requires_redis_url() {
1607 let config = SessionConfig {
1608 backend: SessionBackend::Redis,
1609 ..SessionConfig::default()
1610 };
1611 let error = config.backend_plan(None).unwrap_err();
1612 assert_eq!(error, SessionBackendConfigError::MissingRedisUrl);
1613 }
1614
1615 #[tokio::test]
1616 async fn session_layer_sets_cookie_on_new_session() {
1617 use crate::state::AppState;
1618 async fn handler(session: Session) -> String {
1619 session.insert("visited", "true").await;
1620 "ok".to_owned()
1621 }
1622
1623 let state = AppState::test_default();
1624
1625 let app = Router::new()
1626 .route("/", get(handler))
1627 .layer(SessionLayer::new(
1628 MemoryStore::new(),
1629 SessionConfig::default(),
1630 ))
1631 .with_state(state);
1632
1633 let response = app
1634 .oneshot(HttpRequest::builder().uri("/").body(Body::empty()).unwrap())
1635 .await
1636 .unwrap();
1637
1638 assert_eq!(response.status(), http::StatusCode::OK);
1639 let set_cookie = response
1640 .headers()
1641 .get(SET_COOKIE)
1642 .expect("should set session cookie");
1643 let cookie_str = set_cookie.to_str().unwrap();
1644 assert!(cookie_str.contains("autumn.sid="));
1645 }
1646
1647 fn test_state() -> crate::state::AppState {
1648 crate::state::AppState::test_default()
1649 }
1650
1651 #[tokio::test]
1652 async fn session_layer_persists_data_across_requests() {
1653 async fn write_handler(session: Session) -> String {
1654 session.insert("user", "alice").await;
1655 "saved".to_owned()
1656 }
1657
1658 async fn read_handler(session: Session) -> String {
1659 session.get("user").await.unwrap_or_default()
1660 }
1661
1662 let store = MemoryStore::new();
1663 let config = SessionConfig::default();
1664 let state = test_state();
1665
1666 let app = Router::new()
1667 .route("/write", get(write_handler))
1668 .route("/read", get(read_handler))
1669 .layer(SessionLayer::new(store, config))
1670 .with_state(state);
1671
1672 let resp1 = app
1674 .clone()
1675 .oneshot(
1676 HttpRequest::builder()
1677 .uri("/write")
1678 .body(Body::empty())
1679 .unwrap(),
1680 )
1681 .await
1682 .unwrap();
1683
1684 let cookie = resp1
1685 .headers()
1686 .get(SET_COOKIE)
1687 .unwrap()
1688 .to_str()
1689 .unwrap()
1690 .to_owned();
1691 let session_cookie = cookie.split(';').next().unwrap();
1693
1694 let resp2 = app
1696 .oneshot(
1697 HttpRequest::builder()
1698 .uri("/read")
1699 .header(COOKIE, session_cookie)
1700 .body(Body::empty())
1701 .unwrap(),
1702 )
1703 .await
1704 .unwrap();
1705
1706 let body = axum::body::to_bytes(resp2.into_body(), usize::MAX)
1707 .await
1708 .unwrap();
1709 assert_eq!(std::str::from_utf8(&body).unwrap(), "alice");
1710 }
1711
1712 #[tokio::test]
1713 async fn session_destroy_expires_cookie() {
1714 async fn handler(session: Session) -> String {
1715 session.destroy().await;
1716 "destroyed".to_owned()
1717 }
1718
1719 let state = test_state();
1720
1721 let store = MemoryStore::new();
1722 store
1723 .save("existing-id", HashMap::from([("k".into(), "v".into())]))
1724 .await
1725 .unwrap();
1726
1727 let app = Router::new()
1728 .route("/", get(handler))
1729 .layer(SessionLayer::new(store.clone(), SessionConfig::default()))
1730 .with_state(state);
1731
1732 let response = app
1733 .oneshot(
1734 HttpRequest::builder()
1735 .uri("/")
1736 .header(COOKIE, "autumn.sid=existing-id")
1737 .body(Body::empty())
1738 .unwrap(),
1739 )
1740 .await
1741 .unwrap();
1742
1743 let cookie = response
1744 .headers()
1745 .get(SET_COOKIE)
1746 .unwrap()
1747 .to_str()
1748 .unwrap();
1749 assert!(cookie.contains("Max-Age=0"), "cookie should be expired");
1750
1751 assert!(store.load("existing-id").await.unwrap().is_none());
1753 }
1754
1755 #[tokio::test]
1756 async fn session_layer_returns_503_when_store_load_fails() {
1757 let state = test_state();
1758
1759 let app = Router::new()
1760 .route("/", get(|| async { "ok" }))
1761 .layer(SessionLayer::new(
1762 FailingStore {
1763 fail_on_load: true,
1764 fail_on_save: false,
1765 fail_on_destroy: false,
1766 },
1767 SessionConfig::default(),
1768 ))
1769 .with_state(state);
1770
1771 let response = app
1772 .oneshot(
1773 HttpRequest::builder()
1774 .uri("/")
1775 .header(COOKIE, "autumn.sid=existing-id")
1776 .body(Body::empty())
1777 .unwrap(),
1778 )
1779 .await
1780 .unwrap();
1781
1782 assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
1783 }
1784
1785 #[tokio::test]
1786 async fn session_layer_returns_503_when_store_save_fails() {
1787 let state = test_state();
1788
1789 let app = Router::new()
1790 .route(
1791 "/",
1792 get(|session: Session| async move {
1793 session.insert("user", "alice").await;
1794 "ok"
1795 }),
1796 )
1797 .layer(SessionLayer::new(
1798 FailingStore {
1799 fail_on_load: false,
1800 fail_on_save: true,
1801 fail_on_destroy: false,
1802 },
1803 SessionConfig::default(),
1804 ))
1805 .with_state(state);
1806
1807 let response = app
1808 .oneshot(HttpRequest::builder().uri("/").body(Body::empty()).unwrap())
1809 .await
1810 .unwrap();
1811
1812 assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
1813 }
1814
1815 #[tokio::test]
1816 async fn deadline_cancelled_response_skips_partial_session_save() {
1817 let state = test_state();
1818 let store = MemoryStore::new();
1819 store
1821 .save("existing-id", HashMap::new())
1822 .await
1823 .expect("seed save");
1824
1825 let timeout_layer = axum::middleware::from_fn(
1829 |req: HttpRequest<Body>, next: axum::middleware::Next| async move {
1830 tokio::time::timeout(std::time::Duration::from_millis(50), next.run(req))
1831 .await
1832 .unwrap_or_else(|_| {
1833 let mut resp = StatusCode::SERVICE_UNAVAILABLE.into_response();
1834 resp.extensions_mut()
1835 .insert(crate::router::RequestDeadlineCancelled);
1836 resp
1837 })
1838 },
1839 );
1840
1841 let app = Router::new()
1842 .route(
1843 "/",
1844 get(|session: Session| async move {
1845 session.insert("user", "alice").await;
1846 tokio::time::sleep(std::time::Duration::from_secs(60)).await;
1849 "ok"
1850 }),
1851 )
1852 .layer(timeout_layer)
1853 .layer(SessionLayer::new(store.clone(), SessionConfig::default()))
1854 .with_state(state);
1855
1856 let response = app
1857 .oneshot(
1858 HttpRequest::builder()
1859 .uri("/")
1860 .header(COOKIE, "autumn.sid=existing-id")
1861 .body(Body::empty())
1862 .unwrap(),
1863 )
1864 .await
1865 .unwrap();
1866
1867 assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
1868 let saved = store
1871 .load("existing-id")
1872 .await
1873 .expect("load")
1874 .expect("session still present");
1875 assert!(
1876 !saved.contains_key("user"),
1877 "a deadline-cancelled request must not persist partial session changes"
1878 );
1879 assert!(
1881 response.headers().get(SET_COOKIE).is_none(),
1882 "no Set-Cookie should be written when the partial save is skipped"
1883 );
1884 }
1885
1886 #[tokio::test]
1887 async fn a_handler_returning_a_deadline_stop_skips_partial_session_save() {
1888 let state = test_state();
1889 let store = MemoryStore::new();
1890 store
1891 .save("existing-id", HashMap::new())
1892 .await
1893 .expect("seed save");
1894
1895 let app = Router::new()
1898 .route(
1899 "/",
1900 get(|session: Session| async move {
1901 session.insert("user", "alice").await;
1902 Err::<&str, crate::AutumnError>(crate::deadline::DeadlineExceeded.into())
1903 }),
1904 )
1905 .layer(SessionLayer::new(store.clone(), SessionConfig::default()))
1906 .with_state(state);
1907
1908 let response = app
1909 .oneshot(
1910 HttpRequest::builder()
1911 .uri("/")
1912 .header(COOKIE, "autumn.sid=existing-id")
1913 .body(Body::empty())
1914 .unwrap(),
1915 )
1916 .await
1917 .unwrap();
1918
1919 assert_eq!(response.status(), StatusCode::GATEWAY_TIMEOUT);
1920 let saved = store
1921 .load("existing-id")
1922 .await
1923 .expect("load")
1924 .expect("session still present");
1925 assert!(!saved.contains_key("user"), "no partial session save");
1926 assert!(response.headers().get(SET_COOKIE).is_none());
1927 }
1928
1929 #[tokio::test]
1932 async fn session_cookie_is_signed_when_signing_keys_set() {
1933 use crate::security::config::{SigningSecretConfig, resolve_signing_keys};
1934 use std::sync::Arc;
1935
1936 let config = SigningSecretConfig {
1937 secret: Some("k".repeat(32)),
1938 previous_secrets: vec![],
1939 };
1940 let keys = Arc::new(resolve_signing_keys(&config));
1941
1942 let app = Router::new()
1943 .route(
1944 "/",
1945 get(|s: Session| async move {
1946 s.insert("k", "v").await;
1947 "ok"
1948 }),
1949 )
1950 .layer(
1951 SessionLayer::new(MemoryStore::new(), SessionConfig::default())
1952 .with_signing_keys(keys),
1953 );
1954
1955 let req = HttpRequest::builder().uri("/").body(Body::empty()).unwrap();
1956 let resp = app.oneshot(req).await.unwrap();
1957
1958 let set_cookie = resp
1959 .headers()
1960 .get("set-cookie")
1961 .expect("should set cookie")
1962 .to_str()
1963 .unwrap();
1964 let cookie_value = set_cookie
1965 .split('=')
1966 .nth(1)
1967 .unwrap()
1968 .split(';')
1969 .next()
1970 .unwrap()
1971 .trim();
1972
1973 assert!(
1974 cookie_value.contains('.'),
1975 "signed session cookie must be {{id}}.{{hmac}}, got: {cookie_value}"
1976 );
1977 let (id_part, sig_part) = cookie_value.split_once('.').unwrap();
1978 assert!(!id_part.is_empty());
1979 assert_eq!(sig_part.len(), 64, "HMAC-SHA256 hex must be 64 chars");
1980 }
1981
1982 #[tokio::test]
1983 async fn session_layer_rejects_tampered_cookie() {
1984 use crate::security::config::{SigningSecretConfig, resolve_signing_keys};
1985 use std::sync::Arc;
1986
1987 let keys = Arc::new(resolve_signing_keys(&SigningSecretConfig {
1988 secret: Some("k".repeat(32)),
1989 previous_secrets: vec![],
1990 }));
1991
1992 let store = MemoryStore::new();
1993 let session_id = Uuid::new_v4().to_string();
1994 let mut data = HashMap::new();
1995 data.insert("user".to_owned(), "alice".to_owned());
1996 store.save(&session_id, data).await.unwrap();
1997
1998 let app = Router::new()
1999 .route(
2000 "/",
2001 get(|s: Session| async move {
2002 s.get("user").await.unwrap_or_else(|| "none".to_owned())
2003 }),
2004 )
2005 .layer(SessionLayer::new(store, SessionConfig::default()).with_signing_keys(keys));
2006
2007 let bad_sig = "0".repeat(64);
2009 let bad_cookie = format!("autumn.sid={session_id}.{bad_sig}");
2010 let req = HttpRequest::builder()
2011 .uri("/")
2012 .header("cookie", bad_cookie)
2013 .body(Body::empty())
2014 .unwrap();
2015 let resp = app.oneshot(req).await.unwrap();
2016 let body = axum::body::to_bytes(resp.into_body(), 64).await.unwrap();
2017 assert_eq!(&body[..], b"none", "tampered cookie must not load session");
2018 }
2019
2020 #[tokio::test]
2021 async fn session_layer_accepts_previous_key_signed_cookie() {
2022 use crate::security::config::{
2023 ResolvedSigningKeys, SigningSecretConfig, resolve_signing_keys,
2024 };
2025 use std::sync::Arc;
2026
2027 let old_secret = "old-key".repeat(5); let old_keys = resolve_signing_keys(&SigningSecretConfig {
2029 secret: Some(old_secret.clone()),
2030 previous_secrets: vec![],
2031 });
2032
2033 let session_id = Uuid::new_v4().to_string();
2034 let old_sig = old_keys.sign(session_id.as_bytes());
2035 let signed_value = format!("{session_id}.{old_sig}");
2036
2037 let new_keys = Arc::new(ResolvedSigningKeys::new(
2038 "new-key".repeat(5).into_bytes(),
2039 vec![old_secret.into_bytes()],
2040 ));
2041
2042 let store = MemoryStore::new();
2043 let mut data = HashMap::new();
2044 data.insert("user".to_owned(), "bob".to_owned());
2045 store.save(&session_id, data).await.unwrap();
2046
2047 let app = Router::new()
2048 .route(
2049 "/",
2050 get(|s: Session| async move {
2051 s.get("user").await.unwrap_or_else(|| "none".to_owned())
2052 }),
2053 )
2054 .layer(SessionLayer::new(store, SessionConfig::default()).with_signing_keys(new_keys));
2055
2056 let req = HttpRequest::builder()
2057 .uri("/")
2058 .header("cookie", format!("autumn.sid={signed_value}"))
2059 .body(Body::empty())
2060 .unwrap();
2061 let resp = app.oneshot(req).await.unwrap();
2062 let body = axum::body::to_bytes(resp.into_body(), 64).await.unwrap();
2063 assert_eq!(
2064 &body[..],
2065 b"bob",
2066 "previous-key-signed cookie must load session"
2067 );
2068 }
2069
2070 type RotationLog = Arc<std::sync::Mutex<Vec<(String, String)>>>;
2073
2074 fn rotation_log() -> (SessionRotationHooks, RotationLog) {
2075 let hooks = SessionRotationHooks::default();
2076 let log = Arc::new(std::sync::Mutex::new(Vec::new()));
2077 let sink = Arc::clone(&log);
2078 hooks.register(move |rotation| {
2079 let sink = Arc::clone(&sink);
2080 async move {
2081 sink.lock()
2082 .unwrap()
2083 .push((rotation.old_id, rotation.new_id));
2084 Ok(())
2085 }
2086 });
2087 (hooks, log)
2088 }
2089
2090 async fn rotation_app(hooks: SessionRotationHooks, store: MemoryStore) -> (Router, String) {
2091 async fn seed(session: Session) -> String {
2092 session.insert("k", "v").await;
2093 session.id().await
2094 }
2095 async fn rotate(session: Session) -> String {
2096 session.rotate_id().await;
2097 session.rotate_id().await;
2098 session.id().await
2099 }
2100 async fn destroy(session: Session) -> &'static str {
2101 session.rotate_id().await;
2102 session.destroy().await;
2103 "gone"
2104 }
2105 let app = Router::new()
2106 .route("/seed", get(seed))
2107 .route("/rotate", get(rotate))
2108 .route("/destroy", get(destroy))
2109 .route("/noop", get(|| async { "ok" }))
2110 .layer(SessionLayer::new(store, SessionConfig::default()).with_rotation_hooks(hooks))
2111 .with_state(test_state());
2112 let response = app
2113 .clone()
2114 .oneshot(
2115 HttpRequest::builder()
2116 .uri("/seed")
2117 .body(Body::empty())
2118 .unwrap(),
2119 )
2120 .await
2121 .unwrap();
2122 let cookie = response
2123 .headers()
2124 .get(SET_COOKIE)
2125 .unwrap()
2126 .to_str()
2127 .unwrap();
2128 (app, cookie.split(';').next().unwrap().to_owned())
2129 }
2130
2131 async fn hit(app: &Router, uri: &str, cookie: &str) -> Response {
2132 app.clone()
2133 .oneshot(
2134 HttpRequest::builder()
2135 .uri(uri)
2136 .header(COOKIE, cookie)
2137 .body(Body::empty())
2138 .unwrap(),
2139 )
2140 .await
2141 .unwrap()
2142 }
2143
2144 #[tokio::test]
2145 async fn rotation_hook_gets_the_net_old_and_new_id_once() {
2146 let (hooks, log) = rotation_log();
2147 let (app, cookie) = rotation_app(hooks, MemoryStore::new()).await;
2148 let old_id = cookie.trim_start_matches("autumn.sid=").to_owned();
2149
2150 let response = hit(&app, "/rotate", &cookie).await;
2151 let body = axum::body::to_bytes(response.into_body(), 1024)
2152 .await
2153 .unwrap();
2154 let new_id = String::from_utf8(body.to_vec()).unwrap();
2155
2156 let seen = log.lock().unwrap().clone();
2157 assert_eq!(seen, vec![(old_id, new_id)], "two rotations, one net hop");
2158 }
2159
2160 #[tokio::test]
2161 async fn rotation_event_lists_every_earlier_id() {
2162 let hooks = SessionRotationHooks::default();
2163 let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
2164 let sink = Arc::clone(&seen);
2165 hooks.register(move |rotation| {
2166 let sink = Arc::clone(&sink);
2167 async move {
2168 sink.lock().unwrap().push(rotation);
2169 Ok(())
2170 }
2171 });
2172 let (app, cookie) = rotation_app(hooks, MemoryStore::new()).await;
2173 let old_id = cookie.trim_start_matches("autumn.sid=").to_owned();
2174
2175 hit(&app, "/rotate", &cookie).await;
2176
2177 let events = seen.lock().unwrap().clone();
2178 assert_eq!(events.len(), 1);
2179 let event = &events[0];
2180 assert_eq!(event.earlier_ids.len(), 2, "the start id and one hop");
2181 assert_eq!(event.earlier_ids[0], old_id);
2182 assert_ne!(event.earlier_ids[1], event.new_id);
2183 }
2184
2185 #[tokio::test]
2186 async fn rotation_hook_is_silent_without_a_rotation() {
2187 let (hooks, log) = rotation_log();
2188 let (app, cookie) = rotation_app(hooks, MemoryStore::new()).await;
2189 hit(&app, "/noop", &cookie).await;
2190 assert!(log.lock().unwrap().is_empty());
2191 }
2192
2193 #[tokio::test]
2194 async fn rotation_hook_is_silent_for_a_session_with_no_cookie() {
2195 let (hooks, log) = rotation_log();
2196 let (app, _) = rotation_app(hooks, MemoryStore::new()).await;
2197 let response = app
2198 .oneshot(
2199 HttpRequest::builder()
2200 .uri("/rotate")
2201 .body(Body::empty())
2202 .unwrap(),
2203 )
2204 .await
2205 .unwrap();
2206 assert_eq!(response.status(), http::StatusCode::OK);
2207 assert!(log.lock().unwrap().is_empty());
2208 }
2209
2210 #[tokio::test]
2211 async fn rotation_hook_is_silent_when_the_session_is_destroyed() {
2212 let (hooks, log) = rotation_log();
2213 let (app, cookie) = rotation_app(hooks, MemoryStore::new()).await;
2214 hit(&app, "/destroy", &cookie).await;
2215 assert!(log.lock().unwrap().is_empty());
2216 }
2217
2218 #[tokio::test]
2219 async fn rotation_hook_runs_after_the_new_session_is_saved() {
2220 let store = MemoryStore::new();
2221 let hooks = SessionRotationHooks::default();
2222 let check = store.clone();
2223 let saw_new = Arc::new(std::sync::atomic::AtomicBool::new(false));
2224 let flag = Arc::clone(&saw_new);
2225 hooks.register(move |rotation| {
2226 let check = check.clone();
2227 let flag = Arc::clone(&flag);
2228 async move {
2229 let saved = check.load(&rotation.new_id).await.unwrap().is_some();
2230 let gone = check.load(&rotation.old_id).await.unwrap().is_none();
2231 flag.store(saved && gone, std::sync::atomic::Ordering::SeqCst);
2232 Ok(())
2233 }
2234 });
2235 let (app, cookie) = rotation_app(hooks, store).await;
2236 hit(&app, "/rotate", &cookie).await;
2237 assert!(saw_new.load(std::sync::atomic::Ordering::SeqCst));
2238 }
2239
2240 #[tokio::test]
2241 async fn a_failing_rotation_hook_does_not_fail_the_response() {
2242 let hooks = SessionRotationHooks::default();
2243 hooks.register(|_| async { Err("db down".into()) });
2244 let ran_after = Arc::new(std::sync::atomic::AtomicBool::new(false));
2245 let flag = Arc::clone(&ran_after);
2246 hooks.register(move |_| {
2247 let flag = Arc::clone(&flag);
2248 async move {
2249 flag.store(true, std::sync::atomic::Ordering::SeqCst);
2250 Ok(())
2251 }
2252 });
2253 let (app, cookie) = rotation_app(hooks, MemoryStore::new()).await;
2254 let response = hit(&app, "/rotate", &cookie).await;
2255 assert_eq!(response.status(), http::StatusCode::OK);
2256 assert!(response.headers().get(SET_COOKIE).is_some());
2257 assert!(
2258 ran_after.load(std::sync::atomic::Ordering::SeqCst),
2259 "a later hook still runs after an earlier one fails"
2260 );
2261 }
2262}