use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use hmac::{Hmac, Mac};
use sha2::Sha256;
type HmacSha256 = Hmac<Sha256>;
#[derive(Clone, Copy, Debug)]
pub enum SameSite {
Strict,
Lax,
None,
}
pub struct CookieConfig {
pub name: &'static str,
pub secret: String,
pub max_age_secs: u64,
pub path: &'static str,
pub secure: bool,
pub http_only: bool,
pub same_site: SameSite,
pub domain: Option<String>,
}
impl Default for CookieConfig {
fn default() -> Self {
Self {
name: "arcly_auth",
secret: "change-me-in-production".to_string(),
max_age_secs: 3_600,
path: "/",
secure: true,
http_only: true,
same_site: SameSite::Lax,
domain: None,
}
}
}
struct CookieKeys {
current: Vec<u8>,
previous: Option<Vec<u8>>,
version: u64,
}
pub struct CookieService {
config: CookieConfig,
keys: crate::auth::secrets::Rotating<CookieKeys>,
}
impl CookieService {
pub fn new(config: CookieConfig) -> Self {
let keys = crate::auth::secrets::Rotating::new(CookieKeys {
current: config.secret.as_bytes().to_vec(),
previous: None,
version: 1,
});
Self { config, keys }
}
pub fn rotate_secret(&self, new_secret: &[u8], version: u64) {
let cur = self.keys.load();
if version <= cur.version {
tracing::warn!(
current = cur.version,
offered = version,
"ignoring stale cookie secret rotation",
);
return;
}
self.keys.store(CookieKeys {
current: new_secret.to_vec(),
previous: Some(cur.current.clone()),
version,
});
tracing::info!(version, "CookieService HMAC secret rotated");
}
pub fn sign(&self, value: &str) -> String {
let sig = Self::hmac_b64(&self.keys.load().current, value);
format!("{value}.{sig}")
}
pub fn verify<'a>(&self, signed: &'a str) -> Option<&'a str> {
let dot = signed.rfind('.')?;
let (value, sig_b64) = (&signed[..dot], &signed[dot + 1..]);
let expected_sig = URL_SAFE_NO_PAD.decode(sig_b64).ok()?;
let keys = self.keys.load();
let verify_with = |secret: &[u8]| {
let mut mac =
HmacSha256::new_from_slice(secret).expect("HMAC key is valid for any length");
mac.update(value.as_bytes());
mac.verify_slice(&expected_sig).is_ok()
};
if verify_with(&keys.current) || keys.previous.as_deref().is_some_and(verify_with) {
Some(value)
} else {
None
}
}
pub fn bake(&self, value: &str) -> String {
let signed = self.sign(value);
let mut cookie = format!(
"{}={}; Path={}; Max-Age={}",
self.config.name, signed, self.config.path, self.config.max_age_secs,
);
if self.config.http_only {
cookie.push_str("; HttpOnly");
}
if self.config.secure {
cookie.push_str("; Secure");
}
match self.config.same_site {
SameSite::Strict => cookie.push_str("; SameSite=Strict"),
SameSite::Lax => cookie.push_str("; SameSite=Lax"),
SameSite::None => cookie.push_str("; SameSite=None"),
}
if let Some(domain) = &self.config.domain {
cookie.push_str(&format!("; Domain={domain}"));
}
cookie
}
pub fn clear(&self) -> String {
format!(
"{}=; Path={}; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT",
self.config.name, self.config.path,
)
}
pub fn extract(&self, headers: &axum::http::HeaderMap) -> Option<String> {
let cookie_str = headers.get("cookie")?.to_str().ok()?;
let prefix = format!("{}=", self.config.name);
for part in cookie_str.split(';') {
let part = part.trim();
if let Some(raw_val) = part.strip_prefix(&prefix) {
return self.verify(raw_val).map(|s| s.to_owned());
}
}
None
}
fn hmac_b64(secret: &[u8], value: &str) -> String {
let mut mac = HmacSha256::new_from_slice(secret).expect("HMAC key is valid for any length");
mac.update(value.as_bytes());
URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes())
}
}