Skip to main content

modo/auth/session/cookie/
service.rs

1//! `CookieSessionService` — long-lived service for cookie-backed sessions.
2//!
3//! Holds the session store, cookie signing key, and configuration. Exposes
4//! cross-transport operations (`list`, `revoke`, `revoke_all`, etc.) that are
5//! used by middleware and state-held service handles.
6
7use std::sync::Arc;
8
9use crate::auth::session::data::Session;
10use crate::auth::session::store::SessionStore;
11use crate::cookie::{Key, key_from_config};
12use crate::db::Database;
13use crate::{Error, Result};
14
15use super::CookieSessionsConfig;
16
17/// Long-lived service for cookie-backed sessions.
18///
19/// `CookieSessionService` wraps a [`SessionStore`], a cookie signing [`Key`],
20/// and the full [`CookieSessionsConfig`]. It is constructed once at startup,
21/// held in application state, and used by the session middleware and by
22/// cross-transport management endpoints.
23///
24/// # Construction
25///
26/// ```rust,ignore
27/// let svc = CookieSessionService::new(db, config)?;
28/// ```
29///
30/// Construction validates that the cookie secret meets the minimum length
31/// requirement and fails fast at startup if it does not.
32#[derive(Clone)]
33pub struct CookieSessionService {
34    inner: Arc<Inner>,
35}
36
37struct Inner {
38    store: SessionStore,
39    config: CookieSessionsConfig,
40    cookie_key: Key,
41}
42
43impl CookieSessionService {
44    /// Construct a new `CookieSessionService`.
45    ///
46    /// Derives the HMAC signing key from `config.cookie.secret`. Fails if the
47    /// secret is shorter than 64 characters.
48    ///
49    /// # Errors
50    ///
51    /// Returns [`Error::internal`] if the cookie secret is too short.
52    pub fn new(db: Database, config: CookieSessionsConfig) -> Result<Self> {
53        let cookie_key = key_from_config(&config.cookie)
54            .map_err(|e| Error::internal(format!("cookie key: {e}")))?;
55        let store = SessionStore::new(db, config.clone());
56        Ok(Self {
57            inner: Arc::new(Inner {
58                store,
59                config,
60                cookie_key,
61            }),
62        })
63    }
64
65    /// Return a reference to the underlying session store.
66    #[cfg(any(test, feature = "test-helpers"))]
67    pub fn store(&self) -> &SessionStore {
68        &self.inner.store
69    }
70
71    /// Return a reference to the underlying session store (crate-internal).
72    #[cfg(not(any(test, feature = "test-helpers")))]
73    pub(crate) fn store(&self) -> &SessionStore {
74        &self.inner.store
75    }
76
77    /// Return a reference to the session configuration.
78    pub(crate) fn config(&self) -> &CookieSessionsConfig {
79        &self.inner.config
80    }
81
82    /// Return a reference to the cookie signing key.
83    pub(crate) fn cookie_key(&self) -> &Key {
84        &self.inner.cookie_key
85    }
86
87    /// List all active (non-expired) sessions for the given user.
88    ///
89    /// # Errors
90    ///
91    /// Returns an error if the database query fails.
92    pub async fn list(&self, user_id: &str) -> Result<Vec<Session>> {
93        let raws = self.inner.store.list_for_user(user_id).await?;
94        Ok(raws.into_iter().map(Session::from).collect())
95    }
96
97    /// Revoke a specific session by its ULID identifier.
98    ///
99    /// Looks up the session row by `id`, verifies that it belongs to `user_id`,
100    /// and destroys it. Returns `404 auth:session_not_found` if the session does
101    /// not exist or belongs to a different user.
102    ///
103    /// # Errors
104    ///
105    /// Returns `404 auth:session_not_found` on ownership mismatch, or an
106    /// internal error if the database operation fails.
107    pub async fn revoke(&self, user_id: &str, id: &str) -> Result<()> {
108        let row = self.inner.store.read(id).await?.ok_or_else(|| {
109            Error::not_found("session not found").with_code("auth:session_not_found")
110        })?;
111
112        if row.user_id != user_id {
113            return Err(Error::not_found("session not found").with_code("auth:session_not_found"));
114        }
115
116        self.inner.store.destroy(id).await
117    }
118
119    /// Revoke all sessions for the given user.
120    ///
121    /// # Errors
122    ///
123    /// Returns an error if the database delete fails.
124    pub async fn revoke_all(&self, user_id: &str) -> Result<()> {
125        self.inner.store.destroy_all_for_user(user_id).await
126    }
127
128    /// Revoke all sessions for the given user except the one with `keep_id`.
129    ///
130    /// Used to implement "log out other devices" while keeping the caller's
131    /// current session active.
132    ///
133    /// # Errors
134    ///
135    /// Returns an error if the database delete fails.
136    pub async fn revoke_all_except(&self, user_id: &str, keep_id: &str) -> Result<()> {
137        self.inner.store.destroy_all_except(user_id, keep_id).await
138    }
139
140    /// Delete all expired sessions from the store.
141    ///
142    /// Returns the number of rows deleted. Schedule this periodically (e.g.
143    /// via a cron job) to keep the `authenticated_sessions` table small.
144    ///
145    /// # Errors
146    ///
147    /// Returns an error if the database delete fails.
148    pub async fn cleanup_expired(&self) -> Result<u64> {
149        self.inner.store.cleanup_expired().await
150    }
151
152    /// Build a [`CookieSessionLayer`](super::middleware::CookieSessionLayer) from this service.
153    ///
154    /// Convenience method so callers can write `service.layer()` instead of
155    /// `session::layer(service.clone())`.
156    pub fn layer(&self) -> super::middleware::CookieSessionLayer {
157        super::middleware::layer(self.clone())
158    }
159}