Skip to main content

rainier_session/
cookie.rs

1//! [`CookieSessionStore`] — the whole session, encrypted, in the cookie.
2
3use rainier_crypt::Encryption;
4use rainier_support::{BoxFuture, Error, Result};
5use serde::{Deserialize, Serialize};
6
7use crate::session::{generate_session_id, SessionData};
8use crate::store::SessionStore;
9
10/// A cookie's practical size limit.
11///
12/// Browsers guarantee 4096 bytes **per cookie including its name and
13/// attributes**, so the value itself has less. 3500 leaves room for the name,
14/// `Path`, `SameSite`, `Max-Age` and the rest.
15const MAX_VALUE_BYTES: usize = 3500;
16
17/// What travels in the cookie.
18#[derive(Serialize, Deserialize)]
19struct Envelope {
20    /// The session id. Still present, so [`regenerate`](crate::Session::regenerate)
21    /// means something and so a log line can correlate requests.
22    id: String,
23    /// The bag.
24    data: SessionData,
25}
26
27/// Sessions kept entirely in the cookie, encrypted.
28///
29/// **No server-side state at all**: nothing to store, nothing to expire,
30/// nothing to share between instances. That is the whole appeal, and every
31/// limitation below follows from it.
32///
33/// | | |
34/// |---|---|
35/// | Needs a store | no |
36/// | Works across instances | yes, with no shared infrastructure |
37/// | Size limit | ~3.5 KB, hard |
38/// | Can be revoked server-side | **no** |
39/// | Cost per request | the cookie, on every request, in both directions |
40///
41/// ## What you give up
42///
43/// **Revocation.** A session you cannot see is a session you cannot end. There
44/// is no "log out all my devices", no invalidating everything when a password
45/// changes, and a stolen cookie works until it expires. If any of that matters,
46/// use a server-side store.
47///
48/// **Size.** The limit is small and the failure is at write time. Putting a
49/// user's cart in the session will eventually exceed it.
50///
51/// **Freshness.** The client holds the only copy, so a session it does not send
52/// back is simply gone, and one it sends from an old tab is stale.
53///
54/// ## What protects it
55///
56/// The cookie is [encrypted](rainier_crypt), so the client cannot read it or
57/// change it — tampering fails the AEAD tag and the session is discarded. Which
58/// means the [key ring](rainier_crypt::KeyRing) matters twice over here:
59/// rotating a key out ends every session it wrote.
60///
61/// ```
62/// use rainier_crypt::{Encryption, Key, KeyRing};
63/// use rainier_session::{CookieSessionStore, Session, SessionStore};
64///
65/// # fn main() -> rainier_support::Result<()> {
66/// let store = CookieSessionStore::new(Encryption::from_keys(KeyRing::new(Key::generate())));
67///
68/// let session = Session::new();
69/// session.put("user_id", 42u64)?;
70///
71/// // What would be sent as the cookie value…
72/// let cookie = store.encode(&session.id(), &session.age_and_take())?;
73///
74/// // The field name rather than the value: base64 of random bytes contains a
75/// // given two-character run often enough that asserting on `"42"` fails a
76/// // run every few hundred for no reason at all.
77/// assert!(!cookie.contains("user_id"), "the client cannot read it");
78///
79/// // …and what comes back.
80/// let (id, data) = store.decode(&cookie)?;
81/// assert_eq!(id, session.id());
82/// assert_eq!(data.unwrap().values["user_id"], 42);
83/// # Ok(()) }
84/// ```
85pub struct CookieSessionStore {
86    crypt: Encryption,
87    max_bytes: usize,
88}
89
90impl CookieSessionStore {
91    /// A store encrypting with `crypt`.
92    pub fn new(crypt: Encryption) -> Self {
93        Self { crypt, max_bytes: MAX_VALUE_BYTES }
94    }
95
96    /// Allow a larger cookie than the conservative default.
97    ///
98    /// Only worth touching if you know what your clients and any proxy in front
99    /// of them accept. A cookie over the browser's limit is silently dropped,
100    /// and a session that silently does not persist is a bad afternoon.
101    pub fn with_max_bytes(mut self, max_bytes: usize) -> Self {
102        self.max_bytes = max_bytes;
103        self
104    }
105
106    /// The size limit in force.
107    pub fn max_bytes(&self) -> usize {
108        self.max_bytes
109    }
110}
111
112impl SessionStore for CookieSessionStore {
113    fn name(&self) -> &str {
114        "cookie"
115    }
116
117    /// Never called: the data is in the cookie, and
118    /// [`decode`](SessionStore::decode) has already produced it.
119    fn read<'a>(&'a self, _id: &'a str) -> BoxFuture<'a, Result<Option<SessionData>>> {
120        Box::pin(async { Ok(None) })
121    }
122
123    /// A no-op: there is nowhere to write to.
124    fn write<'a>(&'a self, _id: &'a str, _data: &'a SessionData) -> BoxFuture<'a, Result<()>> {
125        Box::pin(async { Ok(()) })
126    }
127
128    /// A no-op.
129    ///
130    /// This is the limitation to understand before choosing this store: a
131    /// session held only by the client **cannot be destroyed** server-side. The
132    /// middleware still rotates the id and clears the bag on logout, which
133    /// replaces the cookie in that browser — but a copy taken beforehand keeps
134    /// working until it expires.
135    fn destroy<'a>(&'a self, _id: &'a str) -> BoxFuture<'a, Result<()>> {
136        Box::pin(async { Ok(()) })
137    }
138
139    fn is_client_side(&self) -> bool {
140        true
141    }
142
143    fn encode(&self, id: &str, data: &SessionData) -> Result<String> {
144        let envelope = Envelope { id: id.to_string(), data: data.clone() };
145        let sealed = self.crypt.encrypt_json(&envelope)?;
146
147        if sealed.len() > self.max_bytes {
148            // Refused rather than truncated or sent anyway: an over-long cookie
149            // is dropped by the browser without a word, and the symptom is a
150            // session that mysteriously does not persist for some users.
151            return Err(Error::internal(format!(
152                "this session is {} bytes encrypted, over the {} the cookie driver allows. \
153                 Move the large values to a server-side store, or switch the session driver.",
154                sealed.len(),
155                self.max_bytes
156            )));
157        }
158
159        Ok(sealed)
160    }
161
162    fn decode(&self, value: &str) -> Result<(String, Option<SessionData>)> {
163        let envelope: Envelope = self.crypt.decrypt_json(value)?;
164
165        // The id came out of an authenticated payload, so a client cannot have
166        // chosen it — but a payload written by an older version might carry
167        // something unexpected, and a fresh id is cheaper than trusting it.
168        let id = if crate::store::is_well_formed(&envelope.id) {
169            envelope.id
170        } else {
171            generate_session_id()
172        };
173
174        Ok((id, Some(envelope.data)))
175    }
176}
177
178impl std::fmt::Debug for CookieSessionStore {
179    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
180        f.debug_struct("CookieSessionStore").field("max_bytes", &self.max_bytes).finish()
181    }
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187    use rainier_crypt::{Key, KeyRing};
188
189    fn store() -> CookieSessionStore {
190        CookieSessionStore::new(Encryption::from_keys(KeyRing::new(Key::generate())))
191    }
192
193    fn data(user: u64) -> SessionData {
194        let mut values = serde_json::Map::new();
195        values.insert("user_id".into(), user.into());
196        SessionData { values, flash: vec!["status".to_string()] }
197    }
198
199    #[test]
200    fn a_session_round_trips_through_a_cookie() {
201        let store = store();
202        let id = generate_session_id();
203
204        let cookie = store.encode(&id, &data(42)).unwrap();
205        let (recovered, recovered_data) = store.decode(&cookie).unwrap();
206
207        assert_eq!(recovered, id);
208        let recovered_data = recovered_data.expect("client-side stores return their data");
209        assert_eq!(recovered_data.values["user_id"], 42);
210        assert_eq!(recovered_data.flash, vec!["status".to_string()], "flash bookkeeping survives");
211    }
212
213    #[test]
214    fn the_client_cannot_read_it() {
215        let cookie = store().encode(&generate_session_id(), &data(42)).unwrap();
216
217        // The key names and the flash marker, not the value: a two-character
218        // value like "42" turns up in base64 ciphertext by chance often enough
219        // to make that assertion flaky. Longer strings effectively do not.
220        assert!(!cookie.contains("user_id"), "{cookie}");
221        assert!(!cookie.contains("status"), "{cookie}");
222        assert!(!cookie.contains("values"), "{cookie}");
223    }
224
225    #[test]
226    fn the_client_cannot_change_it() {
227        let store = store();
228        let cookie = store.encode(&generate_session_id(), &data(42)).unwrap();
229
230        // Flip a byte in the ciphertext.
231        let mut bytes = cookie.into_bytes();
232        let last = bytes.len() - 1;
233        bytes[last] = if bytes[last] == b'A' { b'B' } else { b'A' };
234
235        assert!(store.decode(&String::from_utf8(bytes).unwrap()).is_err());
236    }
237
238    #[test]
239    fn another_applications_key_cannot_read_it() {
240        let cookie = store().encode(&generate_session_id(), &data(42)).unwrap();
241        assert!(store().decode(&cookie).is_err());
242    }
243
244    #[test]
245    fn rubbish_is_refused() {
246        let store = store();
247        for bad in ["", "nonsense", "xc20p.abc.def.ghi"] {
248            assert!(store.decode(bad).is_err(), "{bad}");
249        }
250    }
251
252    #[test]
253    fn it_is_marked_client_side() {
254        assert!(store().is_client_side());
255        assert_eq!(store().name(), "cookie");
256    }
257
258    #[tokio::test]
259    async fn the_server_side_methods_are_inert() {
260        let store = store();
261
262        assert!(store.read("anything").await.unwrap().is_none());
263        assert!(store.write("anything", &data(1)).await.is_ok());
264        assert!(store.destroy("anything").await.is_ok());
265        assert_eq!(store.gc().await.unwrap(), 0);
266    }
267
268    #[test]
269    fn an_over_large_session_is_refused_with_both_numbers() {
270        let store = store();
271        let mut values = serde_json::Map::new();
272        values.insert("cart".into(), serde_json::json!("x".repeat(8000)));
273
274        let err = store
275            .encode(&generate_session_id(), &SessionData { values, flash: Vec::new() })
276            .unwrap_err();
277
278        assert!(err.message().contains("3500"), "{}", err.message());
279        assert!(err.message().contains("over the"), "{}", err.message());
280    }
281
282    #[test]
283    fn the_limit_is_adjustable() {
284        let store = store().with_max_bytes(64);
285        assert_eq!(store.max_bytes(), 64);
286
287        // 64 bytes is smaller than an encrypted empty session, so even that is
288        // refused — which is the point of the guard being at write time.
289        assert!(store.encode(&generate_session_id(), &SessionData::default()).is_err());
290    }
291
292    #[test]
293    fn an_empty_session_fits_comfortably() {
294        let cookie = store().encode(&generate_session_id(), &SessionData::default()).unwrap();
295        assert!(cookie.len() < 300, "an empty session is {} bytes", cookie.len());
296    }
297
298    #[test]
299    fn the_cookie_value_is_safe_to_put_in_a_header() {
300        let cookie = store().encode(&generate_session_id(), &data(42)).unwrap();
301
302        assert!(
303            cookie.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_')),
304            "{cookie}"
305        );
306    }
307
308    #[test]
309    fn a_malformed_id_inside_a_valid_payload_is_replaced() {
310        // Written by an older version, or by something that did not use
311        // `generate_session_id`. The payload authenticated, so this is not an
312        // attack — but a fresh id is cheaper than trusting the one inside.
313        let store = store();
314        let cookie = store.encode("not-a-session-id", &data(42)).unwrap();
315
316        let (id, data) = store.decode(&cookie).unwrap();
317        assert_ne!(id, "not-a-session-id");
318        assert_eq!(data.unwrap().values["user_id"], 42, "the data is still trusted");
319    }
320}