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}