pub mod session_verifier; use serde::{Deserialize, Serialize}; use std::sync::Arc; use tokio::sync::RwLock; use crate::jellyfin::http_client::HttpClient; use crate::connectivity::ConnectivityMonitor; pub use session_verifier::SessionVerifier; /// Server information returned from Jellyfin #[derive(specta::Type, Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] #[specta(rename = "AuthServerInfo")] pub struct ServerInfo { pub name: String, pub version: String, pub id: String, /// Normalized server URL with protocol and no trailing slash pub normalized_url: String, } /// User information #[derive(specta::Type, Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct User { pub id: String, pub name: String, pub server_id: String, pub primary_image_tag: Option, } /// Authentication result #[derive(specta::Type, Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AuthResult { pub user: User, pub access_token: String, pub server_id: String, } /// Active session for restoration #[derive(specta::Type, Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Session { pub user_id: String, pub username: String, pub server_id: String, pub server_url: String, pub server_name: String, pub access_token: String, pub verified: bool, pub needs_reauth: bool, } // Jellyfin API response types (PascalCase from server) #[derive(specta::Type, Debug, Deserialize)] #[serde(rename_all = "PascalCase")] struct PublicSystemInfo { server_name: String, version: String, id: String, } #[derive(specta::Type, Debug, Deserialize)] #[serde(rename_all = "PascalCase")] struct AuthenticateByNameResponse { user: JellyfinUser, access_token: String, server_id: String, } #[derive(specta::Type, Debug, Deserialize)] #[serde(rename_all = "PascalCase")] struct JellyfinUser { id: String, name: String, server_id: String, primary_image_tag: Option, } /// Authentication manager pub struct AuthManager { http_client: Arc, current_session: Arc>>, connectivity_monitor: Option>>, } impl AuthManager { /// Create a new auth manager pub fn new(http_client: HttpClient) -> Self { Self { http_client: Arc::new(http_client), current_session: Arc::new(RwLock::new(None)), connectivity_monitor: None, } } /// Set the connectivity monitor (for marking server reachability) pub fn set_connectivity_monitor(&mut self, monitor: Arc>) { self.connectivity_monitor = Some(monitor); } /// Normalize and validate server URL. /// Enforces HTTPS — plain HTTP is rejected for security. pub fn normalize_url(url: &str) -> Result { let mut normalized = url.trim().to_string(); // Reject plain HTTP — all connections must use HTTPS if normalized.starts_with("http://") { return Err("HTTP connections are not allowed. Please use HTTPS (e.g., https://your-server.com).".to_string()); } // Add https:// if no protocol specified if !normalized.starts_with("https://") { normalized = format!("https://{}", normalized); } // Remove trailing slash if normalized.ends_with('/') { normalized.pop(); } Ok(normalized) } /// Connect to server and get server info pub async fn connect_to_server(&self, server_url: &str) -> Result { let normalized_url = Self::normalize_url(server_url)?; let endpoint = format!("{}/System/Info/Public", normalized_url); log::info!("[AuthManager] Connecting to server: {}", normalized_url); match self.http_client.get_json_with_retry::(&endpoint).await { Ok(info) => { log::info!("[AuthManager] Connected to server: {} ({})", info.server_name, info.version); // Mark server as reachable if let Some(monitor) = &self.connectivity_monitor { let monitor = monitor.lock().await; monitor.mark_reachable().await; } Ok(ServerInfo { name: info.server_name, version: info.version, id: info.id, normalized_url, }) } Err(e) => { log::error!("[AuthManager] Failed to connect to server: {}", e); // Mark server as unreachable if let Some(monitor) = &self.connectivity_monitor { let monitor = monitor.lock().await; monitor.mark_unreachable(Some(e.clone())).await; } Err(e) } } } /// Authenticate by username and password pub async fn login( &self, server_url: &str, username: &str, password: &str, device_id: &str, ) -> Result { let url = Self::normalize_url(server_url)?; let endpoint = format!("{}/Users/AuthenticateByName", url); log::info!("[AuthManager] Authenticating user: {}", username); // Build auth header for login request let auth_header = HttpClient::build_auth_header(None, device_id); // Build request manually for custom headers let request = self.http_client.client.post(&endpoint) .header("Content-Type", "application/json") .header("X-Emby-Authorization", auth_header) .json(&serde_json::json!({ "Username": username, "Pw": password, })) .build() .map_err(|e| format!("Failed to build request: {}", e))?; // Use retry logic let response = self.http_client.request_with_retry(request).await .map_err(|e| format!("Login request failed: {}", e))?; if !response.status().is_success() { let status = response.status(); let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string()); return Err(format!("Login failed: HTTP {}: {}", status, error_text)); } let auth_response: AuthenticateByNameResponse = response.json().await .map_err(|e| format!("Failed to parse login response: {}", e))?; log::info!("[AuthManager] Login successful for user: {} ({})", auth_response.user.name, auth_response.user.id); // Mark server as reachable if let Some(monitor) = &self.connectivity_monitor { let monitor = monitor.lock().await; monitor.mark_reachable().await; } let user = User { id: auth_response.user.id, name: auth_response.user.name, server_id: auth_response.user.server_id, primary_image_tag: auth_response.user.primary_image_tag, }; Ok(AuthResult { user, access_token: auth_response.access_token, server_id: auth_response.server_id, }) } /// Verify current session by fetching user info pub async fn verify_session( &self, server_url: &str, user_id: &str, access_token: &str, device_id: &str, ) -> Result { let url = Self::normalize_url(server_url)?; let endpoint = format!("{}/Users/{}", url, user_id); log::info!("[AuthManager] Verifying session for user: {}", user_id); // Build auth header let auth_header = HttpClient::build_auth_header(Some(access_token), device_id); // Build request manually for custom headers let request = self.http_client.client.get(&endpoint) .header("X-Emby-Authorization", auth_header) .build() .map_err(|e| format!("Failed to build request: {}", e))?; // Use retry logic let response = self.http_client.request_with_retry(request).await .map_err(|e| { log::warn!("[AuthManager] Session verification failed: {}", e); format!("Session verification failed: {}", e) })?; if !response.status().is_success() { let status = response.status(); let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string()); // Mark server as unreachable for auth errors if status.as_u16() == 401 || status.as_u16() == 403 { log::warn!("[AuthManager] Session invalid: HTTP {}", status); if let Some(monitor) = &self.connectivity_monitor { let monitor = monitor.lock().await; monitor.mark_unreachable(Some(format!("Authentication failed: {}", status))).await; } } return Err(format!("HTTP {}: {}", status, error_text)); } let user_response: JellyfinUser = response.json().await .map_err(|e| format!("Failed to parse user response: {}", e))?; log::info!("[AuthManager] Session verified successfully for: {}", user_response.name); // Mark server as reachable if let Some(monitor) = &self.connectivity_monitor { let monitor = monitor.lock().await; monitor.mark_reachable().await; } Ok(User { id: user_response.id, name: user_response.name, server_id: user_response.server_id, primary_image_tag: user_response.primary_image_tag, }) } /// Logout (call Jellyfin logout endpoint) pub async fn logout( &self, server_url: &str, access_token: &str, device_id: &str, ) -> Result<(), String> { let url = Self::normalize_url(server_url)?; let endpoint = format!("{}/Sessions/Logout", url); log::info!("[AuthManager] Logging out"); // Build auth header let auth_header = HttpClient::build_auth_header(Some(access_token), device_id); // Build request let request = self.http_client.client.post(&endpoint) .header("X-Emby-Authorization", auth_header) .build() .map_err(|e| format!("Failed to build request: {}", e))?; // Don't retry logout - if it fails, we'll still clear local state match self.http_client.client.execute(request).await { Ok(response) => { if response.status().is_success() { log::info!("[AuthManager] Logout successful"); } else { log::warn!("[AuthManager] Logout request failed: {}", response.status()); } } Err(e) => { log::warn!("[AuthManager] Logout request failed: {}", e); } } Ok(()) } /// Get current session pub async fn get_session(&self) -> Option { self.current_session.read().await.clone() } /// Set current session pub async fn set_session(&self, session: Option) { *self.current_session.write().await = session; } } #[cfg(test)] mod tests { use super::*; /// Test URL normalization - adds https:// when missing #[test] fn test_normalize_url_adds_https() { assert_eq!( AuthManager::normalize_url("jellyfin.example.com").unwrap(), "https://jellyfin.example.com" ); assert_eq!( AuthManager::normalize_url("192.168.1.100:8096").unwrap(), "https://192.168.1.100:8096" ); } /// Test URL normalization - preserves existing https #[test] fn test_normalize_url_preserves_https() { assert_eq!( AuthManager::normalize_url("https://jellyfin.example.com").unwrap(), "https://jellyfin.example.com" ); } /// Test URL normalization - rejects HTTP #[test] fn test_normalize_url_rejects_http() { assert!(AuthManager::normalize_url("http://localhost:8096").is_err()); assert!(AuthManager::normalize_url("http://jellyfin.example.com").is_err()); } /// Test URL normalization - removes trailing slash #[test] fn test_normalize_url_removes_trailing_slash() { assert_eq!( AuthManager::normalize_url("https://jellyfin.example.com/").unwrap(), "https://jellyfin.example.com" ); assert_eq!( AuthManager::normalize_url("jellyfin.example.com/").unwrap(), "https://jellyfin.example.com" ); } /// Test URL normalization - trims whitespace #[test] fn test_normalize_url_trims_whitespace() { assert_eq!( AuthManager::normalize_url(" jellyfin.example.com ").unwrap(), "https://jellyfin.example.com" ); assert_eq!( AuthManager::normalize_url(" https://jellyfin.example.com/ ").unwrap(), "https://jellyfin.example.com" ); } /// Test URL normalization - real world case #[test] fn test_normalize_url_real_world_case() { let input = "jellyfin.tourolle.paris"; let normalized = AuthManager::normalize_url(input).unwrap(); assert_eq!(normalized, "https://jellyfin.tourolle.paris"); assert!(normalized.starts_with("https://")); assert!(!normalized.ends_with('/')); } }