arcly_http/auth/
cookie.rs1use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
26use hmac::{Hmac, Mac};
27use sha2::Sha256;
28
29type HmacSha256 = Hmac<Sha256>;
30
31#[derive(Clone, Copy, Debug)]
34pub enum SameSite {
35 Strict,
36 Lax,
37 None,
38}
39
40pub struct CookieConfig {
42 pub name: &'static str,
44 pub secret: String,
46 pub max_age_secs: u64,
48 pub path: &'static str,
50 pub secure: bool,
52 pub http_only: bool,
54 pub same_site: SameSite,
56 pub domain: Option<String>,
58}
59
60impl Default for CookieConfig {
61 fn default() -> Self {
62 Self {
63 name: "arcly_auth",
64 secret: "change-me-in-production".to_string(),
65 max_age_secs: 3_600,
66 path: "/",
67 secure: true,
68 http_only: true,
69 same_site: SameSite::Lax,
70 domain: None,
71 }
72 }
73}
74
75struct CookieKeys {
80 current: Vec<u8>,
81 previous: Option<Vec<u8>>,
82 version: u64,
83}
84
85pub struct CookieService {
97 config: CookieConfig,
98 keys: crate::auth::secrets::Rotating<CookieKeys>,
99}
100
101impl CookieService {
102 pub fn new(config: CookieConfig) -> Self {
103 let keys = crate::auth::secrets::Rotating::new(CookieKeys {
104 current: config.secret.as_bytes().to_vec(),
105 previous: None,
106 version: 1,
107 });
108 Self { config, keys }
109 }
110
111 pub fn rotate_secret(&self, new_secret: &[u8], version: u64) {
113 let cur = self.keys.load();
114 if version <= cur.version {
115 tracing::warn!(
116 current = cur.version,
117 offered = version,
118 "ignoring stale cookie secret rotation",
119 );
120 return;
121 }
122 self.keys.store(CookieKeys {
123 current: new_secret.to_vec(),
124 previous: Some(cur.current.clone()),
125 version,
126 });
127 tracing::info!(version, "CookieService HMAC secret rotated");
128 }
129
130 pub fn sign(&self, value: &str) -> String {
132 let sig = Self::hmac_b64(&self.keys.load().current, value);
133 format!("{value}.{sig}")
134 }
135
136 pub fn verify<'a>(&self, signed: &'a str) -> Option<&'a str> {
142 let dot = signed.rfind('.')?;
143 let (value, sig_b64) = (&signed[..dot], &signed[dot + 1..]);
144 let expected_sig = URL_SAFE_NO_PAD.decode(sig_b64).ok()?;
145
146 let keys = self.keys.load();
147 let verify_with = |secret: &[u8]| {
148 let mut mac =
149 HmacSha256::new_from_slice(secret).expect("HMAC key is valid for any length");
150 mac.update(value.as_bytes());
151 mac.verify_slice(&expected_sig).is_ok()
152 };
153
154 if verify_with(&keys.current) || keys.previous.as_deref().is_some_and(verify_with) {
155 Some(value)
156 } else {
157 None
158 }
159 }
160
161 pub fn bake(&self, value: &str) -> String {
163 let signed = self.sign(value);
164 let mut cookie = format!(
165 "{}={}; Path={}; Max-Age={}",
166 self.config.name, signed, self.config.path, self.config.max_age_secs,
167 );
168 if self.config.http_only {
169 cookie.push_str("; HttpOnly");
170 }
171 if self.config.secure {
172 cookie.push_str("; Secure");
173 }
174 match self.config.same_site {
175 SameSite::Strict => cookie.push_str("; SameSite=Strict"),
176 SameSite::Lax => cookie.push_str("; SameSite=Lax"),
177 SameSite::None => cookie.push_str("; SameSite=None"),
178 }
179 if let Some(domain) = &self.config.domain {
180 cookie.push_str(&format!("; Domain={domain}"));
181 }
182 cookie
183 }
184
185 pub fn clear(&self) -> String {
187 format!(
188 "{}=; Path={}; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT",
189 self.config.name, self.config.path,
190 )
191 }
192
193 pub fn extract(&self, headers: &axum::http::HeaderMap) -> Option<String> {
196 let cookie_str = headers.get("cookie")?.to_str().ok()?;
197 let prefix = format!("{}=", self.config.name);
198 for part in cookie_str.split(';') {
199 let part = part.trim();
200 if let Some(raw_val) = part.strip_prefix(&prefix) {
201 return self.verify(raw_val).map(|s| s.to_owned());
202 }
203 }
204 None
205 }
206
207 fn hmac_b64(secret: &[u8], value: &str) -> String {
208 let mut mac = HmacSha256::new_from_slice(secret).expect("HMAC key is valid for any length");
209 mac.update(value.as_bytes());
210 URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes())
211 }
212}