Skip to main content

axess_core/session/storage/
session_codec.rs

1//! Shared codec for backend session stores (SQLite, PostgreSQL, Valkey).
2//!
3//! Extracts the encode/decode/expiry logic that is identical across backends.
4//! Each backend still owns its own `SessionStore` impl because the storage
5//! shape differs (SQL dialect quirks; key-value semantics), but the session-
6//! data serialization and crypto layer is shared here.
7
8// base64 + Duration only feed the SQL-side helpers (encode/decode/expires_at);
9// gate behind the SQL backend features so a valkey-only build doesn't
10// surface them as unused-import warnings.
11#[cfg(any(feature = "sqlite", feature = "postgres", feature = "mysql"))]
12use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
13
14use crate::session::{crypto::SessionCrypto, data::SessionData};
15
16#[cfg(any(feature = "sqlite", feature = "postgres", feature = "mysql"))]
17use std::time::Duration;
18
19/// Shared session-data codec (MessagePack + optional AES-256-GCM encryption).
20///
21/// Used by [`SqliteSessionStore`](super::sqlite::SqliteSessionStore),
22/// [`PostgresSessionStore`](super::postgres::PostgresSessionStore), and
23/// [`ValkeySessionStore`](super::valkey::ValkeySessionStore). SQL backends
24/// use the base64-TEXT path (`encode` / `decode`); Valkey uses the raw-bytes
25/// path (`encode_bytes` / `decode_bytes`).
26#[derive(Clone)]
27pub(crate) struct SessionCodec {
28    crypto: Option<SessionCrypto>,
29}
30
31impl SessionCodec {
32    pub fn encrypted(crypto: SessionCrypto) -> Self {
33        Self {
34            crypto: Some(crypto),
35        }
36    }
37
38    pub fn plaintext() -> Self {
39        Self { crypto: None }
40    }
41
42    /// Serialize (and optionally encrypt) session data for storage.
43    ///
44    /// Payload is encoded with `rmp-serde` (MessagePack) to
45    /// match the Valkey backend and to avoid the JSON-tag rewrite of
46    /// the full `AuthState` on every save. The Valkey store already
47    /// uses MessagePack; moving SQL to the same codec drops a layer
48    /// of "JSON-only here" branching at no public-API cost. Output
49    /// is always base64-encoded so the column stays TEXT (no schema
50    /// migration), with format detection on read for migration from
51    /// the historical JSON wire form. SQL-only; Valkey uses
52    /// [`encode_bytes`](Self::encode_bytes) for the raw-bytes path.
53    #[cfg(any(feature = "sqlite", feature = "postgres", feature = "mysql"))]
54    pub fn encode(&self, data: &SessionData) -> Result<String, SqlStoreError> {
55        let bytes = rmp_serde::to_vec_named(data)?;
56        let payload = match &self.crypto {
57            Some(crypto) => crypto.encrypt(&bytes)?,
58            None => bytes,
59        };
60        Ok(BASE64.encode(payload))
61    }
62
63    /// Deserialize (and optionally decrypt) session data from storage.
64    ///
65    /// Reads both the new MessagePack wire form and legacy JSON rows.
66    /// New writes are always MessagePack; legacy rows remain readable
67    /// until they expire and are overwritten. SQL-only; Valkey uses
68    /// [`decode_bytes`](Self::decode_bytes) for the raw-bytes path.
69    #[cfg(any(feature = "sqlite", feature = "postgres", feature = "mysql"))]
70    pub fn decode(&self, stored: &str) -> Result<SessionData, SqlStoreError> {
71        // Plaintext-legacy fast path: a JSON object always starts with
72        // `{`, which is never a valid base64 character. If we see one,
73        // we know we are reading a pre-MessagePack plaintext row; decode
74        // it as JSON and return.
75        if self.crypto.is_none() && stored.starts_with('{') {
76            return Ok(serde_json::from_str(stored)?);
77        }
78
79        let payload = BASE64
80            .decode(stored)
81            .map_err(|_| SqlStoreError::Crypto(crate::session::crypto::CryptoError))?;
82        self.decode_bytes(&payload)
83    }
84
85    /// Serialize (and optionally encrypt) session data for binary
86    /// storage; no base64 wrap.
87    ///
88    /// Provided for the Valkey backend (which stores raw bytes)
89    /// so all three encrypted backends share one codec rather than each
90    /// re-implementing the rmp-serde + AES-GCM dance. The SQL backends
91    /// continue to use [`encode`](Self::encode) which base64-wraps the
92    /// output for TEXT-column storage.
93    #[cfg(feature = "valkey")]
94    pub fn encode_bytes(&self, data: &SessionData) -> Result<Vec<u8>, SqlStoreError> {
95        let bytes = rmp_serde::to_vec_named(data)?;
96        Ok(match &self.crypto {
97            Some(crypto) => crypto.encrypt(&bytes)?,
98            None => bytes,
99        })
100    }
101
102    /// Deserialize (and optionally decrypt) session data from binary
103    /// storage; no base64 wrap.
104    ///
105    /// Counterpart to [`encode_bytes`](Self::encode_bytes).
106    /// Tries MessagePack first; falls back to JSON to tolerate legacy
107    /// blobs that adopters may still have in storage from before the
108    /// MessagePack switch.
109    pub fn decode_bytes(&self, payload: &[u8]) -> Result<SessionData, SqlStoreError> {
110        let plaintext = match &self.crypto {
111            Some(crypto) => crypto.decrypt(payload)?,
112            None => payload.to_vec(),
113        };
114        match rmp_serde::from_slice(&plaintext) {
115            Ok(data) => Ok(data),
116            Err(_) => Ok(serde_json::from_slice(&plaintext)?),
117        }
118    }
119}
120
121/// Compute the `expires_at` Unix timestamp from the current time and a TTL.
122/// SQL-only; Valkey delegates TTL enforcement to the server (`EXPIRE`).
123#[cfg(any(feature = "sqlite", feature = "postgres", feature = "mysql"))]
124pub(crate) fn expires_at(clock: &dyn axess_clock::Clock, ttl: Duration) -> i64 {
125    clock
126        .now()
127        .timestamp()
128        .saturating_add(ttl.as_secs().min(i64::MAX as u64) as i64)
129}
130
131/// Shared error type for backend session stores.
132///
133/// The name is historical (originated when only SQL backends used it);
134/// the codec methods used by Valkey return the same enum. Variants
135/// that depend on a SQL driver (`Db`, backed by `sqlx::Error`) are
136/// gated on the SQL backend features so a `--features valkey` build
137/// without any of `sqlite` / `postgres` / `mysql` does not pull
138/// `sqlx` into the dependency graph. The backend-specific error
139/// aliases (`SqliteStoreError`, `PostgresStoreError`) are retained as
140/// type aliases for backward compatibility.
141#[derive(Debug, thiserror::Error)]
142pub enum SqlStoreError {
143    /// Underlying database driver returned an error. Only constructed
144    /// by the SQL backends; gated out under valkey-only builds so the
145    /// enum does not reference `sqlx::Error` when `sqlx` is not in
146    /// the dep graph.
147    #[cfg(any(feature = "sqlite", feature = "postgres", feature = "mysql"))]
148    #[error("database error: {0}")]
149    Db(#[from] sqlx::Error),
150
151    /// JSON deserialisation of a stored row failed. Either a legacy
152    /// row whose JSON is corrupt, or a backend wire-format drift bug.
153    #[error("session JSON deserialisation failed: {0}")]
154    Serialization(#[from] serde_json::Error),
155
156    /// MessagePack encoding of `SessionData` failed. Distinct
157    /// from `Serialization` (which is JSON-only) so the variant
158    /// reflects the actual codec in use on the write path.
159    #[error("session MessagePack encoding failed: {0}")]
160    Encode(#[from] rmp_serde::encode::Error),
161
162    /// MessagePack decoding of a stored row failed AFTER
163    /// the JSON-fallback path also rejected the bytes. Indicates true
164    /// row corruption rather than a routine format-detection miss.
165    #[error("session MessagePack decoding failed: {0}")]
166    Decode(#[from] rmp_serde::decode::Error),
167
168    /// AES-256-GCM encryption or decryption of session payload failed.
169    #[error("encryption/decryption error: {0}")]
170    Crypto(#[from] crate::session::crypto::CryptoError),
171}