Skip to main content

jellytau_lib/commands/
auth.rs

1//! Authentication and session-lifecycle commands.
2//!
3//! TRACES: UR-042 | IR-009, IR-014, JA-002 | DR-054
4
5use std::sync::Arc;
6use tauri::State;
7
8use crate::auth::{AuthManager, AuthResult, ServerInfo, Session, SessionVerifier};
9
10/// Wrapper for AuthManager to manage in Tauri state
11pub struct AuthManagerWrapper(pub Arc<AuthManager>);
12
13/// Wrapper for SessionVerifier to manage in Tauri state
14pub struct SessionVerifierWrapper(pub Arc<tokio::sync::Mutex<Option<SessionVerifier>>>);
15
16/// Initialize the auth manager (call on app startup)
17/// Restores session from storage if available
18#[tauri::command]
19#[specta::specta]
20pub async fn auth_initialize(
21    auth_manager: State<'_, AuthManagerWrapper>,
22    database: State<'_, crate::commands::DatabaseWrapper>,
23    credentials: State<'_, crate::commands::CredentialStoreWrapper>,
24) -> Result<Option<Session>, String> {
25    // First check if we already have a session in memory
26    if let Some(session) = auth_manager.0.get_session().await {
27        return Ok(Some(session));
28    }
29
30    // Try to restore session from storage
31    log::info!("[AuthManager] Restoring session from storage...");
32
33    // A PIN-protected profile is not restored automatically. Restoring it would
34    // hand the app a working token before anybody entered the code, leaving the
35    // picker as decoration over a session that was already live — the gate has
36    // to be on the session itself, not on which screen is shown. The frontend
37    // sees `None`, asks `profiles_startup_target`, and lands on the picker.
38    //
39    // TRACES: UR-083 | DR-268, DR-274
40    {
41        let db_service = {
42            let db = database.0.lock().map_err(|e| e.to_string())?;
43            std::sync::Arc::new(db.service())
44        };
45        let locked: Option<String> = crate::storage::db_service::DatabaseService::query_optional(
46            &*db_service,
47            crate::storage::db_service::Query::new(
48                "SELECT u.id FROM users u
49                 JOIN user_pins p ON p.user_id = u.id
50                 WHERE u.is_active = 1",
51            ),
52            |row| row.get(0),
53        )
54        .await
55        .unwrap_or(None);
56
57        if let Some(user_id) = locked {
58            log::info!(
59                "[AuthManager] Active profile {} is PIN-protected; not restoring its session",
60                user_id
61            );
62            return Ok(None);
63        }
64    }
65
66    // Use the existing storage_get_active_session function
67    let active_session =
68        match crate::commands::storage::storage_get_active_session(database, credentials).await {
69            Ok(Some(session)) => session,
70            Ok(None) => {
71                log::info!("[AuthManager] No active session in storage");
72                return Ok(None);
73            }
74            Err(e) => {
75                log::error!("[AuthManager] Failed to get active session: {}", e);
76                return Err(e);
77            }
78        };
79
80    // Create session object from active session with normalized URL
81    let normalized_url = crate::auth::AuthManager::normalize_url(&active_session.server_url)?;
82
83    let session = Session {
84        user_id: active_session.user_id,
85        username: active_session.username,
86        server_id: active_session.server_id,
87        server_url: normalized_url,
88        server_name: active_session.server_name,
89        access_token: active_session.access_token,
90        verified: false, // Will be verified in background
91        needs_reauth: false,
92    };
93
94    // Store in AuthManager
95    auth_manager.0.set_session(Some(session.clone())).await;
96
97    log::info!(
98        "[AuthManager] Session restored for user: {} with normalized URL: {}",
99        session.username,
100        session.server_url
101    );
102    Ok(Some(session))
103}
104
105/// Connect to a Jellyfin server and get server info
106#[tauri::command]
107#[specta::specta]
108pub async fn auth_connect_to_server(
109    server_url: String,
110    auth_manager: State<'_, AuthManagerWrapper>,
111) -> Result<ServerInfo, String> {
112    auth_manager.0.connect_to_server(&server_url).await
113}
114
115/// Login with username and password
116#[tauri::command]
117#[specta::specta]
118pub async fn auth_login(
119    server_url: String,
120    username: String,
121    password: String,
122    device_id: String,
123    auth_manager: State<'_, AuthManagerWrapper>,
124) -> Result<AuthResult, String> {
125    let result = auth_manager
126        .0
127        .login(&server_url, &username, &password, &device_id)
128        .await?;
129
130    // Create session from auth result with normalized URL
131    let normalized_url = crate::auth::AuthManager::normalize_url(&server_url)?;
132
133    let session = Session {
134        user_id: result.user.id.clone(),
135        username: result.user.name.clone(),
136        server_id: result.server_id.clone(),
137        server_url: normalized_url,
138        server_name: String::new(), // Will be set by frontend
139        access_token: result.access_token.clone(),
140        verified: true,
141        needs_reauth: false,
142    };
143
144    auth_manager.0.set_session(Some(session)).await;
145
146    Ok(result)
147}
148
149/// Verify current session
150#[tauri::command]
151#[specta::specta]
152pub async fn auth_verify_session(
153    server_url: String,
154    user_id: String,
155    access_token: String,
156    device_id: String,
157    auth_manager: State<'_, AuthManagerWrapper>,
158) -> Result<bool, String> {
159    match auth_manager
160        .0
161        .verify_session(&server_url, &user_id, &access_token, &device_id)
162        .await
163    {
164        Ok(_) => Ok(true),
165        Err(e) => {
166            log::warn!("[AuthCommands] Session verification failed: {}", e);
167            Ok(false)
168        }
169    }
170}
171
172/// Logout (clear session and call Jellyfin logout endpoint)
173#[tauri::command]
174#[specta::specta]
175pub async fn auth_logout(
176    server_url: String,
177    access_token: String,
178    device_id: String,
179    auth_manager: State<'_, AuthManagerWrapper>,
180    session_verifier: State<'_, SessionVerifierWrapper>,
181) -> Result<(), String> {
182    // Stop session verification
183    let mut verifier_guard = session_verifier.0.lock().await;
184    if let Some(verifier) = verifier_guard.take() {
185        verifier.stop();
186    }
187    drop(verifier_guard);
188
189    // Call Jellyfin logout endpoint
190    auth_manager
191        .0
192        .logout(&server_url, &access_token, &device_id)
193        .await?;
194
195    // Clear session
196    auth_manager.0.set_session(None).await;
197
198    Ok(())
199}
200
201/// Get current session
202#[tauri::command]
203#[specta::specta]
204pub async fn auth_get_session(
205    auth_manager: State<'_, AuthManagerWrapper>,
206) -> Result<Option<Session>, String> {
207    Ok(auth_manager.0.get_session().await)
208}
209
210/// Set current session (for restoration from storage)
211#[tauri::command]
212#[specta::specta]
213pub async fn auth_set_session(
214    session: Option<Session>,
215    auth_manager: State<'_, AuthManagerWrapper>,
216) -> Result<(), String> {
217    // Normalize the server URL if session is provided
218    let normalized_session = match session {
219        Some(mut s) => {
220            s.server_url = crate::auth::AuthManager::normalize_url(&s.server_url)?;
221            Some(s)
222        }
223        None => None,
224    };
225
226    auth_manager.0.set_session(normalized_session).await;
227    Ok(())
228}
229
230/// Start background session verification
231#[tauri::command]
232#[specta::specta]
233pub async fn auth_start_verification(
234    device_id: String,
235    app_handle: tauri::AppHandle,
236    auth_manager: State<'_, AuthManagerWrapper>,
237    session_verifier: State<'_, SessionVerifierWrapper>,
238) -> Result<(), String> {
239    let mut verifier_guard = session_verifier.0.lock().await;
240
241    // Stop existing verifier if any
242    if let Some(verifier) = verifier_guard.take() {
243        verifier.stop();
244    }
245
246    // Get AuthManager Arc
247    let manager = auth_manager.0.clone();
248
249    // Create new verifier
250    let mut verifier = SessionVerifier::new(manager, device_id);
251    verifier.set_app_handle(app_handle);
252    verifier.start().await;
253
254    *verifier_guard = Some(verifier);
255
256    Ok(())
257}
258
259/// Stop background session verification
260#[tauri::command]
261#[specta::specta]
262pub async fn auth_stop_verification(
263    session_verifier: State<'_, SessionVerifierWrapper>,
264) -> Result<(), String> {
265    let mut verifier_guard = session_verifier.0.lock().await;
266
267    if let Some(verifier) = verifier_guard.take() {
268        verifier.stop();
269    }
270
271    Ok(())
272}
273
274/// Re-authenticate with password (when session expired)
275#[tauri::command]
276#[specta::specta]
277pub async fn auth_reauthenticate(
278    password: String,
279    device_id: String,
280    auth_manager: State<'_, AuthManagerWrapper>,
281) -> Result<AuthResult, String> {
282    // Get current session to extract server_url and username
283    let session = auth_manager
284        .0
285        .get_session()
286        .await
287        .ok_or_else(|| "No active session to re-authenticate".to_string())?;
288
289    // Re-login with stored credentials
290    let result = auth_manager
291        .0
292        .login(
293            &session.server_url,
294            &session.username,
295            &password,
296            &device_id,
297        )
298        .await?;
299
300    // Update session with new token
301    let updated_session = Session {
302        user_id: result.user.id.clone(),
303        username: result.user.name.clone(),
304        server_id: result.server_id.clone(),
305        server_url: session.server_url,
306        server_name: session.server_name,
307        access_token: result.access_token.clone(),
308        verified: true,
309        needs_reauth: false,
310    };
311
312    auth_manager.0.set_session(Some(updated_session)).await;
313
314    Ok(result)
315}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320
321    #[test]
322    fn test_session_serialization() {
323        let session = Session {
324            user_id: "user-123".to_string(),
325            username: "john_doe".to_string(),
326            server_id: "server-456".to_string(),
327            server_url: "https://jellyfin.example.com".to_string(),
328            server_name: "My Jellyfin".to_string(),
329            access_token: "token-789-xyz".to_string(),
330            verified: true,
331            needs_reauth: false,
332        };
333
334        // Should serialize successfully
335        let json = serde_json::to_string(&session);
336        assert!(json.is_ok());
337        let serialized = json.unwrap();
338        assert!(serialized.contains("user-123"));
339        assert!(serialized.contains("john_doe"));
340        assert!(serialized.contains("server-456"));
341    }
342
343    #[test]
344    fn test_session_deserialization() {
345        let json = r#"{
346            "userId": "user-123",
347            "username": "john_doe",
348            "serverId": "server-456",
349            "serverUrl": "https://jellyfin.example.com",
350            "serverName": "My Jellyfin",
351            "accessToken": "token-789",
352            "verified": true,
353            "needsReauth": false
354        }"#;
355
356        let result: Result<Session, _> = serde_json::from_str(json);
357        assert!(result.is_ok());
358
359        let session = result.unwrap();
360        assert_eq!(session.user_id, "user-123");
361        assert_eq!(session.username, "john_doe");
362        assert_eq!(session.server_id, "server-456");
363        assert!(session.verified);
364        assert!(!session.needs_reauth);
365    }
366
367    #[test]
368    fn test_session_roundtrip() {
369        let original = Session {
370            user_id: "user-999".to_string(),
371            username: "alice".to_string(),
372            server_id: "server-111".to_string(),
373            server_url: "https://server.local".to_string(),
374            server_name: "Home Server".to_string(),
375            access_token: "very-long-token-string".to_string(),
376            verified: true,
377            needs_reauth: false,
378        };
379
380        let json = serde_json::to_string(&original).unwrap();
381        let deserialized: Session = serde_json::from_str(&json).unwrap();
382
383        assert_eq!(original.user_id, deserialized.user_id);
384        assert_eq!(original.username, deserialized.username);
385        assert_eq!(original.server_id, deserialized.server_id);
386        assert_eq!(original.server_url, deserialized.server_url);
387        assert_eq!(original.access_token, deserialized.access_token);
388        assert_eq!(original.verified, deserialized.verified);
389    }
390
391    #[test]
392    fn test_session_clone() {
393        let session = Session {
394            user_id: "user-clone".to_string(),
395            username: "test_user".to_string(),
396            server_id: "server-clone".to_string(),
397            server_url: "https://clone.example.com".to_string(),
398            server_name: "Clone Server".to_string(),
399            access_token: "clone-token".to_string(),
400            verified: false,
401            needs_reauth: true,
402        };
403
404        let cloned = session.clone();
405        assert_eq!(session.user_id, cloned.user_id);
406        assert_eq!(session.username, cloned.username);
407        assert_eq!(session.verified, cloned.verified);
408        assert_eq!(session.needs_reauth, cloned.needs_reauth);
409    }
410
411    #[test]
412    fn test_session_unverified() {
413        let session = Session {
414            user_id: "user-unverified".to_string(),
415            username: "newuser".to_string(),
416            server_id: "server-new".to_string(),
417            server_url: "https://new.example.com".to_string(),
418            server_name: "New Server".to_string(),
419            access_token: "new-token".to_string(),
420            verified: false,
421            needs_reauth: true,
422        };
423
424        let json = serde_json::to_string(&session).unwrap();
425        assert!(json.contains("false")); // verified: false
426        assert!(json.contains("true")); // needs_reauth: true
427
428        let deserialized: Session = serde_json::from_str(&json).unwrap();
429        assert!(!deserialized.verified);
430        assert!(deserialized.needs_reauth);
431    }
432
433    #[test]
434    fn test_session_debug() {
435        let session = Session {
436            user_id: "user-debug".to_string(),
437            username: "debug_user".to_string(),
438            server_id: "server-debug".to_string(),
439            server_url: "https://debug.example.com".to_string(),
440            server_name: "Debug Server".to_string(),
441            access_token: "debug-token".to_string(),
442            verified: true,
443            needs_reauth: false,
444        };
445
446        let debug_str = format!("{:?}", session);
447        assert!(debug_str.contains("user-debug"));
448        assert!(debug_str.contains("Session"));
449    }
450
451    #[test]
452    fn test_auth_manager_wrapper_structure() {
453        // Verify wrapper type exists and has correct structure
454        assert!(std::mem::size_of::<AuthManagerWrapper>() > 0);
455    }
456
457    #[test]
458    fn test_session_verifier_wrapper_structure() {
459        // Verify wrapper type exists and has correct structure
460        assert!(std::mem::size_of::<SessionVerifierWrapper>() > 0);
461    }
462
463    #[test]
464    fn test_session_with_special_characters() {
465        let session = Session {
466            user_id: "user-special-éñ".to_string(),
467            username: "user@example.com".to_string(),
468            server_id: "server/123".to_string(),
469            server_url: "https://jellyfin.example.com:8096".to_string(),
470            server_name: "My Jellyfin (v10.8.0)".to_string(),
471            access_token: "token+with/special=chars".to_string(),
472            verified: true,
473            needs_reauth: false,
474        };
475
476        let json = serde_json::to_string(&session).unwrap();
477        let deserialized: Session = serde_json::from_str(&json).unwrap();
478
479        assert_eq!(session.username, deserialized.username);
480        assert_eq!(session.server_name, deserialized.server_name);
481        assert_eq!(session.access_token, deserialized.access_token);
482    }
483
484    #[test]
485    fn test_session_field_presence() {
486        let session = Session {
487            user_id: "u1".to_string(),
488            username: "user1".to_string(),
489            server_id: "s1".to_string(),
490            server_url: "url1".to_string(),
491            server_name: "name1".to_string(),
492            access_token: "token1".to_string(),
493            verified: true,
494            needs_reauth: false,
495        };
496
497        let json = serde_json::to_string(&session).unwrap();
498
499        // Verify camelCase serialization (serde rename_all = "camelCase")
500        assert!(json.contains("userId"));
501        assert!(json.contains("username"));
502        assert!(json.contains("serverId"));
503        assert!(json.contains("serverUrl"));
504        assert!(json.contains("serverName"));
505        assert!(json.contains("accessToken"));
506        assert!(json.contains("verified"));
507        assert!(json.contains("needsReauth"));
508    }
509}