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