From 9bb5b44d0ff5480701e80795967e0a502de5813a Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Tue, 8 Sep 2026 20:09:34 +0200 Subject: [PATCH] fix(auth): use the authentication spellings Jellyfin 12.0 leaves enabled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src-tauri/src/auth/mod.rs | 140 ++++++++++++++++++++- src-tauri/src/jellyfin/client.rs | 15 ++- src-tauri/src/jellyfin/http_client.rs | 32 ++++- src-tauri/src/player/mod.rs | 2 +- src-tauri/src/player/stream_end.rs | 10 +- src-tauri/src/repository/device_profile.rs | 8 +- 6 files changed, 187 insertions(+), 20 deletions(-) diff --git a/src-tauri/src/auth/mod.rs b/src-tauri/src/auth/mod.rs index 3f5888842..8c6d55320 100644 --- a/src-tauri/src/auth/mod.rs +++ b/src-tauri/src/auth/mod.rs @@ -19,6 +19,40 @@ pub struct ServerInfo { 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 @@ -166,11 +200,35 @@ impl AuthManager { 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) => { @@ -210,7 +268,7 @@ impl AuthManager { .client .post(&endpoint) .header("Content-Type", "application/json") - .header("X-Emby-Authorization", auth_header) + .header("Authorization", auth_header) .json(&serde_json::json!({ "Username": username, "Pw": password, @@ -286,7 +344,7 @@ impl AuthManager { .http_client .client .get(&endpoint) - .header("X-Emby-Authorization", auth_header) + .header("Authorization", auth_header) .build() .map_err(|e| format!("Failed to build request: {}", e))?; @@ -365,7 +423,7 @@ impl AuthManager { .http_client .client .post(&endpoint) - .header("X-Emby-Authorization", auth_header) + .header("Authorization", auth_header) .build() .map_err(|e| format!("Failed to build request: {}", e))?; @@ -397,6 +455,82 @@ impl AuthManager { } } +#[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::*; diff --git a/src-tauri/src/jellyfin/client.rs b/src-tauri/src/jellyfin/client.rs index 34514c34a..dd80cfb87 100644 --- a/src-tauri/src/jellyfin/client.rs +++ b/src-tauri/src/jellyfin/client.rs @@ -54,7 +54,10 @@ impl JellyfinClient { return "Unknown"; } - /// Build the X-Emby-Authorization header value + /// Build the value for the `Authorization` header (the `MediaBrowser` + /// scheme — see `HttpClient::build_auth_header`). + /// + /// TRACES: UR-085 | DR-287 fn get_auth_header(&self) -> String { format!( "MediaBrowser Client=\"{}\", Version=\"{}\", Device=\"{}\", DeviceId=\"{}\", Token=\"{}\"", @@ -75,7 +78,7 @@ impl JellyfinClient { let response = self .http_client .get(&url) - .header("X-Emby-Authorization", self.get_auth_header()) + .header("Authorization", self.get_auth_header()) .send() .await .map_err(|e| { @@ -155,7 +158,7 @@ impl JellyfinClient { .http_client .post(&url) .header("Content-Type", "application/json") - .header("X-Emby-Authorization", self.get_auth_header()) + .header("Authorization", self.get_auth_header()) .json(body) .send() .await @@ -293,7 +296,7 @@ impl JellyfinClient { let response = self .http_client .post(&url) - .header("X-Emby-Authorization", self.get_auth_header()) + .header("Authorization", self.get_auth_header()) .send() .await .map_err(|e| { @@ -360,7 +363,7 @@ impl JellyfinClient { let response = self .http_client .post(&url) - .header("X-Emby-Authorization", self.get_auth_header()) + .header("Authorization", self.get_auth_header()) .send() .await .map_err(|e| format!("Network request failed: {}", e))?; @@ -503,7 +506,7 @@ impl JellyfinClient { let response = self .http_client .delete(&url) - .header("X-Emby-Authorization", self.get_auth_header()) + .header("Authorization", self.get_auth_header()) .send() .await .map_err(|e| format!("Network request failed: {}", e))?; diff --git a/src-tauri/src/jellyfin/http_client.rs b/src-tauri/src/jellyfin/http_client.rs index f27c88870..77c24f1da 100644 --- a/src-tauri/src/jellyfin/http_client.rs +++ b/src-tauri/src/jellyfin/http_client.rs @@ -56,6 +56,27 @@ impl HttpClient { Ok(Self { client, config }) } + /// A client that will also talk plain HTTP, for tests only. + /// + /// `new` sets `https_only(true)` and that must stay: it is what stops a + /// downgrade putting a session token on the wire in clear. `wiremock` serves + /// plain HTTP on loopback, so the alternative to this constructor is either + /// weakening the real one or not testing the repository against a server at + /// all — and the latter is what DR-281 exists to end. + /// + /// `#[cfg(test)]` so it cannot reach a shipped binary. + /// + /// TRACES: UR-085 | DR-281 + #[cfg(test)] + pub fn new_allowing_plaintext_for_tests(config: HttpConfig) -> Result { + let client = Client::builder() + .timeout(config.timeout) + .build() + .map_err(|e| format!("Failed to create HTTP client: {}", e))?; + + Ok(Self { client, config }) + } + /// Get device name based on platform fn get_device_name() -> &'static str { #[cfg(target_os = "android")] @@ -78,7 +99,16 @@ impl HttpClient { return "Unknown"; } - /// Build the X-Emby-Authorization header value + /// Build the value for the `Authorization` header. + /// + /// The `MediaBrowser` scheme, which is the non-deprecated one: Jellyfin 12.0 + /// disables `X-Emby-Authorization` (and the `Emby` scheme, `X-Emby-Token` + /// and `X-MediaBrowser-Token`) by default, and a migration turns it off on + /// upgraded servers too. `Authorization: MediaBrowser …` is ungated on both + /// 10.11.x and 12.x, so this is one value for both generations rather than a + /// capability branch. + /// + /// TRACES: UR-085 | DR-287 pub fn build_auth_header(access_token: Option<&str>, device_id: &str) -> String { let mut parts = vec![ format!("MediaBrowser Client=\"{}\"", APP_NAME), diff --git a/src-tauri/src/player/mod.rs b/src-tauri/src/player/mod.rs index 96238936f..6b20e84fe 100644 --- a/src-tauri/src/player/mod.rs +++ b/src-tauri/src/player/mod.rs @@ -4475,7 +4475,7 @@ mod tests { duration: Some(runtime_seconds), source: MediaSource::Remote { stream_url: - "http://s/Audio/ep2/universal?api_key=k&AudioStreamIndex=2&StartTimeTicks=0" + "http://s/Audio/ep2/universal?ApiKey=k&AudioStreamIndex=2&StartTimeTicks=0" .to_string(), jellyfin_item_id: "ep2".to_string(), }, diff --git a/src-tauri/src/player/stream_end.rs b/src-tauri/src/player/stream_end.rs index 02ae3fcd6..dfd19dbc8 100644 --- a/src-tauri/src/player/stream_end.rs +++ b/src-tauri/src/player/stream_end.rs @@ -118,7 +118,7 @@ pub fn is_truncated_end(position: f64, duration: Option, tolerance: f64) -> /// Resuming re-opens *the stream we were already playing*, so the URL is edited /// in place rather than rebuilt from the repository: every other parameter — /// `AudioStreamIndex` (the track the user picked in the video player), -/// `MediaSourceId`, `api_key` — is carried over untouched, and no network call +/// `MediaSourceId`, `ApiKey` — is carried over untouched, and no network call /// is needed to recover from a network failure. pub fn with_start_time(url: &str, position_seconds: f64) -> String { let ticks = (position_seconds.max(0.0) * 10_000_000.0) as i64; @@ -387,22 +387,22 @@ mod tests { #[test] fn test_with_start_time_replaces_existing_ticks() { - let url = "http://s/Audio/ep2/universal?api_key=k&AudioStreamIndex=2&StartTimeTicks=1200000000&Container=mp3"; + let url = "http://s/Audio/ep2/universal?ApiKey=k&AudioStreamIndex=2&StartTimeTicks=1200000000&Container=mp3"; let out = with_start_time(url, 600.0); assert_eq!( out, - "http://s/Audio/ep2/universal?api_key=k&AudioStreamIndex=2&StartTimeTicks=6000000000&Container=mp3" + "http://s/Audio/ep2/universal?ApiKey=k&AudioStreamIndex=2&StartTimeTicks=6000000000&Container=mp3" ); } #[test] fn test_with_start_time_appends_when_absent() { // The next-episode stream is built without StartTimeTicks. - let url = "http://s/Audio/ep3/universal?api_key=k&AudioStreamIndex=0"; + let url = "http://s/Audio/ep3/universal?ApiKey=k&AudioStreamIndex=0"; let out = with_start_time(url, 90.0); assert_eq!( out, - "http://s/Audio/ep3/universal?api_key=k&AudioStreamIndex=0&StartTimeTicks=900000000" + "http://s/Audio/ep3/universal?ApiKey=k&AudioStreamIndex=0&StartTimeTicks=900000000" ); } diff --git a/src-tauri/src/repository/device_profile.rs b/src-tauri/src/repository/device_profile.rs index d0fb83665..fd7c74e49 100644 --- a/src-tauri/src/repository/device_profile.rs +++ b/src-tauri/src/repository/device_profile.rs @@ -434,7 +434,7 @@ mod tests { #[test] fn an_unconditional_burn_in_flag_is_stripped_whatever_its_casing() { let url = without_server_chosen_subtitle( - "/videos/abc/master.m3u8?api_key=k&alwaysBurnInSubtitleWhenTranscoding=true\ + "/videos/abc/master.m3u8?ApiKey=k&alwaysBurnInSubtitleWhenTranscoding=true\ &subtitlestreamindex=3&SubtitleCodec=ass", ); @@ -442,7 +442,7 @@ mod tests { assert!(!url.to_lowercase().contains("subtitlecodec"), "{url}"); assert!(!url.contains("subtitlestreamindex=3"), "{url}"); assert!(url.contains("SubtitleStreamIndex=-1"), "{url}"); - assert!(url.contains("api_key=k"), "{url}"); + assert!(url.contains("ApiKey=k"), "{url}"); } /// A URL the server built without any subtitle in it still has to *say* so: @@ -451,10 +451,10 @@ mod tests { /// TRACES: UR-020, UR-004 | DR-176 | UT-168 #[test] fn a_url_with_no_subtitle_params_is_still_made_to_ask_for_none() { - let url = without_server_chosen_subtitle("/videos/abc/master.m3u8?api_key=k"); + let url = without_server_chosen_subtitle("/videos/abc/master.m3u8?ApiKey=k"); assert_eq!( url, - "/videos/abc/master.m3u8?api_key=k&SubtitleStreamIndex=-1" + "/videos/abc/master.m3u8?ApiKey=k&SubtitleStreamIndex=-1" ); // A bare URL is rare but must not come out malformed.