Skip to main content

doido_controller/
session.rs

1use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
2use doido_cache::CacheStore;
3use doido_core::Result;
4use hmac::{Hmac, KeyInit, Mac};
5use serde::de::DeserializeOwned;
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8use sha2::Sha256;
9use std::sync::Arc;
10
11type HmacSha256 = Hmac<Sha256>;
12
13/// A user session: an id plus a free-form JSON bag of values.
14#[derive(Clone, Debug, Serialize, Deserialize)]
15pub struct Session {
16    pub id: String,
17    pub data: Value,
18}
19
20impl Session {
21    /// A fresh session with a random id and an empty data bag.
22    pub fn new() -> Self {
23        Self {
24            id: uuid::Uuid::new_v4().to_string(),
25            data: Value::Object(Default::default()),
26        }
27    }
28
29    /// A session with a caller-supplied id and an empty data bag.
30    pub fn with_id(id: impl Into<String>) -> Self {
31        Self {
32            id: id.into(),
33            data: Value::Object(Default::default()),
34        }
35    }
36
37    /// Store a serializable value under `key` (Rails `session[key] = value`).
38    pub fn set(&mut self, key: &str, value: impl Serialize) {
39        if !self.data.is_object() {
40            self.data = Value::Object(Default::default());
41        }
42        if let Value::Object(map) = &mut self.data {
43            map.insert(
44                key.to_string(),
45                serde_json::to_value(value).unwrap_or(Value::Null),
46            );
47        }
48    }
49
50    /// Read and deserialize the value under `key`, if present and well-typed.
51    pub fn get<T: DeserializeOwned>(&self, key: &str) -> Option<T> {
52        self.data
53            .get(key)
54            .cloned()
55            .and_then(|v| serde_json::from_value(v).ok())
56    }
57}
58
59impl Default for Session {
60    fn default() -> Self {
61        Self::new()
62    }
63}
64
65/// Pluggable server-side session persistence. The cookie store keeps all state
66/// in the signed cookie itself, so its `load` decodes that cookie and `save` is a
67/// no-op; database/redis stores (see the `session-db`/`session-redis` features in
68/// the spec) key server-side rows by the session id.
69#[async_trait::async_trait]
70pub trait SessionStore: Send + Sync {
71    async fn load(&self, id: &str) -> Result<Option<Session>>;
72    async fn save(&self, session: &Session) -> Result<()>;
73    async fn destroy(&self, id: &str) -> Result<()>;
74}
75
76/// The default, stateless session store: the whole session is serialized into a
77/// cookie value and signed with HMAC-SHA256 so it cannot be tampered with. The
78/// cookie value is `base64url(payload).base64url(signature)`.
79///
80/// (Spec 07 also calls for AES-256-GCM encryption on top of the signature; that
81/// is a follow-up — this signs but does not yet encrypt the payload.)
82pub struct CookieSessionStore {
83    secret: Vec<u8>,
84}
85
86impl CookieSessionStore {
87    /// Build a store that signs cookies with `secret` (e.g. `secret_key_base`).
88    pub fn new(secret: impl Into<Vec<u8>>) -> Self {
89        Self {
90            secret: secret.into(),
91        }
92    }
93
94    /// Serialize and sign `session` into a `payload.signature` cookie value.
95    pub fn encode(&self, session: &Session) -> String {
96        let payload = serde_json::to_vec(session).unwrap_or_default();
97        let msg = URL_SAFE_NO_PAD.encode(payload);
98        let sig = self.sign(msg.as_bytes());
99        format!("{msg}.{}", URL_SAFE_NO_PAD.encode(sig))
100    }
101
102    /// Verify and parse a signed cookie value. Returns `None` when the value is
103    /// malformed, or the signature does not match (tampering or a wrong secret).
104    pub fn decode(&self, raw: &str) -> Option<Session> {
105        let (msg, sig_b64) = raw.split_once('.')?;
106        let sig = URL_SAFE_NO_PAD.decode(sig_b64).ok()?;
107        let mut mac = HmacSha256::new_from_slice(&self.secret).ok()?;
108        mac.update(msg.as_bytes());
109        // Constant-time comparison inside `verify_slice`.
110        mac.verify_slice(&sig).ok()?;
111        let payload = URL_SAFE_NO_PAD.decode(msg).ok()?;
112        serde_json::from_slice(&payload).ok()
113    }
114
115    fn sign(&self, msg: &[u8]) -> Vec<u8> {
116        let mut mac =
117            HmacSha256::new_from_slice(&self.secret).expect("HMAC accepts a key of any length");
118        mac.update(msg);
119        mac.finalize().into_bytes().to_vec()
120    }
121}
122
123impl Default for CookieSessionStore {
124    /// A dev-only store with a fixed, insecure secret. Real apps must call
125    /// [`CookieSessionStore::new`] with a secret from config/credentials.
126    fn default() -> Self {
127        Self::new(b"doido-dev-insecure-secret".to_vec())
128    }
129}
130
131#[async_trait::async_trait]
132impl SessionStore for CookieSessionStore {
133    /// Decode a signed cookie value (the "id" argument carries the raw cookie).
134    async fn load(&self, raw: &str) -> Result<Option<Session>> {
135        Ok(self.decode(raw))
136    }
137
138    /// No-op: a cookie session persists by having the response set its cookie
139    /// (via [`CookieSessionStore::encode`]); there is no server-side state.
140    async fn save(&self, _session: &Session) -> Result<()> {
141        Ok(())
142    }
143
144    async fn destroy(&self, _id: &str) -> Result<()> {
145        Ok(())
146    }
147}
148
149/// The default cookie session store used by [`Context::session`]: the whole
150/// session is serialized and **encrypted with AES-256-GCM** (via
151/// [`doido_core::crypto`]) into the cookie value (base64url of
152/// `nonce || ciphertext+tag`). Unlike [`CookieSessionStore`], the payload is
153/// hidden as well as authenticated (spec 07).
154///
155/// [`Context::session`]: crate::context::Context::session
156pub struct EncryptedCookieSessionStore {
157    secret: Vec<u8>,
158}
159
160impl EncryptedCookieSessionStore {
161    /// Build a store that encrypts cookies with `secret` (e.g. `secret_key_base`).
162    pub fn new(secret: impl Into<Vec<u8>>) -> Self {
163        Self {
164            secret: secret.into(),
165        }
166    }
167
168    /// Serialize and encrypt `session` into a base64url cookie value.
169    pub fn encode(&self, session: &Session) -> String {
170        let payload = serde_json::to_vec(session).unwrap_or_default();
171        let blob = doido_core::crypto::encrypt(&self.secret, &payload);
172        URL_SAFE_NO_PAD.encode(blob)
173    }
174
175    /// Decrypt and parse a cookie value. Returns `None` when the value is
176    /// malformed or fails authentication (tampering or a wrong secret).
177    pub fn decode(&self, raw: &str) -> Option<Session> {
178        let blob = URL_SAFE_NO_PAD.decode(raw).ok()?;
179        let payload = doido_core::crypto::decrypt(&self.secret, &blob)?;
180        serde_json::from_slice(&payload).ok()
181    }
182}
183
184impl Default for EncryptedCookieSessionStore {
185    /// A dev-only store keyed by the process-global secret (see [`crate::secret`]).
186    fn default() -> Self {
187        Self::new(crate::secret::key_base())
188    }
189}
190
191#[async_trait::async_trait]
192impl SessionStore for EncryptedCookieSessionStore {
193    async fn load(&self, raw: &str) -> Result<Option<Session>> {
194        Ok(self.decode(raw))
195    }
196    async fn save(&self, _session: &Session) -> Result<()> {
197        Ok(())
198    }
199    async fn destroy(&self, _id: &str) -> Result<()> {
200        Ok(())
201    }
202}
203
204/// A server-side session store backed by any [`doido_cache::CacheStore`]: only
205/// the session id travels in the cookie, while the data lives in the cache
206/// (memory/redis/memcache). This is the generic backend the spec's
207/// `DbSessionStore`/`RedisSessionStore` specialize.
208pub struct CacheSessionStore {
209    store: Arc<dyn CacheStore>,
210    ttl_secs: Option<u64>,
211}
212
213impl CacheSessionStore {
214    /// Persist sessions in `store` with no expiry.
215    pub fn new(store: Arc<dyn CacheStore>) -> Self {
216        Self {
217            store,
218            ttl_secs: None,
219        }
220    }
221
222    /// Expire stored sessions after `secs` seconds of inactivity.
223    pub fn with_ttl(mut self, secs: u64) -> Self {
224        self.ttl_secs = Some(secs);
225        self
226    }
227
228    fn key(id: &str) -> String {
229        format!("session:{id}")
230    }
231}
232
233#[async_trait::async_trait]
234impl SessionStore for CacheSessionStore {
235    async fn load(&self, id: &str) -> Result<Option<Session>> {
236        match self.store.get(&Self::key(id)).await? {
237            Some(value) => Ok(serde_json::from_value(value).ok()),
238            None => Ok(None),
239        }
240    }
241
242    async fn save(&self, session: &Session) -> Result<()> {
243        let value = serde_json::to_value(session)?;
244        self.store
245            .set(&Self::key(&session.id), value, self.ttl_secs)
246            .await
247    }
248
249    async fn destroy(&self, id: &str) -> Result<()> {
250        self.store.delete(&Self::key(id)).await
251    }
252}