Skip to main content

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