1use 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
19pub struct SessionManager {
24 session_file: PathBuf,
26 key_store: Option<Box<dyn KeyStore>>,
28}
29
30impl SessionManager {
31 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 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 pub fn save(&self, session: &AuthSession, api_key: Option<&str>) -> Result<()> {
63 if let Some(parent) = self.session_file.parent() {
65 fs::create_dir_all(parent)?;
66 }
67
68 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 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 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 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 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 #[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 pub fn delete(&self) -> Result<()> {
120 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 }
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 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 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 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 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 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 pub fn create_session(
191 response: AuthResponse,
192 backend: String,
193 _api_key: String,
194 ) -> AuthSession {
195 AuthSession {
197 user: response.user,
198 supabase: response.supabase,
199 key_name: response.key_name,
200 api_key: String::new(), backend,
202 authenticated_at: Utc::now(),
203 }
204 }
205
206 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), };
214
215 let session = match self.load()? {
216 Some(s) => s,
217 None => return Ok(false),
218 };
219
220 if !session.api_key.is_empty() {
222 debug!("Migrating legacy API key to key store");
223
224 key_store.store_key(&session.user.user_id, &session.api_key)?;
226
227 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 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()); 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 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 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}