Skip to main content

fraiseql_auth/
session.rs

1//! Session management — trait definition and helper functions.
2#[cfg(test)]
3use std::sync::Arc;
4
5use async_trait::async_trait;
6use serde::{Deserialize, Serialize};
7use sha2::{Digest, Sha256};
8
9use crate::error::{AuthError, Result};
10
11/// Session data stored in the backend
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct SessionData {
14    /// User ID (unique per user)
15    pub user_id:            String,
16    /// Session issued timestamp (Unix seconds)
17    pub issued_at:          u64,
18    /// Session expiration timestamp (Unix seconds)
19    pub expires_at:         u64,
20    /// Hash of the refresh token (stored securely)
21    pub refresh_token_hash: String,
22}
23
24impl SessionData {
25    /// Check if session is expired
26    #[must_use]
27    pub fn is_expired(&self) -> bool {
28        let now = std::time::SystemTime::now()
29            .duration_since(std::time::UNIX_EPOCH)
30            .unwrap_or_default()
31            .as_secs();
32        self.expires_at <= now
33    }
34}
35
36/// Token pair returned after successful authentication
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct TokenPair {
39    /// JWT access token (short-lived, typically 15 min - 1 hour)
40    pub access_token:  String,
41    /// Refresh token (long-lived, typically 7-30 days)
42    pub refresh_token: String,
43    /// Time in seconds until access token expires
44    pub expires_in:    u64,
45}
46
47/// SessionStore trait - implement this for your storage backend
48///
49/// # Examples
50///
51/// Implement for PostgreSQL:
52/// ```no_run
53/// // Requires: sqlx PgPool and live PostgreSQL connection.
54/// use async_trait::async_trait;
55/// use fraiseql_auth::session::{SessionStore, SessionData, TokenPair};
56/// use fraiseql_auth::error::Result;
57///
58/// pub struct PostgresSessionStore {
59///     // pool: sqlx::PgPool,
60/// }
61///
62/// #[async_trait]
63/// impl SessionStore for PostgresSessionStore {
64///     async fn create_session(&self, _user_id: &str, _expires_at: u64) -> Result<TokenPair> {
65///         panic!("example stub")
66///     }
67///     async fn get_session(&self, _refresh_token_hash: &str) -> Result<SessionData> {
68///         panic!("example stub")
69///     }
70///     async fn revoke_session(&self, _refresh_token_hash: &str) -> Result<()> {
71///         panic!("example stub")
72///     }
73///     async fn revoke_all_sessions(&self, _user_id: &str) -> Result<()> {
74///         panic!("example stub")
75///     }
76/// }
77/// ```
78///
79/// Implement for Redis:
80/// ```no_run
81/// // Requires: redis crate and live Redis connection.
82/// use async_trait::async_trait;
83/// use fraiseql_auth::session::{SessionStore, SessionData, TokenPair};
84/// use fraiseql_auth::error::Result;
85///
86/// pub struct RedisSessionStore {
87///     // client: redis::Client,
88/// }
89///
90/// #[async_trait]
91/// impl SessionStore for RedisSessionStore {
92///     async fn create_session(&self, _user_id: &str, _expires_at: u64) -> Result<TokenPair> {
93///         panic!("example stub")
94///     }
95///     async fn get_session(&self, _refresh_token_hash: &str) -> Result<SessionData> {
96///         panic!("example stub")
97///     }
98///     async fn revoke_session(&self, _refresh_token_hash: &str) -> Result<()> {
99///         panic!("example stub")
100///     }
101///     async fn revoke_all_sessions(&self, _user_id: &str) -> Result<()> {
102///         panic!("example stub")
103///     }
104/// }
105/// ```
106// Reason: used as dyn Trait (Arc<dyn SessionStore>); async_trait ensures Send bounds and
107// dyn-compatibility async_trait: dyn-dispatch required; remove when RTN + Send is stable (RFC 3425)
108#[async_trait]
109pub trait SessionStore: Send + Sync {
110    /// Create a new session and return token pair
111    ///
112    /// # Arguments
113    /// * `user_id` - The user identifier
114    /// * `expires_at` - When the session should expire (Unix seconds)
115    ///
116    /// # Returns
117    /// TokenPair with access_token and refresh_token
118    ///
119    /// # Errors
120    /// Returns error if session creation fails
121    async fn create_session(&self, user_id: &str, expires_at: u64) -> Result<TokenPair>;
122
123    /// Get session data by refresh token hash
124    ///
125    /// # Arguments
126    /// * `refresh_token_hash` - Hash of the refresh token
127    ///
128    /// # Returns
129    /// SessionData if session exists and is not revoked
130    ///
131    /// # Errors
132    /// Returns SessionError if session not found or revoked
133    async fn get_session(&self, refresh_token_hash: &str) -> Result<SessionData>;
134
135    /// Revoke a single session
136    ///
137    /// # Arguments
138    /// * `refresh_token_hash` - Hash of the refresh token to revoke
139    ///
140    /// # Errors
141    /// Returns error if revocation fails
142    async fn revoke_session(&self, refresh_token_hash: &str) -> Result<()>;
143
144    /// Revoke all sessions for a user
145    ///
146    /// # Arguments
147    /// * `user_id` - The user identifier
148    ///
149    /// # Errors
150    /// Returns error if revocation fails
151    async fn revoke_all_sessions(&self, user_id: &str) -> Result<()>;
152}
153
154/// Return the current Unix timestamp in seconds, or an error if the system clock
155/// is broken (e.g., set before the Unix epoch).
156///
157/// Using saturating / `unwrap_or_default` is explicitly avoided here: a broken clock
158/// would silently produce `issued_at = 0` (far in the past) or `expires_in = max`,
159/// which is a fail-open security defect.  Callers must handle this error.
160///
161/// # Errors
162///
163/// Returns [`AuthError::SystemTimeError`] if the system clock is before the Unix epoch.
164pub fn unix_now() -> Result<u64> {
165    std::time::SystemTime::now()
166        .duration_since(std::time::UNIX_EPOCH)
167        .map(|d| d.as_secs())
168        .map_err(|e| AuthError::SystemTimeError {
169            message: format!("System clock is before Unix epoch: {e}"),
170        })
171}
172
173/// Compute a SHA-256 hex digest of a refresh token for secure storage.
174///
175/// Refresh tokens are stored only as their SHA-256 hash so that a database
176/// breach cannot be used to replay sessions.  The original token is returned
177/// to the client and never persisted.
178#[must_use]
179pub fn hash_token(token: &str) -> String {
180    let mut hasher = Sha256::new();
181    hasher.update(token.as_bytes());
182    format!("{:x}", hasher.finalize())
183}
184
185/// Generate a cryptographically secure refresh token.
186///
187/// Returns 32 random bytes from [`rand::rng`] encoded as standard Base64.
188/// The resulting token is 44 characters long and has approximately 256 bits of entropy.
189#[must_use]
190pub fn generate_refresh_token() -> String {
191    use base64::Engine;
192    use rand::Rng;
193    // SECURITY: rand::rng() is backed by OS-level entropy for refresh tokens.
194    let random_bytes: Vec<u8> = (0..32).map(|_| rand::rng().random()).collect();
195    base64::engine::general_purpose::STANDARD.encode(&random_bytes)
196}
197
198/// In-memory session store for testing
199#[cfg(test)]
200pub struct InMemorySessionStore {
201    sessions: Arc<dashmap::DashMap<String, SessionData>>,
202}
203
204#[cfg(test)]
205impl InMemorySessionStore {
206    /// Create a new in-memory session store
207    #[must_use]
208    pub fn new() -> Self {
209        Self {
210            sessions: Arc::new(dashmap::DashMap::new()),
211        }
212    }
213
214    /// Clear all sessions (useful for tests)
215    pub fn clear(&self) {
216        self.sessions.clear();
217    }
218
219    /// Get number of sessions (useful for tests)
220    #[must_use]
221    pub fn len(&self) -> usize {
222        self.sessions.len()
223    }
224
225    /// Check if there are no sessions
226    #[must_use]
227    pub fn is_empty(&self) -> bool {
228        self.sessions.is_empty()
229    }
230}
231
232#[cfg(test)]
233impl Default for InMemorySessionStore {
234    fn default() -> Self {
235        Self::new()
236    }
237}
238
239#[cfg(test)]
240// Reason: SessionStore is defined with #[async_trait]; all implementations must match
241// its transformed method signatures to satisfy the trait contract
242// async_trait: dyn-dispatch required; remove when RTN + Send is stable (RFC 3425)
243#[async_trait]
244impl SessionStore for InMemorySessionStore {
245    async fn create_session(&self, user_id: &str, expires_at: u64) -> Result<TokenPair> {
246        let refresh_token = generate_refresh_token();
247        let refresh_token_hash = hash_token(&refresh_token);
248
249        // SECURITY: Propagate clock errors rather than accepting a bogus timestamp
250        // (fail-closed). A broken clock would produce issued_at = 0, silently
251        // extending sessions and defeating expiry checks.
252        let now = unix_now()?;
253
254        let session = SessionData {
255            user_id: user_id.to_string(),
256            issued_at: now,
257            expires_at,
258            refresh_token_hash: refresh_token_hash.clone(),
259        };
260
261        self.sessions.insert(refresh_token_hash, session);
262
263        let expires_in = expires_at.saturating_sub(now);
264
265        // For testing, generate a dummy JWT (in real impl, would come from claims)
266        let access_token = format!("access_token_{}", refresh_token);
267
268        Ok(TokenPair {
269            access_token,
270            refresh_token,
271            expires_in,
272        })
273    }
274
275    async fn get_session(&self, refresh_token_hash: &str) -> Result<SessionData> {
276        self.sessions
277            .get(refresh_token_hash)
278            .map(|entry| entry.clone())
279            .ok_or(AuthError::TokenNotFound)
280    }
281
282    async fn revoke_session(&self, refresh_token_hash: &str) -> Result<()> {
283        self.sessions.remove(refresh_token_hash).ok_or(AuthError::SessionError {
284            message: "Session not found".to_string(),
285        })?;
286        Ok(())
287    }
288
289    async fn revoke_all_sessions(&self, user_id: &str) -> Result<()> {
290        let mut to_remove = Vec::new();
291        for entry in self.sessions.iter() {
292            if entry.user_id == user_id {
293                to_remove.push(entry.key().clone());
294            }
295        }
296
297        for key in to_remove {
298            self.sessions.remove(&key);
299        }
300
301        Ok(())
302    }
303}