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`.
39///
40/// **Contract**: never hold the `data` read/write guard across an
41/// `.await` — it is a synchronous `std::sync::RwLock`, scoped to this one
42/// session. Read what you need into locals, drop the guard, then await.
43pub struct Session {
44    pub id: String,
45    pub data: RwLock<SessionData>,
46    pub expires_at: u64,
47}
48
49impl Session {
50    pub fn new_empty(id: String, ttl_secs: u64) -> Arc<Self> {
51        Arc::new(Self {
52            id,
53            data: RwLock::new(HashMap::new()),
54            expires_at: unix_now() + ttl_secs,
55        })
56    }
57
58    pub fn from_data(id: String, data: SessionData, ttl_secs: u64) -> Arc<Self> {
59        Arc::new(Self {
60            id,
61            data: RwLock::new(data),
62            expires_at: unix_now() + ttl_secs,
63        })
64    }
65
66    pub fn id(&self) -> &str {
67        &self.id
68    }
69
70    pub fn get<T: DeserializeOwned>(&self, key: &str) -> Option<T> {
71        let data = self.data.read().ok()?;
72        serde_json::from_value(data.get(key)?.clone()).ok()
73    }
74
75    pub fn set<T: Serialize>(&self, key: &str, value: T) {
76        if let Ok(v) = serde_json::to_value(value) {
77            if let Ok(mut data) = self.data.write() {
78                data.insert(key.to_owned(), v);
79            }
80        }
81    }
82
83    pub fn remove(&self, key: &str) {
84        if let Ok(mut data) = self.data.write() {
85            data.remove(key);
86        }
87    }
88
89    pub fn is_expired(&self) -> bool {
90        unix_now() >= self.expires_at
91    }
92
93    pub fn data_as_json(&self) -> String {
94        self.data
95            .read()
96            .ok()
97            .and_then(|d| serde_json::to_string(&*d).ok())
98            .unwrap_or_else(|| "{}".to_string())
99    }
100}
101
102pub fn unix_now() -> u64 {
103    SystemTime::now()
104        .duration_since(UNIX_EPOCH)
105        .map(|d| d.as_secs())
106        .unwrap_or(0)
107}
108
109// ─── SessionStore trait ───────────────────────────────────────────────────────
110
111/// Backend store for sessions. Implement this to plug in Redis, Postgres, etc.
112pub trait SessionStore: Send + Sync + 'static {
113    /// Load a session by ID. `ttl_secs` is the manager's configured lifetime and
114    /// should be used to stamp the returned `Session::expires_at` so the in-memory
115    /// expiry matches the store's TTL configuration.
116    fn load<'a>(&'a self, id: &'a str, ttl_secs: u64) -> BoxFuture<'a, Option<Arc<Session>>>;
117    fn save<'a>(&'a self, session: &'a Session, ttl_secs: u64) -> BoxFuture<'a, bool>;
118    fn delete<'a>(&'a self, id: &'a str) -> BoxFuture<'a, bool>;
119    fn new_id(&self) -> String;
120}
121
122// ─── Configuration ────────────────────────────────────────────────────────────
123
124/// Configuration for [`SessionManager`].
125pub struct SessionConfig {
126    /// Cookie name for the session ID. Defaults to `"arcly_session"`.
127    pub cookie_name: &'static str,
128    /// Session lifetime in seconds. Defaults to 3 600.
129    pub ttl_secs: u64,
130    /// HMAC signing secret for the session-ID cookie.
131    pub secret: String,
132    /// `Secure` flag on session cookie. Defaults to `true`.
133    pub secure: bool,
134    /// `HttpOnly` flag on session cookie. Defaults to `true`.
135    pub http_only: bool,
136    /// `SameSite` policy for session cookie. Defaults to `Lax`.
137    pub same_site: SameSite,
138}
139
140impl Default for SessionConfig {
141    fn default() -> Self {
142        Self {
143            cookie_name: "arcly_session",
144            ttl_secs: 3_600,
145            secret: "change-session-secret-in-production".to_string(),
146            secure: true,
147            http_only: true,
148            same_site: SameSite::Lax,
149        }
150    }
151}
152
153// ─── SessionManager ───────────────────────────────────────────────────────────
154
155/// Orchestrates session lifecycle: cookie extraction, store lookup, creation,
156/// persistence, and deletion.
157///
158/// Not `#[Injectable]` — provide via `ctx.provide(SessionManager::new(...))`.
159pub struct SessionManager {
160    store: Box<dyn SessionStore>,
161    ttl_secs: u64,
162    cookie: CookieService,
163}
164
165impl SessionManager {
166    pub fn new(store: impl SessionStore, config: SessionConfig) -> Self {
167        let cookie = CookieService::new(CookieConfig {
168            name: config.cookie_name,
169            secret: config.secret,
170            max_age_secs: config.ttl_secs,
171            secure: config.secure,
172            http_only: config.http_only,
173            same_site: config.same_site,
174            ..Default::default()
175        });
176        Self {
177            store: Box::new(store),
178            ttl_secs: config.ttl_secs,
179            cookie,
180        }
181    }
182
183    /// Reads and verifies the session-ID cookie, then loads the session from
184    /// the store. Returns `None` if the cookie is missing, tampered, or the
185    /// session has expired / been deleted.
186    pub async fn load_from_headers(&self, headers: &axum::http::HeaderMap) -> Option<Arc<Session>> {
187        let session_id = self.cookie.extract(headers)?;
188        self.store.load(&session_id, self.ttl_secs).await
189    }
190
191    /// Create a new empty session (not yet persisted — call `save` after
192    /// populating data).
193    pub async fn create(&self) -> Arc<Session> {
194        Session::new_empty(self.store.new_id(), self.ttl_secs)
195    }
196
197    /// Persist `session` to the store with the configured TTL.
198    pub async fn save(&self, session: &Session) -> bool {
199        self.store.save(session, self.ttl_secs).await
200    }
201
202    /// Delete `session` from the store (logout / invalidation).
203    pub async fn delete(&self, id: &str) -> bool {
204        self.store.delete(id).await
205    }
206
207    /// Returns a `Set-Cookie` header value that sets the session-ID cookie.
208    pub fn session_cookie(&self, id: &str) -> String {
209        self.cookie.bake(id)
210    }
211
212    /// Returns a `Set-Cookie` header value that clears the session-ID cookie.
213    pub fn clear_cookie(&self) -> String {
214        self.cookie.clear()
215    }
216
217    /// Hot-swap the session-cookie HMAC secret (delegates to the internal
218    /// `CookieService`; previous secret stays valid for the grace window).
219    pub fn rotate_cookie_secret(&self, new_secret: &[u8], version: u64) {
220        self.cookie.rotate_secret(new_secret, version);
221    }
222}