mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-10-06 13:55:39 -04:00
fix(jellyfin): preserve v12 auth and admin sessions
This commit is contained in:
18 files changed
+257
-148
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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<IWebHostEnvironment>();
|
||||
environment.SetupGet(value => value.EnvironmentName).Returns("Production");
|
||||
var helper = new AdminHelperService(
|
||||
NullLogger<AdminHelperService>.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"));
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<IHttpClientFactory>();
|
||||
clientFactory.Setup(factory => factory.CreateClient(It.IsAny<string>()))
|
||||
.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<RedisCacheService>.Instance);
|
||||
var proxy = new JellyfinProxyService(
|
||||
clientFactory.Object,
|
||||
settings,
|
||||
new HttpContextAccessor { HttpContext = context },
|
||||
NullLogger<JellyfinProxyService>.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<JellyfinController>.Instance)
|
||||
{
|
||||
ControllerContext = new ControllerContext { HttpContext = context }
|
||||
};
|
||||
|
||||
var result = await controller.AuthenticateByName();
|
||||
|
||||
var content = Assert.IsType<ContentResult>(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<HttpRequestMessage, HttpResponseMessage> send) : HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken) => Task.FromResult(send(request));
|
||||
}
|
||||
}
|
||||
@@ -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<Task<HttpResponseMessage>>("SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.Callback<HttpRequestMessage, CancellationToken>((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);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,5 +9,5 @@ public static class AppVersion
|
||||
/// <summary>
|
||||
/// Current application version.
|
||||
/// </summary>
|
||||
public const string Version = "2.0.3";
|
||||
public const string Version = "2.0.4";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
|
||||
namespace allstarr.Services.Common;
|
||||
|
||||
@@ -18,56 +17,37 @@ public static class AuthHeaderHelper
|
||||
/// <returns>True if auth header was added, false otherwise</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts device ID from X-Emby-Authorization header.
|
||||
/// </summary>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 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;
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Extracts client name from MediaBrowser auth string.
|
||||
/// </summary>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Extracts client name from MediaBrowser auth string.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Creates a MediaBrowser auth header string.
|
||||
/// </summary>
|
||||
public static string CreateAuthHeader(string token, string? client = null, string? device = null, string? deviceId = null, string? version = null)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
|
||||
|
||||
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)}";
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user