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#[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 pub normalized_url: String,
22 pub compatibility: ServerCompatibility,
31}
32
33#[derive(specta::Type, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43#[serde(rename_all = "camelCase", tag = "type")]
44pub enum ServerCompatibility {
45 Supported,
47 NewerThanKnown,
51 UnknownVersion,
54 TooOld { minimum: String },
56}
57
58#[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#[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#[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#[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
118pub 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 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 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 pub fn normalize_url(url: &str) -> Result<String, String> {
146 let mut normalized = url.trim().to_string();
147
148 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 if !normalized.starts_with("https://") {
155 normalized = format!("https://{}", normalized);
156 }
157
158 if normalized.ends_with('/') {
160 normalized.pop();
161 }
162
163 Ok(normalized)
164 }
165
166 pub fn normalize_username(username: &str) -> String {
175 username.trim().to_string()
176 }
177
178 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 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 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 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 let auth_header = HttpClient::build_auth_header(None, device_id);
264
265 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 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 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 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 let auth_header = HttpClient::build_auth_header(Some(access_token), device_id);
341
342 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 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 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 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 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 let auth_header = HttpClient::build_auth_header(Some(access_token), device_id);
420
421 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 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 pub async fn get_session(&self) -> Option<Session> {
449 self.current_session.read().await.clone()
450 }
451
452 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 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 #[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 #[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 #[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 #[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]
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]
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]
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]
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]
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 #[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 #[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]
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}