Merge remote-tracking branch 'origin/main' into benvin/session-directory

# Conflicts:
#	.woodpecker/ci.yaml
#	Jellyfin.Server/CoreAppHost.cs
#	tests/Jellyfin.Server.Tests/HighAvailability/RedisTestServer.cs
This commit is contained in:
2026-09-26 20:43:52 +10:00
21 changed files with 1967 additions and 94 deletions
@@ -1,7 +1,5 @@
using System;
using System.Collections.Concurrent;
using System.Globalization;
using System.Linq;
using System.Security.Cryptography;
using System.Threading.Tasks;
using MediaBrowser.Common.Extensions;
@@ -30,12 +28,10 @@ namespace Emby.Server.Implementations.QuickConnect
/// </summary>
private const int Timeout = 10;
private readonly ConcurrentDictionary<string, QuickConnectResult> _currentRequests = new();
private readonly ConcurrentDictionary<string, (DateTime Timestamp, AuthenticationResult AuthenticationResult)> _authorizedSecrets = new();
private readonly IServerConfigurationManager _config;
private readonly ILogger<QuickConnectManager> _logger;
private readonly ISessionManager _sessionManager;
private readonly IQuickConnectStore _store;
/// <summary>
/// Initializes a new instance of the <see cref="QuickConnectManager"/> class.
@@ -44,14 +40,17 @@ namespace Emby.Server.Implementations.QuickConnect
/// <param name="config">Configuration.</param>
/// <param name="logger">Logger.</param>
/// <param name="sessionManager">Session Manager.</param>
/// <param name="store">Quick connect store.</param>
public QuickConnectManager(
IServerConfigurationManager config,
ILogger<QuickConnectManager> logger,
ISessionManager sessionManager)
ISessionManager sessionManager,
IQuickConnectStore store)
{
_config = config;
_logger = logger;
_sessionManager = sessionManager;
_store = store;
}
/// <inheritdoc />
@@ -69,7 +68,7 @@ namespace Emby.Server.Implementations.QuickConnect
}
/// <inheritdoc/>
public QuickConnectResult TryConnect(AuthorizationInfo authorizationInfo)
public async Task<QuickConnectResult> TryConnect(AuthorizationInfo authorizationInfo)
{
ArgumentException.ThrowIfNullOrEmpty(authorizationInfo.DeviceId);
ArgumentException.ThrowIfNullOrEmpty(authorizationInfo.Device);
@@ -77,7 +76,6 @@ namespace Emby.Server.Implementations.QuickConnect
ArgumentException.ThrowIfNullOrEmpty(authorizationInfo.Version);
AssertActive();
ExpireRequests();
var secret = GenerateSecureRandom();
var code = GenerateCode();
@@ -90,19 +88,17 @@ namespace Emby.Server.Implementations.QuickConnect
authorizationInfo.Client,
authorizationInfo.Version);
_currentRequests[code] = result;
await _store.SetRequestAsync(result, ExpiryOf(result)).ConfigureAwait(false);
return result;
}
/// <inheritdoc/>
public QuickConnectResult CheckRequestStatus(string secret)
public async Task<QuickConnectResult> CheckRequestStatus(string secret)
{
AssertActive();
ExpireRequests();
string code = _currentRequests.Where(x => x.Value.Secret == secret).Select(x => x.Value.Code).DefaultIfEmpty(string.Empty).First();
if (!_currentRequests.TryGetValue(code, out QuickConnectResult? result))
var result = await _store.GetRequestBySecretAsync(secret).ConfigureAwait(false);
if (result is null)
{
throw new ResourceNotFoundException("Unable to find request with provided secret");
}
@@ -136,21 +132,27 @@ namespace Emby.Server.Implementations.QuickConnect
public async Task<bool> AuthorizeRequest(Guid userId, string code)
{
AssertActive();
ExpireRequests();
if (!_currentRequests.TryGetValue(code, out QuickConnectResult? result))
var result = await _store.GetRequestByCodeAsync(code).ConfigureAwait(false);
if (result is null)
{
throw new ResourceNotFoundException("Unable to find request");
}
if (result.Authenticated)
{
throw new InvalidOperationException("Request is already authorized");
throw new ConflictException("Request is already authorized");
}
// Change the time on the request so it expires one minute into the future. It can't expire immediately as otherwise some clients wouldn't ever see that they have been authenticated.
result.DateAdded = DateTime.UtcNow.Add(TimeSpan.FromMinutes(1));
// The guard above is a read on shared state, so it cannot settle a race between instances; the claim can.
if (!await _store.TryClaimAuthorizationAsync(result.Secret, ExpiryOf(result)).ConfigureAwait(false))
{
throw await RefusedClaimAsync(result.Secret).ConfigureAwait(false);
}
var authenticationResult = await _sessionManager.AuthenticateDirect(new AuthenticationRequest
{
UserId = userId,
@@ -160,9 +162,10 @@ namespace Emby.Server.Implementations.QuickConnect
AppVersion = result.AppVersion
}).ConfigureAwait(false);
_authorizedSecrets[result.Secret] = (DateTime.UtcNow, authenticationResult);
result.Authenticated = true;
_currentRequests[code] = result;
await _store.SetAuthorizationAsync(result.Secret, authenticationResult, DateTime.UtcNow.AddMinutes(Timeout)).ConfigureAwait(false);
await _store.SetRequestAsync(result, ExpiryOf(result)).ConfigureAwait(false);
_logger.LogDebug("Authorizing device with code {Code} to login as user {UserId}", code, userId);
@@ -170,17 +173,33 @@ namespace Emby.Server.Implementations.QuickConnect
}
/// <inheritdoc/>
public AuthenticationResult GetAuthorizedRequest(string secret)
public async Task<AuthenticationResult> GetAuthorizedRequest(string secret)
{
AssertActive();
ExpireRequests();
if (!_authorizedSecrets.TryGetValue(secret, out var result))
var result = await _store.GetAuthorizationAsync(secret).ConfigureAwait(false);
if (result is null)
{
throw new ResourceNotFoundException("Unable to find request");
}
return result.AuthenticationResult;
return result;
}
private static DateTime ExpiryOf(QuickConnectResult request) => request.DateAdded.AddMinutes(Timeout);
/// <summary>
/// Explains a refused claim. The claim outlives a failed mint on purpose, so it can mean either
/// that the request is authorized or that authorizing it did not finish; the two are told apart
/// by re-reading the request rather than reported as the same thing.
/// </summary>
private async Task<ConflictException> RefusedClaimAsync(string secret)
{
var current = await _store.GetRequestBySecretAsync(secret).ConfigureAwait(false);
return current?.Authenticated == true
? new ConflictException("Request is already authorized")
: new ConflictException("Request is being authorized elsewhere, or an earlier attempt to authorize it did not complete. Start quick connect again for a new code.");
}
private string GenerateSecureRandom(int length = 32)
@@ -190,42 +209,5 @@ namespace Emby.Server.Implementations.QuickConnect
return Convert.ToHexString(bytes);
}
/// <summary>
/// Expire quick connect requests that are over the time limit. If <paramref name="expireAll"/> is true, all requests are unconditionally expired.
/// </summary>
/// <param name="expireAll">If true, all requests will be expired.</param>
private void ExpireRequests(bool expireAll = false)
{
// All requests before this timestamp have expired
var minTime = DateTime.UtcNow.AddMinutes(-Timeout);
// Expire stale connection requests
foreach (var (_, currentRequest) in _currentRequests)
{
if (expireAll || currentRequest.DateAdded < minTime)
{
var code = currentRequest.Code;
_logger.LogDebug("Removing expired request {Code}", code);
if (!_currentRequests.TryRemove(code, out _))
{
_logger.LogWarning("Request {Code} already expired", code);
}
}
}
foreach (var (secret, (timestamp, _)) in _authorizedSecrets)
{
if (expireAll || timestamp < minTime)
{
_logger.LogDebug("Removing expired secret {Secret}", secret);
if (!_authorizedSecrets.TryRemove(secret, out _))
{
_logger.LogWarning("Secret {Secret} already expired", secret);
}
}
}
}
}
}
@@ -0,0 +1,164 @@
using System;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Extensions.Json;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller.Authentication;
using MediaBrowser.Controller.QuickConnect;
using MediaBrowser.Model.QuickConnect;
using Microsoft.Extensions.Logging;
using StackExchange.Redis;
namespace Emby.Server.Implementations.QuickConnect;
/// <summary>
/// A Redis-backed <see cref="IQuickConnectStore"/> that lets the initiate, authorize and exchange legs
/// of a quick connect flow land on different instances. Expiry is the key TTL and an authorization is
/// claimed with a Lua check-and-set, so only one instance can ever mint a given secret's access token.
/// </summary>
/// <remarks>
/// There is no local fallback: a call Redis did not answer is inconclusive, and reporting it as a miss
/// would tell a polling client its secret is invalid. Quick connect is unavailable for as long as Redis
/// is, which password login is not.
/// </remarks>
public sealed class RedisQuickConnectStore : IQuickConnectStore
{
private const string KeyPrefix = "jellyfin:quickconnect:";
/// <summary>
/// Lua script writing the two keys a request is resolvable by in one step, so it can never be
/// reachable by its secret while the code the user is reading off the screen resolves to nothing.
/// </summary>
private const string SetRequestScript = @"
redis.call('SET', KEYS[1], ARGV[1], 'PX', ARGV[3])
redis.call('SET', KEYS[2], ARGV[2], 'PX', ARGV[3])
return 1";
/// <summary>
/// Lua script for the atomic claim of the sole right to authorize a request: the request has to
/// exist and not already be authorized, and the claim marker is taken with <c>SET NX</c>, so of two
/// instances racing on one code exactly one goes on to mint an access token.
/// </summary>
private const string ClaimAuthorizationScript = @"
local raw = redis.call('GET', KEYS[1])
if not raw then return 0 end
if cjson.decode(raw)['Authenticated'] then return 0 end
if redis.call('SET', KEYS[2], '1', 'NX', 'PX', ARGV[1]) then return 1 end
return 0";
private readonly IDatabase _db;
private readonly ILogger<RedisQuickConnectStore> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="RedisQuickConnectStore"/> class.
/// </summary>
/// <param name="redis">The Redis connection multiplexer.</param>
/// <param name="logger">The logger.</param>
public RedisQuickConnectStore(IConnectionMultiplexer redis, ILogger<RedisQuickConnectStore> logger)
{
ArgumentNullException.ThrowIfNull(redis);
_db = redis.GetDatabase();
_logger = logger;
}
/// <inheritdoc />
public async Task<QuickConnectResult?> GetRequestBySecretAsync(string secret, CancellationToken cancellationToken = default)
{
var raw = await CallAsync(() => _db.StringGetAsync(RequestKey(secret))).ConfigureAwait(false);
// Deserialization is outside the guard: a malformed stored value is a fault of its own, not Redis
// being unavailable.
return raw.HasValue ? JsonSerializer.Deserialize<QuickConnectResult>(raw.ToString(), JsonDefaults.Options) : null;
}
/// <inheritdoc />
public async Task<QuickConnectResult?> GetRequestByCodeAsync(string code, CancellationToken cancellationToken = default)
{
var secret = await CallAsync(() => _db.StringGetAsync(CodeKey(code))).ConfigureAwait(false);
return secret.HasValue
? await GetRequestBySecretAsync(secret.ToString(), cancellationToken).ConfigureAwait(false)
: null;
}
/// <inheritdoc />
public async Task SetRequestAsync(QuickConnectResult request, DateTime expiresUtc, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var ttl = expiresUtc - DateTime.UtcNow;
if (ttl <= TimeSpan.Zero)
{
return;
}
var json = JsonSerializer.Serialize(request, JsonDefaults.Options);
await CallAsync(() => _db.ScriptEvaluateAsync(
SetRequestScript,
keys: new RedisKey[] { RequestKey(request.Secret), CodeKey(request.Code) },
values: new RedisValue[] { json, request.Secret, (long)ttl.TotalMilliseconds })).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<bool> TryClaimAuthorizationAsync(string secret, DateTime expiresUtc, CancellationToken cancellationToken = default)
{
var ttl = expiresUtc - DateTime.UtcNow;
if (ttl <= TimeSpan.Zero)
{
return false;
}
var claimed = (long?)await CallAsync(() => _db.ScriptEvaluateAsync(
ClaimAuthorizationScript,
keys: new RedisKey[] { RequestKey(secret), ClaimKey(secret) },
values: new RedisValue[] { (long)ttl.TotalMilliseconds })).ConfigureAwait(false);
return claimed == 1;
}
/// <inheritdoc />
public async Task SetAuthorizationAsync(string secret, AuthenticationResult authenticationResult, DateTime expiresUtc, CancellationToken cancellationToken = default)
{
var ttl = expiresUtc - DateTime.UtcNow;
if (ttl <= TimeSpan.Zero)
{
return;
}
var json = JsonSerializer.Serialize(authenticationResult, JsonDefaults.Options);
await CallAsync(() => _db.StringSetAsync(AuthorizationKey(secret), json, ttl)).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<AuthenticationResult?> GetAuthorizationAsync(string secret, CancellationToken cancellationToken = default)
{
var raw = await CallAsync(() => _db.StringGetAsync(AuthorizationKey(secret))).ConfigureAwait(false);
return raw.HasValue
? JsonSerializer.Deserialize<AuthenticationResult>(raw.ToString(), JsonDefaults.Options)
: null;
}
private static string RequestKey(string secret) => KeyPrefix + "request:" + secret;
private static string CodeKey(string code) => KeyPrefix + "code:" + code;
private static string ClaimKey(string secret) => KeyPrefix + "claim:" + secret;
private static string AuthorizationKey(string secret) => KeyPrefix + "auth:" + secret;
private async Task<T> CallAsync<T>(Func<Task<T>> call)
{
try
{
return await call().ConfigureAwait(false);
}
catch (Exception exception) when (exception is RedisException or RedisCommandException or TimeoutException)
{
_logger.LogError(exception, "Quick connect state could not be reached in Redis.");
throw new ServiceUnavailableException("Quick connect is temporarily unavailable.", exception);
}
}
}