diff --git a/README.md b/README.md index 53a40570..27165b2e 100644 --- a/README.md +++ b/README.md @@ -213,6 +213,10 @@ Choose your preferred provider via the `MUSIC_SERVICE` environment variable. Add JELLYFIN_LIBRARY_ID= ``` + Jellyfin 12 requires the standard `Authorization: MediaBrowser ...` header. + Allstarr forwards a client's login identity separately from its optional + server-side API key, and uses the standard header for API-key requests. + `localhost` inside the Allstarr container points to Allstarr itself, not to Jellyfin. The Compose file maps `host.docker.internal` on Linux, macOS, and Windows for a Jellyfin server running on the Docker host. diff --git a/allstarr.Tests/AdminAuthenticationMiddlewareTests.cs b/allstarr.Tests/AdminAuthenticationMiddlewareTests.cs index 17d23fc8..494ab259 100644 --- a/allstarr.Tests/AdminAuthenticationMiddlewareTests.cs +++ b/allstarr.Tests/AdminAuthenticationMiddlewareTests.cs @@ -32,6 +32,7 @@ public class AdminAuthenticationMiddlewareTests Assert.False(nextInvoked); Assert.Equal(StatusCodes.Status401Unauthorized, context.Response.StatusCode); + Assert.Equal("true", context.Response.Headers["X-Allstarr-Session-Expired"]); var body = await ReadResponseBodyAsync(context); Assert.Contains("Authentication required", body); @@ -70,6 +71,7 @@ public class AdminAuthenticationMiddlewareTests Assert.True(nextInvoked); Assert.Equal(StatusCodes.Status204NoContent, context.Response.StatusCode); Assert.True(context.Items.ContainsKey(AdminAuthSessionService.HttpContextSessionItemKey)); + Assert.False(context.Response.Headers.ContainsKey("X-Allstarr-Session-Expired")); } [Fact] diff --git a/allstarr.Tests/AdminHelperServiceTests.cs b/allstarr.Tests/AdminHelperServiceTests.cs new file mode 100644 index 00000000..46b5ac17 --- /dev/null +++ b/allstarr.Tests/AdminHelperServiceTests.cs @@ -0,0 +1,29 @@ +using allstarr.Models.Settings; +using allstarr.Services.Admin; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Moq; + +namespace allstarr.Tests; + +public class AdminHelperServiceTests +{ + [Fact] + public void CreateJellyfinRequest_UsesStandardAuthorizationForApiKey() + { + var environment = new Mock(); + environment.SetupGet(value => value.EnvironmentName).Returns("Production"); + var helper = new AdminHelperService( + NullLogger.Instance, + Options.Create(new JellyfinSettings { ApiKey = "test-api-key" }), + environment.Object); + + using var request = helper.CreateJellyfinRequest(HttpMethod.Get, "http://jellyfin.local/Users"); + + var authorization = Assert.Single(request.Headers.GetValues("Authorization")); + Assert.Contains("Client=\"Allstarr\"", authorization); + Assert.Contains("Token=\"test-api-key\"", authorization); + Assert.False(request.Headers.Contains("X-Emby-Authorization")); + } +} diff --git a/allstarr.Tests/AuthHeaderHelperTests.cs b/allstarr.Tests/AuthHeaderHelperTests.cs index 0c9a70e8..55df859a 100644 --- a/allstarr.Tests/AuthHeaderHelperTests.cs +++ b/allstarr.Tests/AuthHeaderHelperTests.cs @@ -7,25 +7,27 @@ namespace allstarr.Tests; public class AuthHeaderHelperTests { [Fact] - public void ForwardAuthHeaders_ShouldPreferXEmbyAuthorization() + public void ForwardAuthHeaders_ShouldPreferStandardAuthorization() { var headers = new HeaderDictionary { - ["X-Emby-Authorization"] = "MediaBrowser Token=\"abc\"", - ["Authorization"] = "Bearer xyz" + ["X-Emby-Authorization"] = "MediaBrowser Token=\"old\"", + ["X-Emby-Token"] = "old", + ["Authorization"] = "MediaBrowser Client=\"Yuzic\", Device=\"Phone\", DeviceId=\"device-1\", Version=\"2.0\"" }; using var request = new HttpRequestMessage(); var forwarded = AuthHeaderHelper.ForwardAuthHeaders(headers, request); Assert.True(forwarded); - Assert.True(request.Headers.TryGetValues("X-Emby-Authorization", out var values)); - Assert.Contains("MediaBrowser Token=\"abc\"", values); - Assert.False(request.Headers.Contains("Authorization")); + Assert.True(request.Headers.TryGetValues("Authorization", out var values)); + Assert.Contains("MediaBrowser Client=\"Yuzic\", Device=\"Phone\", DeviceId=\"device-1\", Version=\"2.0\"", values); + Assert.False(request.Headers.Contains("X-Emby-Authorization")); + Assert.False(request.Headers.Contains("X-Emby-Token")); } [Fact] - public void ForwardAuthHeaders_ShouldMapMediaBrowserAuthorizationToXEmby() + public void ForwardAuthHeaders_ShouldPreserveMediaBrowserAuthorization() { var headers = new HeaderDictionary { @@ -36,7 +38,36 @@ public class AuthHeaderHelperTests var forwarded = AuthHeaderHelper.ForwardAuthHeaders(headers, request); Assert.True(forwarded); - Assert.True(request.Headers.Contains("X-Emby-Authorization")); + Assert.Equal(headers["Authorization"].ToString(), request.Headers.GetValues("Authorization").Single()); + Assert.False(request.Headers.Contains("X-Emby-Authorization")); + } + + [Fact] + public void ForwardAuthHeaders_ShouldUpgradeLegacyMediaBrowserHeader() + { + var headers = new HeaderDictionary + { + ["X-Emby-Authorization"] = "MediaBrowser Client=\"OlderClient\", Device=\"Phone\", DeviceId=\"device-2\", Version=\"1.0\"" + }; + + using var request = new HttpRequestMessage(); + Assert.True(AuthHeaderHelper.ForwardAuthHeaders(headers, request)); + Assert.Equal(headers["X-Emby-Authorization"].ToString(), request.Headers.GetValues("Authorization").Single()); + Assert.False(request.Headers.Contains("X-Emby-Authorization")); + } + + [Fact] + public void ForwardAuthHeaders_ShouldKeepLegacyEmbySchemeForOlderServers() + { + var headers = new HeaderDictionary + { + ["X-Emby-Authorization"] = "Emby Client=\"OlderClient\"" + }; + + using var request = new HttpRequestMessage(); + Assert.True(AuthHeaderHelper.ForwardAuthHeaders(headers, request)); + Assert.Equal(headers["X-Emby-Authorization"].ToString(), request.Headers.GetValues("X-Emby-Authorization").Single()); + Assert.False(request.Headers.Contains("Authorization")); } [Fact] diff --git a/allstarr.Tests/JavaScriptSyntaxTests.cs b/allstarr.Tests/JavaScriptSyntaxTests.cs index 6f74e39b..2f26daf5 100644 --- a/allstarr.Tests/JavaScriptSyntaxTests.cs +++ b/allstarr.Tests/JavaScriptSyntaxTests.cs @@ -142,6 +142,7 @@ public class JavaScriptSyntaxTests Assert.Contains("window.openEditSetting", settingsContent); Assert.Contains("window.saveEditSetting", settingsContent); Assert.Contains("window.logoutAdminSession", authContent); + Assert.Contains("response.headers.get(\"X-Allstarr-Session-Expired\") === \"true\"", authContent); Assert.Contains("window.restartContainer", operationsContent); Assert.Contains("window.linkPlaylist", playlistContent); Assert.Contains("window.loadScrobblingConfig", scrobblingContent); diff --git a/allstarr.Tests/JellyfinAuthenticationProxyTests.cs b/allstarr.Tests/JellyfinAuthenticationProxyTests.cs new file mode 100644 index 00000000..0928e2e0 --- /dev/null +++ b/allstarr.Tests/JellyfinAuthenticationProxyTests.cs @@ -0,0 +1,73 @@ +using System.Net; +using System.Text; +using allstarr.Controllers; +using allstarr.Models.Settings; +using allstarr.Services.Common; +using allstarr.Services.Jellyfin; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Moq; + +namespace allstarr.Tests; + +public class JellyfinAuthenticationProxyTests +{ + [Fact] + public async Task AuthenticateByName_PreservesJellyfinErrorStatusAndClientIdentity() + { + string? forwardedAuthorization = null; + var handler = new StubHandler(request => + { + forwardedAuthorization = request.Headers.GetValues("Authorization").Single(); + return new HttpResponseMessage(HttpStatusCode.BadRequest) + { + Content = new StringContent("{\"error\":\"client identity required\"}") + }; + }); + var clientFactory = new Mock(); + clientFactory.Setup(factory => factory.CreateClient(It.IsAny())) + .Returns(new HttpClient(handler)); + var settings = Options.Create(new JellyfinSettings { Url = "http://jellyfin.local" }); + var context = new DefaultHttpContext(); + context.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes("{\"Username\":\"josh\",\"Pw\":\"test\"}")); + context.Request.Headers.Authorization = "MediaBrowser Client=\"Yuzic\", Device=\"Phone\", DeviceId=\"device-1\", Version=\"2.0\""; + var cache = new RedisCacheService( + Options.Create(new RedisSettings { Enabled = false }), + NullLogger.Instance); + var proxy = new JellyfinProxyService( + clientFactory.Object, + settings, + new HttpContextAccessor { HttpContext = context }, + NullLogger.Instance, + cache); + var controller = new JellyfinController( + settings, + Options.Create(new SpotifyImportSettings()), + Options.Create(new SpotifyApiSettings()), + Options.Create(new ScrobblingSettings()), + null!, null!, null!, null!, null!, + proxy, null!, null!, cache, + new ConfigurationBuilder().Build(), + NullLogger.Instance) + { + ControllerContext = new ControllerContext { HttpContext = context } + }; + + var result = await controller.AuthenticateByName(); + + var content = Assert.IsType(result); + Assert.Equal(StatusCodes.Status400BadRequest, content.StatusCode); + Assert.Equal("{\"error\":\"client identity required\"}", content.Content); + Assert.Equal(context.Request.Headers.Authorization.ToString(), forwardedAuthorization); + } + + private sealed class StubHandler(Func send) : HttpMessageHandler + { + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) => Task.FromResult(send(request)); + } +} diff --git a/allstarr.Tests/JellyfinProxyServiceTests.cs b/allstarr.Tests/JellyfinProxyServiceTests.cs index ddb95abe..5d9ad6d0 100644 --- a/allstarr.Tests/JellyfinProxyServiceTests.cs +++ b/allstarr.Tests/JellyfinProxyServiceTests.cs @@ -146,6 +146,28 @@ public class JellyfinProxyServiceTests Assert.Contains("token-123", values); } + [Fact] + public async Task GetJsonAsyncInternal_WithServerApiKey_UsesStandardAuthorization() + { + HttpRequestMessage? captured = null; + _mockHandler.Protected() + .Setup>("SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback((req, _) => captured = req) + .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{}") + }); + + await _service.GetJsonAsyncInternal("Items"); + + Assert.NotNull(captured); + Assert.Contains("Client=\"TestClient\"", captured!.Headers.GetValues("Authorization").Single()); + Assert.Contains("Token=\"test-api-key-12345\"", captured.Headers.GetValues("Authorization").Single()); + Assert.False(captured.Headers.Contains("X-Emby-Authorization")); + } + [Fact] public async Task GetBytesAsync_ReturnsBodyAndContentType() { @@ -367,8 +389,9 @@ public class JellyfinProxyServiceTests // Assert Assert.NotNull(captured); - Assert.True(captured!.Headers.TryGetValues("X-Emby-Authorization", out var values)); + Assert.True(captured!.Headers.TryGetValues("Authorization", out var values)); Assert.Contains("MediaBrowser Token=\"abc\"", values); + Assert.False(captured.Headers.Contains("X-Emby-Authorization")); Assert.Equal(HttpStatusCode.OK, response.StatusCode); } diff --git a/allstarr/AppVersion.cs b/allstarr/AppVersion.cs index 206d9ae5..4e3feea0 100644 --- a/allstarr/AppVersion.cs +++ b/allstarr/AppVersion.cs @@ -9,5 +9,5 @@ public static class AppVersion /// /// Current application version. /// - public const string Version = "2.0.3"; + public const string Version = "2.0.4"; } diff --git a/allstarr/Controllers/JellyfinAdminController.cs b/allstarr/Controllers/JellyfinAdminController.cs index a81dfa7e..a180954e 100644 --- a/allstarr/Controllers/JellyfinAdminController.cs +++ b/allstarr/Controllers/JellyfinAdminController.cs @@ -95,8 +95,7 @@ public class JellyfinAdminController : ControllerBase var request = new HttpRequestMessage(method, url); var authHeader = $"MediaBrowser Client=\"AllstarrAdmin\", Device=\"WebUI\", DeviceId=\"allstarr-admin-webui\", Version=\"{AppVersion.Version}\", Token=\"{session.JellyfinAccessToken}\""; - request.Headers.TryAddWithoutValidation("X-Emby-Authorization", authHeader); - request.Headers.TryAddWithoutValidation("X-Emby-Token", session.JellyfinAccessToken); + request.Headers.TryAddWithoutValidation("Authorization", authHeader); return request; } diff --git a/allstarr/Controllers/JellyfinController.Authentication.cs b/allstarr/Controllers/JellyfinController.Authentication.cs index ec34d810..e02353e3 100644 --- a/allstarr/Controllers/JellyfinController.Authentication.cs +++ b/allstarr/Controllers/JellyfinController.Authentication.cs @@ -49,7 +49,12 @@ public partial class JellyfinController } // Return Jellyfin's exact response - return Content(responseJson, "application/json"); + return new ContentResult + { + Content = responseJson, + ContentType = "application/json", + StatusCode = statusCode + }; } // No response body from Jellyfin - return status code only diff --git a/allstarr/Middleware/AdminAuthenticationMiddleware.cs b/allstarr/Middleware/AdminAuthenticationMiddleware.cs index f0f500eb..d7883d53 100644 --- a/allstarr/Middleware/AdminAuthenticationMiddleware.cs +++ b/allstarr/Middleware/AdminAuthenticationMiddleware.cs @@ -103,6 +103,7 @@ public class AdminAuthenticationMiddleware context.Request.Path); context.Response.StatusCode = StatusCodes.Status401Unauthorized; + context.Response.Headers["X-Allstarr-Session-Expired"] = "true"; context.Response.ContentType = "application/json"; await context.Response.WriteAsync(JsonSerializer.Serialize(new { diff --git a/allstarr/Middleware/WebSocketProxyMiddleware.cs b/allstarr/Middleware/WebSocketProxyMiddleware.cs index e620f79f..6254168d 100644 --- a/allstarr/Middleware/WebSocketProxyMiddleware.cs +++ b/allstarr/Middleware/WebSocketProxyMiddleware.cs @@ -2,6 +2,7 @@ using System.Net.WebSockets; using Microsoft.Extensions.Options; using allstarr.Models.Settings; using allstarr.Services.Jellyfin; +using allstarr.Services.Common; namespace allstarr.Middleware; @@ -113,32 +114,11 @@ public class WebSocketProxyMiddleware // Connect to Jellyfin WebSocket serverWebSocket = new ClientWebSocket(); - // Forward authentication headers - check X-Emby-Authorization FIRST - // Most Jellyfin clients use X-Emby-Authorization, not Authorization - if (context.Request.Headers.TryGetValue("X-Emby-Authorization", out var embyAuthHeader)) + var auth = AuthHeaderHelper.GetForwardAuthHeader(context.Request.Headers); + if (auth is { } selected) { - serverWebSocket.Options.SetRequestHeader("X-Emby-Authorization", embyAuthHeader.ToString()); - _logger.LogDebug("🔑 WEBSOCKET: Forwarded X-Emby-Authorization header"); - } - else if (context.Request.Headers.TryGetValue("X-Emby-Token", out var tokenHeader)) - { - serverWebSocket.Options.SetRequestHeader("X-Emby-Token", tokenHeader.ToString()); - _logger.LogDebug("🔑 WEBSOCKET: Forwarded X-Emby-Token header"); - } - else if (context.Request.Headers.TryGetValue("Authorization", out var authHeader2)) - { - var authValue = authHeader2.ToString(); - // If it's a MediaBrowser auth header, use X-Emby-Authorization - if (authValue.Contains("MediaBrowser", StringComparison.OrdinalIgnoreCase)) - { - serverWebSocket.Options.SetRequestHeader("X-Emby-Authorization", authValue); - _logger.LogDebug("🔑 WEBSOCKET: Converted Authorization to X-Emby-Authorization header"); - } - else - { - serverWebSocket.Options.SetRequestHeader("Authorization", authValue); - _logger.LogDebug("🔑 WEBSOCKET: Forwarded Authorization header"); - } + serverWebSocket.Options.SetRequestHeader(selected.Name, selected.Value); + _logger.LogDebug("🔑 WEBSOCKET: Forwarded {HeaderName} header", selected.Name); } // Set user agent diff --git a/allstarr/Services/Admin/AdminHelperService.cs b/allstarr/Services/Admin/AdminHelperService.cs index ba0726dc..92371e9e 100644 --- a/allstarr/Services/Admin/AdminHelperService.cs +++ b/allstarr/Services/Admin/AdminHelperService.cs @@ -582,7 +582,7 @@ public class AdminHelperService public HttpRequestMessage CreateJellyfinRequest(HttpMethod method, string url) { var request = new HttpRequestMessage(method, url); - request.Headers.Add("X-Emby-Authorization", GetJellyfinAuthHeader()); + request.Headers.Add("Authorization", GetJellyfinAuthHeader()); return request; } diff --git a/allstarr/Services/Common/AuthHeaderHelper.cs b/allstarr/Services/Common/AuthHeaderHelper.cs index 361c906c..9b3b6073 100644 --- a/allstarr/Services/Common/AuthHeaderHelper.cs +++ b/allstarr/Services/Common/AuthHeaderHelper.cs @@ -1,5 +1,4 @@ using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Primitives; namespace allstarr.Services.Common; @@ -18,56 +17,37 @@ public static class AuthHeaderHelper /// True if auth header was added, false otherwise public static bool ForwardAuthHeaders(IHeaderDictionary sourceHeaders, HttpRequestMessage targetRequest) { - // Try X-Emby-Authorization first (case-insensitive) - foreach (var header in sourceHeaders) - { - if (header.Key.Equals("X-Emby-Authorization", StringComparison.OrdinalIgnoreCase)) - { - var headerValue = header.Value.ToString(); - targetRequest.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue); - return true; - } - } - - // Some Jellyfin clients send the raw token separately instead of a MediaBrowser auth header. - foreach (var header in sourceHeaders) - { - if (header.Key.Equals("X-Emby-Token", StringComparison.OrdinalIgnoreCase)) - { - var headerValue = header.Value.ToString(); - targetRequest.Headers.TryAddWithoutValidation("X-Emby-Token", headerValue); - return true; - } - } - - // If no X-Emby-Authorization, check if Authorization header contains MediaBrowser format - foreach (var header in sourceHeaders) - { - if (header.Key.Equals("Authorization", StringComparison.OrdinalIgnoreCase)) - { - var headerValue = header.Value.ToString(); - - // Check if it's a MediaBrowser/Jellyfin auth header - if (headerValue.Contains("MediaBrowser", StringComparison.OrdinalIgnoreCase) || - headerValue.Contains("Client=", StringComparison.OrdinalIgnoreCase) || - headerValue.Contains("Token=", StringComparison.OrdinalIgnoreCase)) - { - // Forward as X-Emby-Authorization (Jellyfin's expected header) - targetRequest.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue); - return true; - } - else - { - // Standard Bearer token - targetRequest.Headers.TryAddWithoutValidation("Authorization", headerValue); - return true; - } - } - } - - return false; + var auth = GetForwardAuthHeader(sourceHeaders); + return auth is { } selected && + targetRequest.Headers.TryAddWithoutValidation(selected.Name, selected.Value); } - + + public static (string Name, string Value)? GetForwardAuthHeader(IHeaderDictionary sourceHeaders) + { + if (sourceHeaders.TryGetValue("Authorization", out var authorization) && + !string.IsNullOrWhiteSpace(authorization)) + { + return ("Authorization", authorization.ToString()); + } + + if (sourceHeaders.TryGetValue("X-Emby-Authorization", out var legacyAuthorization) && + !string.IsNullOrWhiteSpace(legacyAuthorization)) + { + var value = legacyAuthorization.ToString(); + return value.StartsWith("MediaBrowser ", StringComparison.OrdinalIgnoreCase) + ? ("Authorization", value) + : ("X-Emby-Authorization", value); + } + + if (sourceHeaders.TryGetValue("X-Emby-Token", out var legacyToken) && + !string.IsNullOrWhiteSpace(legacyToken)) + { + return ("X-Emby-Token", legacyToken.ToString()); + } + + return null; + } + /// /// Extracts device ID from X-Emby-Authorization header. /// @@ -80,7 +60,7 @@ public static class AuthHeaderHelper var authValue = authHeader.ToString(); return ExtractDeviceIdFromAuthString(authValue); } - + if (headers.TryGetValue("Authorization", out var authHeader2)) { var authValue = authHeader2.ToString(); @@ -89,10 +69,10 @@ public static class AuthHeaderHelper return ExtractDeviceIdFromAuthString(authValue); } } - + return null; } - + /// /// Extracts device ID from MediaBrowser auth string. /// Format: MediaBrowser Client="...", Device="...", DeviceId="...", Version="...", Token="..." @@ -100,18 +80,18 @@ public static class AuthHeaderHelper private static string? ExtractDeviceIdFromAuthString(string authValue) { var deviceIdMatch = System.Text.RegularExpressions.Regex.Match( - authValue, - @"DeviceId=""([^""]+)""", + authValue, + @"DeviceId=""([^""]+)""", System.Text.RegularExpressions.RegexOptions.IgnoreCase); - + if (deviceIdMatch.Success) { return deviceIdMatch.Groups[1].Value; } - + return null; } - + /// /// Extracts client name from MediaBrowser auth string. /// @@ -122,7 +102,7 @@ public static class AuthHeaderHelper var authValue = authHeader.ToString(); return ExtractClientNameFromAuthString(authValue); } - + if (headers.TryGetValue("Authorization", out var authHeader2)) { var authValue = authHeader2.ToString(); @@ -131,49 +111,49 @@ public static class AuthHeaderHelper return ExtractClientNameFromAuthString(authValue); } } - + return null; } - + /// /// Extracts client name from MediaBrowser auth string. /// private static string? ExtractClientNameFromAuthString(string authValue) { var clientMatch = System.Text.RegularExpressions.Regex.Match( - authValue, - @"Client=""([^""]+)""", + authValue, + @"Client=""([^""]+)""", System.Text.RegularExpressions.RegexOptions.IgnoreCase); - + if (clientMatch.Success) { return clientMatch.Groups[1].Value; } - + return null; } - + /// /// Creates a MediaBrowser auth header string. /// public static string CreateAuthHeader(string token, string? client = null, string? device = null, string? deviceId = null, string? version = null) { var parts = new List(); - + if (!string.IsNullOrEmpty(client)) parts.Add($"Client=\"{client}\""); - + if (!string.IsNullOrEmpty(device)) parts.Add($"Device=\"{device}\""); - + if (!string.IsNullOrEmpty(deviceId)) parts.Add($"DeviceId=\"{deviceId}\""); - + if (!string.IsNullOrEmpty(version)) parts.Add($"Version=\"{version}\""); - + parts.Add($"Token=\"{token}\""); - + return $"MediaBrowser {string.Join(", ", parts)}"; } } diff --git a/allstarr/Services/Jellyfin/JellyfinProxyService.cs b/allstarr/Services/Jellyfin/JellyfinProxyService.cs index 1f46e71f..54c47b57 100644 --- a/allstarr/Services/Jellyfin/JellyfinProxyService.cs +++ b/allstarr/Services/Jellyfin/JellyfinProxyService.cs @@ -1013,7 +1013,7 @@ public class JellyfinProxyService // Use server's API key for authentication var authHeader = GetAuthorizationHeader(); - request.Headers.TryAddWithoutValidation("X-Emby-Authorization", authHeader); + request.Headers.TryAddWithoutValidation("Authorization", authHeader); request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); diff --git a/allstarr/Services/Jellyfin/JellyfinSessionManager.cs b/allstarr/Services/Jellyfin/JellyfinSessionManager.cs index d4553834..27e0397f 100644 --- a/allstarr/Services/Jellyfin/JellyfinSessionManager.cs +++ b/allstarr/Services/Jellyfin/JellyfinSessionManager.cs @@ -5,6 +5,7 @@ using System.Text.Json; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Options; using allstarr.Models.Settings; +using allstarr.Services.Common; namespace allstarr.Services.Jellyfin; @@ -503,9 +504,7 @@ public class JellyfinSessionManager : IDisposable var jellyfinHost = jellyfinUrl.Replace("https://", "").Replace("http://", ""); var jellyfinWsUrl = $"{wsScheme}{jellyfinHost}/socket"; - // IMPORTANT: Do NOT add api_key to URL - we want to authenticate as the CLIENT, not the server - // The client's token is passed via X-Emby-Authorization header - // Using api_key would create a session for the server/admin, not the actual user's client + // Use the client's identity when available; URL query credentials are not needed. webSocket = new ClientWebSocket(); session.WebSocket = webSocket; @@ -517,44 +516,25 @@ public class JellyfinSessionManager : IDisposable _logger.LogDebug("🔍 WEBSOCKET: Available headers for {DeviceId}: {Headers}", deviceId, string.Join(", ", sessionHeaders.Keys)); - // Forward authentication headers from the CLIENT - this is critical for session to appear under the right user - bool authFound = false; - if (sessionHeaders.TryGetValue("X-Emby-Authorization", out var embyAuth)) + // Keep the same client identity used for proxied HTTP requests. + var auth = AuthHeaderHelper.GetForwardAuthHeader(sessionHeaders); + if (auth is { } selected) { - webSocket.Options.SetRequestHeader("X-Emby-Authorization", embyAuth.ToString()); - _logger.LogDebug("🔑 WEBSOCKET: Using X-Emby-Authorization for {DeviceId}", deviceId); - authFound = true; - } - else if (sessionHeaders.TryGetValue("X-Emby-Token", out var token)) - { - webSocket.Options.SetRequestHeader("X-Emby-Token", token.ToString()); - _logger.LogDebug("🔑 WEBSOCKET: Using X-Emby-Token for {DeviceId}", deviceId); - authFound = true; - } - else if (sessionHeaders.TryGetValue("Authorization", out var auth)) - { - var authValue = auth.ToString(); - if (authValue.Contains("MediaBrowser", StringComparison.OrdinalIgnoreCase)) - { - webSocket.Options.SetRequestHeader("X-Emby-Authorization", authValue); - _logger.LogDebug("🔑 WEBSOCKET: Converted Authorization to X-Emby-Authorization for {DeviceId}", - deviceId); - authFound = true; - } - else - { - webSocket.Options.SetRequestHeader("Authorization", authValue); - _logger.LogDebug("🔑 WEBSOCKET: Using Authorization for {DeviceId}", deviceId); - authFound = true; - } + webSocket.Options.SetRequestHeader(selected.Name, selected.Value); + _logger.LogDebug("🔑 WEBSOCKET: Using {HeaderName} for {DeviceId}", selected.Name, deviceId); } - if (!authFound) + if (auth is null) { - // No client auth found - fall back to server API key as last resort + // Preserve the existing server-key fallback without a legacy query credential. if (!string.IsNullOrEmpty(_settings.ApiKey)) { - jellyfinWsUrl += $"?api_key={_settings.ApiKey}"; + webSocket.Options.SetRequestHeader("Authorization", AuthHeaderHelper.CreateAuthHeader( + _settings.ApiKey, + _settings.ClientName, + _settings.DeviceName, + _settings.DeviceId, + _settings.ClientVersion)); _logger.LogWarning("WEBSOCKET: No client auth found in headers, falling back to server API key for {DeviceId}", deviceId); } else diff --git a/allstarr/Services/Spotify/SpotifyTrackMatchingService.cs b/allstarr/Services/Spotify/SpotifyTrackMatchingService.cs index ce99af60..104737f1 100644 --- a/allstarr/Services/Spotify/SpotifyTrackMatchingService.cs +++ b/allstarr/Services/Spotify/SpotifyTrackMatchingService.cs @@ -1499,7 +1499,7 @@ public class SpotifyTrackMatchingService : BackgroundService var headers = new HeaderDictionary(); if (!string.IsNullOrEmpty(jellyfinSettings.ApiKey)) { - headers["X-Emby-Authorization"] = $"MediaBrowser Token=\"{jellyfinSettings.ApiKey}\""; + headers["Authorization"] = $"MediaBrowser Client=\"Allstarr\", Device=\"Server\", DeviceId=\"allstarr-playlists\", Version=\"{AppVersion.Version}\", Token=\"{jellyfinSettings.ApiKey}\""; } // Request all fields that clients typically need (not just MediaSources) diff --git a/allstarr/wwwroot/js/auth-session.js b/allstarr/wwwroot/js/auth-session.js index 57ec000a..714a265f 100644 --- a/allstarr/wwwroot/js/auth-session.js +++ b/allstarr/wwwroot/js/auth-session.js @@ -122,6 +122,7 @@ function patchFetchForAuthRecovery() { if ( response.status === 401 && + response.headers.get("X-Allstarr-Session-Expired") === "true" && url.includes("/api/admin") && !url.includes("/api/admin/auth/") && !authRecoveryInProgress