Skip to main content

siderite_sessions/
layer.rs

1//! [`SessionLayer`]: loads, enforces expiry on, and commits sessions.
2
3use crate::config::{SessionConfig, SessionConfigError};
4use crate::id::{id_hash, well_formed};
5use crate::session::{CookieAction, Loaded, Plan, Save, Session};
6use crate::store::{SessionRecord, SessionStore, StoreError};
7use http::header::COOKIE;
8use http::{HeaderMap, Method, StatusCode};
9use siderite_core::middleware::{BoxService, Next, from_fn};
10use siderite_core::{
11    ApiError, IntoResponse, Request, Response, SameSite, SetCookie,
12    WithCookies,
13};
14use std::future::Future;
15use std::sync::Arc;
16use std::time::{Duration, SystemTime, UNIX_EPOCH};
17use tower::Layer;
18
19/// Loads the session named by the request cookie, exposes it to handlers
20/// as [`Session`], and writes changes back when the handler returns.
21///
22/// What the layer guarantees:
23///
24/// - Idle and absolute expiry are enforced on the server from the stored
25///   record; the cookie's own expiry is advisory.
26/// - The idle timestamp is written at most once per
27///   [`write_interval`](SessionConfig::write_interval), and a request that
28///   changed nothing writes nothing else.
29/// - Fail closed: when the store cannot be read for a request that carries
30///   a session cookie (error or timeout), the request gets a `503`
31///   problem and never reaches the handler as an anonymous visitor.
32/// - When a write that a request needed (login, rotation, a change) fails,
33///   the response becomes `503` and no cookie is set for a session that
34///   was not stored.
35/// - Every store call is bounded by the
36///   [`store_timeout`](SessionConfig::store_timeout).
37/// - With `SameSite=None` the layer also requires a valid CSRF token in a
38///   header on unsafe requests that carry a live session.
39///
40/// Register it with `App::layer` after (inside)
41/// [`CsrfOrigin`](siderite_core::middleware::CsrfOrigin), so forged
42/// requests are refused before any session is loaded.
43#[derive(Clone)]
44pub struct SessionLayer {
45    inner: Arc<Inner>,
46}
47
48struct Inner {
49    store: Box<dyn SessionStore>,
50    config: Arc<SessionConfig>,
51}
52
53impl SessionLayer {
54    /// Create the layer.
55    ///
56    /// # Errors
57    /// [`SessionConfigError`] when `config` is invalid.
58    pub fn new(
59        store: impl SessionStore,
60        config: SessionConfig,
61    ) -> Result<Self, SessionConfigError> {
62        config.validate()?;
63        Ok(Self {
64            inner: Arc::new(Inner {
65                store: Box::new(store),
66                config: Arc::new(config),
67            }),
68        })
69    }
70
71    fn wrap(&self, inner: BoxService) -> BoxService {
72        let this = Arc::clone(&self.inner);
73        from_fn(move |req: Request, next: Next| {
74            let this = Arc::clone(&this);
75            async move { this.handle(req, next).await }
76        })
77        .layer(inner)
78    }
79}
80
81impl Layer<BoxService> for SessionLayer {
82    type Service = BoxService;
83
84    fn layer(&self, inner: BoxService) -> BoxService {
85        self.wrap(inner)
86    }
87}
88
89fn unavailable() -> Response {
90    ApiError::new(StatusCode::SERVICE_UNAVAILABLE, "Sessions unavailable.")
91        .into_response()
92}
93
94fn forbidden() -> Response {
95    ApiError::new(StatusCode::FORBIDDEN, "Request forbidden.").into_response()
96}
97
98fn unix_now() -> u64 {
99    // A clock before the epoch makes every session look expired.
100    SystemTime::now()
101        .duration_since(UNIX_EPOCH)
102        .map_or(u64::MAX, |elapsed| elapsed.as_secs())
103}
104
105fn is_safe(method: &Method) -> bool {
106    matches!(
107        *method,
108        Method::GET | Method::HEAD | Method::OPTIONS | Method::TRACE
109    )
110}
111
112/// The first cookie called `name`.
113fn cookie_value(headers: &HeaderMap, name: &str) -> Option<String> {
114    headers
115        .get_all(COOKIE)
116        .iter()
117        .filter_map(|value| value.to_str().ok())
118        .flat_map(|line| line.split(';'))
119        .filter_map(|pair| pair.trim().split_once('='))
120        .find(|(key, _)| *key == name)
121        .map(|(_, value)| value.to_owned())
122}
123
124impl Inner {
125    /// Bound a store call by the configured timeout.
126    async fn timed<T>(
127        &self,
128        call: impl Future<Output = Result<T, StoreError>>,
129    ) -> Result<T, StoreError> {
130        match tokio::time::timeout(self.config.store_timeout, call).await {
131            Ok(result) => result,
132            Err(_) => Err(StoreError::new("operation timed out")),
133        }
134    }
135
136    /// Apply the server-side lifetimes; `Some(touch)` when still alive.
137    fn alive(&self, record: &SessionRecord, now: u64) -> Option<bool> {
138        let age = now.saturating_sub(record.created_at);
139        let idle = now.saturating_sub(record.last_seen);
140        let config = &self.config;
141        if age >= config.absolute.as_secs() || idle >= config.idle.as_secs() {
142            return None;
143        }
144        Some(idle >= config.effective_write_interval().as_secs())
145    }
146
147    /// Resolve the request's cookie. `Err` is the response to send.
148    async fn load(
149        &self,
150        cookie: Option<String>,
151        now: u64,
152    ) -> Result<(Option<Loaded>, bool), Box<Response>> {
153        let Some(raw) = cookie else {
154            return Ok((None, false));
155        };
156        if !well_formed(&raw) {
157            return Ok((None, true));
158        }
159        let hash = id_hash(&raw);
160        let record = match self.timed(self.store.load(&hash)).await {
161            Ok(Some(record)) => record,
162            Ok(None) => return Ok((None, true)),
163            Err(error) => {
164                tracing::warn!(%error, "session store read failed");
165                return Err(Box::new(unavailable()));
166            }
167        };
168        let Some(touch) = self.alive(&record, now) else {
169            // Dead by our clock; removing it is housekeeping only.
170            let _ = self.timed(self.store.delete(&hash)).await;
171            return Ok((None, true));
172        };
173        Ok((
174            Some(Loaded {
175                hash,
176                record,
177                touch,
178            }),
179            false,
180        ))
181    }
182
183    /// The token check `SameSite=None` demands of unsafe requests.
184    fn token_ok(&self, req: &Request, session: &Session) -> bool {
185        let mut values =
186            req.headers().get_all(&self.config.csrf_header).iter();
187        match (values.next(), values.next()) {
188            (Some(value), None) => {
189                value.to_str().is_ok_and(|token| session.verify_csrf(token))
190            }
191            _ => false,
192        }
193    }
194
195    async fn handle(&self, mut req: Request, next: Next) -> Response {
196        let now = unix_now();
197        let cookie = cookie_value(req.headers(), &self.config.name);
198        let (loaded, expire) = match self.load(cookie, now).await {
199            Ok(found) => found,
200            Err(response) => return *response,
201        };
202        let session = Session::new(Arc::clone(&self.config), loaded, expire);
203        let enforce = self.config.same_site == SameSite::None
204            && !is_safe(req.method())
205            && session.is_loaded();
206        if enforce && !self.token_ok(&req, &session) {
207            return forbidden();
208        }
209        req.extensions_mut().insert(session.clone());
210        let response = next.run(req).await;
211        self.commit(session.plan(now), response).await
212    }
213
214    /// Store, delete and set the cookie as `plan` says.
215    async fn commit(&self, mut plan: Plan, response: Response) -> Response {
216        if let Err(error) = self.apply(&mut plan).await {
217            tracing::warn!(%error, "session store write failed");
218            return unavailable();
219        }
220        let cookie = match plan.cookie {
221            CookieAction::Keep => return response,
222            CookieAction::Set { value, max_age } => {
223                self.cookie(&value, max_age)
224            }
225            CookieAction::Expire => self.cookie("", Duration::ZERO),
226        };
227        WithCookies::new(response).cookie(cookie).into_response()
228    }
229
230    async fn apply(&self, plan: &mut Plan) -> Result<(), StoreError> {
231        if plan.failed {
232            return Err(StoreError::new("session id generation failed"));
233        }
234        if let Some(save) = plan.save.as_mut() {
235            if let Some(user) = &plan.login {
236                let epoch = self.timed(self.store.auth_epoch(user)).await?;
237                save.record.auth_epoch = epoch;
238            }
239            match self.timed(self.write(save)).await {
240                Ok(true) => {}
241                // Deleted meanwhile (a concurrent logout or rotation):
242                // the session is over for this client too.
243                Ok(false) => plan.cookie = CookieAction::Expire,
244                Err(error) if save.required => return Err(error),
245                Err(error) => {
246                    tracing::warn!(%error, "session touch write failed");
247                }
248            }
249        }
250        for hash in &plan.delete {
251            if let Err(error) = self.timed(self.store.delete(hash)).await {
252                // Never leave both the old and the new id live.
253                if let Some(fresh) = &plan.fresh {
254                    let _ = self.timed(self.store.delete(fresh)).await;
255                }
256                return Err(error);
257            }
258        }
259        Ok(())
260    }
261
262    /// Create a new id's record, or replace a loaded one only while it is
263    /// still stored. `Ok(false)` when the loaded record is gone.
264    async fn write(&self, save: &Save) -> Result<bool, StoreError> {
265        let (hash, record, ttl) = (&save.hash, &save.record, save.ttl);
266        if save.existing {
267            self.store.update(hash, record, ttl).await
268        } else {
269            self.store.save(hash, record, ttl).await.map(|()| true)
270        }
271    }
272
273    fn cookie(&self, value: &str, max_age: Duration) -> SetCookie {
274        SetCookie::new(self.config.name.clone(), value)
275            .http_only(true)
276            .secure(self.config.secure)
277            .same_site(self.config.same_site)
278            .path("/")
279            .max_age(max_age)
280    }
281}