Skip to main content

alibi_core/session/cookie_cache/
jwt.rs

1//! Symmetric session-cache JWT authentication and typed snapshot decoding.
2use super::CompactCache;
3use crate::{AuthError, AuthResult, utils::json::JsValue};
4use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
5use hmac::{Hmac, KeyInit, Mac};
6use serde_json::{Value, json};
7use sha2::{Sha256, Sha384, Sha512};
8
9/// Complete session payload before a configured signer adds protected claims.
10pub(crate) fn payload(
11    user: &crate::UserView,
12    session: &crate::SessionView,
13    version: &str,
14    now_ms: i64,
15) -> Value {
16    json!({"session":session,"user":user,"updatedAt":now_ms,"version":version})
17}
18
19/// Add issued-at and expiry claims with fractional seconds retained.
20///
21/// # Errors
22/// Rejects nonfinite expiry or a nonobject payload.
23pub fn time_claims(mut payload: Value, max_age: f64) -> AuthResult<Value> {
24    let now = chrono::Utc::now().timestamp();
25    let expiry = serde_json::Number::from(now)
26        .as_f64()
27        .ok_or_else(|| AuthError::internal("Invalid issued-at time"))?
28        + max_age;
29    if !expiry.is_finite() {
30        return Err(AuthError::internal("Invalid cookie-cache expiration time"));
31    }
32    let claims = payload
33        .as_object_mut()
34        .ok_or_else(|| AuthError::internal("Invalid cookie-cache payload"))?;
35    _ = claims.insert("iat".into(), json!(now));
36    _ = claims.insert("exp".into(), json!(expiry));
37    Ok(payload)
38}
39
40pub(crate) fn encode(payload: Value, secret: &str, max_age: f64) -> AuthResult<String> {
41    let input = format!(
42        "{}.{}",
43        URL_SAFE_NO_PAD.encode(br#"{"alg":"HS256"}"#),
44        URL_SAFE_NO_PAD.encode(crate::utils::json::to_vec(&time_claims(payload, max_age)?)?)
45    );
46    let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes())
47        .map_err(|_| AuthError::internal("Invalid signing key"))?;
48    mac.update(input.as_bytes());
49    Ok(format!(
50        "{input}.{}",
51        URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes())
52    ))
53}
54
55pub(crate) fn decode(token: &str, secret: &str) -> Option<CompactCache> {
56    let parts: Vec<_> = token.split('.').collect();
57    let [header, payload, signature] = parts.as_slice() else {
58        return None;
59    };
60    let header_bytes = URL_SAFE_NO_PAD.decode(header).ok()?;
61    let header_value: Value = serde_json::from_slice(&header_bytes).ok()?;
62    if header_value.get("crit").is_some()
63        || header_value
64            .get("b64")
65            .is_some_and(|value| value != &Value::Bool(true))
66    {
67        return None;
68    }
69    let signature = URL_SAFE_NO_PAD.decode(signature).ok()?;
70    let input = format!("{header}.{payload}");
71    macro_rules! verify {
72        ($hash:ty) => {{
73            let mut mac = Hmac::<$hash>::new_from_slice(secret.as_bytes()).ok()?;
74            mac.update(input.as_bytes());
75            mac.verify_slice(&signature).ok()?;
76        }};
77    }
78    match header_value.get("alg")?.as_str()? {
79        "HS256" => verify!(Sha256),
80        "HS384" => verify!(Sha384),
81        "HS512" => verify!(Sha512),
82        _ => return None,
83    }
84    let claims: JsValue =
85        crate::utils::json::from_slice(&URL_SAFE_NO_PAD.decode(payload).ok()?).ok()?;
86    decode_payload(&claims, 0.0)
87}
88
89/// Parse a verified JWT/JWE snapshot and its registered time claims.
90#[must_use]
91pub fn decode_payload(claims: &JsValue, tolerance: f64) -> Option<CompactCache> {
92    let now = serde_json::Number::from(chrono::Utc::now().timestamp()).as_f64()?;
93    for name in ["iat", "exp", "nbf"] {
94        if let Some(value) = claims.get(name) {
95            let value = value.as_f64().filter(|value| value.is_finite())?;
96            if name == "exp" && value <= now - tolerance || name == "nbf" && value > now + tolerance
97            {
98                return None;
99            }
100        }
101    }
102    let expires_at = claims
103        .get("exp")
104        .and_then(JsValue::as_f64)
105        .filter(|value| *value != 0.0)
106        .map_or_else(
107            || serde_json::Number::from(chrono::Utc::now().timestamp_millis()).as_f64(),
108            |value| Some(value * 1000.0),
109        )?;
110    super::parse_payload(claims, expires_at)
111}
112
113/// Managed JWT plugin signing/verification for the core cookie-cache lifecycle.
114#[async_trait::async_trait]
115pub trait CookieCacheSigner<S: crate::AuthSchema>: Send + Sync {
116    /// # Errors
117    /// Propagates managed key resolution and signing failures.
118    async fn sign(
119        &self,
120        payload: Value,
121        max_age: f64,
122        ctx: &crate::AuthContext<S>,
123        transaction: Option<&dyn crate::store::AuthTransaction<S>>,
124    ) -> AuthResult<String>;
125    /// # Errors
126    /// Invalid tokens and unavailable verification keys return `None`.
127    async fn verify(&self, token: &str, ctx: &crate::AuthContext<S>) -> AuthResult<Option<Value>>;
128}
129
130/// A signer installed by the locally managed JWT plugin during initialization.
131pub struct CookieCacheSignerHandle<S: crate::AuthSchema>(
132    pub std::sync::Arc<dyn CookieCacheSigner<S>>,
133);