Skip to main content

doido_controller/
cookies.rs

1//! A request/response cookie jar with plain and signed cookies (Rails
2//! `cookies[...]` / `cookies.signed[...]`).
3//!
4//! Incoming cookies are parsed from the request `Cookie` header; outgoing
5//! cookies are collected and rendered as `Set-Cookie` header values. Signed
6//! cookies carry an HMAC-SHA256 signature so a tampered value is rejected on
7//! read.
8
9use crate::signing;
10use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
11use std::collections::BTreeMap;
12
13/// A cookie to be written back in a `Set-Cookie` header.
14struct SetCookie {
15    name: String,
16    value: String,
17    /// `Max-Age` in seconds for a persistent cookie; `None` = session cookie.
18    max_age: Option<i64>,
19}
20
21/// Read incoming cookies and stage outgoing ones for a single request.
22pub struct CookieJar {
23    incoming: BTreeMap<String, String>,
24    outgoing: Vec<SetCookie>,
25    secret: Vec<u8>,
26}
27
28impl CookieJar {
29    /// Build a jar from an optional `Cookie` header and a signing secret.
30    pub fn from_header(cookie_header: Option<&str>, secret: Vec<u8>) -> Self {
31        let mut incoming = BTreeMap::new();
32        if let Some(header) = cookie_header {
33            for (name, value) in header
34                .split(';')
35                .filter_map(|pair| pair.trim().split_once('='))
36            {
37                incoming.insert(name.to_string(), value.to_string());
38            }
39        }
40        Self {
41            incoming,
42            outgoing: Vec::new(),
43            secret,
44        }
45    }
46
47    /// Read a plain incoming cookie value.
48    pub fn get(&self, name: &str) -> Option<&str> {
49        self.incoming.get(name).map(String::as_str)
50    }
51
52    /// Stage a plain cookie to be written on the response.
53    pub fn set(&mut self, name: impl Into<String>, value: impl Into<String>) {
54        self.outgoing.push(SetCookie {
55            name: name.into(),
56            value: value.into(),
57            max_age: None,
58        });
59    }
60
61    /// Read a signed incoming cookie, returning `None` if it is missing,
62    /// malformed, or its signature does not verify.
63    pub fn get_signed(&self, name: &str) -> Option<String> {
64        let raw = self.incoming.get(name)?;
65        let (msg, sig) = raw.split_once('.')?;
66        if !signing::verify(&self.secret, msg.as_bytes(), sig) {
67            return None;
68        }
69        let bytes = URL_SAFE_NO_PAD.decode(msg).ok()?;
70        String::from_utf8(bytes).ok()
71    }
72
73    /// Stage a signed cookie (`base64url(value).signature`).
74    pub fn set_signed(&mut self, name: impl Into<String>, value: impl AsRef<str>) {
75        self.push_signed(name, value, None);
76    }
77
78    /// Stage a signed cookie that persists for `max_age` seconds (e.g. a
79    /// "remember me" cookie). Unlike [`set_signed`](Self::set_signed) it is not a
80    /// session cookie — it survives the browser session until `Max-Age` elapses.
81    pub fn set_signed_permanent(
82        &mut self,
83        name: impl Into<String>,
84        value: impl AsRef<str>,
85        max_age: i64,
86    ) {
87        self.push_signed(name, value, Some(max_age));
88    }
89
90    fn push_signed(
91        &mut self,
92        name: impl Into<String>,
93        value: impl AsRef<str>,
94        max_age: Option<i64>,
95    ) {
96        let msg = URL_SAFE_NO_PAD.encode(value.as_ref().as_bytes());
97        let sig = signing::sign(&self.secret, msg.as_bytes());
98        self.outgoing.push(SetCookie {
99            name: name.into(),
100            value: format!("{msg}.{sig}"),
101            max_age,
102        });
103    }
104
105    /// Render the staged cookies as `Set-Cookie` header values.
106    pub fn to_set_cookie_headers(&self) -> Vec<String> {
107        self.outgoing
108            .iter()
109            .map(|c| match c.max_age {
110                Some(age) => format!("{}={}; Path=/; HttpOnly; Max-Age={age}", c.name, c.value),
111                None => format!("{}={}; Path=/; HttpOnly", c.name, c.value),
112            })
113            .collect()
114    }
115}