Skip to main content

codlet_core/
cookie.rs

1//! Secure cookie construction (RFC-006 §13.2).
2//!
3//! [`CookiePolicy`] encodes named profiles. `HttpOnly` and `Secure` are
4//! mandatory in all production profiles. `SameSite=Strict` is the default.
5//!
6//! The resulting header values are plain strings so they can be passed to any
7//! HTTP framework without coupling to a specific `Cookie` crate.
8
9use std::time::Duration;
10
11/// SameSite cookie attribute values (RFC-6265bis).
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
13pub enum SameSitePolicy {
14    /// `SameSite=Strict` — cookies sent only in same-site requests.
15    /// Default and recommended for session cookies (RFC-006 §4).
16    #[default]
17    Strict,
18    /// `SameSite=Lax` — cookies sent on top-level cross-site navigation.
19    /// Appropriate when the host needs to receive a cookie after a redirect
20    /// from an external flow (RFC-006 §13.2 `ProductionLax` profile).
21    Lax,
22    /// `SameSite=None` — must always be accompanied by `Secure`. Not emitted
23    /// by any built-in profile; available for framework adapters.
24    None,
25}
26
27impl SameSitePolicy {
28    /// The attribute string fragment, without the leading `; `.
29    #[must_use]
30    pub const fn attr(self) -> &'static str {
31        match self {
32            SameSitePolicy::Strict => "SameSite=Strict",
33            SameSitePolicy::Lax => "SameSite=Lax",
34            SameSitePolicy::None => "SameSite=None",
35        }
36    }
37}
38
39/// Named cookie profile (RFC-006 §13.2).
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
41pub enum CookieProfile {
42    /// `Secure=true; HttpOnly=true; SameSite=Strict`. Default.
43    #[default]
44    ProductionStrict,
45    /// `Secure=true; HttpOnly=true; SameSite=Lax`.
46    ProductionLax,
47    /// `Secure=false; HttpOnly=true; SameSite=Lax`. Must be explicitly chosen;
48    /// not for production. `Secure=false` is rejected if the active profile is
49    /// a production one.
50    LocalDevelopment,
51}
52
53/// Policy governing cookie construction (RFC-006 §4).
54///
55/// Build with [`CookiePolicy::production_strict`] for the standard profile,
56/// or use the builder methods to customise. `HttpOnly=true` cannot be disabled
57/// (RFC-006 §13.2: "A production profile should reject `Secure=false`").
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct CookiePolicy {
60    name: String,
61    path: String,
62    max_age: Duration,
63    same_site: SameSitePolicy,
64    secure: bool,
65    domain: Option<String>,
66}
67
68impl CookiePolicy {
69    /// Standard production policy: `HttpOnly; Secure; SameSite=Strict; Path=/`.
70    /// `Domain` is omitted to produce a host-only cookie (RFC-006 §5,
71    /// implementation note: omitting `Domain` avoids subdomain leakage).
72    #[must_use]
73    pub fn production_strict(name: impl Into<String>, max_age: Duration) -> Self {
74        Self {
75            name: name.into(),
76            path: "/".to_string(),
77            max_age,
78            same_site: SameSitePolicy::Strict,
79            secure: true,
80            domain: None,
81        }
82    }
83
84    /// Production policy with `SameSite=Lax` for cross-site top-level flows.
85    #[must_use]
86    pub fn production_lax(name: impl Into<String>, max_age: Duration) -> Self {
87        Self {
88            name: name.into(),
89            path: "/".to_string(),
90            max_age,
91            same_site: SameSitePolicy::Lax,
92            secure: true,
93            domain: None,
94        }
95    }
96
97    /// Development-only policy: `Secure=false; SameSite=Lax`. The caller must
98    /// document why this is acceptable; it must not be used in production.
99    #[must_use]
100    pub fn local_development(name: impl Into<String>, max_age: Duration) -> Self {
101        Self {
102            name: name.into(),
103            path: "/".to_string(),
104            max_age,
105            same_site: SameSitePolicy::Lax,
106            secure: false,
107            domain: None,
108        }
109    }
110
111    /// Override `Path`. Defaults to `/`.
112    #[must_use]
113    pub fn with_path(mut self, path: impl Into<String>) -> Self {
114        self.path = path.into();
115        self
116    }
117
118    /// Set an explicit `Domain` attribute. Pass `None` to produce a host-only
119    /// cookie (the default and recommended choice).
120    #[must_use]
121    pub fn with_domain(mut self, domain: Option<impl Into<String>>) -> Self {
122        self.domain = domain.map(Into::into);
123        self
124    }
125
126    /// The configured cookie name.
127    #[must_use]
128    pub fn name(&self) -> &str {
129        &self.name
130    }
131
132    /// Whether this policy requires the `Secure` attribute.
133    #[must_use]
134    pub fn is_secure(&self) -> bool {
135        self.secure
136    }
137
138    /// Build a `Set-Cookie` header value that delivers `secret` to the client.
139    ///
140    /// `secret` must be the **plaintext** session or token secret — the only
141    /// moment it crosses the wire. The caller must not log the returned string.
142    #[must_use]
143    pub fn build_set_cookie(&self, secret: &str) -> String {
144        let mut parts = format!(
145            "{}={}; Max-Age={}; Path={}; HttpOnly; {}",
146            self.name,
147            secret,
148            self.max_age.as_secs(),
149            self.path,
150            self.same_site.attr(),
151        );
152        if self.secure {
153            parts.push_str("; Secure");
154        }
155        if let Some(d) = &self.domain {
156            parts.push_str("; Domain=");
157            parts.push_str(d);
158        }
159        parts
160    }
161
162    /// Build a `Set-Cookie` header value that clears this cookie (e.g. logout).
163    ///
164    /// Uses `Max-Age=0` with the same path/domain/name so browsers delete the
165    /// existing cookie (RFC-006 §4 "clear cookie helper mirrors path/domain/name").
166    #[must_use]
167    pub fn build_clear_cookie(&self) -> String {
168        let mut parts = format!(
169            "{}=; Max-Age=0; Path={}; HttpOnly; {}",
170            self.name,
171            self.path,
172            self.same_site.attr(),
173        );
174        if self.secure {
175            parts.push_str("; Secure");
176        }
177        if let Some(d) = &self.domain {
178            parts.push_str("; Domain=");
179            parts.push_str(d);
180        }
181        parts
182    }
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188
189    const HOUR: Duration = Duration::from_secs(3_600);
190
191    fn p() -> CookiePolicy {
192        CookiePolicy::production_strict("test_sid", HOUR)
193    }
194
195    #[test]
196    fn set_cookie_contains_required_attributes() {
197        let c = p().build_set_cookie("mysecret");
198        assert!(c.contains("HttpOnly"), "missing HttpOnly");
199        assert!(c.contains("Secure"), "missing Secure");
200        assert!(c.contains("SameSite=Strict"), "missing SameSite=Strict");
201        assert!(c.contains("Path=/"), "missing Path=/");
202        assert!(c.contains("Max-Age=3600"), "missing Max-Age");
203        assert!(c.starts_with("test_sid=mysecret"), "wrong name=value");
204    }
205
206    #[test]
207    fn clear_cookie_uses_max_age_zero() {
208        let c = p().build_clear_cookie();
209        assert!(c.contains("Max-Age=0"), "clear must use Max-Age=0");
210        assert!(c.contains("HttpOnly"), "missing HttpOnly");
211        assert!(c.contains("Secure"), "missing Secure");
212        assert!(c.contains("SameSite=Strict"));
213        assert!(c.starts_with("test_sid=;"), "wrong name on clear");
214    }
215
216    #[test]
217    fn domain_omitted_by_default() {
218        let c = p().build_set_cookie("s");
219        assert!(!c.contains("Domain="), "default must omit Domain");
220    }
221
222    #[test]
223    fn explicit_domain_is_emitted() {
224        let c = p().with_domain(Some("example.com")).build_set_cookie("s");
225        assert!(c.contains("Domain=example.com"), "explicit domain missing");
226    }
227
228    #[test]
229    fn clear_cookie_mirrors_path_and_domain() {
230        let policy = CookiePolicy::production_strict("sid", HOUR)
231            .with_path("/app")
232            .with_domain(Some("example.com"));
233        let set = policy.build_set_cookie("s");
234        let clear = policy.build_clear_cookie();
235        assert!(set.contains("Path=/app"));
236        assert!(clear.contains("Path=/app"));
237        assert!(set.contains("Domain=example.com"));
238        assert!(clear.contains("Domain=example.com"));
239    }
240
241    #[test]
242    fn local_development_omits_secure() {
243        let c = CookiePolicy::local_development("dev_sid", HOUR).build_set_cookie("s");
244        assert!(!c.contains("; Secure"), "dev profile must not set Secure");
245        assert!(c.contains("HttpOnly"), "HttpOnly always required");
246    }
247
248    #[test]
249    fn lax_profile_uses_lax_samesite() {
250        let c = CookiePolicy::production_lax("sid", HOUR).build_set_cookie("s");
251        assert!(c.contains("SameSite=Lax"));
252        assert!(c.contains("; Secure"));
253    }
254
255    #[test]
256    fn secret_not_duplicated_elsewhere_in_value() {
257        // Sanity: the secret appears exactly once (as the value), not in any
258        // attribute name.
259        let c = p().build_set_cookie("hunter2");
260        let count = c.matches("hunter2").count();
261        assert_eq!(count, 1, "secret appeared {count} times in {c:?}");
262    }
263}