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}