Skip to main content

siderite_sessions/
session.rs

1//! The [`Session`] handle handlers use, and the commit plan it yields.
2
3use crate::config::SessionConfig;
4use crate::id::{generate, id_hash};
5use crate::store::SessionRecord;
6use crate::token;
7use http::request::Parts;
8use serde::Serialize;
9use serde::de::DeserializeOwned;
10use serde_json::{Value, json};
11use siderite_core::{ApiError, FromRequestParts};
12use siderite_openapi::{Operation, SchemaRegistry};
13use std::collections::BTreeMap;
14use std::fmt;
15use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
16use std::time::Duration;
17
18/// Name of the documented security scheme (`components.securitySchemes`).
19const SCHEME: &str = "sessionCookie";
20/// Cookie name the documented scheme describes.
21const DOCUMENTED_COOKIE: &str = "__Host-session";
22
23/// A session loaded from the store and ready for the handler.
24pub(crate) struct Loaded {
25    pub(crate) hash: String,
26    pub(crate) record: SessionRecord,
27    /// The idle timestamp is due for a write.
28    pub(crate) touch: bool,
29}
30
31/// What the layer must do after the handler ran.
32pub(crate) struct Plan {
33    /// Randomness failed: nothing may be stored or issued.
34    pub(crate) failed: bool,
35    pub(crate) save: Option<Save>,
36    /// Hashes to delete once the save succeeded.
37    pub(crate) delete: Vec<String>,
38    /// Hash of a freshly issued session, to undo if a delete fails.
39    pub(crate) fresh: Option<String>,
40    /// The user to read the revocation epoch of before saving.
41    pub(crate) login: Option<String>,
42    pub(crate) cookie: CookieAction,
43}
44
45/// One store write.
46pub(crate) struct Save {
47    pub(crate) hash: String,
48    pub(crate) record: SessionRecord,
49    pub(crate) ttl: Duration,
50    /// A failed write fails the request (otherwise it is only logged).
51    pub(crate) required: bool,
52    /// The id was loaded from the store, so the write must not recreate
53    /// it once another request deleted it.
54    pub(crate) existing: bool,
55}
56
57/// What to do with the session cookie.
58pub(crate) enum CookieAction {
59    Keep,
60    Set { value: String, max_age: Duration },
61    Expire,
62}
63
64struct State {
65    record: SessionRecord,
66    /// Hash of the loaded session still in force.
67    existing: Option<String>,
68    /// A newly issued id: raw value and hash.
69    fresh: Option<(String, String)>,
70    /// Hashes of sessions to delete (rotated or logged out).
71    stale: Vec<String>,
72    dirty: bool,
73    touch: bool,
74    login: Option<String>,
75    failed: bool,
76    expire_cookie: bool,
77}
78
79impl State {
80    fn current_hash(&self) -> Option<&str> {
81        match &self.fresh {
82            Some((_, hash)) => Some(hash),
83            None => self.existing.as_deref(),
84        }
85    }
86
87    /// Make sure the session has an id, issuing one if it has none.
88    fn ensure_id(&mut self) {
89        if self.existing.is_none() && self.fresh.is_none() {
90            self.issue();
91        }
92    }
93
94    fn issue(&mut self) {
95        match generate() {
96            Ok(raw) => {
97                let hash = id_hash(&raw);
98                self.fresh = Some((raw, hash));
99            }
100            Err(_) => {
101                tracing::error!("session id generation failed");
102                self.failed = true;
103            }
104        }
105    }
106
107    /// Replace the id: the old one is deleted when the request ends.
108    fn rotate(&mut self) {
109        if let Some(old) = self.existing.take() {
110            self.stale.push(old);
111        }
112        self.issue();
113        self.dirty = true;
114    }
115}
116
117/// The request's session, available once a
118/// [`SessionLayer`](crate::SessionLayer) is installed.
119///
120/// Take it as a handler argument. It is cheap to clone and every clone
121/// refers to the same session. Changes are written when the handler
122/// returns. A request without a session cookie gets no stored session
123/// until it inserts data, logs in, rotates or asks for a CSRF token.
124///
125/// Requests of one session are last-writer-wins: changes made by two
126/// concurrent requests are not merged.
127#[derive(Clone)]
128pub struct Session {
129    state: Arc<Mutex<State>>,
130    config: Arc<SessionConfig>,
131}
132
133impl fmt::Debug for Session {
134    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
135        // Never the id or the data.
136        f.debug_struct("Session").finish_non_exhaustive()
137    }
138}
139
140impl Session {
141    pub(crate) fn new(
142        config: Arc<SessionConfig>,
143        loaded: Option<Loaded>,
144        expire_cookie: bool,
145    ) -> Self {
146        let mut state = State {
147            record: SessionRecord::default(),
148            existing: None,
149            fresh: None,
150            stale: Vec::new(),
151            dirty: false,
152            touch: false,
153            login: None,
154            failed: false,
155            expire_cookie,
156        };
157        if let Some(loaded) = loaded {
158            state.record = loaded.record;
159            state.existing = Some(loaded.hash);
160            state.touch = loaded.touch;
161        }
162        Self {
163            state: Arc::new(Mutex::new(state)),
164            config,
165        }
166    }
167
168    fn lock(&self) -> MutexGuard<'_, State> {
169        self.state.lock().unwrap_or_else(PoisonError::into_inner)
170    }
171
172    /// Read the value stored under `key`; `None` when it is absent or is
173    /// not a `T`.
174    #[must_use]
175    pub fn get<T: DeserializeOwned>(&self, key: &str) -> Option<T> {
176        let value = self.lock().record.data.get(key)?.clone();
177        serde_json::from_value(value).ok()
178    }
179
180    /// Store `value` under `key`.
181    ///
182    /// # Errors
183    /// The serialization error when `value` is not representable as JSON.
184    pub fn insert<T: Serialize>(
185        &self,
186        key: impl Into<String>,
187        value: T,
188    ) -> Result<(), serde_json::Error> {
189        let value = serde_json::to_value(value)?;
190        let mut state = self.lock();
191        state.record.data.insert(key.into(), value);
192        state.dirty = true;
193        state.ensure_id();
194        Ok(())
195    }
196
197    /// Remove and return the value under `key`.
198    pub fn remove(&self, key: &str) -> Option<Value> {
199        let mut state = self.lock();
200        let removed = state.record.data.remove(key)?;
201        state.dirty = true;
202        Some(removed)
203    }
204
205    /// The logged-in user, if any.
206    #[must_use]
207    pub fn user(&self) -> Option<String> {
208        self.lock().record.user.clone()
209    }
210
211    /// Log `user` in and rotate the session id.
212    ///
213    /// The old id is deleted when the request ends, with no grace period,
214    /// which defeats session fixation. The absolute lifetime restarts. Data
215    /// stored before login is kept unless a different user was logged in.
216    /// Call it only after the application has authenticated the user.
217    pub fn login(&self, user: impl Into<String>) {
218        let user = user.into();
219        let mut state = self.lock();
220        if state.record.user.as_ref().is_some_and(|prev| *prev != user) {
221            state.record.data.clear();
222        }
223        state.record.user = Some(user.clone());
224        state.login = Some(user);
225        state.rotate();
226    }
227
228    /// Delete the session and expire the cookie.
229    ///
230    /// Anything stored afterwards in the same request starts a new
231    /// session.
232    pub fn logout(&self) {
233        let mut state = self.lock();
234        if let Some(old) = state.existing.take() {
235            state.stale.push(old);
236        }
237        state.fresh = None;
238        state.record = SessionRecord::default();
239        state.dirty = false;
240        state.touch = false;
241        state.login = None;
242        state.expire_cookie = true;
243    }
244
245    /// Replace the session id, keeping the data. Use it on any privilege
246    /// change and on re-authentication. The old id is deleted when the
247    /// request ends.
248    pub fn rotate(&self) {
249        self.lock().rotate();
250    }
251
252    /// The CSRF token of this session: HMAC-SHA256 of the session id hash
253    /// under the configured key, base64url. It changes when the id does.
254    ///
255    /// Returns `None` when no [`csrf_key`](SessionConfig::csrf_key) is
256    /// configured. Asking for a token creates the session if the request
257    /// had none.
258    #[must_use]
259    pub fn csrf_token(&self) -> Option<String> {
260        let key = self.config.csrf_key.as_deref()?;
261        let mut state = self.lock();
262        state.ensure_id();
263        token::issue(key, state.current_hash()?)
264    }
265
266    /// Check a CSRF token from the client against this session, in
267    /// constant time. `false` when there is no session or no key.
268    #[must_use]
269    pub fn verify_csrf(&self, token: &str) -> bool {
270        let Some(key) = self.config.csrf_key.as_deref() else {
271            return false;
272        };
273        let state = self.lock();
274        state
275            .current_hash()
276            .is_some_and(|hash| token::verify(key, hash, token))
277    }
278
279    /// Whether the request arrived with a live stored session.
280    pub(crate) fn is_loaded(&self) -> bool {
281        self.lock().existing.is_some()
282    }
283
284    /// Decide what to store, delete and set after the handler returned.
285    pub(crate) fn plan(&self, now: u64) -> Plan {
286        let mut state = self.lock();
287        let mut plan = Plan {
288            failed: state.failed,
289            save: None,
290            delete: std::mem::take(&mut state.stale),
291            fresh: None,
292            login: state.login.take(),
293            cookie: CookieAction::Keep,
294        };
295        if state.failed {
296            return plan;
297        }
298        let mut record = state.record.clone();
299        if plan.login.is_some() || record.created_at == 0 {
300            record.created_at = now;
301        }
302        record.last_seen = now;
303        let ttl = self.config.ttl(&record, now);
304        if let Some((value, hash)) = state.fresh.take() {
305            plan.fresh = Some(hash.clone());
306            let max_age = self.config.remaining(&record, now);
307            plan.cookie = CookieAction::Set { value, max_age };
308            plan.save = Some(Save {
309                hash,
310                record,
311                ttl,
312                required: true,
313                existing: false,
314            });
315        } else if let Some(hash) = state.existing.clone()
316            && (state.dirty || state.touch)
317        {
318            let required = state.dirty;
319            plan.save = Some(Save {
320                hash,
321                record,
322                ttl,
323                required,
324                existing: true,
325            });
326        }
327        if plan.save.is_none() && state.expire_cookie {
328            plan.cookie = CookieAction::Expire;
329        }
330        plan
331    }
332}
333
334impl SessionConfig {
335    /// Time left of the absolute lifetime, at least one second.
336    pub(crate) fn remaining(
337        &self,
338        record: &SessionRecord,
339        now: u64,
340    ) -> Duration {
341        let age = now.saturating_sub(record.created_at);
342        let left = self.absolute.as_secs().saturating_sub(age);
343        Duration::from_secs(left.max(1))
344    }
345
346    /// How long a store keeps a record written now: the idle timeout plus
347    /// one write interval (the stored timestamp can lag that much), but
348    /// never past the absolute lifetime.
349    fn ttl(&self, record: &SessionRecord, now: u64) -> Duration {
350        let idle = self.idle + self.effective_write_interval();
351        idle.min(self.remaining(record, now))
352    }
353}
354
355impl FromRequestParts for Session {
356    async fn from_request_parts(parts: &mut Parts) -> Result<Self, ApiError> {
357        parts
358            .extensions
359            .get::<Session>()
360            .cloned()
361            .ok_or_else(|| ApiError::internal("SessionLayer is not installed"))
362    }
363
364    /// Registers the cookie scheme as an optional requirement: every
365    /// request has a session, but only a logged-in one is authenticated.
366    fn describe(op: &mut Operation, registry: &mut SchemaRegistry) {
367        let definition = json!({
368            "type": "apiKey",
369            "in": "cookie",
370            "name": DOCUMENTED_COOKIE,
371        });
372        registry.add_security_scheme(SCHEME, definition);
373        if op.security.is_empty() {
374            op.security.push(BTreeMap::new());
375        }
376        let requirement = BTreeMap::from([(SCHEME.to_owned(), Vec::new())]);
377        if !op.security.contains(&requirement) {
378            op.security.push(requirement);
379        }
380    }
381}