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}
23
24#[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#[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#[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#[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
84pub 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 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 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 pub fn normalize_url(url: &str) -> Result<String, String> {
112 let mut normalized = url.trim().to_string();
113
114 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 if !normalized.starts_with("https://") {
121 normalized = format!("https://{}", normalized);
122 }
123
124 if normalized.ends_with('/') {
126 normalized.pop();
127 }
128
129 Ok(normalized)
130 }
131
132 pub fn normalize_username(username: &str) -> String {
141 username.trim().to_string()
142 }
143
144 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 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 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 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 let auth_header = HttpClient::build_auth_header(None, device_id);
206
207 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 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 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 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 let auth_header = HttpClient::build_auth_header(Some(access_token), device_id);
283
284 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 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 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 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 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 let auth_header = HttpClient::build_auth_header(Some(access_token), device_id);
362
363 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 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 pub async fn get_session(&self) -> Option<Session> {
391 self.current_session.read().await.clone()
392 }
393
394 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]
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]
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]
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]
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]
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 #[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 #[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]
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}