First working POC
This commit is contained in:
@@ -0,0 +1,409 @@
|
||||
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(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
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(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<String>,
|
||||
}
|
||||
|
||||
/// Authentication result
|
||||
#[derive(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(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(Debug, Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
struct PublicSystemInfo {
|
||||
server_name: String,
|
||||
version: String,
|
||||
id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
struct AuthenticateByNameResponse {
|
||||
user: JellyfinUser,
|
||||
access_token: String,
|
||||
server_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
struct JellyfinUser {
|
||||
id: String,
|
||||
name: String,
|
||||
server_id: String,
|
||||
primary_image_tag: Option<String>,
|
||||
}
|
||||
|
||||
/// Authentication manager
|
||||
pub struct AuthManager {
|
||||
http_client: Arc<HttpClient>,
|
||||
current_session: Arc<RwLock<Option<Session>>>,
|
||||
connectivity_monitor: Option<Arc<tokio::sync::Mutex<ConnectivityMonitor>>>,
|
||||
}
|
||||
|
||||
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<tokio::sync::Mutex<ConnectivityMonitor>>) {
|
||||
self.connectivity_monitor = Some(monitor);
|
||||
}
|
||||
|
||||
/// Normalize and validate server URL
|
||||
pub fn normalize_url(url: &str) -> String {
|
||||
let mut normalized = url.trim().to_string();
|
||||
|
||||
// Add https:// if no protocol specified
|
||||
if !normalized.starts_with("http://") && !normalized.starts_with("https://") {
|
||||
normalized = format!("https://{}", normalized);
|
||||
}
|
||||
|
||||
// Remove trailing slash
|
||||
if normalized.ends_with('/') {
|
||||
normalized.pop();
|
||||
}
|
||||
|
||||
normalized
|
||||
}
|
||||
|
||||
/// Connect to server and get server info
|
||||
pub async fn connect_to_server(&self, server_url: &str) -> Result<ServerInfo, String> {
|
||||
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::<PublicSystemInfo>(&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<AuthResult, String> {
|
||||
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<User, String> {
|
||||
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<Session> {
|
||||
self.current_session.read().await.clone()
|
||||
}
|
||||
|
||||
/// Set current session
|
||||
pub async fn set_session(&self, session: Option<Session>) {
|
||||
*self.current_session.write().await = session;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Test URL normalization - adds https:// when missing
|
||||
///
|
||||
/// Ensures that URLs without protocol are normalized to https://
|
||||
/// This prevents "builder error" when constructing HTTP requests.
|
||||
#[test]
|
||||
fn test_normalize_url_adds_https() {
|
||||
assert_eq!(
|
||||
AuthManager::normalize_url("jellyfin.example.com"),
|
||||
"https://jellyfin.example.com"
|
||||
);
|
||||
assert_eq!(
|
||||
AuthManager::normalize_url("192.168.1.100:8096"),
|
||||
"https://192.168.1.100:8096"
|
||||
);
|
||||
}
|
||||
|
||||
/// Test URL normalization - preserves existing protocol
|
||||
#[test]
|
||||
fn test_normalize_url_preserves_protocol() {
|
||||
assert_eq!(
|
||||
AuthManager::normalize_url("https://jellyfin.example.com"),
|
||||
"https://jellyfin.example.com"
|
||||
);
|
||||
assert_eq!(
|
||||
AuthManager::normalize_url("http://localhost:8096"),
|
||||
"http://localhost:8096"
|
||||
);
|
||||
}
|
||||
|
||||
/// Test URL normalization - removes trailing slash
|
||||
#[test]
|
||||
fn test_normalize_url_removes_trailing_slash() {
|
||||
assert_eq!(
|
||||
AuthManager::normalize_url("https://jellyfin.example.com/"),
|
||||
"https://jellyfin.example.com"
|
||||
);
|
||||
assert_eq!(
|
||||
AuthManager::normalize_url("jellyfin.example.com/"),
|
||||
"https://jellyfin.example.com"
|
||||
);
|
||||
}
|
||||
|
||||
/// Test URL normalization - trims whitespace
|
||||
#[test]
|
||||
fn test_normalize_url_trims_whitespace() {
|
||||
assert_eq!(
|
||||
AuthManager::normalize_url(" jellyfin.example.com "),
|
||||
"https://jellyfin.example.com"
|
||||
);
|
||||
assert_eq!(
|
||||
AuthManager::normalize_url(" https://jellyfin.example.com/ "),
|
||||
"https://jellyfin.example.com"
|
||||
);
|
||||
}
|
||||
|
||||
/// Test URL normalization - complex case
|
||||
///
|
||||
/// This is the bug that caused the login issue: user enters URL
|
||||
/// without protocol, it gets stored in DB, then fails when building
|
||||
/// HTTP requests.
|
||||
#[test]
|
||||
fn test_normalize_url_real_world_case() {
|
||||
// User input: "jellyfin.tourolle.paris"
|
||||
let input = "jellyfin.tourolle.paris";
|
||||
let normalized = AuthManager::normalize_url(input);
|
||||
|
||||
assert_eq!(normalized, "https://jellyfin.tourolle.paris");
|
||||
assert!(normalized.starts_with("https://"));
|
||||
assert!(!normalized.ends_with('/'));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user