rainier_session/
cookie.rs1use 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
10const MAX_VALUE_BYTES: usize = 3500;
16
17#[derive(Serialize, Deserialize)]
19struct Envelope {
20 id: String,
23 data: SessionData,
25}
26
27pub struct CookieSessionStore {
86 crypt: Encryption,
87 max_bytes: usize,
88}
89
90impl CookieSessionStore {
91 pub fn new(crypt: Encryption) -> Self {
93 Self { crypt, max_bytes: MAX_VALUE_BYTES }
94 }
95
96 pub fn with_max_bytes(mut self, max_bytes: usize) -> Self {
102 self.max_bytes = max_bytes;
103 self
104 }
105
106 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 fn read<'a>(&'a self, _id: &'a str) -> BoxFuture<'a, Result<Option<SessionData>>> {
120 Box::pin(async { Ok(None) })
121 }
122
123 fn write<'a>(&'a self, _id: &'a str, _data: &'a SessionData) -> BoxFuture<'a, Result<()>> {
125 Box::pin(async { Ok(()) })
126 }
127
128 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 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 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 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 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 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 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}