Skip to main content

siderite_sessions/
store.rs

1//! The [`SessionStore`] trait and the data it persists.
2
3use async_trait::async_trait;
4use serde::{Deserialize, Serialize};
5use serde_json::{Map, Value};
6use std::fmt;
7use std::time::Duration;
8
9/// One stored session.
10///
11/// Times are seconds since the Unix epoch. The record never contains the
12/// session id: stores key it by the SHA-256 hex of the id (see
13/// [`id_hash`](crate::id_hash)).
14#[derive(Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
15pub struct SessionRecord {
16    /// The authenticated user, set by [`Session::login`](crate::Session).
17    pub user: Option<String>,
18    /// Application data.
19    pub data: Map<String, Value>,
20    /// When the session was created (login resets it).
21    pub created_at: u64,
22    /// When the session was last written; drives the idle timeout.
23    pub last_seen: u64,
24    /// The user's revocation epoch at login. A store that revokes by
25    /// epoch (see [`SessionStore::revoke_user`]) rejects older records.
26    pub auth_epoch: u64,
27}
28
29impl fmt::Debug for SessionRecord {
30    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
31        // Application data and user names stay out of logs.
32        f.debug_struct("SessionRecord")
33            .field("authenticated", &self.user.is_some())
34            .field("keys", &self.data.len())
35            .field("created_at", &self.created_at)
36            .field("last_seen", &self.last_seen)
37            .field("auth_epoch", &self.auth_epoch)
38            .finish()
39    }
40}
41
42/// A store failure.
43///
44/// The message must never contain addresses, credentials, session ids or
45/// stored data: it can reach logs, and a failure never reaches clients.
46#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
47#[error("session store error: {0}")]
48pub struct StoreError(String);
49
50impl StoreError {
51    /// Create an error with a short, secret-free `message`.
52    #[must_use]
53    pub fn new(message: impl Into<String>) -> Self {
54        Self(message.into())
55    }
56}
57
58/// Server-side storage of sessions, keyed by the SHA-256 hex of the id.
59///
60/// The store never sees a raw session id, so a leaked table or backup does
61/// not hand out live sessions. Implementations must be safe for concurrent
62/// use. Requests of one session are last-writer-wins: the layer does not
63/// merge concurrent changes.
64///
65/// A store is not asked to enforce idle or absolute lifetimes (the layer
66/// does, from the record), but it must drop entries after `ttl` so that
67/// abandoned sessions disappear.
68#[async_trait]
69pub trait SessionStore: Send + Sync + 'static {
70    /// Fetch the record for `id_hash`; `Ok(None)` when absent or expired
71    /// or revoked.
72    ///
73    /// # Errors
74    /// [`StoreError`] when the store cannot be read. The layer then
75    /// answers `503` instead of treating the request as anonymous.
76    async fn load(
77        &self,
78        id_hash: &str,
79    ) -> Result<Option<SessionRecord>, StoreError>;
80
81    /// Store `record` under `id_hash`, replacing any record, for `ttl`.
82    ///
83    /// # Errors
84    /// [`StoreError`] when the record was not stored.
85    async fn save(
86        &self,
87        id_hash: &str,
88        record: &SessionRecord,
89        ttl: Duration,
90    ) -> Result<(), StoreError>;
91
92    /// Replace the record under `id_hash` only if one is stored, for
93    /// `ttl`; `Ok(false)` when there is none.
94    ///
95    /// The layer writes existing sessions through this method, so a slow
96    /// request that loaded a session before another request logged it
97    /// out or rotated its id cannot store it again. Implementations must
98    /// never let a deleted id become loadable again, even when the check
99    /// and the write race with [`delete`](Self::delete).
100    ///
101    /// # Errors
102    /// [`StoreError`] when the record may not have been stored.
103    async fn update(
104        &self,
105        id_hash: &str,
106        record: &SessionRecord,
107        ttl: Duration,
108    ) -> Result<bool, StoreError>;
109
110    /// Remove `id_hash`. Removing an absent record is not an error.
111    ///
112    /// # Errors
113    /// [`StoreError`] when the record may still exist.
114    async fn delete(&self, id_hash: &str) -> Result<(), StoreError>;
115
116    /// Invalidate every session of `user`, now and for stale in-flight
117    /// writes. Use it after password changes and account recovery.
118    ///
119    /// # Errors
120    /// [`StoreError`] when the revocation may not have taken effect.
121    async fn revoke_user(&self, user: &str) -> Result<(), StoreError>;
122
123    /// The user's current revocation epoch, recorded in
124    /// [`SessionRecord::auth_epoch`] at login. Stores that revoke by
125    /// deleting records can keep the default of `0`.
126    ///
127    /// # Errors
128    /// [`StoreError`] when the epoch cannot be read; the login then fails.
129    async fn auth_epoch(&self, _user: &str) -> Result<u64, StoreError> {
130        Ok(0)
131    }
132}