X-Emby-Authorization at the remaining request builders, and api_key= in the player-facing URLs, become Authorization and ApiKey. Jellyfin 12.0 disables X-Emby-Authorization, X-Emby-Token, X-MediaBrowser-Token, the Emby scheme and the api_key query parameter by default — and a migration (DisableLegacyAuthorization) turns them off on servers upgraded from 10.11 as well, so this is not confined to fresh installs. A client using them stops working against an upgraded server rather than degrading. Verified at source level rather than inferred: AuthorizationContext.cs is byte-identical between v10.11.5 and v12.0 apart from whitespace. The only change is the default of the gate that guards the legacy spellings. Authorization with the MediaBrowser scheme, and ApiKey as a query parameter, are ungated in both trees — and the server itself emits ApiKey in both (StreamInfo.cs). So one spelling is correct everywhere and no capability flag is involved. Also adds ServerCompatibility to ServerInfo: an opaque verdict the frontend renders without ever comparing a version number, with three states rather than a boolean. A server newer than this build is usable, not refused; an unreadable version string is not grounds for refusal either. Only a server below the floor is refused. TRACES: UR-085 | DR-286, DR-287 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
624 lines
21 KiB
Rust
624 lines
21 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,
|
|
/// Whether this build can talk to this server, as an **opaque state**.
|
|
///
|
|
/// The version string above is informational — for display and for the log.
|
|
/// This is the judgement, made in Rust, because deciding whether an API
|
|
/// version is usable is domain reasoning: the frontend must never compare a
|
|
/// version number, for the same reason it never receives an item-type list.
|
|
///
|
|
/// TRACES: UR-085 | DR-286
|
|
pub compatibility: ServerCompatibility,
|
|
}
|
|
|
|
/// The verdict on a server's version.
|
|
///
|
|
/// Deliberately three states rather than a boolean. "Unrecognised" is not a
|
|
/// failure: a server newer than this build resolves forward and works, and
|
|
/// refusing it would make every JellyTau release expire the moment the server
|
|
/// upgrades. Only a server below the supported floor is refused, where failure
|
|
/// is certain rather than merely likely.
|
|
///
|
|
/// TRACES: UR-085 | DR-286
|
|
#[derive(specta::Type, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase", tag = "type")]
|
|
pub enum ServerCompatibility {
|
|
/// A generation this build knows and was tested against.
|
|
Supported,
|
|
/// Parsed, but newer than anything this build knows. Treated as the newest
|
|
/// known generation; everything works, and this exists so the UI *may*
|
|
/// mention it rather than so it must.
|
|
NewerThanKnown,
|
|
/// The version string could not be parsed. Treated as supported — we do not
|
|
/// refuse a server on the strength of not understanding its version string.
|
|
UnknownVersion,
|
|
/// Below the supported floor. This one is a refusal.
|
|
TooOld { minimum: 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;
|
|
}
|
|
|
|
let capabilities =
|
|
crate::repository::capabilities::ServerCapabilities::from_reported(
|
|
&info.version,
|
|
);
|
|
let compatibility = if capabilities.is_below_supported_floor() {
|
|
let (major, minor) =
|
|
crate::repository::capabilities::MINIMUM_SUPPORTED_MAJOR_MINOR;
|
|
ServerCompatibility::TooOld {
|
|
minimum: format!("{major}.{minor}"),
|
|
}
|
|
} else {
|
|
use crate::repository::capabilities::ServerGeneration;
|
|
match capabilities.generation {
|
|
ServerGeneration::Unknown => ServerCompatibility::UnknownVersion,
|
|
ServerGeneration::V12Plus
|
|
if capabilities.version.as_ref().is_some_and(|v| v.major > 12) =>
|
|
{
|
|
ServerCompatibility::NewerThanKnown
|
|
}
|
|
_ => ServerCompatibility::Supported,
|
|
}
|
|
};
|
|
|
|
Ok(ServerInfo {
|
|
name: info.server_name,
|
|
version: info.version,
|
|
id: info.id,
|
|
normalized_url,
|
|
compatibility,
|
|
})
|
|
}
|
|
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("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("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("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 compatibility_tests {
|
|
use super::*;
|
|
use crate::repository::capabilities::ServerCapabilities;
|
|
|
|
/// Mirror of the mapping in `connect_to_server`, so the verdict can be
|
|
/// asserted without standing up an HTTP server.
|
|
fn verdict(reported: &str) -> ServerCompatibility {
|
|
let capabilities = ServerCapabilities::from_reported(reported);
|
|
if capabilities.is_below_supported_floor() {
|
|
let (major, minor) = crate::repository::capabilities::MINIMUM_SUPPORTED_MAJOR_MINOR;
|
|
return ServerCompatibility::TooOld {
|
|
minimum: format!("{major}.{minor}"),
|
|
};
|
|
}
|
|
use crate::repository::capabilities::ServerGeneration;
|
|
match capabilities.generation {
|
|
ServerGeneration::Unknown => ServerCompatibility::UnknownVersion,
|
|
ServerGeneration::V12Plus
|
|
if capabilities.version.as_ref().is_some_and(|v| v.major > 12) =>
|
|
{
|
|
ServerCompatibility::NewerThanKnown
|
|
}
|
|
_ => ServerCompatibility::Supported,
|
|
}
|
|
}
|
|
|
|
/// Both live generations are supported outright. 12.0 is the current stable
|
|
/// and 10.11.x is what this client was built against.
|
|
///
|
|
/// TRACES: UR-085 | DR-286
|
|
#[test]
|
|
fn both_live_generations_are_supported() {
|
|
assert_eq!(verdict("10.11.5"), ServerCompatibility::Supported);
|
|
assert_eq!(verdict("10.11.11"), ServerCompatibility::Supported);
|
|
assert_eq!(verdict("12.0.0"), ServerCompatibility::Supported);
|
|
}
|
|
|
|
/// A server newer than this build is usable, not refused — otherwise every
|
|
/// release would expire the moment the server upgraded.
|
|
///
|
|
/// TRACES: UR-085 | DR-286
|
|
#[test]
|
|
fn a_newer_server_is_usable_not_refused() {
|
|
assert_eq!(verdict("13.0.0"), ServerCompatibility::NewerThanKnown);
|
|
assert_eq!(verdict("99.1.2"), ServerCompatibility::NewerThanKnown);
|
|
}
|
|
|
|
/// An unreadable version is not grounds for refusal.
|
|
///
|
|
/// TRACES: UR-085 | DR-286
|
|
#[test]
|
|
fn an_unreadable_version_is_not_a_refusal() {
|
|
assert_eq!(
|
|
verdict("not-a-version"),
|
|
ServerCompatibility::UnknownVersion
|
|
);
|
|
assert_eq!(verdict(""), ServerCompatibility::UnknownVersion);
|
|
}
|
|
|
|
/// Only a server below the floor is refused, and it says what the floor is
|
|
/// so the message can name it.
|
|
///
|
|
/// TRACES: UR-085 | DR-286
|
|
#[test]
|
|
fn only_a_server_below_the_floor_is_refused() {
|
|
assert_eq!(
|
|
verdict("10.9.11"),
|
|
ServerCompatibility::TooOld {
|
|
minimum: "10.10".to_string()
|
|
}
|
|
);
|
|
assert_eq!(verdict("10.10.0"), ServerCompatibility::Supported);
|
|
}
|
|
}
|
|
|
|
#[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('/'));
|
|
}
|
|
}
|