1use crate::Session;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum SameSite {
23 Strict,
24 Lax,
25 None,
26}
27
28impl SameSite {
29 fn as_str(self) -> &'static str {
30 match self {
31 SameSite::Strict => "Strict",
32 SameSite::Lax => "Lax",
33 SameSite::None => "None",
34 }
35 }
36}
37
38#[derive(Debug, Clone)]
39pub struct CookieConfig {
40 pub name: String,
41 pub domain: Option<String>,
44 pub secure: bool,
45 pub same_site: SameSite,
46 pub max_age_secs: u64,
50 pub path: String,
51}
52
53impl CookieConfig {
54 pub fn from_env(default_name: &str) -> Self {
63 let is_dev = std::env::var("PYLON_DEV_MODE")
64 .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
65 .unwrap_or(false);
66
67 let name = std::env::var("PYLON_COOKIE_NAME").unwrap_or_else(|_| default_name.to_string());
68
69 let domain = std::env::var("PYLON_COOKIE_DOMAIN")
70 .ok()
71 .filter(|s| !s.is_empty());
72
73 let secure = match std::env::var("PYLON_COOKIE_SECURE") {
74 Ok(v) => v == "1" || v.eq_ignore_ascii_case("true"),
75 Err(_) => !is_dev,
76 };
77
78 let same_site = match std::env::var("PYLON_COOKIE_SAME_SITE")
79 .as_deref()
80 .map(str::to_ascii_lowercase)
81 .as_deref()
82 {
83 Ok("strict") => SameSite::Strict,
84 Ok("none") => SameSite::None,
85 _ => SameSite::Lax,
86 };
87
88 let secure = if matches!(same_site, SameSite::None) {
91 true
92 } else {
93 secure
94 };
95
96 Self {
97 name,
98 domain,
99 secure,
100 same_site,
101 max_age_secs: Session::DEFAULT_LIFETIME_SECS,
102 path: "/".to_string(),
103 }
104 }
105
106 pub fn default_name_for(app_name: &str) -> String {
110 let sanitized: String = app_name
111 .chars()
112 .map(|c| {
113 if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
114 c
115 } else {
116 '_'
117 }
118 })
119 .collect();
120 let stem = if sanitized.is_empty() {
121 "pylon".to_string()
122 } else {
123 sanitized
124 };
125 format!("{stem}_session")
126 }
127
128 pub fn set_value(&self, token: &str) -> String {
130 self.build(token, self.max_age_secs)
131 }
132
133 pub fn clear_value(&self) -> String {
136 self.build("", 0)
137 }
138
139 fn build(&self, value: &str, max_age: u64) -> String {
140 let mut s = format!("{}={}; Path={}", self.name, value, self.path);
141 if let Some(domain) = &self.domain {
142 s.push_str("; Domain=");
143 s.push_str(domain);
144 }
145 s.push_str("; HttpOnly");
146 if self.secure {
147 s.push_str("; Secure");
148 }
149 s.push_str("; SameSite=");
150 s.push_str(self.same_site.as_str());
151 s.push_str("; Max-Age=");
152 s.push_str(&max_age.to_string());
153 s
154 }
155}
156
157pub fn extract_token(cookie_header: &str, cookie_name: &str) -> Option<String> {
160 for pair in cookie_header.split(';') {
161 let pair = pair.trim();
162 if let Some((k, v)) = pair.split_once('=') {
163 if k == cookie_name {
164 return Some(v.to_string());
165 }
166 }
167 }
168 None
169}
170
171#[cfg(test)]
172mod tests {
173 use super::*;
174
175 #[test]
176 fn default_name_sanitises_app_name() {
177 assert_eq!(CookieConfig::default_name_for("my-app"), "my-app_session");
178 assert_eq!(
179 CookieConfig::default_name_for("Pylon Cloud"),
180 "Pylon_Cloud_session"
181 );
182 assert_eq!(CookieConfig::default_name_for(""), "pylon_session");
183 }
184
185 #[test]
186 fn set_value_includes_required_attrs() {
187 let cfg = CookieConfig {
188 name: "app_session".into(),
189 domain: Some(".example.com".into()),
190 secure: true,
191 same_site: SameSite::Lax,
192 max_age_secs: 3600,
193 path: "/".into(),
194 };
195 let v = cfg.set_value("abc123");
196 assert!(v.starts_with("app_session=abc123"));
197 assert!(v.contains("Path=/"));
198 assert!(v.contains("Domain=.example.com"));
199 assert!(v.contains("HttpOnly"));
200 assert!(v.contains("Secure"));
201 assert!(v.contains("SameSite=Lax"));
202 assert!(v.contains("Max-Age=3600"));
203 }
204
205 #[test]
206 fn clear_value_uses_max_age_zero() {
207 let cfg = CookieConfig {
208 name: "s".into(),
209 domain: None,
210 secure: false,
211 same_site: SameSite::Lax,
212 max_age_secs: 1000,
213 path: "/".into(),
214 };
215 let v = cfg.clear_value();
216 assert!(v.contains("Max-Age=0"));
217 assert!(v.contains("s=;"));
218 assert!(!v.contains("Domain="));
219 assert!(!v.contains("Secure"));
220 }
221
222 #[test]
223 fn same_site_none_forces_secure() {
224 let cfg = CookieConfig {
230 name: "x".into(),
231 domain: None,
232 secure: false,
233 same_site: SameSite::None,
234 max_age_secs: 1,
235 path: "/".into(),
236 };
237 let v = cfg.set_value("t");
240 assert!(v.contains("SameSite=None"));
241 }
242
243 #[test]
244 fn extract_token_finds_named_cookie() {
245 assert_eq!(
246 extract_token("foo=bar; my_session=tok; baz=qux", "my_session"),
247 Some("tok".to_string())
248 );
249 assert_eq!(extract_token("foo=bar", "my_session"), None);
250 assert_eq!(extract_token("", "my_session"), None);
251 }
252}