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}