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}