arcly-http 0.1.1

Enterprise-grade NestJS-inspired web framework on axum: zero-lock DI, declarative controllers, multi-tenant data routing, transactional outbox, ABAC, and a self-documenting OpenAPI surface
Documentation
//! HTTP cookie signing and extraction via HMAC-SHA256.
//!
//! `CookieService` signs cookie values as `{value}.{base64_hmac}` so that a
//! tampered cookie is rejected before the JWT inside it is decoded. A signed
//! cookie that carries a JWT access token lets browser clients authenticate
//! without storing tokens in `localStorage`.
//!
//! ## Usage
//!
//! ```ignore
//! ctx.provide(CookieService::new(CookieConfig {
//!     name:         "arcly_auth",
//!     secret:       env_or("COOKIE_SECRET", "change-in-prod"),
//!     max_age_secs: 900,
//!     secure:       true,
//!     http_only:    true,
//!     same_site:    SameSite::Lax,
//!     ..Default::default()
//! }));
//! ```
//!
//! Once provided, the HTTP boundary automatically tries to decode a JWT from
//! the named cookie if no `Authorization: Bearer` header is present.

use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use hmac::{Hmac, Mac};
use sha2::Sha256;

type HmacSha256 = Hmac<Sha256>;

// ─── Configuration ────────────────────────────────────────────────────────────

#[derive(Clone, Copy, Debug)]
pub enum SameSite {
    Strict,
    Lax,
    None,
}

/// Configuration for [`CookieService`]. Build once at startup.
pub struct CookieConfig {
    /// Cookie name, e.g. `"arcly_auth"`.
    pub name: &'static str,
    /// HMAC-SHA256 signing secret. Must be kept secret.
    pub secret: String,
    /// `Max-Age` in seconds. Defaults to 3 600 (1 hour).
    pub max_age_secs: u64,
    /// Cookie `Path` attribute. Defaults to `"/"`.
    pub path: &'static str,
    /// Set `Secure` flag (HTTPS only). Defaults to `true`.
    pub secure: bool,
    /// Set `HttpOnly` flag (no JS access). Defaults to `true`.
    pub http_only: bool,
    /// `SameSite` policy. Defaults to `Lax`.
    pub same_site: SameSite,
    /// Optional `Domain` attribute.
    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,
        }
    }
}

// ─── CookieService ────────────────────────────────────────────────────────────

/// Live HMAC secrets: sign with `current`, verify against `current` then
/// `previous` (rotation grace window). Swapped as one atomic bundle.
struct CookieKeys {
    current: Vec<u8>,
    previous: Option<Vec<u8>>,
    version: u64,
}

/// Signs and verifies HTTP cookie values.
///
/// Not `#[Injectable]` — provide manually via `ctx.provide(CookieService::new(...))`.
///
/// ## Secret rotation
///
/// The HMAC secret lives behind [`Rotating`](crate::auth::secrets::Rotating):
/// signing/verification pay one atomic pointer load, and
/// [`rotate_secret`](Self::rotate_secret) hot-swaps the secret with the
/// previous one retained for verification, so cookies issued just before
/// rotation stay valid until their natural `Max-Age` expiry.
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 }
    }

    /// Hot-swap the HMAC secret. Stale (≤ current) versions are ignored.
    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");
    }

    /// Returns `"{value}.{base64url_hmac}"` — the signed form stored in the cookie.
    pub fn sign(&self, value: &str) -> String {
        let sig = Self::hmac_b64(&self.keys.load().current, value);
        format!("{value}.{sig}")
    }

    /// Verifies the HMAC signature and returns the original value if valid.
    ///
    /// Tries the current secret, then the previous one (rotation grace
    /// window). Returns `None` for any tampered, malformed, or missing
    /// signature.
    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
        }
    }

    /// Returns a full `Set-Cookie` header value for `value`.
    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
    }

    /// Returns a `Set-Cookie` header that expires the named cookie immediately.
    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,
        )
    }

    /// Reads the named cookie from request headers, verifies its HMAC, and
    /// returns the original (unsigned) value on success.
    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())
    }
}