use rainier_crypt::Encryption;
use rainier_support::{BoxFuture, Error, Result};
use serde::{Deserialize, Serialize};
use crate::session::{generate_session_id, SessionData};
use crate::store::SessionStore;
const MAX_VALUE_BYTES: usize = 3500;
#[derive(Serialize, Deserialize)]
struct Envelope {
id: String,
data: SessionData,
}
pub struct CookieSessionStore {
crypt: Encryption,
max_bytes: usize,
}
impl CookieSessionStore {
pub fn new(crypt: Encryption) -> Self {
Self { crypt, max_bytes: MAX_VALUE_BYTES }
}
pub fn with_max_bytes(mut self, max_bytes: usize) -> Self {
self.max_bytes = max_bytes;
self
}
pub fn max_bytes(&self) -> usize {
self.max_bytes
}
}
impl SessionStore for CookieSessionStore {
fn name(&self) -> &str {
"cookie"
}
fn read<'a>(&'a self, _id: &'a str) -> BoxFuture<'a, Result<Option<SessionData>>> {
Box::pin(async { Ok(None) })
}
fn write<'a>(&'a self, _id: &'a str, _data: &'a SessionData) -> BoxFuture<'a, Result<()>> {
Box::pin(async { Ok(()) })
}
fn destroy<'a>(&'a self, _id: &'a str) -> BoxFuture<'a, Result<()>> {
Box::pin(async { Ok(()) })
}
fn is_client_side(&self) -> bool {
true
}
fn encode(&self, id: &str, data: &SessionData) -> Result<String> {
let envelope = Envelope { id: id.to_string(), data: data.clone() };
let sealed = self.crypt.encrypt_json(&envelope)?;
if sealed.len() > self.max_bytes {
return Err(Error::internal(format!(
"this session is {} bytes encrypted, over the {} the cookie driver allows. \
Move the large values to a server-side store, or switch the session driver.",
sealed.len(),
self.max_bytes
)));
}
Ok(sealed)
}
fn decode(&self, value: &str) -> Result<(String, Option<SessionData>)> {
let envelope: Envelope = self.crypt.decrypt_json(value)?;
let id = if crate::store::is_well_formed(&envelope.id) {
envelope.id
} else {
generate_session_id()
};
Ok((id, Some(envelope.data)))
}
}
impl std::fmt::Debug for CookieSessionStore {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CookieSessionStore").field("max_bytes", &self.max_bytes).finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
use rainier_crypt::{Key, KeyRing};
fn store() -> CookieSessionStore {
CookieSessionStore::new(Encryption::from_keys(KeyRing::new(Key::generate())))
}
fn data(user: u64) -> SessionData {
let mut values = serde_json::Map::new();
values.insert("user_id".into(), user.into());
SessionData { values, flash: vec!["status".to_string()] }
}
#[test]
fn a_session_round_trips_through_a_cookie() {
let store = store();
let id = generate_session_id();
let cookie = store.encode(&id, &data(42)).unwrap();
let (recovered, recovered_data) = store.decode(&cookie).unwrap();
assert_eq!(recovered, id);
let recovered_data = recovered_data.expect("client-side stores return their data");
assert_eq!(recovered_data.values["user_id"], 42);
assert_eq!(recovered_data.flash, vec!["status".to_string()], "flash bookkeeping survives");
}
#[test]
fn the_client_cannot_read_it() {
let cookie = store().encode(&generate_session_id(), &data(42)).unwrap();
assert!(!cookie.contains("user_id"), "{cookie}");
assert!(!cookie.contains("status"), "{cookie}");
assert!(!cookie.contains("values"), "{cookie}");
}
#[test]
fn the_client_cannot_change_it() {
let store = store();
let cookie = store.encode(&generate_session_id(), &data(42)).unwrap();
let mut bytes = cookie.into_bytes();
let last = bytes.len() - 1;
bytes[last] = if bytes[last] == b'A' { b'B' } else { b'A' };
assert!(store.decode(&String::from_utf8(bytes).unwrap()).is_err());
}
#[test]
fn another_applications_key_cannot_read_it() {
let cookie = store().encode(&generate_session_id(), &data(42)).unwrap();
assert!(store().decode(&cookie).is_err());
}
#[test]
fn rubbish_is_refused() {
let store = store();
for bad in ["", "nonsense", "xc20p.abc.def.ghi"] {
assert!(store.decode(bad).is_err(), "{bad}");
}
}
#[test]
fn it_is_marked_client_side() {
assert!(store().is_client_side());
assert_eq!(store().name(), "cookie");
}
#[tokio::test]
async fn the_server_side_methods_are_inert() {
let store = store();
assert!(store.read("anything").await.unwrap().is_none());
assert!(store.write("anything", &data(1)).await.is_ok());
assert!(store.destroy("anything").await.is_ok());
assert_eq!(store.gc().await.unwrap(), 0);
}
#[test]
fn an_over_large_session_is_refused_with_both_numbers() {
let store = store();
let mut values = serde_json::Map::new();
values.insert("cart".into(), serde_json::json!("x".repeat(8000)));
let err = store
.encode(&generate_session_id(), &SessionData { values, flash: Vec::new() })
.unwrap_err();
assert!(err.message().contains("3500"), "{}", err.message());
assert!(err.message().contains("over the"), "{}", err.message());
}
#[test]
fn the_limit_is_adjustable() {
let store = store().with_max_bytes(64);
assert_eq!(store.max_bytes(), 64);
assert!(store.encode(&generate_session_id(), &SessionData::default()).is_err());
}
#[test]
fn an_empty_session_fits_comfortably() {
let cookie = store().encode(&generate_session_id(), &SessionData::default()).unwrap();
assert!(cookie.len() < 300, "an empty session is {} bytes", cookie.len());
}
#[test]
fn the_cookie_value_is_safe_to_put_in_a_header() {
let cookie = store().encode(&generate_session_id(), &data(42)).unwrap();
assert!(
cookie.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_')),
"{cookie}"
);
}
#[test]
fn a_malformed_id_inside_a_valid_payload_is_replaced() {
let store = store();
let cookie = store.encode("not-a-session-id", &data(42)).unwrap();
let (id, data) = store.decode(&cookie).unwrap();
assert_ne!(id, "not-a-session-id");
assert_eq!(data.unwrap().values["user_id"], 42, "the data is still trusted");
}
}