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}
18
19/// Read incoming cookies and stage outgoing ones for a single request.
20pub struct CookieJar {
21    incoming: BTreeMap<String, String>,
22    outgoing: Vec<SetCookie>,
23    secret: Vec<u8>,
24}
25
26impl CookieJar {
27    /// Build a jar from an optional `Cookie` header and a signing secret.
28    pub fn from_header(cookie_header: Option<&str>, secret: Vec<u8>) -> Self {
29        let mut incoming = BTreeMap::new();
30        if let Some(header) = cookie_header {
31            for (name, value) in header
32                .split(';')
33                .filter_map(|pair| pair.trim().split_once('='))
34            {
35                incoming.insert(name.to_string(), value.to_string());
36            }
37        }
38        Self {
39            incoming,
40            outgoing: Vec::new(),
41            secret,
42        }
43    }
44
45    /// Read a plain incoming cookie value.
46    pub fn get(&self, name: &str) -> Option<&str> {
47        self.incoming.get(name).map(String::as_str)
48    }
49
50    /// Stage a plain cookie to be written on the response.
51    pub fn set(&mut self, name: impl Into<String>, value: impl Into<String>) {
52        self.outgoing.push(SetCookie {
53            name: name.into(),
54            value: value.into(),
55        });
56    }
57
58    /// Read a signed incoming cookie, returning `None` if it is missing,
59    /// malformed, or its signature does not verify.
60    pub fn get_signed(&self, name: &str) -> Option<String> {
61        let raw = self.incoming.get(name)?;
62        let (msg, sig) = raw.split_once('.')?;
63        if !signing::verify(&self.secret, msg.as_bytes(), sig) {
64            return None;
65        }
66        let bytes = URL_SAFE_NO_PAD.decode(msg).ok()?;
67        String::from_utf8(bytes).ok()
68    }
69
70    /// Stage a signed cookie (`base64url(value).signature`).
71    pub fn set_signed(&mut self, name: impl Into<String>, value: impl AsRef<str>) {
72        let msg = URL_SAFE_NO_PAD.encode(value.as_ref().as_bytes());
73        let sig = signing::sign(&self.secret, msg.as_bytes());
74        self.outgoing.push(SetCookie {
75            name: name.into(),
76            value: format!("{msg}.{sig}"),
77        });
78    }
79
80    /// Render the staged cookies as `Set-Cookie` header values.
81    pub fn to_set_cookie_headers(&self) -> Vec<String> {
82        self.outgoing
83            .iter()
84            .map(|c| format!("{}={}; Path=/; HttpOnly", c.name, c.value))
85            .collect()
86    }
87}