use crate::Session;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SameSite {
Strict,
Lax,
None,
}
impl SameSite {
fn as_str(self) -> &'static str {
match self {
SameSite::Strict => "Strict",
SameSite::Lax => "Lax",
SameSite::None => "None",
}
}
}
#[derive(Debug, Clone)]
pub struct CookieConfig {
pub name: String,
pub domain: Option<String>,
pub secure: bool,
pub same_site: SameSite,
pub max_age_secs: u64,
pub path: String,
}
impl CookieConfig {
pub fn from_env(default_name: &str) -> Self {
let is_dev = std::env::var("PYLON_DEV_MODE")
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or(false);
let name = std::env::var("PYLON_COOKIE_NAME").unwrap_or_else(|_| default_name.to_string());
let domain = std::env::var("PYLON_COOKIE_DOMAIN")
.ok()
.filter(|s| !s.is_empty());
let secure = match std::env::var("PYLON_COOKIE_SECURE") {
Ok(v) => v == "1" || v.eq_ignore_ascii_case("true"),
Err(_) => !is_dev,
};
let same_site = match std::env::var("PYLON_COOKIE_SAME_SITE")
.as_deref()
.map(str::to_ascii_lowercase)
.as_deref()
{
Ok("strict") => SameSite::Strict,
Ok("none") => SameSite::None,
_ => SameSite::Lax,
};
let secure = if matches!(same_site, SameSite::None) {
true
} else {
secure
};
Self {
name,
domain,
secure,
same_site,
max_age_secs: Session::DEFAULT_LIFETIME_SECS,
path: "/".to_string(),
}
}
pub fn default_name_for(app_name: &str) -> String {
let sanitized: String = app_name
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
c
} else {
'_'
}
})
.collect();
let stem = if sanitized.is_empty() {
"pylon".to_string()
} else {
sanitized
};
format!("{stem}_session")
}
pub fn set_value(&self, token: &str) -> String {
self.build(token, self.max_age_secs)
}
pub fn clear_value(&self) -> String {
self.build("", 0)
}
fn build(&self, value: &str, max_age: u64) -> String {
let mut s = format!("{}={}; Path={}", self.name, value, self.path);
if let Some(domain) = &self.domain {
s.push_str("; Domain=");
s.push_str(domain);
}
s.push_str("; HttpOnly");
if self.secure {
s.push_str("; Secure");
}
s.push_str("; SameSite=");
s.push_str(self.same_site.as_str());
s.push_str("; Max-Age=");
s.push_str(&max_age.to_string());
s
}
}
pub fn extract_token(cookie_header: &str, cookie_name: &str) -> Option<String> {
for pair in cookie_header.split(';') {
let pair = pair.trim();
if let Some((k, v)) = pair.split_once('=') {
if k == cookie_name {
return Some(v.to_string());
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_name_sanitises_app_name() {
assert_eq!(CookieConfig::default_name_for("my-app"), "my-app_session");
assert_eq!(
CookieConfig::default_name_for("Pylon Cloud"),
"Pylon_Cloud_session"
);
assert_eq!(CookieConfig::default_name_for(""), "pylon_session");
}
#[test]
fn set_value_includes_required_attrs() {
let cfg = CookieConfig {
name: "app_session".into(),
domain: Some(".example.com".into()),
secure: true,
same_site: SameSite::Lax,
max_age_secs: 3600,
path: "/".into(),
};
let v = cfg.set_value("abc123");
assert!(v.starts_with("app_session=abc123"));
assert!(v.contains("Path=/"));
assert!(v.contains("Domain=.example.com"));
assert!(v.contains("HttpOnly"));
assert!(v.contains("Secure"));
assert!(v.contains("SameSite=Lax"));
assert!(v.contains("Max-Age=3600"));
}
#[test]
fn clear_value_uses_max_age_zero() {
let cfg = CookieConfig {
name: "s".into(),
domain: None,
secure: false,
same_site: SameSite::Lax,
max_age_secs: 1000,
path: "/".into(),
};
let v = cfg.clear_value();
assert!(v.contains("Max-Age=0"));
assert!(v.contains("s=;"));
assert!(!v.contains("Domain="));
assert!(!v.contains("Secure"));
}
#[test]
fn same_site_none_forces_secure() {
let cfg = CookieConfig {
name: "x".into(),
domain: None,
secure: false,
same_site: SameSite::None,
max_age_secs: 1,
path: "/".into(),
};
let v = cfg.set_value("t");
assert!(v.contains("SameSite=None"));
}
#[test]
fn extract_token_finds_named_cookie() {
assert_eq!(
extract_token("foo=bar; my_session=tok; baz=qux", "my_session"),
Some("tok".to_string())
);
assert_eq!(extract_token("foo=bar", "my_session"), None);
assert_eq!(extract_token("", "my_session"), None);
}
}