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 hex::encode(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}