Skip to main content

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