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