Files
jellytau/src-tauri/src/auth/mod.rs
dtourolle d9e1e256e9 fix(auth): trim the username before authenticating
The login form guarded on `username.trim()` but sent the raw value, so a
trailing space from a soft keyboard reached the server verbatim. Jellyfin
reports that as an unknown user, which surfaces as a 401 indistinguishable
from a wrong password — the user is certain of their credentials and the app
insists otherwise.

Normalising in AuthManager rather than the form keeps it on the path every
caller uses, alongside normalize_url. Only surrounding whitespace is
stripped; interior spaces are legal in Jellyfin usernames.
2026-08-16 11:31:34 +02:00

490 lines
15 KiB
Rust

pub mod session_verifier;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tokio::sync::RwLock;
use crate::connectivity::ConnectivityMonitor;
use crate::jellyfin::http_client::HttpClient;
pub use session_verifier::SessionVerifier;
/// Server information returned from Jellyfin
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[specta(rename = "AuthServerInfo")]
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(specta::Type, 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(specta::Type, 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(specta::Type, 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(specta::Type, Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct PublicSystemInfo {
server_name: String,
version: String,
id: String,
}
#[derive(specta::Type, Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct AuthenticateByNameResponse {
user: JellyfinUser,
access_token: String,
server_id: String,
}
#[derive(specta::Type, 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.
/// Enforces HTTPS — plain HTTP is rejected for security.
pub fn normalize_url(url: &str) -> Result<String, String> {
let mut normalized = url.trim().to_string();
// Reject plain HTTP — all connections must use HTTPS
if normalized.starts_with("http://") {
return Err("HTTP connections are not allowed. Please use HTTPS (e.g., https://your-server.com).".to_string());
}
// Add https:// if no protocol specified
if !normalized.starts_with("https://") {
normalized = format!("https://{}", normalized);
}
// Remove trailing slash
if normalized.ends_with('/') {
normalized.pop();
}
Ok(normalized)
}
/// Normalize a username before it goes to the server.
///
/// Only surrounding whitespace is stripped — interior spaces are legal in
/// Jellyfin usernames. Without this, a trailing space from a soft keyboard's
/// autocorrect makes the server report an unknown user, which surfaces as a
/// 401 that looks exactly like a wrong password.
///
/// TRACES: UR-042 | DR-054
pub fn normalize_username(username: &str) -> String {
username.trim().to_string()
}
/// 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_fast::<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);
let username = Self::normalize_username(username);
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
#[test]
fn test_normalize_url_adds_https() {
assert_eq!(
AuthManager::normalize_url("jellyfin.example.com").unwrap(),
"https://jellyfin.example.com"
);
assert_eq!(
AuthManager::normalize_url("192.168.1.100:8096").unwrap(),
"https://192.168.1.100:8096"
);
}
/// Test URL normalization - preserves existing https
#[test]
fn test_normalize_url_preserves_https() {
assert_eq!(
AuthManager::normalize_url("https://jellyfin.example.com").unwrap(),
"https://jellyfin.example.com"
);
}
/// Test URL normalization - rejects HTTP
#[test]
fn test_normalize_url_rejects_http() {
assert!(AuthManager::normalize_url("http://localhost:8096").is_err());
assert!(AuthManager::normalize_url("http://jellyfin.example.com").is_err());
}
/// Test URL normalization - removes trailing slash
#[test]
fn test_normalize_url_removes_trailing_slash() {
assert_eq!(
AuthManager::normalize_url("https://jellyfin.example.com/").unwrap(),
"https://jellyfin.example.com"
);
assert_eq!(
AuthManager::normalize_url("jellyfin.example.com/").unwrap(),
"https://jellyfin.example.com"
);
}
/// Test URL normalization - trims whitespace
#[test]
fn test_normalize_url_trims_whitespace() {
assert_eq!(
AuthManager::normalize_url(" jellyfin.example.com ").unwrap(),
"https://jellyfin.example.com"
);
assert_eq!(
AuthManager::normalize_url(" https://jellyfin.example.com/ ").unwrap(),
"https://jellyfin.example.com"
);
}
/// Usernames must be trimmed before they reach the server: the Android soft
/// keyboard appends a trailing space after autocorrect, and Jellyfin then
/// reports an unknown user — a 401 indistinguishable from a wrong password.
#[test]
fn test_normalize_username_trims_whitespace() {
assert_eq!(AuthManager::normalize_username("duncan "), "duncan");
assert_eq!(AuthManager::normalize_username(" duncan"), "duncan");
assert_eq!(AuthManager::normalize_username(" duncan "), "duncan");
assert_eq!(AuthManager::normalize_username("duncan\n"), "duncan");
}
/// Interior spaces are legal in Jellyfin usernames and must survive.
#[test]
fn test_normalize_username_preserves_interior_spaces() {
assert_eq!(
AuthManager::normalize_username(" duncan tourolle "),
"duncan tourolle"
);
}
/// Test URL normalization - real world case
#[test]
fn test_normalize_url_real_world_case() {
let input = "jellyfin.tourolle.paris";
let normalized = AuthManager::normalize_url(input).unwrap();
assert_eq!(normalized, "https://jellyfin.tourolle.paris");
assert!(normalized.starts_with("https://"));
assert!(!normalized.ends_with('/'));
}
}