Files
unkin-agent a167d00b73
ci/woodpecker/pr/ci Pipeline was successful
store OAuth state in the shared cache
Keys each in-flight OpenID flow in the server's Valkey/Redis connection so a login
can start on one replica and finish on another. Falls back to an in-memory store
when no multiplexer is registered.
2026-09-19 22:47:43 +10:00

116 lines
3.4 KiB
C#

using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Jellyfin.Plugin.SSO_Auth.Api;
using StackExchange.Redis;
namespace Jellyfin.Plugin.SSO_Auth.State;
/// <summary>
/// A Redis/Valkey backed <see cref="ISsoStateStore"/>. Every login-path operation addresses a single
/// key so the store works unchanged against a cluster-mode deployment, and expiry is a key TTL rather
/// than a sweep, so a state times out even if no further login ever happens.
/// </summary>
public sealed class RedisSsoStateStore : ISsoStateStore
{
/// <summary>
/// The prefix every OpenID state key is stored under.
/// </summary>
public const string KeyPrefix = "jellyfin:sso:oid:state:";
private readonly IConnectionMultiplexer _redis;
private readonly IDatabase _database;
/// <summary>
/// Initializes a new instance of the <see cref="RedisSsoStateStore"/> class.
/// </summary>
/// <param name="redis">The connection multiplexer shared with the server.</param>
public RedisSsoStateStore(IConnectionMultiplexer redis)
{
ArgumentNullException.ThrowIfNull(redis);
_redis = redis;
_database = redis.GetDatabase();
}
/// <inheritdoc />
public Task CreateAsync(TimedAuthorizeState state, TimeSpan expiry)
{
return WriteAsync(state, expiry);
}
/// <inheritdoc />
public Task UpdateAsync(TimedAuthorizeState state, TimeSpan expiry)
{
return WriteAsync(state, expiry);
}
/// <inheritdoc />
public async Task<TimedAuthorizeState> GetAsync(string state)
{
if (string.IsNullOrEmpty(state))
{
return null;
}
var payload = await _database.StringGetAsync(KeyFor(state)).ConfigureAwait(false);
return SsoStateSerializer.Deserialize(payload);
}
/// <inheritdoc />
public async Task<TimedAuthorizeState> ConsumeAsync(string state)
{
if (string.IsNullOrEmpty(state))
{
return null;
}
var payload = await _database.StringGetDeleteAsync(KeyFor(state)).ConfigureAwait(false);
return SsoStateSerializer.Deserialize(payload);
}
/// <inheritdoc />
public async Task<IReadOnlyList<TimedAuthorizeState>> ListAsync()
{
var states = new List<TimedAuthorizeState>();
foreach (var endpoint in _redis.GetEndPoints())
{
var server = _redis.GetServer(endpoint);
if (!server.IsConnected || server.IsReplica)
{
continue;
}
await foreach (var key in server.KeysAsync(pattern: KeyPrefix + "*").ConfigureAwait(false))
{
var parsed = SsoStateSerializer.Deserialize(await _database.StringGetAsync(key).ConfigureAwait(false));
if (parsed is not null)
{
states.Add(parsed);
}
}
}
return states;
}
private static RedisKey KeyFor(string state)
{
return KeyPrefix + state;
}
private Task WriteAsync(TimedAuthorizeState state, TimeSpan expiry)
{
ArgumentNullException.ThrowIfNull(state);
return _database.StringSetAsync(
KeyFor(state.State.State),
SsoStateSerializer.Serialize(state),
expiry,
keepTtl: false,
when: When.Always,
flags: CommandFlags.None);
}
}