From 4ba580a7614846eb2df8927219a180ccae462eee Mon Sep 17 00:00:00 2001 From: Wendell Jones Date: Thu, 30 Apr 2026 12:42:50 -0400 Subject: [PATCH] feat: WebSocket token authentication via api_key query parameter --- .../WebSocketAuthenticationMiddleware.cs | 136 ++++++++++++++++++ .../ApiApplicationBuilderExtensions.cs | 9 ++ Jellyfin.Server/Startup.cs | 2 + 3 files changed, 147 insertions(+) create mode 100644 Jellyfin.Api/Middleware/WebSocketAuthenticationMiddleware.cs 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(); -- 2.53.0