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}