Skip to main content

arcly_http/auth/
session.rs

1//! Server-side session store abstraction.
2//!
3//! `SessionStore` is a trait; the app provides a concrete implementation
4//! (e.g. `RedisSessionStore`). `SessionManager` wraps the store, handles
5//! the session-ID cookie (signed via HMAC, same as `CookieService`), and
6//! exposes `load_from_headers` / `create` / `save` / `delete`.
7//!
8//! `RequestContext::session()` returns the loaded session (if any) so
9//! handlers can read/write typed values without knowing how they're stored.
10//!
11//! ## Usage
12//!
13//! ```ignore
14//! ctx.provide(SessionManager::new(
15//!     RedisSessionStore::new(redis),
16//!     SessionConfig {
17//!         cookie_name: "arcly_session",
18//!         ttl_secs:    3600,
19//!         secret:      env_or("SESSION_SECRET", "change-in-prod"),
20//!         ..Default::default()
21//!     },
22//! ));
23//! ```
24
25use std::collections::HashMap;
26use std::sync::{Arc, RwLock};
27use std::time::{SystemTime, UNIX_EPOCH};
28
29use futures::future::BoxFuture;
30use serde::{de::DeserializeOwned, Serialize};
31
32use crate::cookie::{CookieConfig, CookieService, SameSite};
33
34// ─── Session ──────────────────────────────────────────────────────────────────
35
36pub type SessionData = HashMap<String, serde_json::Value>;
37
38/// A single server-side session. Thread-safe via an interior `RwLock`.
39pub struct Session {
40    pub id: String,
41    pub data: RwLock<SessionData>,
42    pub expires_at: u64,
43}
44
45impl Session {
46    pub fn new_empty(id: String, ttl_secs: u64) -> Arc<Self> {
47        Arc::new(Self {
48            id,
49            data: RwLock::new(HashMap::new()),
50            expires_at: unix_now() + ttl_secs,
51        })
52    }
53
54    pub fn from_data(id: String, data: SessionData, ttl_secs: u64) -> Arc<Self> {
55        Arc::new(Self {
56            id,
57            data: RwLock::new(data),
58            expires_at: unix_now() + ttl_secs,
59        })
60    }
61
62    pub fn id(&self) -> &str {
63        &self.id
64    }
65
66    pub fn get<T: DeserializeOwned>(&self, key: &str) -> Option<T> {
67        let data = self.data.read().ok()?;
68        serde_json::from_value(data.get(key)?.clone()).ok()
69    }
70
71    pub fn set<T: Serialize>(&self, key: &str, value: T) {
72        if let Ok(v) = serde_json::to_value(value) {
73            if let Ok(mut data) = self.data.write() {
74                data.insert(key.to_owned(), v);
75            }
76        }
77    }
78
79    pub fn remove(&self, key: &str) {
80        if let Ok(mut data) = self.data.write() {
81            data.remove(key);
82        }
83    }
84
85    pub fn is_expired(&self) -> bool {
86        unix_now() >= self.expires_at
87    }
88
89    pub fn data_as_json(&self) -> String {
90        self.data
91            .read()
92            .ok()
93            .and_then(|d| serde_json::to_string(&*d).ok())
94            .unwrap_or_else(|| "{}".to_string())
95    }
96}
97
98pub fn unix_now() -> u64 {
99    SystemTime::now()
100        .duration_since(UNIX_EPOCH)
101        .map(|d| d.as_secs())
102        .unwrap_or(0)
103}
104
105// ─── SessionStore trait ───────────────────────────────────────────────────────
106
107/// Backend store for sessions. Implement this to plug in Redis, Postgres, etc.
108pub trait SessionStore: Send + Sync + 'static {
109    /// Load a session by ID. `ttl_secs` is the manager's configured lifetime and
110    /// should be used to stamp the returned `Session::expires_at` so the in-memory
111    /// expiry matches the store's TTL configuration.
112    fn load<'a>(&'a self, id: &'a str, ttl_secs: u64) -> BoxFuture<'a, Option<Arc<Session>>>;
113    fn save<'a>(&'a self, session: &'a Session, ttl_secs: u64) -> BoxFuture<'a, bool>;
114    fn delete<'a>(&'a self, id: &'a str) -> BoxFuture<'a, bool>;
115    fn new_id(&self) -> String;
116}
117
118// ─── Configuration ────────────────────────────────────────────────────────────
119
120/// Configuration for [`SessionManager`].
121pub struct SessionConfig {
122    /// Cookie name for the session ID. Defaults to `"arcly_session"`.
123    pub cookie_name: &'static str,
124    /// Session lifetime in seconds. Defaults to 3 600.
125    pub ttl_secs: u64,
126    /// HMAC signing secret for the session-ID cookie.
127    pub secret: String,
128    /// `Secure` flag on session cookie. Defaults to `true`.
129    pub secure: bool,
130    /// `HttpOnly` flag on session cookie. Defaults to `true`.
131    pub http_only: bool,
132    /// `SameSite` policy for session cookie. Defaults to `Lax`.
133    pub same_site: SameSite,
134}
135
136impl Default for SessionConfig {
137    fn default() -> Self {
138        Self {
139            cookie_name: "arcly_session",
140            ttl_secs: 3_600,
141            secret: "change-session-secret-in-production".to_string(),
142            secure: true,
143            http_only: true,
144            same_site: SameSite::Lax,
145        }
146    }
147}
148
149// ─── SessionManager ───────────────────────────────────────────────────────────
150
151/// Orchestrates session lifecycle: cookie extraction, store lookup, creation,
152/// persistence, and deletion.
153///
154/// Not `#[Injectable]` — provide via `ctx.provide(SessionManager::new(...))`.
155pub struct SessionManager {
156    store: Box<dyn SessionStore>,
157    ttl_secs: u64,
158    cookie: CookieService,
159}
160
161impl SessionManager {
162    pub fn new(store: impl SessionStore, config: SessionConfig) -> Self {
163        let cookie = CookieService::new(CookieConfig {
164            name: config.cookie_name,
165            secret: config.secret,
166            max_age_secs: config.ttl_secs,
167            secure: config.secure,
168            http_only: config.http_only,
169            same_site: config.same_site,
170            ..Default::default()
171        });
172        Self {
173            store: Box::new(store),
174            ttl_secs: config.ttl_secs,
175            cookie,
176        }
177    }
178
179    /// Reads and verifies the session-ID cookie, then loads the session from
180    /// the store. Returns `None` if the cookie is missing, tampered, or the
181    /// session has expired / been deleted.
182    pub async fn load_from_headers(&self, headers: &axum::http::HeaderMap) -> Option<Arc<Session>> {
183        let session_id = self.cookie.extract(headers)?;
184        self.store.load(&session_id, self.ttl_secs).await
185    }
186
187    /// Create a new empty session (not yet persisted — call `save` after
188    /// populating data).
189    pub async fn create(&self) -> Arc<Session> {
190        Session::new_empty(self.store.new_id(), self.ttl_secs)
191    }
192
193    /// Persist `session` to the store with the configured TTL.
194    pub async fn save(&self, session: &Session) -> bool {
195        self.store.save(session, self.ttl_secs).await
196    }
197
198    /// Delete `session` from the store (logout / invalidation).
199    pub async fn delete(&self, id: &str) -> bool {
200        self.store.delete(id).await
201    }
202
203    /// Returns a `Set-Cookie` header value that sets the session-ID cookie.
204    pub fn session_cookie(&self, id: &str) -> String {
205        self.cookie.bake(id)
206    }
207
208    /// Returns a `Set-Cookie` header value that clears the session-ID cookie.
209    pub fn clear_cookie(&self) -> String {
210        self.cookie.clear()
211    }
212
213    /// Hot-swap the session-cookie HMAC secret (delegates to the internal
214    /// `CookieService`; previous secret stays valid for the grace window).
215    pub fn rotate_cookie_secret(&self, new_secret: &[u8], version: u64) {
216        self.cookie.rotate_secret(new_secret, version);
217    }
218}