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    /// Whether this build can talk to this server, as an **opaque state**.
23    ///
24    /// The version string above is informational — for display and for the log.
25    /// This is the judgement, made in Rust, because deciding whether an API
26    /// version is usable is domain reasoning: the frontend must never compare a
27    /// version number, for the same reason it never receives an item-type list.
28    ///
29    /// TRACES: UR-085 | DR-286
30    pub compatibility: ServerCompatibility,
31}
32
33/// The verdict on a server's version.
34///
35/// Deliberately three states rather than a boolean. "Unrecognised" is not a
36/// failure: a server newer than this build resolves forward and works, and
37/// refusing it would make every JellyTau release expire the moment the server
38/// upgrades. Only a server below the supported floor is refused, where failure
39/// is certain rather than merely likely.
40///
41/// TRACES: UR-085 | DR-286
42#[derive(specta::Type, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43#[serde(rename_all = "camelCase", tag = "type")]
44pub enum ServerCompatibility {
45    /// A generation this build knows and was tested against.
46    Supported,
47    /// Parsed, but newer than anything this build knows. Treated as the newest
48    /// known generation; everything works, and this exists so the UI *may*
49    /// mention it rather than so it must.
50    NewerThanKnown,
51    /// The version string could not be parsed. Treated as supported — we do not
52    /// refuse a server on the strength of not understanding its version string.
53    UnknownVersion,
54    /// Below the supported floor. This one is a refusal.
55    TooOld { minimum: String },
56}
57
58/// User information
59#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
60#[serde(rename_all = "camelCase")]
61pub struct User {
62    pub id: String,
63    pub name: String,
64    pub server_id: String,
65    pub primary_image_tag: Option<String>,
66}
67
68/// Authentication result
69#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
70#[serde(rename_all = "camelCase")]
71pub struct AuthResult {
72    pub user: User,
73    pub access_token: String,
74    pub server_id: String,
75}
76
77/// Active session for restoration
78#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
79#[serde(rename_all = "camelCase")]
80pub struct Session {
81    pub user_id: String,
82    pub username: String,
83    pub server_id: String,
84    pub server_url: String,
85    pub server_name: String,
86    pub access_token: String,
87    pub verified: bool,
88    pub needs_reauth: bool,
89}
90
91// Jellyfin API response types (PascalCase from server)
92
93#[derive(specta::Type, Debug, Deserialize)]
94#[serde(rename_all = "PascalCase")]
95struct PublicSystemInfo {
96    server_name: String,
97    version: String,
98    id: String,
99}
100
101#[derive(specta::Type, Debug, Deserialize)]
102#[serde(rename_all = "PascalCase")]
103struct AuthenticateByNameResponse {
104    user: JellyfinUser,
105    access_token: String,
106    server_id: String,
107}
108
109#[derive(specta::Type, Debug, Deserialize)]
110#[serde(rename_all = "PascalCase")]
111struct JellyfinUser {
112    id: String,
113    name: String,
114    server_id: String,
115    primary_image_tag: Option<String>,
116}
117
118/// Authentication manager
119pub struct AuthManager {
120    http_client: Arc<HttpClient>,
121    current_session: Arc<RwLock<Option<Session>>>,
122    connectivity_monitor: Option<Arc<tokio::sync::Mutex<ConnectivityMonitor>>>,
123}
124
125impl AuthManager {
126    /// Create a new auth manager
127    pub fn new(http_client: HttpClient) -> Self {
128        Self {
129            http_client: Arc::new(http_client),
130            current_session: Arc::new(RwLock::new(None)),
131            connectivity_monitor: None,
132        }
133    }
134
135    /// Set the connectivity monitor (for marking server reachability)
136    pub fn set_connectivity_monitor(
137        &mut self,
138        monitor: Arc<tokio::sync::Mutex<ConnectivityMonitor>>,
139    ) {
140        self.connectivity_monitor = Some(monitor);
141    }
142
143    /// Normalize and validate server URL.
144    /// Enforces HTTPS — plain HTTP is rejected for security.
145    pub fn normalize_url(url: &str) -> Result<String, String> {
146        let mut normalized = url.trim().to_string();
147
148        // Reject plain HTTP — all connections must use HTTPS
149        if normalized.starts_with("http://") {
150            return Err("HTTP connections are not allowed. Please use HTTPS (e.g., https://your-server.com).".to_string());
151        }
152
153        // Add https:// if no protocol specified
154        if !normalized.starts_with("https://") {
155            normalized = format!("https://{}", normalized);
156        }
157
158        // Remove trailing slash
159        if normalized.ends_with('/') {
160            normalized.pop();
161        }
162
163        Ok(normalized)
164    }
165
166    /// Normalize a username before it goes to the server.
167    ///
168    /// Only surrounding whitespace is stripped — interior spaces are legal in
169    /// Jellyfin usernames. Without this, a trailing space from a soft keyboard's
170    /// autocorrect makes the server report an unknown user, which surfaces as a
171    /// 401 that looks exactly like a wrong password.
172    ///
173    /// TRACES: UR-042 | DR-054
174    pub fn normalize_username(username: &str) -> String {
175        username.trim().to_string()
176    }
177
178    /// Connect to server and get server info
179    pub async fn connect_to_server(&self, server_url: &str) -> Result<ServerInfo, String> {
180        let normalized_url = Self::normalize_url(server_url)?;
181        let endpoint = format!("{}/System/Info/Public", normalized_url);
182
183        log::info!("[AuthManager] Connecting to server: {}", normalized_url);
184
185        match self
186            .http_client
187            .get_json_fast::<PublicSystemInfo>(&endpoint)
188            .await
189        {
190            Ok(info) => {
191                log::info!(
192                    "[AuthManager] Connected to server: {} ({})",
193                    info.server_name,
194                    info.version
195                );
196
197                // Mark server as reachable
198                if let Some(monitor) = &self.connectivity_monitor {
199                    let monitor = monitor.lock().await;
200                    monitor.mark_reachable().await;
201                }
202
203                let capabilities =
204                    crate::repository::capabilities::ServerCapabilities::from_reported(
205                        &info.version,
206                    );
207                let compatibility = if capabilities.is_below_supported_floor() {
208                    let (major, minor) =
209                        crate::repository::capabilities::MINIMUM_SUPPORTED_MAJOR_MINOR;
210                    ServerCompatibility::TooOld {
211                        minimum: format!("{major}.{minor}"),
212                    }
213                } else {
214                    use crate::repository::capabilities::ServerGeneration;
215                    match capabilities.generation {
216                        ServerGeneration::Unknown => ServerCompatibility::UnknownVersion,
217                        ServerGeneration::V12Plus
218                            if capabilities.version.as_ref().is_some_and(|v| v.major > 12) =>
219                        {
220                            ServerCompatibility::NewerThanKnown
221                        }
222                        _ => ServerCompatibility::Supported,
223                    }
224                };
225
226                Ok(ServerInfo {
227                    name: info.server_name,
228                    version: info.version,
229                    id: info.id,
230                    normalized_url,
231                    compatibility,
232                })
233            }
234            Err(e) => {
235                log::error!("[AuthManager] Failed to connect to server: {}", e);
236
237                // Mark server as unreachable
238                if let Some(monitor) = &self.connectivity_monitor {
239                    let monitor = monitor.lock().await;
240                    monitor.mark_unreachable(Some(e.clone())).await;
241                }
242
243                Err(e)
244            }
245        }
246    }
247
248    /// Authenticate by username and password
249    pub async fn login(
250        &self,
251        server_url: &str,
252        username: &str,
253        password: &str,
254        device_id: &str,
255    ) -> Result<AuthResult, String> {
256        let url = Self::normalize_url(server_url)?;
257        let endpoint = format!("{}/Users/AuthenticateByName", url);
258        let username = Self::normalize_username(username);
259
260        log::info!("[AuthManager] Authenticating user: {}", username);
261
262        // Build auth header for login request
263        let auth_header = HttpClient::build_auth_header(None, device_id);
264
265        // Build request manually for custom headers
266        let request = self
267            .http_client
268            .client
269            .post(&endpoint)
270            .header("Content-Type", "application/json")
271            .header("Authorization", auth_header)
272            .json(&serde_json::json!({
273                "Username": username,
274                "Pw": password,
275            }))
276            .build()
277            .map_err(|e| format!("Failed to build request: {}", e))?;
278
279        // Use retry logic
280        let response = self
281            .http_client
282            .request_with_retry(request)
283            .await
284            .map_err(|e| format!("Login request failed: {}", e))?;
285
286        if !response.status().is_success() {
287            let status = response.status();
288            let error_text = response
289                .text()
290                .await
291                .unwrap_or_else(|_| "Unknown error".to_string());
292            return Err(format!("Login failed: HTTP {}: {}", status, error_text));
293        }
294
295        let auth_response: AuthenticateByNameResponse = response
296            .json()
297            .await
298            .map_err(|e| format!("Failed to parse login response: {}", e))?;
299
300        log::info!(
301            "[AuthManager] Login successful for user: {} ({})",
302            auth_response.user.name,
303            auth_response.user.id
304        );
305
306        // Mark server as reachable
307        if let Some(monitor) = &self.connectivity_monitor {
308            let monitor = monitor.lock().await;
309            monitor.mark_reachable().await;
310        }
311
312        let user = User {
313            id: auth_response.user.id,
314            name: auth_response.user.name,
315            server_id: auth_response.user.server_id,
316            primary_image_tag: auth_response.user.primary_image_tag,
317        };
318
319        Ok(AuthResult {
320            user,
321            access_token: auth_response.access_token,
322            server_id: auth_response.server_id,
323        })
324    }
325
326    /// Verify current session by fetching user info
327    pub async fn verify_session(
328        &self,
329        server_url: &str,
330        user_id: &str,
331        access_token: &str,
332        device_id: &str,
333    ) -> Result<User, String> {
334        let url = Self::normalize_url(server_url)?;
335        let endpoint = format!("{}/Users/{}", url, user_id);
336
337        log::info!("[AuthManager] Verifying session for user: {}", user_id);
338
339        // Build auth header
340        let auth_header = HttpClient::build_auth_header(Some(access_token), device_id);
341
342        // Build request manually for custom headers
343        let request = self
344            .http_client
345            .client
346            .get(&endpoint)
347            .header("Authorization", auth_header)
348            .build()
349            .map_err(|e| format!("Failed to build request: {}", e))?;
350
351        // Use retry logic
352        let response = self
353            .http_client
354            .request_with_retry(request)
355            .await
356            .map_err(|e| {
357                log::warn!("[AuthManager] Session verification failed: {}", e);
358                format!("Session verification failed: {}", e)
359            })?;
360
361        if !response.status().is_success() {
362            let status = response.status();
363            let error_text = response
364                .text()
365                .await
366                .unwrap_or_else(|_| "Unknown error".to_string());
367
368            // Mark server as unreachable for auth errors
369            if status.as_u16() == 401 || status.as_u16() == 403 {
370                log::warn!("[AuthManager] Session invalid: HTTP {}", status);
371                if let Some(monitor) = &self.connectivity_monitor {
372                    let monitor = monitor.lock().await;
373                    monitor
374                        .mark_unreachable(Some(format!("Authentication failed: {}", status)))
375                        .await;
376                }
377            }
378
379            return Err(format!("HTTP {}: {}", status, error_text));
380        }
381
382        let user_response: JellyfinUser = response
383            .json()
384            .await
385            .map_err(|e| format!("Failed to parse user response: {}", e))?;
386
387        log::info!(
388            "[AuthManager] Session verified successfully for: {}",
389            user_response.name
390        );
391
392        // Mark server as reachable
393        if let Some(monitor) = &self.connectivity_monitor {
394            let monitor = monitor.lock().await;
395            monitor.mark_reachable().await;
396        }
397
398        Ok(User {
399            id: user_response.id,
400            name: user_response.name,
401            server_id: user_response.server_id,
402            primary_image_tag: user_response.primary_image_tag,
403        })
404    }
405
406    /// Logout (call Jellyfin logout endpoint)
407    pub async fn logout(
408        &self,
409        server_url: &str,
410        access_token: &str,
411        device_id: &str,
412    ) -> Result<(), String> {
413        let url = Self::normalize_url(server_url)?;
414        let endpoint = format!("{}/Sessions/Logout", url);
415
416        log::info!("[AuthManager] Logging out");
417
418        // Build auth header
419        let auth_header = HttpClient::build_auth_header(Some(access_token), device_id);
420
421        // Build request
422        let request = self
423            .http_client
424            .client
425            .post(&endpoint)
426            .header("Authorization", auth_header)
427            .build()
428            .map_err(|e| format!("Failed to build request: {}", e))?;
429
430        // Don't retry logout - if it fails, we'll still clear local state
431        match self.http_client.client.execute(request).await {
432            Ok(response) => {
433                if response.status().is_success() {
434                    log::info!("[AuthManager] Logout successful");
435                } else {
436                    log::warn!("[AuthManager] Logout request failed: {}", response.status());
437                }
438            }
439            Err(e) => {
440                log::warn!("[AuthManager] Logout request failed: {}", e);
441            }
442        }
443
444        Ok(())
445    }
446
447    /// Get current session
448    pub async fn get_session(&self) -> Option<Session> {
449        self.current_session.read().await.clone()
450    }
451
452    /// Set current session
453    pub async fn set_session(&self, session: Option<Session>) {
454        *self.current_session.write().await = session;
455    }
456}
457
458#[cfg(test)]
459mod compatibility_tests {
460    use super::*;
461    use crate::repository::capabilities::ServerCapabilities;
462
463    /// Mirror of the mapping in `connect_to_server`, so the verdict can be
464    /// asserted without standing up an HTTP server.
465    fn verdict(reported: &str) -> ServerCompatibility {
466        let capabilities = ServerCapabilities::from_reported(reported);
467        if capabilities.is_below_supported_floor() {
468            let (major, minor) = crate::repository::capabilities::MINIMUM_SUPPORTED_MAJOR_MINOR;
469            return ServerCompatibility::TooOld {
470                minimum: format!("{major}.{minor}"),
471            };
472        }
473        use crate::repository::capabilities::ServerGeneration;
474        match capabilities.generation {
475            ServerGeneration::Unknown => ServerCompatibility::UnknownVersion,
476            ServerGeneration::V12Plus
477                if capabilities.version.as_ref().is_some_and(|v| v.major > 12) =>
478            {
479                ServerCompatibility::NewerThanKnown
480            }
481            _ => ServerCompatibility::Supported,
482        }
483    }
484
485    /// Both live generations are supported outright. 12.0 is the current stable
486    /// and 10.11.x is what this client was built against.
487    ///
488    /// TRACES: UR-085 | DR-286
489    #[test]
490    fn both_live_generations_are_supported() {
491        assert_eq!(verdict("10.11.5"), ServerCompatibility::Supported);
492        assert_eq!(verdict("10.11.11"), ServerCompatibility::Supported);
493        assert_eq!(verdict("12.0.0"), ServerCompatibility::Supported);
494    }
495
496    /// A server newer than this build is usable, not refused — otherwise every
497    /// release would expire the moment the server upgraded.
498    ///
499    /// TRACES: UR-085 | DR-286
500    #[test]
501    fn a_newer_server_is_usable_not_refused() {
502        assert_eq!(verdict("13.0.0"), ServerCompatibility::NewerThanKnown);
503        assert_eq!(verdict("99.1.2"), ServerCompatibility::NewerThanKnown);
504    }
505
506    /// An unreadable version is not grounds for refusal.
507    ///
508    /// TRACES: UR-085 | DR-286
509    #[test]
510    fn an_unreadable_version_is_not_a_refusal() {
511        assert_eq!(
512            verdict("not-a-version"),
513            ServerCompatibility::UnknownVersion
514        );
515        assert_eq!(verdict(""), ServerCompatibility::UnknownVersion);
516    }
517
518    /// Only a server below the floor is refused, and it says what the floor is
519    /// so the message can name it.
520    ///
521    /// TRACES: UR-085 | DR-286
522    #[test]
523    fn only_a_server_below_the_floor_is_refused() {
524        assert_eq!(
525            verdict("10.9.11"),
526            ServerCompatibility::TooOld {
527                minimum: "10.10".to_string()
528            }
529        );
530        assert_eq!(verdict("10.10.0"), ServerCompatibility::Supported);
531    }
532}
533
534#[cfg(test)]
535mod tests {
536    use super::*;
537
538    /// Test URL normalization - adds https:// when missing
539    #[test]
540    fn test_normalize_url_adds_https() {
541        assert_eq!(
542            AuthManager::normalize_url("jellyfin.example.com").unwrap(),
543            "https://jellyfin.example.com"
544        );
545        assert_eq!(
546            AuthManager::normalize_url("192.168.1.100:8096").unwrap(),
547            "https://192.168.1.100:8096"
548        );
549    }
550
551    /// Test URL normalization - preserves existing https
552    #[test]
553    fn test_normalize_url_preserves_https() {
554        assert_eq!(
555            AuthManager::normalize_url("https://jellyfin.example.com").unwrap(),
556            "https://jellyfin.example.com"
557        );
558    }
559
560    /// Test URL normalization - rejects HTTP
561    #[test]
562    fn test_normalize_url_rejects_http() {
563        assert!(AuthManager::normalize_url("http://localhost:8096").is_err());
564        assert!(AuthManager::normalize_url("http://jellyfin.example.com").is_err());
565    }
566
567    /// Test URL normalization - removes trailing slash
568    #[test]
569    fn test_normalize_url_removes_trailing_slash() {
570        assert_eq!(
571            AuthManager::normalize_url("https://jellyfin.example.com/").unwrap(),
572            "https://jellyfin.example.com"
573        );
574        assert_eq!(
575            AuthManager::normalize_url("jellyfin.example.com/").unwrap(),
576            "https://jellyfin.example.com"
577        );
578    }
579
580    /// Test URL normalization - trims whitespace
581    #[test]
582    fn test_normalize_url_trims_whitespace() {
583        assert_eq!(
584            AuthManager::normalize_url("  jellyfin.example.com  ").unwrap(),
585            "https://jellyfin.example.com"
586        );
587        assert_eq!(
588            AuthManager::normalize_url("  https://jellyfin.example.com/  ").unwrap(),
589            "https://jellyfin.example.com"
590        );
591    }
592
593    /// Usernames must be trimmed before they reach the server: the Android soft
594    /// keyboard appends a trailing space after autocorrect, and Jellyfin then
595    /// reports an unknown user — a 401 indistinguishable from a wrong password.
596    #[test]
597    fn test_normalize_username_trims_whitespace() {
598        assert_eq!(AuthManager::normalize_username("duncan "), "duncan");
599        assert_eq!(AuthManager::normalize_username(" duncan"), "duncan");
600        assert_eq!(AuthManager::normalize_username("  duncan  "), "duncan");
601        assert_eq!(AuthManager::normalize_username("duncan\n"), "duncan");
602    }
603
604    /// Interior spaces are legal in Jellyfin usernames and must survive.
605    #[test]
606    fn test_normalize_username_preserves_interior_spaces() {
607        assert_eq!(
608            AuthManager::normalize_username("  duncan tourolle  "),
609            "duncan tourolle"
610        );
611    }
612
613    /// Test URL normalization - real world case
614    #[test]
615    fn test_normalize_url_real_world_case() {
616        let input = "jellyfin.tourolle.paris";
617        let normalized = AuthManager::normalize_url(input).unwrap();
618
619        assert_eq!(normalized, "https://jellyfin.tourolle.paris");
620        assert!(normalized.starts_with("https://"));
621        assert!(!normalized.ends_with('/'));
622    }
623}