fraiseql-auth 2.2.0

Authentication, authorization, and session management for FraiseQL
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
//! Session management — trait definition and helper functions.
#[cfg(test)]
use std::sync::Arc;

use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};

#[cfg(test)]
use crate::error::AuthError;
use crate::error::Result;

/// Session data stored in the backend
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionData {
    /// User ID (unique per user)
    pub user_id:            String,
    /// Session issued timestamp (Unix seconds)
    pub issued_at:          u64,
    /// Session expiration timestamp (Unix seconds)
    pub expires_at:         u64,
    /// Hash of the refresh token (stored securely)
    pub refresh_token_hash: String,
}

impl SessionData {
    /// Check if session is expired
    pub fn is_expired(&self) -> bool {
        let now = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap_or_default()
            .as_secs();
        self.expires_at <= now
    }
}

/// Token pair returned after successful authentication
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TokenPair {
    /// JWT access token (short-lived, typically 15 min - 1 hour)
    pub access_token:  String,
    /// Refresh token (long-lived, typically 7-30 days)
    pub refresh_token: String,
    /// Time in seconds until access token expires
    pub expires_in:    u64,
}

/// SessionStore trait - implement this for your storage backend
///
/// # Examples
///
/// Implement for PostgreSQL:
/// ```no_run
/// // Requires: sqlx PgPool and live PostgreSQL connection.
/// use async_trait::async_trait;
/// use fraiseql_auth::session::{SessionStore, SessionData, TokenPair};
/// use fraiseql_auth::error::Result;
///
/// pub struct PostgresSessionStore {
///     // pool: sqlx::PgPool,
/// }
///
/// #[async_trait]
/// impl SessionStore for PostgresSessionStore {
///     async fn create_session(&self, _user_id: &str, _expires_at: u64) -> Result<TokenPair> {
///         panic!("example stub")
///     }
///     async fn get_session(&self, _refresh_token_hash: &str) -> Result<SessionData> {
///         panic!("example stub")
///     }
///     async fn revoke_session(&self, _refresh_token_hash: &str) -> Result<()> {
///         panic!("example stub")
///     }
///     async fn revoke_all_sessions(&self, _user_id: &str) -> Result<()> {
///         panic!("example stub")
///     }
/// }
/// ```
///
/// Implement for Redis:
/// ```no_run
/// // Requires: redis crate and live Redis connection.
/// use async_trait::async_trait;
/// use fraiseql_auth::session::{SessionStore, SessionData, TokenPair};
/// use fraiseql_auth::error::Result;
///
/// pub struct RedisSessionStore {
///     // client: redis::Client,
/// }
///
/// #[async_trait]
/// impl SessionStore for RedisSessionStore {
///     async fn create_session(&self, _user_id: &str, _expires_at: u64) -> Result<TokenPair> {
///         panic!("example stub")
///     }
///     async fn get_session(&self, _refresh_token_hash: &str) -> Result<SessionData> {
///         panic!("example stub")
///     }
///     async fn revoke_session(&self, _refresh_token_hash: &str) -> Result<()> {
///         panic!("example stub")
///     }
///     async fn revoke_all_sessions(&self, _user_id: &str) -> Result<()> {
///         panic!("example stub")
///     }
/// }
/// ```
// Reason: used as dyn Trait (Arc<dyn SessionStore>); async_trait ensures Send bounds and
// dyn-compatibility async_trait: dyn-dispatch required; remove when RTN + Send is stable (RFC 3425)
#[async_trait]
pub trait SessionStore: Send + Sync {
    /// Create a new session and return token pair
    ///
    /// # Arguments
    /// * `user_id` - The user identifier
    /// * `expires_at` - When the session should expire (Unix seconds)
    ///
    /// # Returns
    /// TokenPair with access_token and refresh_token
    ///
    /// # Errors
    /// Returns error if session creation fails
    async fn create_session(&self, user_id: &str, expires_at: u64) -> Result<TokenPair>;

    /// Get session data by refresh token hash
    ///
    /// # Arguments
    /// * `refresh_token_hash` - Hash of the refresh token
    ///
    /// # Returns
    /// SessionData if session exists and is not revoked
    ///
    /// # Errors
    /// Returns SessionError if session not found or revoked
    async fn get_session(&self, refresh_token_hash: &str) -> Result<SessionData>;

    /// Revoke a single session
    ///
    /// # Arguments
    /// * `refresh_token_hash` - Hash of the refresh token to revoke
    ///
    /// # Errors
    /// Returns error if revocation fails
    async fn revoke_session(&self, refresh_token_hash: &str) -> Result<()>;

    /// Revoke all sessions for a user
    ///
    /// # Arguments
    /// * `user_id` - The user identifier
    ///
    /// # Errors
    /// Returns error if revocation fails
    async fn revoke_all_sessions(&self, user_id: &str) -> Result<()>;
}

/// Compute a SHA-256 hex digest of a refresh token for secure storage.
///
/// Refresh tokens are stored only as their SHA-256 hash so that a database
/// breach cannot be used to replay sessions.  The original token is returned
/// to the client and never persisted.
pub fn hash_token(token: &str) -> String {
    let mut hasher = Sha256::new();
    hasher.update(token.as_bytes());
    format!("{:x}", hasher.finalize())
}

/// Generate a cryptographically secure refresh token.
///
/// Returns 32 random bytes from [`rand::rngs::OsRng`] encoded as standard Base64.
/// The resulting token is 44 characters long and has approximately 256 bits of entropy.
pub fn generate_refresh_token() -> String {
    use base64::Engine;
    use rand::{Rng, rngs::OsRng};
    // SECURITY: OsRng ensures OS-level entropy for refresh tokens.
    let random_bytes: Vec<u8> = (0..32).map(|_| OsRng.gen()).collect();
    base64::engine::general_purpose::STANDARD.encode(&random_bytes)
}

/// In-memory session store for testing
#[cfg(test)]
pub struct InMemorySessionStore {
    sessions: Arc<dashmap::DashMap<String, SessionData>>,
}

#[cfg(test)]
impl InMemorySessionStore {
    /// Create a new in-memory session store
    pub fn new() -> Self {
        Self {
            sessions: Arc::new(dashmap::DashMap::new()),
        }
    }

    /// Clear all sessions (useful for tests)
    pub fn clear(&self) {
        self.sessions.clear();
    }

    /// Get number of sessions (useful for tests)
    pub fn len(&self) -> usize {
        self.sessions.len()
    }

    /// Check if there are no sessions
    pub fn is_empty(&self) -> bool {
        self.sessions.is_empty()
    }
}

#[cfg(test)]
impl Default for InMemorySessionStore {
    fn default() -> Self {
        Self::new()
    }
}

#[cfg(test)]
// Reason: SessionStore is defined with #[async_trait]; all implementations must match
// its transformed method signatures to satisfy the trait contract
// async_trait: dyn-dispatch required; remove when RTN + Send is stable (RFC 3425)
#[async_trait]
impl SessionStore for InMemorySessionStore {
    async fn create_session(&self, user_id: &str, expires_at: u64) -> Result<TokenPair> {
        let refresh_token = generate_refresh_token();
        let refresh_token_hash = hash_token(&refresh_token);

        let now = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap_or_default()
            .as_secs();

        let session = SessionData {
            user_id: user_id.to_string(),
            issued_at: now,
            expires_at,
            refresh_token_hash: refresh_token_hash.clone(),
        };

        self.sessions.insert(refresh_token_hash, session);

        let expires_in = expires_at.saturating_sub(now);

        // For testing, generate a dummy JWT (in real impl, would come from claims)
        let access_token = format!("access_token_{}", refresh_token);

        Ok(TokenPair {
            access_token,
            refresh_token,
            expires_in,
        })
    }

    async fn get_session(&self, refresh_token_hash: &str) -> Result<SessionData> {
        self.sessions
            .get(refresh_token_hash)
            .map(|entry| entry.clone())
            .ok_or(AuthError::TokenNotFound)
    }

    async fn revoke_session(&self, refresh_token_hash: &str) -> Result<()> {
        self.sessions.remove(refresh_token_hash).ok_or(AuthError::SessionError {
            message: "Session not found".to_string(),
        })?;
        Ok(())
    }

    async fn revoke_all_sessions(&self, user_id: &str) -> Result<()> {
        let mut to_remove = Vec::new();
        for entry in self.sessions.iter() {
            if entry.user_id == user_id {
                to_remove.push(entry.key().clone());
            }
        }

        for key in to_remove {
            self.sessions.remove(&key);
        }

        Ok(())
    }
}

#[allow(clippy::unwrap_used)] // Reason: test code, panics are acceptable
#[cfg(test)]
mod tests {
    #[allow(clippy::wildcard_imports)]
    // Reason: test module — wildcard keeps test boilerplate minimal
    use super::*;

    #[test]
    fn test_hash_token() {
        let token = "my_secret_token";
        let hash1 = hash_token(token);
        let hash2 = hash_token(token);

        // Same token should produce same hash
        assert_eq!(hash1, hash2);

        // Different token should produce different hash
        let different_hash = hash_token("different_token");
        assert_ne!(hash1, different_hash);
    }

    #[test]
    fn test_generate_refresh_token() {
        let token1 = generate_refresh_token();
        let token2 = generate_refresh_token();

        // Tokens should be random and different
        assert_ne!(token1, token2);
        // Should be non-empty
        assert!(!token1.is_empty());
        assert!(!token2.is_empty());
    }

    #[test]
    fn test_session_data_not_expired() {
        let now = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap_or_default()
            .as_secs();

        let session = SessionData {
            user_id:            "user123".to_string(),
            issued_at:          now,
            expires_at:         now + 3600,
            refresh_token_hash: "hash".to_string(),
        };

        assert!(!session.is_expired());
    }

    #[test]
    fn test_session_data_expired() {
        let now = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap_or_default()
            .as_secs();

        let session = SessionData {
            user_id:            "user123".to_string(),
            issued_at:          now - 3600,
            expires_at:         now - 100,
            refresh_token_hash: "hash".to_string(),
        };

        assert!(session.is_expired());
    }

    #[tokio::test]
    async fn test_in_memory_store_create_session() {
        let store = InMemorySessionStore::new();
        let now = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap_or_default()
            .as_secs();

        let result = store.create_session("user123", now + 3600).await;
        let tokens = result.unwrap_or_else(|e| panic!("expected Ok from create_session: {e}"));
        assert!(!tokens.access_token.is_empty());
        assert!(!tokens.refresh_token.is_empty());
        assert!(tokens.expires_in > 0);
    }

    #[tokio::test]
    async fn test_in_memory_store_get_session() {
        let store = InMemorySessionStore::new();
        let now = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap_or_default()
            .as_secs();

        let tokens = store.create_session("user123", now + 3600).await.unwrap();
        let refresh_token_hash = hash_token(&tokens.refresh_token);

        let session = store
            .get_session(&refresh_token_hash)
            .await
            .unwrap_or_else(|e| panic!("expected Ok from get_session: {e}"));
        assert_eq!(session.user_id, "user123");
    }

    #[tokio::test]
    async fn test_in_memory_store_revoke_session() {
        let store = InMemorySessionStore::new();
        let now = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap_or_default()
            .as_secs();

        let tokens = store.create_session("user123", now + 3600).await.unwrap();
        let refresh_token_hash = hash_token(&tokens.refresh_token);

        store
            .revoke_session(&refresh_token_hash)
            .await
            .unwrap_or_else(|e| panic!("expected Ok from revoke_session: {e}"));

        let session = store.get_session(&refresh_token_hash).await;
        assert!(
            matches!(session, Err(AuthError::TokenNotFound)),
            "expected TokenNotFound after revocation, got: {session:?}"
        );
    }

    #[tokio::test]
    async fn test_in_memory_store_revoke_all_sessions() {
        let store = InMemorySessionStore::new();
        let now = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap_or_default()
            .as_secs();

        // Create multiple sessions for same user
        let tokens1 = store.create_session("user123", now + 3600).await.unwrap();
        let tokens2 = store.create_session("user123", now + 3600).await.unwrap();

        // Create session for different user
        let tokens3 = store.create_session("user456", now + 3600).await.unwrap();

        assert_eq!(store.len(), 3);

        // Revoke all for user123
        store
            .revoke_all_sessions("user123")
            .await
            .unwrap_or_else(|e| panic!("expected Ok from revoke_all_sessions: {e}"));

        // user456 session should still exist
        let hash3 = hash_token(&tokens3.refresh_token);
        store
            .get_session(&hash3)
            .await
            .unwrap_or_else(|e| panic!("expected user456 session to still exist: {e}"));

        // user123 sessions should be gone
        let hash1 = hash_token(&tokens1.refresh_token);
        let hash2 = hash_token(&tokens2.refresh_token);
        assert!(
            matches!(store.get_session(&hash1).await, Err(AuthError::TokenNotFound)),
            "expected user123 session 1 to be revoked"
        );
        assert!(
            matches!(store.get_session(&hash2).await, Err(AuthError::TokenNotFound)),
            "expected user123 session 2 to be revoked"
        );
    }
}