Skip to main content

alibi_core/session/cookie_cache/
mod.rs

1//! Authenticated session cookie cache snapshots and the compact wire codec.
2//!
3//! The codec authenticates data; storage bypass and authoritative-read policy
4//! belong to the session resolver. No database model is reconstructed here.
5
6use crate::utils::json::JsValue;
7use crate::{AdapterOutput, AdapterRecord};
8pub(crate) mod date;
9
10pub mod jwt;
11pub mod runtime;
12
13use crate::{AuthResult, AuthSession, AuthUser, SessionView, UserView};
14use async_trait::async_trait;
15use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
16use chrono::{SecondsFormat, Utc};
17use hmac::{Hmac, KeyInit, Mac};
18use serde_json::{Value, json};
19use sha2::Sha256;
20use std::{any::Any, fmt, sync::Arc};
21
22/// Which actual snapshot a version callback receives.
23#[derive(Clone, Copy, Debug, PartialEq, Eq)]
24pub enum CacheVersionSource {
25    Created,
26    /// Physical lookup returned genuine adapter output.
27    Stored,
28    Cached,
29}
30
31/// Immutable inputs to the cache version policy.
32///
33/// Creation exposes raw trusted adapter output. Physical findSession reads and
34/// cache hits expose their already-filtered snapshots. Only the public projection
35/// enters signed cookie data; cached inputs have no original database models.
36#[derive(Clone)]
37pub struct CacheVersionContext {
38    user: UserView,
39    session: SessionView,
40    originals: Option<(Arc<dyn Any + Send + Sync>, Arc<dyn Any + Send + Sync>)>,
41    source: CacheVersionSource,
42    public_projection: Option<(UserView, SessionView)>,
43    user_output: Option<AdapterOutput>,
44    session_output: Option<AdapterOutput>,
45}
46
47impl fmt::Debug for CacheVersionContext {
48    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
49        f.debug_struct("CacheVersionContext")
50            .field("source", &self.source())
51            .field("user", &self.user)
52            .field("session", &self.session)
53            .finish_non_exhaustive()
54    }
55}
56
57impl CacheVersionContext {
58    /// Retain genuine stored models and their canonical view projection.
59    pub fn created<U: AuthUser, T: AuthSession>(
60        user: U,
61        session: T,
62        user_view: UserView,
63        session_view: SessionView,
64    ) -> Self {
65        Self {
66            user_output: user.adapter_snapshot().cloned(),
67            session_output: session.adapter_snapshot().cloned(),
68            user: user_view,
69            session: session_view,
70            originals: Some((Arc::new(user), Arc::new(session))),
71            source: CacheVersionSource::Created,
72            public_projection: None,
73        }
74    }
75    /// A cache hit has no original database models.
76    #[must_use]
77    pub fn cached(user: UserView, session: SessionView) -> Self {
78        Self {
79            user,
80            session,
81            originals: None,
82            source: CacheVersionSource::Cached,
83            public_projection: None,
84            user_output: None,
85            session_output: None,
86        }
87    }
88    pub(crate) fn stored(
89        user: UserView,
90        session: SessionView,
91        user_output: Option<AdapterOutput>,
92        session_output: Option<AdapterOutput>,
93    ) -> Self {
94        Self {
95            user,
96            session,
97            originals: None,
98            source: CacheVersionSource::Stored,
99            public_projection: None,
100            user_output,
101            session_output,
102        }
103    }
104    pub(crate) fn with_public_projection(mut self, user: UserView, session: SessionView) -> Self {
105        self.public_projection = Some((user, session));
106        self
107    }
108    pub(crate) fn public_user(&self) -> &UserView {
109        self.public_projection
110            .as_ref()
111            .map_or(&self.user, |projection| &projection.0)
112    }
113    pub(crate) fn public_session(&self) -> &SessionView {
114        self.public_projection
115            .as_ref()
116            .map_or(&self.session, |projection| &projection.1)
117    }
118    #[must_use]
119    pub const fn source(&self) -> CacheVersionSource {
120        self.source
121    }
122    #[must_use]
123    pub const fn user(&self) -> &UserView {
124        &self.user
125    }
126    #[must_use]
127    pub const fn session(&self) -> &SessionView {
128        &self.session
129    }
130    /// Actual callback-stage adapter values. Cache hits have no retained record;
131    /// their exact values remain available through `user()` and `session()`.
132    #[must_use]
133    pub const fn user_output(&self) -> Option<&AdapterOutput> {
134        self.user_output.as_ref()
135    }
136    #[must_use]
137    pub const fn session_output(&self) -> Option<&AdapterOutput> {
138        self.session_output.as_ref()
139    }
140    #[must_use]
141    pub fn stored_user<T: AuthUser>(&self) -> Option<&T> {
142        let original = &self.originals.as_ref()?.0;
143        original.downcast_ref().or_else(|| {
144            original
145                .downcast_ref::<AdapterRecord<T>>()
146                .map(AdapterRecord::stored)
147        })
148    }
149    #[must_use]
150    pub fn stored_session<T: AuthSession>(&self) -> Option<&T> {
151        let original = &self.originals.as_ref()?.1;
152        original.downcast_ref().or_else(|| {
153            original
154                .downcast_ref::<AdapterRecord<T>>()
155                .map(AdapterRecord::stored)
156        })
157    }
158}
159
160/// Application-owned asynchronous cache version policy.
161#[async_trait]
162pub trait CookieCacheVersionResolver: Send + Sync {
163    async fn resolve(&self, context: &CacheVersionContext) -> AuthResult<String>;
164}
165
166/// Immutable configured cache version; empty literals use the source default.
167#[derive(Clone)]
168pub enum CookieCacheVersion {
169    Literal(String),
170    Resolver(Arc<dyn CookieCacheVersionResolver>),
171}
172
173impl fmt::Debug for CookieCacheVersion {
174    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
175        match self {
176            Self::Literal(value) => f.debug_tuple("Literal").field(value).finish(),
177            Self::Resolver(_) => f.write_str("Resolver(..)"),
178        }
179    }
180}
181
182impl CookieCacheVersion {
183    pub async fn resolve(&self, context: &CacheVersionContext) -> AuthResult<String> {
184        match self {
185            Self::Literal(value) => Ok(if value.is_empty() {
186                "1".into()
187            } else {
188                value.clone()
189            }),
190            Self::Resolver(resolver) => resolver.resolve(context).await,
191        }
192    }
193}
194
195/// Verified compact cache data. The separately signed session token and both
196/// expiries must still be checked before treating this as a session.
197#[derive(Clone, Debug)]
198pub struct CompactCache {
199    pub user: UserView,
200    pub session: SessionView,
201    pub updated_at: f64,
202    pub version: Option<String>,
203    pub expires_at: f64,
204}
205
206/// Result of the authenticated envelope checks, before any storage fallback.
207#[derive(Clone, Debug)]
208pub enum CacheValidation {
209    Invalid,
210    Hit(Box<CompactCache>),
211}
212
213/// Source `maxAge || 300`, preserving fractions, negatives and infinity.
214#[must_use]
215pub fn effective_max_age(max_age: f64) -> f64 {
216    if max_age == 0.0 || max_age.is_nan() {
217        300.0
218    } else {
219        max_age
220    }
221}
222
223/// Encode the already-filtered canonical output.
224///
225/// Infinite or out-of-range expiry becomes JSON null, as for an invalid
226/// JavaScript Date; the reader rejects that envelope instead of pretending
227/// that it expires normally.
228#[expect(
229    clippy::as_conversions,
230    clippy::cast_precision_loss,
231    clippy::suboptimal_flops,
232    reason = "Match JavaScript Number arithmetic and its separate rounding steps for cookie expiry"
233)]
234pub fn encode_compact(
235    user: &UserView,
236    session: &SessionView,
237    version: &str,
238    now_ms: i64,
239    max_age: f64,
240    dont_remember: bool,
241    secret: &str,
242) -> AuthResult<String> {
243    let payload = jwt::payload(user, session, version, now_ms);
244    let expiry = now_ms as f64
245        + if dont_remember {
246            60.0
247        } else {
248            effective_max_age(max_age)
249        } * 1000.0;
250    let expiry = if expiry.is_finite() && expiry.abs() <= 8.64e15 {
251        json!(expiry.trunc())
252    } else {
253        Value::Null
254    };
255    let mut signed = payload.as_object().cloned().unwrap_or_default();
256    _ = signed.insert("expiresAt".into(), expiry.clone());
257    let signature = signature(secret, crate::utils::json::to_string(&signed)?.as_bytes());
258    let envelope = json!({"session":payload,"expiresAt":expiry,"signature":signature});
259    Ok(URL_SAFE_NO_PAD.encode(crate::utils::json::to_string(&envelope)?))
260}
261
262#[expect(clippy::expect_used, reason = "HMAC-SHA256 accepts keys of any length")]
263fn signature(secret: &str, data: &[u8]) -> String {
264    let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).expect("HMAC accepts any key");
265    mac.update(data);
266    URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes())
267}
268
269// Published base64 decoding stops at the first padding byte and discards
270// residual bits. It chooses one alphabet for the complete input.
271pub(crate) fn decode_base64(value: &str) -> Result<Vec<u8>, &'static str> {
272    let alphabet = if value.contains(['-', '_']) {
273        b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
274    } else {
275        b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
276    };
277    let mut output = Vec::new();
278    let mut buffer = 0u32;
279    let mut bits = 0;
280    for byte in value.bytes().take_while(|byte| *byte != b'=') {
281        let digit = alphabet
282            .iter()
283            .position(|candidate| *candidate == byte)
284            .and_then(|digit| u32::try_from(digit).ok())
285            .ok_or("Invalid Base64 character")?;
286        buffer = buffer.wrapping_shl(6) | digit;
287        bits += 6;
288        if bits >= 8 {
289            bits -= 8;
290            output.push(u8::try_from((buffer >> bits) & 255).map_err(|_| "Invalid Base64 byte")?);
291        }
292    }
293    Ok(output)
294}
295
296/// Authenticate a compact envelope. Malformed data is a cache miss, allowing
297/// the caller's genuine storage fallback; it never supplies an identity.
298pub fn decode_compact(value: &str, secret: &str) -> Option<CompactCache> {
299    decode_compact_http(value, secret).ok().flatten()
300}
301
302// HTTP preserves thrown alphabet errors; get-session maps these to its 500.
303// Parse and authenticate once, before reviving the typed payload projection.
304pub(crate) fn decode_compact_http(value: &str, secret: &str) -> AuthResult<Option<CompactCache>> {
305    let bytes = decode_base64(value).map_err(crate::AuthError::internal)?;
306    let text = String::from_utf8_lossy(&bytes);
307    let text = text.strip_prefix('\u{feff}').unwrap_or(&text);
308    let Ok(parsed) = crate::utils::json::parse_value(text) else {
309        return Ok(None);
310    };
311    let Some((payload, expires_at, signature)) = compact_envelope(&parsed) else {
312        return Ok(None);
313    };
314    let signature = decode_base64(signature).map_err(crate::AuthError::internal)?;
315    Ok(authenticate_compact(
316        payload, expires_at, &signature, secret,
317    ))
318}
319
320fn compact_envelope(parsed: &JsValue) -> Option<(&JsValue, f64, &str)> {
321    let expires_at = parsed
322        .get("expiresAt")?
323        .as_f64()
324        .filter(|value| value.is_finite())?;
325    let signature = parsed.get("signature")?.as_str()?;
326    let payload = parsed.get("session")?;
327    _ = payload.as_object()?;
328    Some((payload, expires_at, signature))
329}
330
331fn authenticate_compact(
332    original_payload: &JsValue,
333    expires_at: f64,
334    signature: &[u8],
335    secret: &str,
336) -> Option<CompactCache> {
337    let mut normalized = original_payload.clone();
338    date::revive(&mut normalized);
339    let mut signed = normalized.as_object()?.clone();
340    _ = signed.insert("expiresAt".into(), JsValue::Number(expires_at));
341    let message = crate::utils::json::to_string(&signed).ok()?;
342    let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).ok()?;
343    mac.update(message.as_bytes());
344    mac.verify_slice(signature).ok()?;
345    parse_payload(original_payload, expires_at)
346}
347
348/// Validate and revive an authenticated session snapshot without granting storage authority.
349pub(crate) fn parse_payload(original_payload: &JsValue, expires_at: f64) -> Option<CompactCache> {
350    // Revived dates are runtime Date objects in the source. Required string
351    // fields reject them even when their canonical JSON text did not change.
352    for (object, required, optional) in [
353        (
354            original_payload.get("user")?.as_object()?,
355            &["id", "email", "name"][..],
356            &["image"][..],
357        ),
358        (
359            original_payload.get("session")?.as_object()?,
360            &["id", "token"][..],
361            &["ipAddress", "userAgent"][..],
362        ),
363    ] {
364        for field in required {
365            let text = object.get(*field)?.as_str()?;
366            if date::parse(text).is_some() {
367                return None;
368            }
369        }
370        for field in optional {
371            if object
372                .get(*field)
373                .and_then(JsValue::as_str)
374                .is_some_and(|text| date::parse(text).is_some())
375            {
376                return None;
377            }
378        }
379        for field in ["createdAt", "updatedAt"] {
380            if let Some(value) = object.get(field) {
381                _ = date::parse(value.as_str()?)?;
382            }
383        }
384    }
385    _ = date::parse(
386        original_payload
387            .get("session")?
388            .get("expiresAt")?
389            .as_str()?,
390    )?;
391    if original_payload
392        .get("version")
393        .and_then(JsValue::as_str)
394        .is_some_and(|text| date::parse(text).is_some())
395    {
396        return None;
397    }
398    let mut normalized = original_payload.clone();
399    date::revive_parsed(&mut normalized);
400    let payload = normalized.as_object()?;
401    let updated_at = payload
402        .get("updatedAt")?
403        .as_f64()
404        .filter(|value| value.is_finite())?;
405    let version = match payload.get("version") {
406        None => None,
407        Some(value) => Some(value.as_str()?.to_owned()),
408    };
409    let mut user = payload.get("user")?.to_json_value().ok()?;
410    let mut session = payload.get("session")?.to_json_value().ok()?;
411    let user_id = date::coerce_id(original_payload.get("session")?.get("userId")?)?;
412    _ = session
413        .as_object_mut()?
414        .insert("userId".into(), Value::String(user_id));
415    // The source schemas supply absent creation/update dates and a false
416    // emailVerified value. Producer dates are canonical JavaScript Date JSON.
417    for object in [&mut user, &mut session] {
418        let map = object.as_object_mut()?;
419        for key in ["createdAt", "updatedAt"] {
420            if !map.contains_key(key) {
421                _ = map.insert(
422                    key.into(),
423                    json!(Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true)),
424                );
425            }
426        }
427    }
428    let map = user.as_object_mut()?;
429    let email = map.get("email")?.as_str()?.to_lowercase();
430    _ = map.insert("email".into(), Value::String(email));
431    _ = map.get("name")?.as_str()?;
432    if !map.contains_key("emailVerified") {
433        _ = map.insert("emailVerified".into(), Value::Bool(false));
434    }
435    let null_user_extensions: Vec<_> = [
436        "username",
437        "displayUsername",
438        "twoFactorEnabled",
439        "role",
440        "banned",
441        "banReason",
442        "banExpires",
443        "isAnonymous",
444        "phoneNumber",
445        "phoneNumberVerified",
446        "lastLoginMethod",
447    ]
448    .into_iter()
449    .filter(|name| user.get(*name).is_some_and(Value::is_null))
450    .collect();
451    let mut user: UserView = serde_json::from_value(user).ok()?;
452    if original_payload.get("user")?.get("image").is_none() {
453        _ = user.omitted_fields.insert("image".into());
454    }
455    for name in null_user_extensions {
456        _ = user.extension_fields.insert(name.into(), Value::Null);
457    }
458    let null_extensions: Vec<_> = ["activeOrganizationId", "activeTeamId", "impersonatedBy"]
459        .into_iter()
460        .filter(|name| session.get(*name).is_some_and(Value::is_null))
461        .collect();
462    let mut session: SessionView = serde_json::from_value(session).ok()?;
463    for name in ["ipAddress", "userAgent"] {
464        if original_payload.get("session")?.get(name).is_none() {
465            _ = session.omitted_fields.insert(name.into());
466        }
467    }
468    for name in null_extensions {
469        _ = session.extension_fields.insert(name.into(), Value::Null);
470    }
471    Some(CompactCache {
472        user,
473        session,
474        updated_at,
475        version,
476        expires_at,
477    })
478}
479
480/// Check the token binding, version callback, and source expiry guards.
481///
482/// Callback errors propagate; invalid envelopes permit storage fallback.
483#[expect(
484    clippy::as_conversions,
485    clippy::cast_precision_loss,
486    reason = "Preserve JavaScript Number rounding at the compatibility boundary"
487)]
488pub async fn validate_compact(
489    cache: CompactCache,
490    token: &str,
491    policy: Option<&CookieCacheVersion>,
492    now_ms: i64,
493) -> AuthResult<CacheValidation> {
494    if cache.session.token != token {
495        return Ok(CacheValidation::Invalid);
496    }
497    let expected = match policy {
498        Some(policy) => {
499            policy
500                .resolve(&CacheVersionContext::cached(
501                    cache.user.clone(),
502                    cache.session.clone(),
503                ))
504                .await?
505        }
506        None => "1".into(),
507    };
508    let version = cache
509        .version
510        .as_deref()
511        .filter(|version| !version.is_empty())
512        .unwrap_or("1");
513    if version != expected
514        || cache.expires_at < now_ms as f64
515        || cache.session.expires_at.timestamp_millis() < now_ms
516    {
517        return Ok(CacheValidation::Invalid);
518    }
519    Ok(CacheValidation::Hit(Box::new(cache)))
520}
521
522/// Render a cache-related cookie using the source numeric Max-Age policy.
523///
524/// Negative and NaN ages omit Max-Age; nonnegative ages are floored, and
525/// values above the cookie serializer's 400-day ceiling fail rather than
526/// saturating. No Expires attribute is synthesized.
527pub fn cookie_header(
528    name: &str,
529    value: &str,
530    max_age: Option<f64>,
531    config: &crate::AuthConfig,
532) -> AuthResult<String> {
533    let bases = ["session_data", "account_data"]
534        .map(|logical| crate::utils::cookie_utils::related_cookie_name(config, logical));
535    let base = (!bases.iter().any(|base| base == name))
536        .then(|| {
537            bases
538                .iter()
539                .find(|base| runtime::chunk_index(name, base).is_some())
540        })
541        .flatten();
542    let encoded =
543        percent_encoding::utf8_percent_encode(value, crate::utils::cookie_utils::URI_COMPONENT)
544            .to_string();
545    crate::utils::cookie_utils::create_numeric_cookie_header(
546        name,
547        base.map_or(name, String::as_str),
548        &encoded,
549        max_age,
550        config,
551    )
552}