Skip to main content

brainwires_network/auth/
session.rs

1//! Session Management
2//!
3//! Manages authentication sessions with file-based storage and optional
4//! keyring integration for secure API key storage.
5//!
6//! Unlike the CLI version, this module takes explicit paths and a `KeyStore`
7//! trait object instead of depending on `PlatformPaths` and the `keyring` module.
8
9use anyhow::{Context, Result};
10use chrono::Utc;
11use std::fs;
12use std::path::{Path, PathBuf};
13use tracing::{debug, warn};
14use zeroize::Zeroizing;
15
16use super::types::{AuthResponse, AuthSession};
17use crate::traits::KeyStore;
18
19/// Session manager for handling authentication sessions
20///
21/// API keys are stored securely via the `KeyStore` trait (system keyring, encrypted file, etc.).
22/// The session file only contains non-sensitive metadata.
23pub struct SessionManager {
24    /// Path to the session JSON file
25    session_file: PathBuf,
26    /// Optional secure key store for API keys
27    key_store: Option<Box<dyn KeyStore>>,
28}
29
30impl SessionManager {
31    /// Create a new session manager
32    ///
33    /// # Arguments
34    /// * `session_file` - Path to the session JSON file
35    /// * `key_store` - Optional secure key store for API keys. If None, API keys
36    ///   are stored in the session file as a fallback.
37    pub fn new(session_file: PathBuf, key_store: Option<Box<dyn KeyStore>>) -> Self {
38        Self {
39            session_file,
40            key_store,
41        }
42    }
43
44    /// Load session from disk
45    pub fn load(&self) -> Result<Option<AuthSession>> {
46        if !self.session_file.exists() {
47            return Ok(None);
48        }
49
50        let contents =
51            fs::read_to_string(&self.session_file).context("Failed to read session file")?;
52
53        let session: AuthSession =
54            serde_json::from_str(&contents).context("Failed to parse session file")?;
55
56        Ok(Some(session))
57    }
58
59    /// Save session to disk and optionally store API key in key store
60    ///
61    /// The API key is stored in the key store if available, not in the session file.
62    pub fn save(&self, session: &AuthSession, api_key: Option<&str>) -> Result<()> {
63        // Ensure parent directory exists
64        if let Some(parent) = self.session_file.parent() {
65            fs::create_dir_all(parent)?;
66        }
67
68        // Store API key in key store if provided and available
69        if let Some(key) = api_key {
70            if let Some(ref key_store) = self.key_store {
71                if let Err(e) = key_store.store_key(&session.user.user_id, key) {
72                    warn!(
73                        "Failed to store API key in key store: {}. Using fallback.",
74                        e
75                    );
76                    // Fall back to storing in session file (less secure)
77                    let mut session_with_key = session.clone();
78                    session_with_key.api_key = key.to_string();
79                    return self.save_session_file(&session_with_key);
80                }
81            } else {
82                // No key store available - store in session file
83                let mut session_with_key = session.clone();
84                session_with_key.api_key = key.to_string();
85                return self.save_session_file(&session_with_key);
86            }
87        }
88
89        // Save session without API key
90        let mut session_no_key = session.clone();
91        session_no_key.api_key = String::new();
92        self.save_session_file(&session_no_key)
93    }
94
95    /// Internal: Save session struct to file
96    fn save_session_file(&self, session: &AuthSession) -> Result<()> {
97        let contents =
98            serde_json::to_string_pretty(session).context("Failed to serialize session")?;
99
100        fs::write(&self.session_file, &contents).with_context(|| {
101            format!(
102                "Failed to write session file: {}",
103                self.session_file.display()
104            )
105        })?;
106
107        // Set file permissions to 0600 (owner read/write only)
108        #[cfg(unix)]
109        {
110            use std::os::unix::fs::PermissionsExt;
111            fs::set_permissions(&self.session_file, fs::Permissions::from_mode(0o600))
112                .context("Failed to set session file permissions")?;
113        }
114
115        Ok(())
116    }
117
118    /// Delete session from disk and key store
119    pub fn delete(&self) -> Result<()> {
120        // First, try to get the user_id to delete the key store entry
121        if let Ok(Some(session)) = self.load()
122            && let Some(ref key_store) = self.key_store
123            && let Err(e) = key_store.delete_key(&session.user.user_id)
124        {
125            debug!("Failed to delete API key from key store: {}", e);
126            // Continue anyway - deleting session file is more important
127        }
128
129        if self.session_file.exists() {
130            fs::remove_file(&self.session_file).with_context(|| {
131                format!(
132                    "Failed to delete session file: {}",
133                    self.session_file.display()
134                )
135            })?;
136        }
137
138        Ok(())
139    }
140
141    /// Check if user is authenticated (has valid session)
142    pub fn is_authenticated(&self) -> Result<bool> {
143        match self.load()? {
144            Some(session) => Ok(!session.is_expired()),
145            None => Ok(false),
146        }
147    }
148
149    /// Get the current session if valid
150    pub fn get_session(&self) -> Result<Option<AuthSession>> {
151        match self.load()? {
152            Some(session) if !session.is_expired() => Ok(Some(session)),
153            _ => Ok(None),
154        }
155    }
156
157    /// Get the API key for the current session
158    ///
159    /// Tries key store first, falls back to session file for backwards compatibility.
160    /// Returns Zeroizing to ensure key is cleared from memory when dropped.
161    pub fn get_api_key(&self) -> Result<Option<Zeroizing<String>>> {
162        let session = match self.load()? {
163            Some(s) => s,
164            None => return Ok(None),
165        };
166
167        // Try key store first
168        if let Some(ref key_store) = self.key_store {
169            match key_store.get_key(&session.user.user_id) {
170                Ok(Some(key)) => return Ok(Some(key)),
171                Ok(None) => {
172                    debug!("No key in key store, checking session file fallback");
173                }
174                Err(e) => {
175                    debug!("Key store error: {}, checking session file fallback", e);
176                }
177            }
178        }
179
180        // Fall back to session file (for backwards compatibility)
181        if !session.api_key.is_empty() {
182            debug!("Using API key from session file (legacy)");
183            return Ok(Some(Zeroizing::new(session.api_key)));
184        }
185
186        Ok(None)
187    }
188
189    /// Create session from authentication response
190    pub fn create_session(
191        response: AuthResponse,
192        backend: String,
193        _api_key: String,
194    ) -> AuthSession {
195        // Note: api_key is passed but stored in key store, not in session
196        AuthSession {
197            user: response.user,
198            supabase: response.supabase,
199            key_name: response.key_name,
200            api_key: String::new(), // Stored in key store, not here
201            backend,
202            authenticated_at: Utc::now(),
203        }
204    }
205
206    /// Migrate legacy session (with api_key in file) to key store
207    ///
208    /// Call this during login to migrate old sessions to secure storage.
209    pub fn migrate_to_key_store(&self) -> Result<bool> {
210        let key_store = match &self.key_store {
211            Some(ks) => ks,
212            None => return Ok(false), // No key store to migrate to
213        };
214
215        let session = match self.load()? {
216            Some(s) => s,
217            None => return Ok(false),
218        };
219
220        // If there's an API key in the session file, migrate it
221        if !session.api_key.is_empty() {
222            debug!("Migrating legacy API key to key store");
223
224            // Store in key store
225            key_store.store_key(&session.user.user_id, &session.api_key)?;
226
227            // Clear from session file
228            let mut updated = session;
229            updated.api_key = String::new();
230            self.save_session_file(&updated)?;
231
232            return Ok(true);
233        }
234
235        Ok(false)
236    }
237
238    /// Get the session file path
239    pub fn session_file(&self) -> &Path {
240        &self.session_file
241    }
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247    use crate::auth::types::{SupabaseConfig, UserProfile};
248
249    fn create_test_session() -> AuthSession {
250        AuthSession {
251            user: UserProfile {
252                user_id: "test-user-id".to_string(),
253                username: "testuser".to_string(),
254                display_name: "Test User".to_string(),
255                role: "basic".to_string(),
256            },
257            supabase: SupabaseConfig {
258                url: "https://test.supabase.co".to_string(),
259                anon_key: "test-anon-key".to_string(),
260            },
261            key_name: "test-key".to_string(),
262            api_key: "bw_dev_12345678901234567890123456789012".to_string(),
263            backend: "https://brainwires.studio".to_string(),
264            authenticated_at: Utc::now(),
265        }
266    }
267
268    fn make_manager() -> (tempfile::TempDir, SessionManager) {
269        let temp_dir = tempfile::tempdir().unwrap();
270        let session_file = temp_dir.path().join("session.json");
271        let mgr = SessionManager::new(session_file, None);
272        (temp_dir, mgr)
273    }
274
275    #[test]
276    fn test_session_never_expires() {
277        let session = create_test_session();
278        assert!(!session.is_expired());
279    }
280
281    #[test]
282    fn test_create_session() {
283        let auth_response = AuthResponse {
284            user: UserProfile {
285                user_id: "user123".to_string(),
286                username: "john".to_string(),
287                display_name: "John Doe".to_string(),
288                role: "admin".to_string(),
289            },
290            supabase: SupabaseConfig {
291                url: "https://test.supabase.co".to_string(),
292                anon_key: "anon-test".to_string(),
293            },
294            key_name: "my_key".to_string(),
295        };
296
297        let session = SessionManager::create_session(
298            auth_response,
299            "https://brainwires.studio".to_string(),
300            "bw_dev_12345678901234567890123456789012".to_string(),
301        );
302
303        assert_eq!(session.user.user_id, "user123");
304        assert_eq!(session.key_name, "my_key");
305        assert_eq!(session.backend, "https://brainwires.studio");
306        assert!(session.api_key.is_empty()); // Stored in key store, not session
307        assert!(!session.is_expired());
308    }
309
310    #[test]
311    fn test_save_and_load_session() {
312        let (_dir, mgr) = make_manager();
313        let session = create_test_session();
314
315        mgr.save(&session, None).unwrap();
316        let loaded = mgr.load().unwrap();
317        assert!(loaded.is_some());
318    }
319
320    #[test]
321    fn test_load_nonexistent_session() {
322        let (_dir, mgr) = make_manager();
323        let result = mgr.load().unwrap();
324        assert!(result.is_none());
325    }
326
327    #[test]
328    fn test_delete_session() {
329        let (_dir, mgr) = make_manager();
330        let session = create_test_session();
331
332        mgr.save(&session, None).unwrap();
333        mgr.delete().unwrap();
334
335        let loaded = mgr.load().unwrap();
336        assert!(loaded.is_none());
337    }
338
339    #[test]
340    fn test_delete_nonexistent_session() {
341        let (_dir, mgr) = make_manager();
342        let result = mgr.delete();
343        assert!(result.is_ok());
344    }
345
346    #[test]
347    fn test_is_authenticated_with_valid_session() {
348        let (_dir, mgr) = make_manager();
349        let session = create_test_session();
350        mgr.save(&session, None).unwrap();
351        assert!(mgr.is_authenticated().unwrap());
352    }
353
354    #[test]
355    fn test_is_authenticated_without_session() {
356        let (_dir, mgr) = make_manager();
357        assert!(!mgr.is_authenticated().unwrap());
358    }
359
360    #[test]
361    fn test_get_session_valid() {
362        let (_dir, mgr) = make_manager();
363        let session = create_test_session();
364        mgr.save(&session, None).unwrap();
365
366        let result = mgr.get_session().unwrap();
367        assert!(result.is_some());
368        assert_eq!(result.unwrap().user.user_id, "test-user-id");
369    }
370
371    #[test]
372    fn test_get_session_none() {
373        let (_dir, mgr) = make_manager();
374        let result = mgr.get_session().unwrap();
375        assert!(result.is_none());
376    }
377
378    #[test]
379    fn test_save_with_api_key_no_keystore() {
380        let (_dir, mgr) = make_manager();
381        let session = create_test_session();
382
383        // Without a key store, API key should be stored in the session file
384        mgr.save(&session, Some("bw_test_00000000000000000000000000000000"))
385            .unwrap();
386
387        let loaded = mgr.load().unwrap().unwrap();
388        assert_eq!(loaded.api_key, "bw_test_00000000000000000000000000000000");
389    }
390
391    #[test]
392    fn test_get_api_key_from_session_file() {
393        let (_dir, mgr) = make_manager();
394        let session = create_test_session();
395
396        // Save with API key in file (no key store)
397        mgr.save(&session, Some("bw_test_00000000000000000000000000000000"))
398            .unwrap();
399
400        let key = mgr.get_api_key().unwrap();
401        assert!(key.is_some());
402        assert_eq!(
403            key.unwrap().as_str(),
404            "bw_test_00000000000000000000000000000000"
405        );
406    }
407
408    #[test]
409    fn test_session_serialization() {
410        let session = create_test_session();
411        let json = serde_json::to_string(&session).unwrap();
412        let deserialized: AuthSession = serde_json::from_str(&json).unwrap();
413
414        assert_eq!(deserialized.user.user_id, session.user.user_id);
415        assert_eq!(deserialized.key_name, session.key_name);
416        assert_eq!(deserialized.backend, session.backend);
417    }
418
419    #[test]
420    fn test_old_session_does_not_expire() {
421        let mut session = create_test_session();
422        session.authenticated_at = Utc::now() - chrono::Duration::days(365);
423        assert!(!session.is_expired(), "Sessions should never expire");
424    }
425}