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
9#[cfg(test)]
10use crate::error::AuthError;
11use crate::error::Result;
12
13/// Session data stored in the backend
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct SessionData {
16    /// User ID (unique per user)
17    pub user_id:            String,
18    /// Session issued timestamp (Unix seconds)
19    pub issued_at:          u64,
20    /// Session expiration timestamp (Unix seconds)
21    pub expires_at:         u64,
22    /// Hash of the refresh token (stored securely)
23    pub refresh_token_hash: String,
24}
25
26impl SessionData {
27    /// Check if session is expired
28    pub fn is_expired(&self) -> bool {
29        let now = std::time::SystemTime::now()
30            .duration_since(std::time::UNIX_EPOCH)
31            .unwrap_or_default()
32            .as_secs();
33        self.expires_at <= now
34    }
35}
36
37/// Token pair returned after successful authentication
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct TokenPair {
40    /// JWT access token (short-lived, typically 15 min - 1 hour)
41    pub access_token:  String,
42    /// Refresh token (long-lived, typically 7-30 days)
43    pub refresh_token: String,
44    /// Time in seconds until access token expires
45    pub expires_in:    u64,
46}
47
48/// SessionStore trait - implement this for your storage backend
49///
50/// # Examples
51///
52/// Implement for PostgreSQL:
53/// ```no_run
54/// // Requires: sqlx PgPool and live PostgreSQL connection.
55/// use async_trait::async_trait;
56/// use fraiseql_auth::session::{SessionStore, SessionData, TokenPair};
57/// use fraiseql_auth::error::Result;
58///
59/// pub struct PostgresSessionStore {
60///     // pool: sqlx::PgPool,
61/// }
62///
63/// #[async_trait]
64/// impl SessionStore for PostgresSessionStore {
65///     async fn create_session(&self, _user_id: &str, _expires_at: u64) -> Result<TokenPair> {
66///         panic!("example stub")
67///     }
68///     async fn get_session(&self, _refresh_token_hash: &str) -> Result<SessionData> {
69///         panic!("example stub")
70///     }
71///     async fn revoke_session(&self, _refresh_token_hash: &str) -> Result<()> {
72///         panic!("example stub")
73///     }
74///     async fn revoke_all_sessions(&self, _user_id: &str) -> Result<()> {
75///         panic!("example stub")
76///     }
77/// }
78/// ```
79///
80/// Implement for Redis:
81/// ```no_run
82/// // Requires: redis crate and live Redis connection.
83/// use async_trait::async_trait;
84/// use fraiseql_auth::session::{SessionStore, SessionData, TokenPair};
85/// use fraiseql_auth::error::Result;
86///
87/// pub struct RedisSessionStore {
88///     // client: redis::Client,
89/// }
90///
91/// #[async_trait]
92/// impl SessionStore for RedisSessionStore {
93///     async fn create_session(&self, _user_id: &str, _expires_at: u64) -> Result<TokenPair> {
94///         panic!("example stub")
95///     }
96///     async fn get_session(&self, _refresh_token_hash: &str) -> Result<SessionData> {
97///         panic!("example stub")
98///     }
99///     async fn revoke_session(&self, _refresh_token_hash: &str) -> Result<()> {
100///         panic!("example stub")
101///     }
102///     async fn revoke_all_sessions(&self, _user_id: &str) -> Result<()> {
103///         panic!("example stub")
104///     }
105/// }
106/// ```
107// Reason: used as dyn Trait (Arc<dyn SessionStore>); async_trait ensures Send bounds and
108// dyn-compatibility async_trait: dyn-dispatch required; remove when RTN + Send is stable (RFC 3425)
109#[async_trait]
110pub trait SessionStore: Send + Sync {
111    /// Create a new session and return token pair
112    ///
113    /// # Arguments
114    /// * `user_id` - The user identifier
115    /// * `expires_at` - When the session should expire (Unix seconds)
116    ///
117    /// # Returns
118    /// TokenPair with access_token and refresh_token
119    ///
120    /// # Errors
121    /// Returns error if session creation fails
122    async fn create_session(&self, user_id: &str, expires_at: u64) -> Result<TokenPair>;
123
124    /// Get session data by refresh token hash
125    ///
126    /// # Arguments
127    /// * `refresh_token_hash` - Hash of the refresh token
128    ///
129    /// # Returns
130    /// SessionData if session exists and is not revoked
131    ///
132    /// # Errors
133    /// Returns SessionError if session not found or revoked
134    async fn get_session(&self, refresh_token_hash: &str) -> Result<SessionData>;
135
136    /// Revoke a single session
137    ///
138    /// # Arguments
139    /// * `refresh_token_hash` - Hash of the refresh token to revoke
140    ///
141    /// # Errors
142    /// Returns error if revocation fails
143    async fn revoke_session(&self, refresh_token_hash: &str) -> Result<()>;
144
145    /// Revoke all sessions for a user
146    ///
147    /// # Arguments
148    /// * `user_id` - The user identifier
149    ///
150    /// # Errors
151    /// Returns error if revocation fails
152    async fn revoke_all_sessions(&self, user_id: &str) -> Result<()>;
153}
154
155/// Compute a SHA-256 hex digest of a refresh token for secure storage.
156///
157/// Refresh tokens are stored only as their SHA-256 hash so that a database
158/// breach cannot be used to replay sessions.  The original token is returned
159/// to the client and never persisted.
160pub fn hash_token(token: &str) -> String {
161    let mut hasher = Sha256::new();
162    hasher.update(token.as_bytes());
163    format!("{:x}", hasher.finalize())
164}
165
166/// Generate a cryptographically secure refresh token.
167///
168/// Returns 32 random bytes from [`rand::rngs::OsRng`] encoded as standard Base64.
169/// The resulting token is 44 characters long and has approximately 256 bits of entropy.
170pub fn generate_refresh_token() -> String {
171    use base64::Engine;
172    use rand::{Rng, rngs::OsRng};
173    // SECURITY: OsRng ensures OS-level entropy for refresh tokens.
174    let random_bytes: Vec<u8> = (0..32).map(|_| OsRng.gen()).collect();
175    base64::engine::general_purpose::STANDARD.encode(&random_bytes)
176}
177
178/// In-memory session store for testing
179#[cfg(test)]
180pub struct InMemorySessionStore {
181    sessions: Arc<dashmap::DashMap<String, SessionData>>,
182}
183
184#[cfg(test)]
185impl InMemorySessionStore {
186    /// Create a new in-memory session store
187    pub fn new() -> Self {
188        Self {
189            sessions: Arc::new(dashmap::DashMap::new()),
190        }
191    }
192
193    /// Clear all sessions (useful for tests)
194    pub fn clear(&self) {
195        self.sessions.clear();
196    }
197
198    /// Get number of sessions (useful for tests)
199    pub fn len(&self) -> usize {
200        self.sessions.len()
201    }
202
203    /// Check if there are no sessions
204    pub fn is_empty(&self) -> bool {
205        self.sessions.is_empty()
206    }
207}
208
209#[cfg(test)]
210impl Default for InMemorySessionStore {
211    fn default() -> Self {
212        Self::new()
213    }
214}
215
216#[cfg(test)]
217// Reason: SessionStore is defined with #[async_trait]; all implementations must match
218// its transformed method signatures to satisfy the trait contract
219// async_trait: dyn-dispatch required; remove when RTN + Send is stable (RFC 3425)
220#[async_trait]
221impl SessionStore for InMemorySessionStore {
222    async fn create_session(&self, user_id: &str, expires_at: u64) -> Result<TokenPair> {
223        let refresh_token = generate_refresh_token();
224        let refresh_token_hash = hash_token(&refresh_token);
225
226        let now = std::time::SystemTime::now()
227            .duration_since(std::time::UNIX_EPOCH)
228            .unwrap_or_default()
229            .as_secs();
230
231        let session = SessionData {
232            user_id: user_id.to_string(),
233            issued_at: now,
234            expires_at,
235            refresh_token_hash: refresh_token_hash.clone(),
236        };
237
238        self.sessions.insert(refresh_token_hash, session);
239
240        let expires_in = expires_at.saturating_sub(now);
241
242        // For testing, generate a dummy JWT (in real impl, would come from claims)
243        let access_token = format!("access_token_{}", refresh_token);
244
245        Ok(TokenPair {
246            access_token,
247            refresh_token,
248            expires_in,
249        })
250    }
251
252    async fn get_session(&self, refresh_token_hash: &str) -> Result<SessionData> {
253        self.sessions
254            .get(refresh_token_hash)
255            .map(|entry| entry.clone())
256            .ok_or(AuthError::TokenNotFound)
257    }
258
259    async fn revoke_session(&self, refresh_token_hash: &str) -> Result<()> {
260        self.sessions.remove(refresh_token_hash).ok_or(AuthError::SessionError {
261            message: "Session not found".to_string(),
262        })?;
263        Ok(())
264    }
265
266    async fn revoke_all_sessions(&self, user_id: &str) -> Result<()> {
267        let mut to_remove = Vec::new();
268        for entry in self.sessions.iter() {
269            if entry.user_id == user_id {
270                to_remove.push(entry.key().clone());
271            }
272        }
273
274        for key in to_remove {
275            self.sessions.remove(&key);
276        }
277
278        Ok(())
279    }
280}
281
282#[allow(clippy::unwrap_used)] // Reason: test code, panics are acceptable
283#[cfg(test)]
284mod tests {
285    #[allow(clippy::wildcard_imports)]
286    // Reason: test module — wildcard keeps test boilerplate minimal
287    use super::*;
288
289    #[test]
290    fn test_hash_token() {
291        let token = "my_secret_token";
292        let hash1 = hash_token(token);
293        let hash2 = hash_token(token);
294
295        // Same token should produce same hash
296        assert_eq!(hash1, hash2);
297
298        // Different token should produce different hash
299        let different_hash = hash_token("different_token");
300        assert_ne!(hash1, different_hash);
301    }
302
303    #[test]
304    fn test_generate_refresh_token() {
305        let token1 = generate_refresh_token();
306        let token2 = generate_refresh_token();
307
308        // Tokens should be random and different
309        assert_ne!(token1, token2);
310        // Should be non-empty
311        assert!(!token1.is_empty());
312        assert!(!token2.is_empty());
313    }
314
315    #[test]
316    fn test_session_data_not_expired() {
317        let now = std::time::SystemTime::now()
318            .duration_since(std::time::UNIX_EPOCH)
319            .unwrap_or_default()
320            .as_secs();
321
322        let session = SessionData {
323            user_id:            "user123".to_string(),
324            issued_at:          now,
325            expires_at:         now + 3600,
326            refresh_token_hash: "hash".to_string(),
327        };
328
329        assert!(!session.is_expired());
330    }
331
332    #[test]
333    fn test_session_data_expired() {
334        let now = std::time::SystemTime::now()
335            .duration_since(std::time::UNIX_EPOCH)
336            .unwrap_or_default()
337            .as_secs();
338
339        let session = SessionData {
340            user_id:            "user123".to_string(),
341            issued_at:          now - 3600,
342            expires_at:         now - 100,
343            refresh_token_hash: "hash".to_string(),
344        };
345
346        assert!(session.is_expired());
347    }
348
349    #[tokio::test]
350    async fn test_in_memory_store_create_session() {
351        let store = InMemorySessionStore::new();
352        let now = std::time::SystemTime::now()
353            .duration_since(std::time::UNIX_EPOCH)
354            .unwrap_or_default()
355            .as_secs();
356
357        let result = store.create_session("user123", now + 3600).await;
358        let tokens = result.unwrap_or_else(|e| panic!("expected Ok from create_session: {e}"));
359        assert!(!tokens.access_token.is_empty());
360        assert!(!tokens.refresh_token.is_empty());
361        assert!(tokens.expires_in > 0);
362    }
363
364    #[tokio::test]
365    async fn test_in_memory_store_get_session() {
366        let store = InMemorySessionStore::new();
367        let now = std::time::SystemTime::now()
368            .duration_since(std::time::UNIX_EPOCH)
369            .unwrap_or_default()
370            .as_secs();
371
372        let tokens = store.create_session("user123", now + 3600).await.unwrap();
373        let refresh_token_hash = hash_token(&tokens.refresh_token);
374
375        let session = store
376            .get_session(&refresh_token_hash)
377            .await
378            .unwrap_or_else(|e| panic!("expected Ok from get_session: {e}"));
379        assert_eq!(session.user_id, "user123");
380    }
381
382    #[tokio::test]
383    async fn test_in_memory_store_revoke_session() {
384        let store = InMemorySessionStore::new();
385        let now = std::time::SystemTime::now()
386            .duration_since(std::time::UNIX_EPOCH)
387            .unwrap_or_default()
388            .as_secs();
389
390        let tokens = store.create_session("user123", now + 3600).await.unwrap();
391        let refresh_token_hash = hash_token(&tokens.refresh_token);
392
393        store
394            .revoke_session(&refresh_token_hash)
395            .await
396            .unwrap_or_else(|e| panic!("expected Ok from revoke_session: {e}"));
397
398        let session = store.get_session(&refresh_token_hash).await;
399        assert!(
400            matches!(session, Err(AuthError::TokenNotFound)),
401            "expected TokenNotFound after revocation, got: {session:?}"
402        );
403    }
404
405    #[tokio::test]
406    async fn test_in_memory_store_revoke_all_sessions() {
407        let store = InMemorySessionStore::new();
408        let now = std::time::SystemTime::now()
409            .duration_since(std::time::UNIX_EPOCH)
410            .unwrap_or_default()
411            .as_secs();
412
413        // Create multiple sessions for same user
414        let tokens1 = store.create_session("user123", now + 3600).await.unwrap();
415        let tokens2 = store.create_session("user123", now + 3600).await.unwrap();
416
417        // Create session for different user
418        let tokens3 = store.create_session("user456", now + 3600).await.unwrap();
419
420        assert_eq!(store.len(), 3);
421
422        // Revoke all for user123
423        store
424            .revoke_all_sessions("user123")
425            .await
426            .unwrap_or_else(|e| panic!("expected Ok from revoke_all_sessions: {e}"));
427
428        // user456 session should still exist
429        let hash3 = hash_token(&tokens3.refresh_token);
430        store
431            .get_session(&hash3)
432            .await
433            .unwrap_or_else(|e| panic!("expected user456 session to still exist: {e}"));
434
435        // user123 sessions should be gone
436        let hash1 = hash_token(&tokens1.refresh_token);
437        let hash2 = hash_token(&tokens2.refresh_token);
438        assert!(
439            matches!(store.get_session(&hash1).await, Err(AuthError::TokenNotFound)),
440            "expected user123 session 1 to be revoked"
441        );
442        assert!(
443            matches!(store.get_session(&hash2).await, Err(AuthError::TokenNotFound)),
444            "expected user123 session 2 to be revoked"
445        );
446    }
447}