Skip to main content

arcly_http/auth/
cookie.rs

1//! HTTP cookie signing and extraction via HMAC-SHA256.
2//!
3//! `CookieService` signs cookie values as `{value}.{base64_hmac}` so that a
4//! tampered cookie is rejected before the JWT inside it is decoded. A signed
5//! cookie that carries a JWT access token lets browser clients authenticate
6//! without storing tokens in `localStorage`.
7//!
8//! ## Usage
9//!
10//! ```ignore
11//! ctx.provide(CookieService::new(CookieConfig {
12//!     name:         "arcly_auth",
13//!     secret:       env_or("COOKIE_SECRET", "change-in-prod"),
14//!     max_age_secs: 900,
15//!     secure:       true,
16//!     http_only:    true,
17//!     same_site:    SameSite::Lax,
18//!     ..Default::default()
19//! }));
20//! ```
21//!
22//! Once provided, the HTTP boundary automatically tries to decode a JWT from
23//! the named cookie if no `Authorization: Bearer` header is present.
24
25use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
26use hmac::{Hmac, Mac};
27use sha2::Sha256;
28
29type HmacSha256 = Hmac<Sha256>;
30
31// ─── Configuration ────────────────────────────────────────────────────────────
32
33#[derive(Clone, Copy, Debug)]
34pub enum SameSite {
35    Strict,
36    Lax,
37    None,
38}
39
40/// Configuration for [`CookieService`]. Build once at startup.
41pub struct CookieConfig {
42    /// Cookie name, e.g. `"arcly_auth"`.
43    pub name: &'static str,
44    /// HMAC-SHA256 signing secret. Must be kept secret.
45    pub secret: String,
46    /// `Max-Age` in seconds. Defaults to 3 600 (1 hour).
47    pub max_age_secs: u64,
48    /// Cookie `Path` attribute. Defaults to `"/"`.
49    pub path: &'static str,
50    /// Set `Secure` flag (HTTPS only). Defaults to `true`.
51    pub secure: bool,
52    /// Set `HttpOnly` flag (no JS access). Defaults to `true`.
53    pub http_only: bool,
54    /// `SameSite` policy. Defaults to `Lax`.
55    pub same_site: SameSite,
56    /// Optional `Domain` attribute.
57    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
75// ─── CookieService ────────────────────────────────────────────────────────────
76
77/// Live HMAC secrets: sign with `current`, verify against `current` then
78/// `previous` (rotation grace window). Swapped as one atomic bundle.
79struct CookieKeys {
80    current: Vec<u8>,
81    previous: Option<Vec<u8>>,
82    version: u64,
83}
84
85/// Signs and verifies HTTP cookie values.
86///
87/// Not `#[Injectable]` — provide manually via `ctx.provide(CookieService::new(...))`.
88///
89/// ## Secret rotation
90///
91/// The HMAC secret lives behind [`Rotating`](crate::auth::secrets::Rotating):
92/// signing/verification pay one atomic pointer load, and
93/// [`rotate_secret`](Self::rotate_secret) hot-swaps the secret with the
94/// previous one retained for verification, so cookies issued just before
95/// rotation stay valid until their natural `Max-Age` expiry.
96pub 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    /// Hot-swap the HMAC secret. Stale (≤ current) versions are ignored.
112    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    /// Returns `"{value}.{base64url_hmac}"` — the signed form stored in the cookie.
131    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    /// Verifies the HMAC signature and returns the original value if valid.
137    ///
138    /// Tries the current secret, then the previous one (rotation grace
139    /// window). Returns `None` for any tampered, malformed, or missing
140    /// signature.
141    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    /// Returns a full `Set-Cookie` header value for `value`.
162    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    /// Returns a `Set-Cookie` header that expires the named cookie immediately.
186    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    /// Reads the named cookie from request headers, verifies its HMAC, and
194    /// returns the original (unsigned) value on success.
195    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}