diff --git a/Jellyfin.Api/Middleware/WebSocketAuthenticationMiddleware.cs b/Jellyfin.Api/Middleware/WebSocketAuthenticationMiddleware.cs
new file mode 100644
index 00000000..7796eb64
--- /dev/null
+++ b/Jellyfin.Api/Middleware/WebSocketAuthenticationMiddleware.cs
@@ -0,0 +1,136 @@
+//
+// Copyright (c) PlaceholderCompany. All rights reserved.
+//
+
+namespace Jellyfin.Api.Middleware;
+
+using System;
+using System.Globalization;
+using System.Security.Claims;
+using System.Threading.Tasks;
+using Jellyfin.Api.Constants;
+using Jellyfin.Data;
+using Jellyfin.Database.Implementations.Enums;
+using MediaBrowser.Controller.Net;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Logging;
+
+///
+/// WebSocket Authentication Middleware.
+/// Extracts and validates API tokens from WebSocket query strings.
+///
+public class WebSocketAuthenticationMiddleware
+{
+ private readonly RequestDelegate _next;
+ private readonly ILogger _logger;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The next delegate in the pipeline.
+ /// The logger.
+ public WebSocketAuthenticationMiddleware(RequestDelegate next, ILogger logger)
+ {
+ _next = next;
+ _logger = logger;
+ }
+
+ ///
+ /// Executes the middleware action.
+ ///
+ /// The current HTTP context.
+ /// The authentication service.
+ /// The async task.
+ public async Task Invoke(HttpContext httpContext, IAuthService authService)
+ {
+ // Only process WebSocket upgrade requests on the /socket endpoint
+ if (httpContext.WebSockets.IsWebSocketRequestUpgrade
+ && httpContext.Request.Path.StartsWithSegments("/socket", StringComparison.OrdinalIgnoreCase))
+ {
+ await AuthenticateWebSocketRequest(httpContext, authService).ConfigureAwait(false);
+ }
+
+ await _next(httpContext).ConfigureAwait(false);
+ }
+
+ ///
+ /// Authenticates WebSocket requests by extracting api_key from query string.
+ ///
+ /// The HTTP context.
+ /// The authentication service.
+ /// The async task.
+ private async Task AuthenticateWebSocketRequest(HttpContext httpContext, IAuthService authService)
+ {
+ try
+ {
+ // Extract api_key from query string
+ if (!httpContext.Request.Query.TryGetValue("api_key", out var apiKeyValues) || apiKeyValues.Count == 0)
+ {
+ _logger.LogDebug("WebSocket connection attempted without api_key query parameter. IP: {IP}",
+ httpContext.Connection.RemoteIpAddress);
+ return;
+ }
+
+ var apiKey = apiKeyValues[0];
+ if (string.IsNullOrWhiteSpace(apiKey))
+ {
+ _logger.LogDebug("WebSocket connection attempted with empty api_key. IP: {IP}",
+ httpContext.Connection.RemoteIpAddress);
+ return;
+ }
+
+ // Authenticate using the provided api_key
+ var authorizationInfo = await authService.Authenticate(httpContext.Request).ConfigureAwait(false);
+
+ if (!authorizationInfo.HasToken)
+ {
+ _logger.LogDebug("WebSocket authentication failed: Invalid or expired token. IP: {IP}",
+ httpContext.Connection.RemoteIpAddress);
+ return;
+ }
+
+ // Set the authenticated user in the context for downstream middleware
+ var role = UserRoles.User;
+ if (authorizationInfo.IsApiKey
+ || (authorizationInfo.User?.HasPermission(PermissionKind.IsAdministrator) ?? false))
+ {
+ role = UserRoles.Administrator;
+ }
+
+ var claims = new[]
+ {
+ new Claim(ClaimTypes.Name, authorizationInfo.User?.Username ?? string.Empty),
+ new Claim(ClaimTypes.Role, role),
+ new Claim(InternalClaimTypes.UserId, authorizationInfo.UserId.ToString("N", CultureInfo.InvariantCulture)),
+ new Claim(InternalClaimTypes.DeviceId, authorizationInfo.DeviceId ?? string.Empty),
+ new Claim(InternalClaimTypes.Device, authorizationInfo.Device ?? string.Empty),
+ new Claim(InternalClaimTypes.Client, authorizationInfo.Client ?? string.Empty),
+ new Claim(InternalClaimTypes.Version, authorizationInfo.Version ?? string.Empty),
+ new Claim(InternalClaimTypes.Token, authorizationInfo.Token),
+ new Claim(InternalClaimTypes.IsApiKey, authorizationInfo.IsApiKey.ToString(CultureInfo.InvariantCulture))
+ };
+
+ var identity = new ClaimsIdentity(claims, "WebSocket");
+ var principal = new ClaimsPrincipal(identity);
+ httpContext.User = principal;
+
+ _logger.LogDebug("WebSocket authentication successful for user {Username}. IP: {IP}",
+ authorizationInfo.User?.Username ?? "Unknown",
+ httpContext.Connection.RemoteIpAddress);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogDebug(ex, "Error during WebSocket authentication. IP: {IP}",
+ httpContext.Connection.RemoteIpAddress);
+ }
+ }
+}
+ ///
+ /// Adds WebSocket authentication to the application pipeline.
+ ///
+ /// The application builder.
+ /// The updated application builder.
+ public static IApplicationBuilder UseWebSocketAuthentication(this IApplicationBuilder appBuilder)
+ {
+ return appBuilder.UseMiddleware();
+ }
diff --git a/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs b/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs
index 3b0fa690..b9dcfda3 100644
--- a/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs
+++ b/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs
@@ -121,5 +121,14 @@ namespace Jellyfin.Server.Extensions
{
return appBuilder.UseMiddleware();
}
+ ///
+ /// Adds WebSocket authentication to the application pipeline.
+ ///
+ /// The application builder.
+ /// The updated application builder.
+ public static IApplicationBuilder UseWebSocketAuthentication(this IApplicationBuilder appBuilder)
+ {
+ return appBuilder.UseMiddleware();
+ }
}
}
diff --git a/Jellyfin.Server/Startup.cs b/Jellyfin.Server/Startup.cs
index 8edba923..3a2269cc 100644
--- a/Jellyfin.Server/Startup.cs
+++ b/Jellyfin.Server/Startup.cs
@@ -168,6 +168,8 @@ namespace Jellyfin.Server
mainApp.UseWebSockets();
+ mainApp.UseWebSocketAuthentication();
+
mainApp.UseResponseCompression();
mainApp.UseCors();