using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Jellyfin.Plugin.SSO_Auth.Api;
namespace Jellyfin.Plugin.SSO_Auth.State;
///
/// A process-local used when the server has no shared cache configured.
/// States are held serialized so that this store and behave identically:
/// a mutation is only visible to a later leg of the flow once it has been written back.
///
public sealed class InMemorySsoStateStore : ISsoStateStore
{
///
/// The most states this store keeps. The endpoint that creates them is unauthenticated, so the store
/// is capped as well as swept; past the cap the states closest to expiring are dropped first.
/// Every state carries the same expiry, so that is the oldest state: the cap trades unbounded growth
/// for a login DoS, where enough anonymous starts evict the in-flight logins of real users.
///
public const int Capacity = 1024;
private readonly ConcurrentDictionary _entries = new ConcurrentDictionary(StringComparer.Ordinal);
///
/// Gets the number of states currently held.
///
public int Count => _entries.Count;
///
public Task CreateAsync(TimedAuthorizeState state, TimeSpan expiry)
{
return WriteAsync(state, expiry);
}
///
public Task UpdateAsync(TimedAuthorizeState state, TimeSpan expiry)
{
return WriteAsync(state, expiry);
}
///
public Task GetAsync(string state)
{
if (string.IsNullOrEmpty(state) || !_entries.TryGetValue(state, out var entry))
{
return Task.FromResult(null);
}
if (IsExpired(entry))
{
_entries.TryRemove(state, out _);
return Task.FromResult(null);
}
return Task.FromResult(SsoStateSerializer.Deserialize(entry.Payload));
}
///
public Task ConsumeAsync(string state)
{
if (string.IsNullOrEmpty(state) || !_entries.TryRemove(state, out var entry) || IsExpired(entry))
{
return Task.FromResult(null);
}
return Task.FromResult(SsoStateSerializer.Deserialize(entry.Payload));
}
///
public Task> ListAsync()
{
var states = new List();
foreach (var pair in _entries)
{
if (IsExpired(pair.Value))
{
_entries.TryRemove(pair.Key, out _);
continue;
}
var parsed = SsoStateSerializer.Deserialize(pair.Value.Payload);
if (parsed is not null)
{
states.Add(parsed);
}
}
return Task.FromResult>(states);
}
private static bool IsExpired(Entry entry)
{
return entry.ExpiresAt <= DateTimeOffset.UtcNow;
}
private Task WriteAsync(TimedAuthorizeState state, TimeSpan expiry)
{
ArgumentNullException.ThrowIfNull(state);
_entries[state.State.State] = new Entry(SsoStateSerializer.Serialize(state), DateTimeOffset.UtcNow.Add(expiry));
Evict();
return Task.CompletedTask;
}
private void Evict()
{
foreach (var pair in _entries)
{
if (IsExpired(pair.Value))
{
_entries.TryRemove(pair.Key, out _);
}
}
// ConcurrentDictionary.ToArray() is a locked snapshot; LINQ buffering throws if a write races it.
var snapshot = _entries.ToArray();
var excess = snapshot.Length - Capacity;
if (excess <= 0)
{
return;
}
foreach (var pair in snapshot.OrderBy(p => p.Value.ExpiresAt).Take(excess))
{
_entries.TryRemove(pair.Key, out _);
}
}
private sealed record Entry(string Payload, DateTimeOffset ExpiresAt);
}