Skip to main content

#
jellytau_lib/auth/
mod.rs

    1pub mod session_verifier;
    2
    3use serde::{Deserialize, Serialize};
    4use std::sync::Arc;
    5use tokio::sync::RwLock;
    6
    7use crate::connectivity::ConnectivityMonitor;
    8use crate::jellyfin::http_client::HttpClient;
    9
    10pub use session_verifier::SessionVerifier;
    11
    12/// Server information returned from Jellyfin
    13#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
    14#[serde(rename_all = "camelCase")]
    15#[specta(rename = "AuthServerInfo")]
    16pub struct ServerInfo {
    17    pub name: String,
    18    pub version: String,
    19    pub id: String,
    20    /// Normalized server URL with protocol and no trailing slash
    21    pub normalized_url: String,
    22}
    23
    24/// User information
    25#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
    26#[serde(rename_all = "camelCase")]
    27pub struct User {
    28    pub id: String,
    29    pub name: String,
    30    pub server_id: String,
    31    pub primary_image_tag: Option<String>,
    32}
    33
    34/// Authentication result
    35#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
    36#[serde(rename_all = "camelCase")]
    37pub struct AuthResult {
    38    pub user: User,
    39    pub access_token: String,
    40    pub server_id: String,
    41}
    42
    43/// Active session for restoration
    44#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
    45#[serde(rename_all = "camelCase")]
    46pub struct Session {
    47    pub user_id: String,
    48    pub username: String,
    49    pub server_id: String,
    50    pub server_url: String,
    51    pub server_name: String,
    52    pub access_token: String,
    53    pub verified: bool,
    54    pub needs_reauth: bool,
    55}
    56
    57// Jellyfin API response types (PascalCase from server)
    58
    59#[derive(specta::Type, Debug, Deserialize)]
    60#[serde(rename_all = "PascalCase")]
    61struct PublicSystemInfo {
    62    server_name: String,
    63    version: String,
    64    id: String,
    65}
    66
    67#[derive(specta::Type, Debug, Deserialize)]
    68#[serde(rename_all = "PascalCase")]
    69struct AuthenticateByNameResponse {
    70    user: JellyfinUser,
    71    access_token: String,
    72    server_id: String,
    73}
    74
    75#[derive(specta::Type, Debug, Deserialize)]
    76#[serde(rename_all = "PascalCase")]
    77struct JellyfinUser {
    78    id: String,
    79    name: String,
    80    server_id: String,
    81    primary_image_tag: Option<String>,
    82}
    83
    84/// Authentication manager
    85pub struct AuthManager {
    86    http_client: Arc<HttpClient>,
    87    current_session: Arc<RwLock<Option<Session>>>,
    88    connectivity_monitor: Option<Arc<tokio::sync::Mutex<ConnectivityMonitor>>>,
    89}
    90
    91impl AuthManager {
    92    /// Create a new auth manager
    93    pub fn new(http_client: HttpClient) -> Self {
    94        Self {
    95            http_client: Arc::new(http_client),
    96            current_session: Arc::new(RwLock::new(None)),
    97            connectivity_monitor: None,
    98        }
    99    }
    100
    101    /// Set the connectivity monitor (for marking server reachability)
    102    pub fn set_connectivity_monitor(
    103        &mut self,
    104        monitor: Arc<tokio::sync::Mutex<ConnectivityMonitor>>,
    105    ) {
    106        self.connectivity_monitor = Some(monitor);
    107    }
    108
    109    /// Normalize and validate server URL.
    110    /// Enforces HTTPS — plain HTTP is rejected for security.
    111    pub fn normalize_url(url: &str) -> Result<String, String> {
    112        let mut normalized = url.trim().to_string();
    113
    114        // Reject plain HTTP — all connections must use HTTPS
    115        if normalized.starts_with("http://") {
    116            return Err("HTTP connections are not allowed. Please use HTTPS (e.g., https://your-server.com).".to_string());
    117        }
    118
    119        // Add https:// if no protocol specified
    120        if !normalized.starts_with("https://") {
    121            normalized = format!("https://{}", normalized);
    122        }
    123
    124        // Remove trailing slash
    125        if normalized.ends_with('/') {
    126            normalized.pop();
    127        }
    128
    129        Ok(normalized)
    130    }
    131
    132    /// Normalize a username before it goes to the server.
    133    ///
    134    /// Only surrounding whitespace is stripped — interior spaces are legal in
    135    /// Jellyfin usernames. Without this, a trailing space from a soft keyboard's
    136    /// autocorrect makes the server report an unknown user, which surfaces as a
    137    /// 401 that looks exactly like a wrong password.
    138    ///
    139    /// TRACES: UR-042 | DR-054
    140    pub fn normalize_username(username: &str) -> String {
    141        username.trim().to_string()
    142    }
    143
    144    /// Connect to server and get server info
    145    pub async fn connect_to_server(&self, server_url: &str) -> Result<ServerInfo, String> {
    146        let normalized_url = Self::normalize_url(server_url)?;
    147        let endpoint = format!("{}/System/Info/Public", normalized_url);
    148
    149        log::info!("[AuthManager] Connecting to server: {}", normalized_url);
    150
    151        match self
    152            .http_client
    153            .get_json_fast::<PublicSystemInfo>(&endpoint)
    154            .await
    155        {
    156            Ok(info) => {
    157                log::info!(
    158                    "[AuthManager] Connected to server: {} ({})",
    159                    info.server_name,
    160                    info.version
    161                );
    162
    163                // Mark server as reachable
    164                if let Some(monitor) = &self.connectivity_monitor {
    165                    let monitor = monitor.lock().await;
    166                    monitor.mark_reachable().await;
    167                }
    168
    169                Ok(ServerInfo {
    170                    name: info.server_name,
    171                    version: info.version,
    172                    id: info.id,
    173                    normalized_url,
    174                })
    175            }
    176            Err(e) => {
    177                log::error!("[AuthManager] Failed to connect to server: {}", e);
    178
    179                // Mark server as unreachable
    180                if let Some(monitor) = &self.connectivity_monitor {
    181                    let monitor = monitor.lock().await;
    182                    monitor.mark_unreachable(Some(e.clone())).await;
    183                }
    184
    185                Err(e)
    186            }
    187        }
    188    }
    189
    190    /// Authenticate by username and password
    191    pub async fn login(
    192        &self,
    193        server_url: &str,
    194        username: &str,
    195        password: &str,
    196        device_id: &str,
    197    ) -> Result<AuthResult, String> {
    198        let url = Self::normalize_url(server_url)?;
    199        let endpoint = format!("{}/Users/AuthenticateByName", url);
    200        let username = Self::normalize_username(username);
    201
    202        log::info!("[AuthManager] Authenticating user: {}", username);
    203
    204        // Build auth header for login request
    205        let auth_header = HttpClient::build_auth_header(None, device_id);
    206
    207        // Build request manually for custom headers
    208        let request = self
    209            .http_client
    210            .client
    211            .post(&endpoint)
    212            .header("Content-Type", "application/json")
    213            .header("X-Emby-Authorization", auth_header)
    214            .json(&serde_json::json!({
    215                "Username": username,
    216                "Pw": password,
    217            }))
    218            .build()
    219            .map_err(|e| format!("Failed to build request: {}", e))?;
    220
    221        // Use retry logic
    222        let response = self
    223            .http_client
    224            .request_with_retry(request)
    225            .await
    226            .map_err(|e| format!("Login request failed: {}", e))?;
    227
    228        if !response.status().is_success() {
    229            let status = response.status();
    230            let error_text = response
    231                .text()
    232                .await
    233                .unwrap_or_else(|_| "Unknown error".to_string());
    234            return Err(format!("Login failed: HTTP {}: {}", status, error_text));
    235        }
    236
    237        let auth_response: AuthenticateByNameResponse = response
    238            .json()
    239            .await
    240            .map_err(|e| format!("Failed to parse login response: {}", e))?;
    241
    242        log::info!(
    243            "[AuthManager] Login successful for user: {} ({})",
    244            auth_response.user.name,
    245            auth_response.user.id
    246        );
    247
    248        // Mark server as reachable
    249        if let Some(monitor) = &self.connectivity_monitor {
    250            let monitor = monitor.lock().await;
    251            monitor.mark_reachable().await;
    252        }
    253
    254        let user = User {
    255            id: auth_response.user.id,
    256            name: auth_response.user.name,
    257            server_id: auth_response.user.server_id,
    258            primary_image_tag: auth_response.user.primary_image_tag,
    259        };
    260
    261        Ok(AuthResult {
    262            user,
    263            access_token: auth_response.access_token,
    264            server_id: auth_response.server_id,
    265        })
    266    }
    267
    268    /// Verify current session by fetching user info
    269    pub async fn verify_session(
    270        &self,
    271        server_url: &str,
    272        user_id: &str,
    273        access_token: &str,
    274        device_id: &str,
    275    ) -> Result<User, String> {
    276        let url = Self::normalize_url(server_url)?;
    277        let endpoint = format!("{}/Users/{}", url, user_id);
    278
    279        log::info!("[AuthManager] Verifying session for user: {}", user_id);
    280
    281        // Build auth header
    282        let auth_header = HttpClient::build_auth_header(Some(access_token), device_id);
    283
    284        // Build request manually for custom headers
    285        let request = self
    286            .http_client
    287            .client
    288            .get(&endpoint)
    289            .header("X-Emby-Authorization", auth_header)
    290            .build()
    291            .map_err(|e| format!("Failed to build request: {}", e))?;
    292
    293        // Use retry logic
    294        let response = self
    295            .http_client
    296            .request_with_retry(request)
    297            .await
    298            .map_err(|e| {
    299                log::warn!("[AuthManager] Session verification failed: {}", e);
    300                format!("Session verification failed: {}", e)
    301            })?;
    302
    303        if !response.status().is_success() {
    304            let status = response.status();
    305            let error_text = response
    306                .text()
    307                .await
    308                .unwrap_or_else(|_| "Unknown error".to_string());
    309
    310            // Mark server as unreachable for auth errors
    311            if status.as_u16() == 401 || status.as_u16() == 403 {
    312                log::warn!("[AuthManager] Session invalid: HTTP {}", status);
    313                if let Some(monitor) = &self.connectivity_monitor {
    314                    let monitor = monitor.lock().await;
    315                    monitor
    316                        .mark_unreachable(Some(format!("Authentication failed: {}", status)))
    317                        .await;
    318                }
    319            }
    320
    321            return Err(format!("HTTP {}: {}", status, error_text));
    322        }
    323
    324        let user_response: JellyfinUser = response
    325            .json()
    326            .await
    327            .map_err(|e| format!("Failed to parse user response: {}", e))?;
    328
    329        log::info!(
    330            "[AuthManager] Session verified successfully for: {}",
    331            user_response.name
    332        );
    333
    334        // Mark server as reachable
    335        if let Some(monitor) = &self.connectivity_monitor {
    336            let monitor = monitor.lock().await;
    337            monitor.mark_reachable().await;
    338        }
    339
    340        Ok(User {
    341            id: user_response.id,
    342            name: user_response.name,
    343            server_id: user_response.server_id,
    344            primary_image_tag: user_response.primary_image_tag,
    345        })
    346    }
    347
    348    /// Logout (call Jellyfin logout endpoint)
    349    pub async fn logout(
    350        &self,
    351        server_url: &str,
    352        access_token: &str,
    353        device_id: &str,
    354    ) -> Result<(), String> {
    355        let url = Self::normalize_url(server_url)?;
    356        let endpoint = format!("{}/Sessions/Logout", url);
    357
    358        log::info!("[AuthManager] Logging out");
    359
    360        // Build auth header
    361        let auth_header = HttpClient::build_auth_header(Some(access_token), device_id);
    362
    363        // Build request
    364        let request = self
    365            .http_client
    366            .client
    367            .post(&endpoint)
    368            .header("X-Emby-Authorization", auth_header)
    369            .build()
    370            .map_err(|e| format!("Failed to build request: {}", e))?;
    371
    372        // Don't retry logout - if it fails, we'll still clear local state
    373        match self.http_client.client.execute(request).await {
    374            Ok(response) => {
    375                if response.status().is_success() {
    376                    log::info!("[AuthManager] Logout successful");
    377                } else {
    378                    log::warn!("[AuthManager] Logout request failed: {}", response.status());
    379                }
    380            }
    381            Err(e) => {
    382                log::warn!("[AuthManager] Logout request failed: {}", e);
    383            }
    384        }
    385
    386        Ok(())
    387    }
    388
    389    /// Get current session
    390    pub async fn get_session(&self) -> Option<Session> {
    391        self.current_session.read().await.clone()
    392    }
    393
    394    /// Set current session
    395    pub async fn set_session(&self, session: Option<Session>) {
    396        *self.current_session.write().await = session;
    397    }
    398}
    399
    400#[cfg(test)]
    401mod tests {
    402    use super::*;
    403
    404    /// Test URL normalization - adds https:// when missing
    405    #[test]
    406    fn test_normalize_url_adds_https() {
    407        assert_eq!(
    408            AuthManager::normalize_url("jellyfin.example.com").unwrap(),
    409            "https://jellyfin.example.com"
    410        );
    411        assert_eq!(
    412            AuthManager::normalize_url("192.168.1.100:8096").unwrap(),
    413            "https://192.168.1.100:8096"
    414        );
    415    }
    416
    417    /// Test URL normalization - preserves existing https
    418    #[test]
    419    fn test_normalize_url_preserves_https() {
    420        assert_eq!(
    421            AuthManager::normalize_url("https://jellyfin.example.com").unwrap(),
    422            "https://jellyfin.example.com"
    423        );
    424    }
    425
    426    /// Test URL normalization - rejects HTTP
    427    #[test]
    428    fn test_normalize_url_rejects_http() {
    429        assert!(AuthManager::normalize_url("http://localhost:8096").is_err());
    430        assert!(AuthManager::normalize_url("http://jellyfin.example.com").is_err());
    431    }
    432
    433    /// Test URL normalization - removes trailing slash
    434    #[test]
    435    fn test_normalize_url_removes_trailing_slash() {
    436        assert_eq!(
    437            AuthManager::normalize_url("https://jellyfin.example.com/").unwrap(),
    438            "https://jellyfin.example.com"
    439        );
    440        assert_eq!(
    441            AuthManager::normalize_url("jellyfin.example.com/").unwrap(),
    442            "https://jellyfin.example.com"
    443        );
    444    }
    445
    446    /// Test URL normalization - trims whitespace
    447    #[test]
    448    fn test_normalize_url_trims_whitespace() {
    449        assert_eq!(
    450            AuthManager::normalize_url("  jellyfin.example.com  ").unwrap(),
    451            "https://jellyfin.example.com"
    452        );
    453        assert_eq!(
    454            AuthManager::normalize_url("  https://jellyfin.example.com/  ").unwrap(),
    455            "https://jellyfin.example.com"
    456        );
    457    }
    458
    459    /// Usernames must be trimmed before they reach the server: the Android soft
    460    /// keyboard appends a trailing space after autocorrect, and Jellyfin then
    461    /// reports an unknown user — a 401 indistinguishable from a wrong password.
    462    #[test]
    463    fn test_normalize_username_trims_whitespace() {
    464        assert_eq!(AuthManager::normalize_username("duncan "), "duncan");
    465        assert_eq!(AuthManager::normalize_username(" duncan"), "duncan");
    466        assert_eq!(AuthManager::normalize_username("  duncan  "), "duncan");
    467        assert_eq!(AuthManager::normalize_username("duncan\n"), "duncan");
    468    }
    469
    470    /// Interior spaces are legal in Jellyfin usernames and must survive.
    471    #[test]
    472    fn test_normalize_username_preserves_interior_spaces() {
    473        assert_eq!(
    474            AuthManager::normalize_username("  duncan tourolle  "),
    475            "duncan tourolle"
    476        );
    477    }
    478
    479    /// Test URL normalization - real world case
    480    #[test]
    481    fn test_normalize_url_real_world_case() {
    482        let input = "jellyfin.tourolle.paris";
    483        let normalized = AuthManager::normalize_url(input).unwrap();
    484
    485        assert_eq!(normalized, "https://jellyfin.tourolle.paris");
    486        assert!(normalized.starts_with("https://"));
    487        assert!(!normalized.ends_with('/'));
    488    }
    489}