Skip to main content

modo/auth/session/
extractor.rs

1use std::sync::atomic::{AtomicBool, Ordering};
2use std::sync::{Arc, Mutex};
3
4use axum::extract::FromRequestParts;
5use http::request::Parts;
6use serde::Serialize;
7use serde::de::DeserializeOwned;
8
9use crate::error::{Error, HttpError};
10
11use super::meta::SessionMeta;
12use super::store::{SessionData, Store};
13use super::token::SessionToken;
14
15#[derive(Clone)]
16pub(crate) enum SessionAction {
17    None,
18    Set(SessionToken),
19    Remove,
20}
21
22pub(crate) struct SessionState {
23    pub store: Store,
24    pub meta: SessionMeta,
25    pub current: Mutex<Option<SessionData>>,
26    pub dirty: AtomicBool,
27    pub action: Mutex<SessionAction>,
28}
29
30/// Axum extractor providing access to the current session.
31///
32/// `Session` is inserted into the request extensions by [`super::middleware::SessionLayer`].
33/// Extracting it in a handler does not require the user to be authenticated —
34/// call [`Session::is_authenticated`] or [`Session::user_id`] to check.
35///
36/// All read methods are synchronous (lock-free from the caller's perspective).
37/// Write methods that only modify in-memory data ([`Session::set`],
38/// [`Session::remove_key`]) are also synchronous. Methods that touch the
39/// database ([`Session::authenticate`], [`Session::logout`], etc.) are `async`.
40///
41/// # Panics
42///
43/// Panics if `SessionLayer` is not present in the middleware stack. Apply the
44/// layer with [`super::layer`] before using this extractor.
45pub struct Session {
46    state: Arc<SessionState>,
47}
48
49impl<S: Send + Sync> FromRequestParts<S> for Session {
50    type Rejection = Error;
51
52    async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
53        let state = parts
54            .extensions
55            .get::<Arc<SessionState>>()
56            .cloned()
57            .ok_or_else(|| Error::internal("Session extractor requires session middleware"))?;
58
59        Ok(Self { state })
60    }
61}
62
63impl Session {
64    // --- Synchronous reads ---
65
66    /// Return the authenticated user's ID, or `None` if no session is active.
67    pub fn user_id(&self) -> Option<String> {
68        let guard = self.state.current.lock().expect("session mutex poisoned");
69        guard.as_ref().map(|s| s.user_id.clone())
70    }
71
72    /// Deserialise a value stored in the session under `key`.
73    ///
74    /// Returns `Ok(None)` when there is no active session or the key is absent.
75    ///
76    /// # Errors
77    ///
78    /// Returns an error if the stored value cannot be deserialised into `T`.
79    pub fn get<T: DeserializeOwned>(&self, key: &str) -> crate::Result<Option<T>> {
80        let guard = self.state.current.lock().expect("session mutex poisoned");
81        let session = match guard.as_ref() {
82            Some(s) => s,
83            None => return Ok(None),
84        };
85        match session.data.get(key) {
86            Some(v) => {
87                let val = serde_json::from_value(v.clone()).map_err(|e| {
88                    Error::internal(format!("deserialize session key '{key}': {e}"))
89                })?;
90                Ok(Some(val))
91            }
92            None => Ok(None),
93        }
94    }
95
96    /// Return `true` when a valid, authenticated session exists for this request.
97    pub fn is_authenticated(&self) -> bool {
98        let guard = self.state.current.lock().expect("session mutex poisoned");
99        guard.is_some()
100    }
101
102    /// Return a clone of the full session data, or `None` if unauthenticated.
103    pub fn current(&self) -> Option<SessionData> {
104        let guard = self.state.current.lock().expect("session mutex poisoned");
105        guard.clone()
106    }
107
108    // --- In-memory data writes (deferred) ---
109
110    /// Store a serialisable value under `key` in the session data.
111    ///
112    /// The change is held in memory and flushed to the database by the
113    /// middleware after the handler returns. No-op when there is no active
114    /// session.
115    ///
116    /// # Errors
117    ///
118    /// Returns an error if the value cannot be serialised to JSON.
119    pub fn set<T: Serialize>(&self, key: &str, value: &T) -> crate::Result<()> {
120        let mut guard = self.state.current.lock().expect("session mutex poisoned");
121        let session = match guard.as_mut() {
122            Some(s) => s,
123            None => return Ok(()), // no-op if no session
124        };
125        if let serde_json::Value::Object(ref mut map) = session.data {
126            map.insert(
127                key.to_string(),
128                serde_json::to_value(value)
129                    .map_err(|e| Error::internal(format!("serialize session value: {e}")))?,
130            );
131            self.state.dirty.store(true, Ordering::SeqCst);
132        }
133        Ok(())
134    }
135
136    /// Remove a key from the session data.
137    ///
138    /// No-op when there is no active session or the key does not exist.
139    /// The change is flushed to the database by the middleware after the
140    /// handler returns.
141    pub fn remove_key(&self, key: &str) {
142        let mut guard = self.state.current.lock().expect("session mutex poisoned");
143        if let Some(ref mut session) = *guard
144            && let serde_json::Value::Object(ref mut map) = session.data
145            && map.remove(key).is_some()
146        {
147            self.state.dirty.store(true, Ordering::SeqCst);
148        }
149    }
150
151    // --- Auth lifecycle (immediate DB writes) ---
152
153    /// Create a new authenticated session for `user_id` with empty data.
154    ///
155    /// If a session already exists, it is destroyed first (session fixation
156    /// prevention). A new token is generated and set on the cookie. Equivalent
157    /// to `authenticate_with(user_id, serde_json::json!({}))`.
158    ///
159    /// # Errors
160    ///
161    /// Returns an error if the existing session cannot be destroyed or the
162    /// new session cannot be created in the database.
163    pub async fn authenticate(&self, user_id: &str) -> crate::Result<()> {
164        self.authenticate_with(user_id, serde_json::json!({})).await
165    }
166
167    /// Create a new authenticated session for `user_id` with initial `data`.
168    ///
169    /// If a session already exists, it is destroyed first (session fixation
170    /// prevention). A new token is generated and set on the cookie.
171    ///
172    /// # Errors
173    ///
174    /// Returns an error if the existing session cannot be destroyed or the
175    /// new session cannot be created in the database.
176    pub async fn authenticate_with(
177        &self,
178        user_id: &str,
179        data: serde_json::Value,
180    ) -> crate::Result<()> {
181        // Destroy current session (fixation prevention)
182        let existing_id = {
183            let current = self.state.current.lock().expect("session mutex poisoned");
184            current.as_ref().map(|s| s.id.clone())
185        };
186        if let Some(id) = existing_id {
187            self.state.store.destroy(&id).await?;
188        }
189
190        let (session_data, token) = self
191            .state
192            .store
193            .create(&self.state.meta, user_id, Some(data))
194            .await?;
195
196        *self.state.current.lock().expect("session mutex poisoned") = Some(session_data);
197        *self.state.action.lock().expect("session mutex poisoned") = SessionAction::Set(token);
198        self.state.dirty.store(false, Ordering::SeqCst);
199        Ok(())
200    }
201
202    /// Issue a new session token and refresh the session expiry.
203    ///
204    /// Returns `401 Unauthorized` if there is no active session. Use this
205    /// after privilege escalation to prevent session fixation.
206    ///
207    /// # Errors
208    ///
209    /// Returns `401 Unauthorized` when no active session exists, or an
210    /// internal error if the database update fails.
211    pub async fn rotate(&self) -> crate::Result<()> {
212        let session_id = {
213            let current = self.state.current.lock().expect("session mutex poisoned");
214            let session = current
215                .as_ref()
216                .ok_or_else(|| Error::from(HttpError::Unauthorized))?;
217            session.id.clone()
218        };
219
220        let new_token = self.state.store.rotate_token(&session_id).await?;
221
222        let now = chrono::Utc::now();
223        let new_expires =
224            now + chrono::Duration::seconds(self.state.store.config().session_ttl_secs as i64);
225        self.state
226            .store
227            .touch(&session_id, now, new_expires)
228            .await?;
229
230        *self.state.action.lock().expect("session mutex poisoned") = SessionAction::Set(new_token);
231        Ok(())
232    }
233
234    /// Destroy the current session and clear the session cookie.
235    ///
236    /// No-op (succeeds silently) when there is no active session.
237    ///
238    /// # Errors
239    ///
240    /// Returns an error if the database delete fails.
241    pub async fn logout(&self) -> crate::Result<()> {
242        let existing_id = {
243            let current = self.state.current.lock().expect("session mutex poisoned");
244            current.as_ref().map(|s| s.id.clone())
245        };
246        if let Some(id) = existing_id {
247            self.state.store.destroy(&id).await?;
248        }
249        *self.state.action.lock().expect("session mutex poisoned") = SessionAction::Remove;
250        *self.state.current.lock().expect("session mutex poisoned") = None;
251        Ok(())
252    }
253
254    /// Destroy all sessions for the current user and clear the session cookie.
255    ///
256    /// Returns `401 Unauthorized` if there is no active session.
257    ///
258    /// # Errors
259    ///
260    /// Returns an error if the database delete fails.
261    pub async fn logout_all(&self) -> crate::Result<()> {
262        let existing_user_id = {
263            let current = self.state.current.lock().expect("session mutex poisoned");
264            current.as_ref().map(|s| s.user_id.clone())
265        };
266        if let Some(user_id) = existing_user_id {
267            self.state.store.destroy_all_for_user(&user_id).await?;
268        }
269        *self.state.action.lock().expect("session mutex poisoned") = SessionAction::Remove;
270        *self.state.current.lock().expect("session mutex poisoned") = None;
271        Ok(())
272    }
273
274    /// Destroy all sessions for the current user except the current one.
275    ///
276    /// Returns `401 Unauthorized` if there is no active session.
277    ///
278    /// # Errors
279    ///
280    /// Returns `401 Unauthorized` when no active session exists, or an
281    /// internal error if the database delete fails.
282    pub async fn logout_other(&self) -> crate::Result<()> {
283        let (user_id, session_id) = {
284            let current = self.state.current.lock().expect("session mutex poisoned");
285            let session = current
286                .as_ref()
287                .ok_or_else(|| Error::from(HttpError::Unauthorized))?;
288            (session.user_id.clone(), session.id.clone())
289        };
290        self.state
291            .store
292            .destroy_all_except(&user_id, &session_id)
293            .await
294    }
295
296    /// Return all active sessions for the current user.
297    ///
298    /// Returns `401 Unauthorized` if there is no active session.
299    ///
300    /// # Errors
301    ///
302    /// Returns `401 Unauthorized` when no active session exists, or an
303    /// internal error if the database query fails.
304    pub async fn list_my_sessions(&self) -> crate::Result<Vec<SessionData>> {
305        let user_id = {
306            let current = self.state.current.lock().expect("session mutex poisoned");
307            let session = current
308                .as_ref()
309                .ok_or_else(|| Error::from(HttpError::Unauthorized))?;
310            session.user_id.clone()
311        };
312        self.state.store.list_for_user(&user_id).await
313    }
314
315    /// Revoke a specific session belonging to the current user.
316    ///
317    /// Returns `401 Unauthorized` if there is no active session and `404 Not
318    /// Found` if `id` does not belong to the current user (deliberately
319    /// indistinguishable to prevent enumeration).
320    ///
321    /// # Errors
322    ///
323    /// Returns `401 Unauthorized` when no active session exists, `404 Not
324    /// Found` when the target session does not exist or belongs to another
325    /// user, or an internal error if the database operation fails.
326    pub async fn revoke(&self, id: &str) -> crate::Result<()> {
327        let current_user_id = {
328            let current = self.state.current.lock().expect("session mutex poisoned");
329            let session = current
330                .as_ref()
331                .ok_or_else(|| Error::from(HttpError::Unauthorized))?;
332            session.user_id.clone()
333        };
334
335        let target = self
336            .state
337            .store
338            .read(id)
339            .await?
340            .ok_or_else(|| Error::from(HttpError::NotFound))?;
341
342        if target.user_id != current_user_id {
343            return Err(Error::from(HttpError::NotFound));
344        }
345
346        self.state.store.destroy(id).await
347    }
348}