diff --git a/.woodpecker/ci.yaml b/.woodpecker/ci.yaml
index eec789e..9fe1005 100644
--- a/.woodpecker/ci.yaml
+++ b/.woodpecker/ci.yaml
@@ -13,7 +13,6 @@ steps:
- dotnet --info
- dotnet restore SSO-Auth.sln
- dotnet build SSO-Auth.sln -c Release --no-restore --warnaserror
- # No test project exists yet; this is a no-op until one lands.
- dotnet test SSO-Auth.sln -c Release --no-build --verbosity minimal
backend_options:
kubernetes:
diff --git a/Makefile b/Makefile
index 7644a35..62d0bd9 100644
--- a/Makefile
+++ b/Makefile
@@ -14,7 +14,7 @@ lint:
clean:
dotnet clean $(SLN) -c Release || true
- rm -rf SSO-Auth/bin SSO-Auth/obj SSO-Auth/Lib/bin SSO-Auth/Lib/obj
+ rm -rf SSO-Auth/bin SSO-Auth/obj tests/SSO-Auth.Tests/bin tests/SSO-Auth.Tests/obj
_LATEST := $(shell git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$$' | head -1)
_BASE := $(if $(_LATEST),$(_LATEST),v0.0.0)
diff --git a/SSO-Auth.sln b/SSO-Auth.sln
index 295babf..523c8f9 100644
--- a/SSO-Auth.sln
+++ b/SSO-Auth.sln
@@ -5,6 +5,8 @@ VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SSO-Auth", "SSO-Auth\SSO-Auth.csproj", "{C30A5CFB-B27E-4E83-9E96-1E0362B36748}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SSO-Auth.Tests", "tests\SSO-Auth.Tests\SSO-Auth.Tests.csproj", "{7C4C3F90-33B5-4A8E-9C1A-5A6D2F9E8B41}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -18,5 +20,9 @@ Global
{C30A5CFB-B27E-4E83-9E96-1E0362B36748}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C30A5CFB-B27E-4E83-9E96-1E0362B36748}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C30A5CFB-B27E-4E83-9E96-1E0362B36748}.Release|Any CPU.Build.0 = Release|Any CPU
+ {7C4C3F90-33B5-4A8E-9C1A-5A6D2F9E8B41}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {7C4C3F90-33B5-4A8E-9C1A-5A6D2F9E8B41}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {7C4C3F90-33B5-4A8E-9C1A-5A6D2F9E8B41}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {7C4C3F90-33B5-4A8E-9C1A-5A6D2F9E8B41}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal
diff --git a/SSO-Auth/Api/SSOController.cs b/SSO-Auth/Api/SSOController.cs
index 3132b32..13c7347 100644
--- a/SSO-Auth/Api/SSOController.cs
+++ b/SSO-Auth/Api/SSOController.cs
@@ -14,6 +14,7 @@ using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Database.Implementations.Enums;
using Jellyfin.Plugin.SSO_Auth.Config;
using Jellyfin.Plugin.SSO_Auth.Helpers;
+using Jellyfin.Plugin.SSO_Auth.State;
using MediaBrowser.Common.Api;
using MediaBrowser.Controller.Authentication;
using MediaBrowser.Controller.Configuration;
@@ -38,6 +39,12 @@ namespace Jellyfin.Plugin.SSO_Auth.Api;
[Route("[controller]")]
public class SSOController : ControllerBase
{
+ ///
+ /// What the user is told when the state store cannot be reached. A store failure is never treated
+ /// as a valid state, so the login is rejected rather than allowed through.
+ ///
+ private const string StateStoreUnavailable = "Login state is unavailable. Try again.";
+
private readonly IUserManager _userManager;
private readonly ISessionManager _sessionManager;
private readonly IAuthorizationContext _authContext;
@@ -47,7 +54,13 @@ public class SSOController : ControllerBase
private readonly IProviderManager _providerManager;
private readonly IServerConfigurationManager _serverConfigurationManager;
private readonly IHttpClientFactory _httpClientFactory;
- private static readonly IDictionary StateManager = new Dictionary();
+ private readonly ISsoStateStore _stateStore;
+
+ ///
+ /// How long a prepared OpenID state stays usable. Long enough to cover an interactive provider
+ /// login including multi-factor prompts, short enough to bound replay of an unredeemed state.
+ ///
+ private static readonly TimeSpan StateExpiry = TimeSpan.FromSeconds(300);
///
/// Initializes a new instance of the class.
@@ -61,6 +74,7 @@ public class SSOController : ControllerBase
/// Instance of the interface.
/// Instance of the interface.
/// Instance of the interface.
+ /// Instance of the interface.
public SSOController(
ILogger logger,
ILoggerFactory loggerFactory,
@@ -70,7 +84,8 @@ public class SSOController : ControllerBase
ICryptoProvider cryptoProvider,
IProviderManager providerManager,
IHttpClientFactory httpClientFactory,
- IServerConfigurationManager serverConfigurationManager)
+ IServerConfigurationManager serverConfigurationManager,
+ ISsoStateStore stateStore)
{
_sessionManager = sessionManager;
_userManager = userManager;
@@ -81,6 +96,7 @@ public class SSOController : ControllerBase
_providerManager = providerManager;
_serverConfigurationManager = serverConfigurationManager;
_httpClientFactory = httpClientFactory;
+ _stateStore = stateStore;
_logger.LogInformation("SSO Controller initialized");
}
@@ -114,7 +130,14 @@ public class SSOController : ControllerBase
return BadRequest("Missing state");
}
- if (!StateManager.TryGetValue(state, out var timedState))
+ var lookup = await TryStateReadAsync(() => _stateStore.GetAsync(state), "read").ConfigureAwait(false);
+ if (!lookup.Succeeded)
+ {
+ return ReturnError(StatusCodes.Status503ServiceUnavailable, StateStoreUnavailable);
+ }
+
+ var timedState = lookup.Value;
+ if (timedState is null)
{
return BadRequest("Invalid or expired state");
}
@@ -327,23 +350,16 @@ public class SSOController : ControllerBase
}
}
- bool isLinking = timedState.IsLinking;
-
- if (timedState.Valid)
- {
- _logger.LogInformation($"Is request linking: {isLinking}");
- return Content(WebResponse.Generator(data: state, provider: provider, baseUrl: GetRequestBase(config.SchemeOverride, config.PortOverride), mode: "OID", isLinking: isLinking), MediaTypeNames.Text.Html);
- }
- else
+ if (!timedState.Valid)
{
_logger.LogWarning(
"OpenID user {Username} has one or more incorrect role claims: {@Claims}. Expected any one of: {@ExpectedClaims}",
timedState.Username,
result.User.Claims.Select(o => new { o.Type, o.Value }),
config.Roles);
-
- return ReturnError(StatusCodes.Status401Unauthorized, "Error. Check permissions.");
}
+
+ return await CompleteOidState(provider, state, timedState, GetRequestBase(config.SchemeOverride, config.PortOverride)).ConfigureAwait(false);
}
// If the config doesn't have an active provider matching the requeset, show an error
@@ -360,7 +376,6 @@ public class SSOController : ControllerBase
[HttpGet("OID/start/{provider}")]
public async Task OidChallenge(string provider, [FromQuery] bool isLinking = false)
{
- Invalidate();
OidConfig config;
try
{
@@ -416,10 +431,13 @@ public class SSOController : ControllerBase
return ReturnError(StatusCodes.Status400BadRequest, $"Error preparing login: {state.Error} - {state.ErrorDescription}");
}
- StateManager.Add(state.State, new TimedAuthorizeState(state, DateTime.Now));
-
// Track whether this is a linking request or not.
- StateManager[state.State].IsLinking = isLinking;
+ var timedState = new TimedAuthorizeState(state, DateTime.Now) { IsLinking = isLinking };
+ if (!await TryStateWriteAsync(() => _stateStore.CreateAsync(timedState, StateExpiry), "create").ConfigureAwait(false))
+ {
+ return ReturnError(StatusCodes.Status503ServiceUnavailable, StateStoreUnavailable);
+ }
+
return Redirect(state.StartUrl);
}
@@ -487,12 +505,18 @@ public class SSOController : ControllerBase
///
/// This is a debug endpoint to list all running OpenID flows. Requires administrator privileges.
///
- /// The list of OpenID flows in progress.
+ /// The list of OpenID flows in progress, without their PKCE code verifiers.
[Authorize(Policy = Policies.RequiresElevation)]
[HttpGet("OID/States")]
- public ActionResult OidStates()
+ public async Task OidStates()
{
- return Ok(StateManager);
+ var listed = await TryStateReadAsync(() => _stateStore.ListAsync(), "list").ConfigureAwait(false);
+ if (!listed.Succeeded)
+ {
+ return ReturnError(StatusCodes.Status503ServiceUnavailable, StateStoreUnavailable);
+ }
+
+ return Ok(listed.Value.Select(SsoStateInfo.FromState).ToList());
}
///
@@ -518,27 +542,31 @@ public class SSOController : ControllerBase
if (config.Enabled)
{
- foreach (var kvp in StateManager)
+ // The state is redeemed here, so a replay of the same response finds nothing.
+ var redeemed = await TryStateReadAsync(() => _stateStore.ConsumeAsync(response.Data), "redeem").ConfigureAwait(false);
+ if (!redeemed.Succeeded)
{
- if (kvp.Value.State.State.Equals(response.Data) && kvp.Value.Valid)
- {
- Guid userId = await CreateCanonicalLinkAndUserIfNotExist("oid", provider, kvp.Value.Username);
+ return Problem(StateStoreUnavailable, statusCode: StatusCodes.Status503ServiceUnavailable);
+ }
- var authenticationResult = await Authenticate(
- userId,
- kvp.Value.Admin,
- config.EnableAuthorization,
- config.EnableAllFolders,
- kvp.Value.Folders.ToArray(),
- kvp.Value.EnableLiveTv,
- kvp.Value.EnableLiveTvManagement,
- response,
- config.DefaultProvider?.Trim(),
- kvp.Value.AvatarURL)
- .ConfigureAwait(false);
- StateManager.Remove(kvp.Key);
- return Ok(authenticationResult);
- }
+ var timedState = redeemed.Value;
+ if (timedState is not null && timedState.Valid)
+ {
+ Guid userId = await CreateCanonicalLinkAndUserIfNotExist("oid", provider, timedState.Username);
+
+ var authenticationResult = await Authenticate(
+ userId,
+ timedState.Admin,
+ config.EnableAuthorization,
+ config.EnableAllFolders,
+ timedState.Folders.ToArray(),
+ timedState.EnableLiveTv,
+ timedState.EnableLiveTvManagement,
+ response,
+ config.DefaultProvider?.Trim(),
+ timedState.AvatarURL)
+ .ConfigureAwait(false);
+ return Ok(authenticationResult);
}
}
@@ -964,7 +992,7 @@ public class SSOController : ControllerBase
case "saml":
return SamlLink(provider, jellyfinUserId, authResponse);
case "oid":
- return OidLink(provider, jellyfinUserId, authResponse);
+ return await OidLink(provider, jellyfinUserId, authResponse).ConfigureAwait(false);
default:
throw new ArgumentException($"{mode} is not a valid choice between 'saml' and 'oid'");
}
@@ -1107,7 +1135,7 @@ public class SSOController : ControllerBase
/// JSON for the client to populate information with.
[Consumes(MediaTypeNames.Application.Json)]
[Produces(MediaTypeNames.Application.Json)]
- private ActionResult OidLink(string provider, Guid jellyfinUserId, AuthResponse response)
+ private async Task OidLink(string provider, Guid jellyfinUserId, AuthResponse response)
{
OidConfig config;
try
@@ -1119,13 +1147,16 @@ public class SSOController : ControllerBase
return BadRequest("No matching provider found");
}
- foreach (var kvp in StateManager)
+ var lookup = await TryStateReadAsync(() => _stateStore.GetAsync(response.Data), "read").ConfigureAwait(false);
+ if (!lookup.Succeeded)
{
- if (kvp.Value.State.State.Equals(response.Data) && kvp.Value.Valid)
- {
- string providerUserId = kvp.Value.Username;
- return CreateCanonicalLink("oid", provider, jellyfinUserId, providerUserId);
- }
+ return Problem(StateStoreUnavailable, statusCode: StatusCodes.Status503ServiceUnavailable);
+ }
+
+ var timedState = lookup.Value;
+ if (timedState is not null && timedState.Valid)
+ {
+ return CreateCanonicalLink("oid", provider, jellyfinUserId, timedState.Username);
}
return Problem("Something went wrong!");
@@ -1267,18 +1298,6 @@ public class SSOController : ControllerBase
return await _sessionManager.AuthenticateDirect(authRequest).ConfigureAwait(false);
}
- private void Invalidate()
- {
- foreach (var kvp in StateManager)
- {
- var now = DateTime.Now;
- if (now.Subtract(kvp.Value.Created).TotalMinutes > 1)
- {
- StateManager.Remove(kvp.Key);
- }
- }
- }
-
private string GetRequestBase(string schemeOverride = null, int? portOverride = null)
{
int requestPort;
@@ -1311,6 +1330,61 @@ public class SSOController : ControllerBase
}.ToString().TrimEnd('/');
}
+ ///
+ /// Finishes a redirect leg once the provider response has been evaluated. A rejected login discards
+ /// its state instead of writing it back, so a failed role check cannot extend the state's lifetime.
+ ///
+ /// The name of the provider.
+ /// The OpenID state value.
+ /// The evaluated state.
+ /// The base URL of the Jellyfin installation.
+ /// The response to serve to the client.
+ internal async Task CompleteOidState(string provider, string state, TimedAuthorizeState timedState, string baseUrl)
+ {
+ ArgumentNullException.ThrowIfNull(timedState);
+
+ if (!timedState.Valid)
+ {
+ await TryStateWriteAsync(() => _stateStore.ConsumeAsync(state), "discard").ConfigureAwait(false);
+ return ReturnError(StatusCodes.Status401Unauthorized, "Error. Check permissions.");
+ }
+
+ if (!await TryStateWriteAsync(() => _stateStore.UpdateAsync(timedState, StateExpiry), "update").ConfigureAwait(false))
+ {
+ return ReturnError(StatusCodes.Status503ServiceUnavailable, StateStoreUnavailable);
+ }
+
+ _logger.LogInformation("Is request linking: {IsLinking}", timedState.IsLinking);
+ return Content(WebResponse.Generator(data: state, provider: provider, baseUrl: baseUrl, mode: "OID", isLinking: timedState.IsLinking), MediaTypeNames.Text.Html);
+ }
+
+ private async Task<(bool Succeeded, T Value)> TryStateReadAsync(Func> operation, string action)
+ {
+ try
+ {
+ return (true, await operation().ConfigureAwait(false));
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "SSO state store {Action} failed; rejecting the request", action);
+ return (false, default);
+ }
+ }
+
+ private async Task TryStateWriteAsync(Func operation, string action)
+ {
+ try
+ {
+ await operation().ConfigureAwait(false);
+ return true;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "SSO state store {Action} failed; rejecting the request", action);
+ return false;
+ }
+ }
+
private ContentResult ReturnError(int code, string message)
{
var errorResult = new ContentResult();
diff --git a/SSO-Auth/SSO-Auth.csproj b/SSO-Auth/SSO-Auth.csproj
index 1f4ea7a..28be963 100644
--- a/SSO-Auth/SSO-Auth.csproj
+++ b/SSO-Auth/SSO-Auth.csproj
@@ -33,6 +33,12 @@
+
+
+
+
+
+
diff --git a/SSO-Auth/SsoAuthServiceRegistrator.cs b/SSO-Auth/SsoAuthServiceRegistrator.cs
new file mode 100644
index 0000000..7a2a635
--- /dev/null
+++ b/SSO-Auth/SsoAuthServiceRegistrator.cs
@@ -0,0 +1,44 @@
+using System;
+using Jellyfin.Plugin.SSO_Auth.State;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Plugins;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using StackExchange.Redis;
+
+namespace Jellyfin.Plugin.SSO_Auth;
+
+///
+/// Registers the plugin's services. The OAuth state store is backed by the shared cache the server
+/// already connects to when one is configured, which is what lets a login start on one server instance
+/// and be completed on another.
+///
+public class SsoAuthServiceRegistrator : IPluginServiceRegistrator
+{
+ ///
+ public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost)
+ {
+ ArgumentNullException.ThrowIfNull(serviceCollection);
+
+ serviceCollection.AddSingleton(sp =>
+ {
+ var logger = sp.GetRequiredService().CreateLogger();
+ var multiplexer = sp.GetService();
+
+ if (multiplexer is null)
+ {
+ logger.LogWarning(
+ "SSO OAuth state store: {Store}. Logins must start and finish on the same server instance.",
+ nameof(InMemorySsoStateStore));
+
+ return new InMemorySsoStateStore();
+ }
+
+ logger.LogInformation(
+ "SSO OAuth state store: {Store}. Logins are shared across server instances.",
+ nameof(RedisSsoStateStore));
+
+ return new RedisSsoStateStore(multiplexer);
+ });
+ }
+}
diff --git a/SSO-Auth/State/ISsoStateStore.cs b/SSO-Auth/State/ISsoStateStore.cs
new file mode 100644
index 0000000..806f885
--- /dev/null
+++ b/SSO-Auth/State/ISsoStateStore.cs
@@ -0,0 +1,48 @@
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using Jellyfin.Plugin.SSO_Auth.Api;
+
+namespace Jellyfin.Plugin.SSO_Auth.State;
+
+///
+/// Stores the in-flight OpenID authorization states of a login or linking flow.
+///
+public interface ISsoStateStore
+{
+ ///
+ /// Stores a newly prepared authorization state.
+ ///
+ /// The state to store, keyed on its OpenID state value.
+ /// How long the state remains usable.
+ /// A task representing the write.
+ Task CreateAsync(TimedAuthorizeState state, TimeSpan expiry);
+
+ ///
+ /// Reads an authorization state without removing it.
+ ///
+ /// The OpenID state value.
+ /// The stored state, or null when it is unknown or expired.
+ Task GetAsync(string state);
+
+ ///
+ /// Persists mutations made to an authorization state and refreshes its expiry.
+ ///
+ /// The state to persist, keyed on its OpenID state value.
+ /// How long the state remains usable.
+ /// A task representing the write.
+ Task UpdateAsync(TimedAuthorizeState state, TimeSpan expiry);
+
+ ///
+ /// Reads an authorization state and removes it in the same operation, so it can only be redeemed once.
+ ///
+ /// The OpenID state value.
+ /// The stored state, or null when it is unknown, expired or already redeemed.
+ Task ConsumeAsync(string state);
+
+ ///
+ /// Lists every authorization state currently in flight.
+ ///
+ /// The states in flight.
+ Task> ListAsync();
+}
diff --git a/SSO-Auth/State/InMemorySsoStateStore.cs b/SSO-Auth/State/InMemorySsoStateStore.cs
new file mode 100644
index 0000000..4e3da80
--- /dev/null
+++ b/SSO-Auth/State/InMemorySsoStateStore.cs
@@ -0,0 +1,134 @@
+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);
+}
diff --git a/SSO-Auth/State/RedisSsoStateStore.cs b/SSO-Auth/State/RedisSsoStateStore.cs
new file mode 100644
index 0000000..43bf75f
--- /dev/null
+++ b/SSO-Auth/State/RedisSsoStateStore.cs
@@ -0,0 +1,115 @@
+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;
+
+///
+/// A Redis/Valkey backed . 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.
+///
+public sealed class RedisSsoStateStore : ISsoStateStore
+{
+ ///
+ /// The prefix every OpenID state key is stored under.
+ ///
+ public const string KeyPrefix = "jellyfin:sso:oid:state:";
+
+ private readonly IConnectionMultiplexer _redis;
+ private readonly IDatabase _database;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The connection multiplexer shared with the server.
+ public RedisSsoStateStore(IConnectionMultiplexer redis)
+ {
+ ArgumentNullException.ThrowIfNull(redis);
+
+ _redis = redis;
+ _database = redis.GetDatabase();
+ }
+
+ ///
+ public Task CreateAsync(TimedAuthorizeState state, TimeSpan expiry)
+ {
+ return WriteAsync(state, expiry);
+ }
+
+ ///
+ public Task UpdateAsync(TimedAuthorizeState state, TimeSpan expiry)
+ {
+ return WriteAsync(state, expiry);
+ }
+
+ ///
+ public async Task GetAsync(string state)
+ {
+ if (string.IsNullOrEmpty(state))
+ {
+ return null;
+ }
+
+ var payload = await _database.StringGetAsync(KeyFor(state)).ConfigureAwait(false);
+ return SsoStateSerializer.Deserialize(payload);
+ }
+
+ ///
+ public async Task ConsumeAsync(string state)
+ {
+ if (string.IsNullOrEmpty(state))
+ {
+ return null;
+ }
+
+ var payload = await _database.StringGetDeleteAsync(KeyFor(state)).ConfigureAwait(false);
+ return SsoStateSerializer.Deserialize(payload);
+ }
+
+ ///
+ public async Task> ListAsync()
+ {
+ var states = new List();
+
+ 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);
+ }
+}
diff --git a/SSO-Auth/State/SsoStateInfo.cs b/SSO-Auth/State/SsoStateInfo.cs
new file mode 100644
index 0000000..1ab4826
--- /dev/null
+++ b/SSO-Auth/State/SsoStateInfo.cs
@@ -0,0 +1,92 @@
+using System;
+using System.Collections.Generic;
+using Jellyfin.Plugin.SSO_Auth.Api;
+
+namespace Jellyfin.Plugin.SSO_Auth.State;
+
+///
+/// A redacted view of an in-flight authorization state. The PKCE code verifier and the authorization
+/// URL derived from it are deliberately omitted so that listing flows cannot leak them.
+///
+public class SsoStateInfo
+{
+ ///
+ /// Gets or sets the OpenID state value.
+ ///
+ public string State { get; set; }
+
+ ///
+ /// Gets or sets the redirect URI the provider calls back to.
+ ///
+ public string RedirectUri { get; set; }
+
+ ///
+ /// Gets or sets when the flow started.
+ ///
+ public DateTime Created { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether the flow has been validated.
+ ///
+ public bool Valid { get; set; }
+
+ ///
+ /// Gets or sets the user tied to the flow.
+ ///
+ public string Username { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether the user is an administrator.
+ ///
+ public bool Admin { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether the flow links an existing account.
+ ///
+ public bool IsLinking { get; set; }
+
+ ///
+ /// Gets or sets the folders the user is allowed access to.
+ ///
+ public IReadOnlyList Folders { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether the user is allowed to view live TV.
+ ///
+ public bool EnableLiveTv { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether the user is allowed to manage live TV.
+ ///
+ public bool EnableLiveTvManagement { get; set; }
+
+ ///
+ /// Gets or sets the user avatar url.
+ ///
+ public string AvatarURL { get; set; }
+
+ ///
+ /// Projects a stored state onto its redacted view.
+ ///
+ /// The stored state.
+ /// The redacted view.
+ public static SsoStateInfo FromState(TimedAuthorizeState state)
+ {
+ ArgumentNullException.ThrowIfNull(state);
+
+ return new SsoStateInfo
+ {
+ State = state.State?.State,
+ RedirectUri = state.State?.RedirectUri,
+ Created = state.Created,
+ Valid = state.Valid,
+ Username = state.Username,
+ Admin = state.Admin,
+ IsLinking = state.IsLinking,
+ Folders = state.Folders,
+ EnableLiveTv = state.EnableLiveTv,
+ EnableLiveTvManagement = state.EnableLiveTvManagement,
+ AvatarURL = state.AvatarURL
+ };
+ }
+}
diff --git a/SSO-Auth/State/SsoStateSerializer.cs b/SSO-Auth/State/SsoStateSerializer.cs
new file mode 100644
index 0000000..6592206
--- /dev/null
+++ b/SSO-Auth/State/SsoStateSerializer.cs
@@ -0,0 +1,36 @@
+using Jellyfin.Plugin.SSO_Auth.Api;
+using Newtonsoft.Json;
+
+namespace Jellyfin.Plugin.SSO_Auth.State;
+
+///
+/// Converts to and from the representation held by a state store.
+///
+public static class SsoStateSerializer
+{
+ private static readonly JsonSerializerSettings Settings = new JsonSerializerSettings
+ {
+ NullValueHandling = NullValueHandling.Include,
+ DateTimeZoneHandling = DateTimeZoneHandling.RoundtripKind
+ };
+
+ ///
+ /// Serializes an authorization state.
+ ///
+ /// The state to serialize.
+ /// The serialized state.
+ public static string Serialize(TimedAuthorizeState state)
+ {
+ return JsonConvert.SerializeObject(state, Settings);
+ }
+
+ ///
+ /// Deserializes an authorization state.
+ ///
+ /// The serialized state, which may be null or empty.
+ /// The state, or null when there is nothing to deserialize.
+ public static TimedAuthorizeState Deserialize(string payload)
+ {
+ return string.IsNullOrEmpty(payload) ? null : JsonConvert.DeserializeObject(payload, Settings);
+ }
+}
diff --git a/tests/SSO-Auth.Tests/InMemorySsoStateStoreTests.cs b/tests/SSO-Auth.Tests/InMemorySsoStateStoreTests.cs
new file mode 100644
index 0000000..efcdbbb
--- /dev/null
+++ b/tests/SSO-Auth.Tests/InMemorySsoStateStoreTests.cs
@@ -0,0 +1,143 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using Duende.IdentityModel.OidcClient;
+using Jellyfin.Plugin.SSO_Auth.Api;
+using Jellyfin.Plugin.SSO_Auth.State;
+using Xunit;
+
+namespace Jellyfin.Plugin.SSO_Auth.Tests;
+
+public class InMemorySsoStateStoreTests
+{
+ private static readonly TimeSpan Expiry = TimeSpan.FromSeconds(300);
+
+ [Fact]
+ public async Task GetAsync_WhenUnknown_ReturnsNull()
+ {
+ Assert.Null(await new InMemorySsoStateStore().GetAsync("nope"));
+ }
+
+ [Fact]
+ public async Task Mutations_AreOnlyVisibleOnceWrittenBack()
+ {
+ var store = new InMemorySsoStateStore();
+ await store.CreateAsync(NewState("abc123"), Expiry);
+
+ var dropped = await store.GetAsync("abc123");
+ dropped.Valid = true;
+ dropped.Username = "mallory";
+
+ // Not written back, so the mutation must not be observable.
+ var reread = await store.GetAsync("abc123");
+ Assert.False(reread.Valid);
+ Assert.Null(reread.Username);
+
+ reread.Valid = true;
+ reread.Username = "alice";
+ reread.Admin = true;
+ reread.Folders = new List { "movies" };
+ await store.UpdateAsync(reread, Expiry);
+
+ var redeemed = await store.ConsumeAsync("abc123");
+ Assert.True(redeemed.Valid);
+ Assert.Equal("alice", redeemed.Username);
+ Assert.True(redeemed.Admin);
+ Assert.Equal(new[] { "movies" }, redeemed.Folders);
+ }
+
+ [Fact]
+ public async Task ConsumeAsync_RedeemsOnlyOnce()
+ {
+ var store = new InMemorySsoStateStore();
+ await store.CreateAsync(NewState("abc123"), Expiry);
+
+ Assert.NotNull(await store.ConsumeAsync("abc123"));
+ Assert.Null(await store.ConsumeAsync("abc123"));
+ Assert.Null(await store.GetAsync("abc123"));
+ }
+
+ [Fact]
+ public async Task ExpiredState_IsNotReturned()
+ {
+ var store = new InMemorySsoStateStore();
+ await store.CreateAsync(NewState("abc123"), TimeSpan.FromMilliseconds(-1));
+
+ Assert.Null(await store.GetAsync("abc123"));
+ Assert.Empty(await store.ListAsync());
+ }
+
+ [Fact]
+ public async Task ListAsync_ReturnsLiveStates()
+ {
+ var store = new InMemorySsoStateStore();
+ await store.CreateAsync(NewState("abc123"), Expiry);
+ await store.CreateAsync(NewState("def456"), Expiry);
+
+ var states = await store.ListAsync();
+
+ Assert.Equal(2, states.Count);
+ }
+
+ [Fact]
+ public async Task Writing_SweepsExpiredEntriesOutOfTheStore()
+ {
+ var store = new InMemorySsoStateStore();
+ await store.CreateAsync(NewState("stale1"), TimeSpan.FromMilliseconds(-1));
+ await store.CreateAsync(NewState("stale2"), TimeSpan.FromMilliseconds(-1));
+ await store.CreateAsync(NewState("stale3"), TimeSpan.FromMilliseconds(-1));
+
+ await store.CreateAsync(NewState("live"), Expiry);
+
+ Assert.Equal(1, store.Count);
+ }
+
+ [Fact]
+ public async Task Writing_BoundsTheStoreAtItsCapacity()
+ {
+ var store = new InMemorySsoStateStore();
+
+ for (var i = 0; i < InMemorySsoStateStore.Capacity + 64; i++)
+ {
+ await store.CreateAsync(NewState($"state{i}"), Expiry);
+ }
+
+ Assert.Equal(InMemorySsoStateStore.Capacity, store.Count);
+ }
+
+ [Fact]
+ public async Task Writing_AtCapacity_IsSafeUnderConcurrency()
+ {
+ var store = new InMemorySsoStateStore();
+
+ for (var i = 0; i < InMemorySsoStateStore.Capacity; i++)
+ {
+ await store.CreateAsync(NewState($"seed{i}"), Expiry);
+ }
+
+ const int Writers = 8;
+
+ var writers = Enumerable.Range(0, Writers).Select(writer => Task.Run(async () =>
+ {
+ for (var i = 0; i < 2000; i++)
+ {
+ var state = NewState($"w{writer}-{i}");
+ await store.CreateAsync(state, Expiry);
+ await store.UpdateAsync(state, Expiry);
+ }
+ }));
+
+ await Task.WhenAll(writers);
+
+ // Each in-flight writer can miss or repeat one eviction, so the cap holds to within the writer count.
+ Assert.InRange(store.Count, InMemorySsoStateStore.Capacity - Writers, InMemorySsoStateStore.Capacity + Writers);
+ }
+
+ private static TimedAuthorizeState NewState(string state)
+ {
+ return new TimedAuthorizeState(
+ new AuthorizeState { State = state, CodeVerifier = "code-verifier-secret" },
+ DateTime.UtcNow);
+ }
+}
diff --git a/tests/SSO-Auth.Tests/RedisSsoStateStoreTests.cs b/tests/SSO-Auth.Tests/RedisSsoStateStoreTests.cs
new file mode 100644
index 0000000..396bd61
--- /dev/null
+++ b/tests/SSO-Auth.Tests/RedisSsoStateStoreTests.cs
@@ -0,0 +1,219 @@
+using System;
+using System.Collections.Generic;
+using System.Net;
+using System.Threading.Tasks;
+using Duende.IdentityModel.OidcClient;
+using Jellyfin.Plugin.SSO_Auth.Api;
+using Jellyfin.Plugin.SSO_Auth.State;
+using Moq;
+using StackExchange.Redis;
+using Xunit;
+
+namespace Jellyfin.Plugin.SSO_Auth.Tests;
+
+public class RedisSsoStateStoreTests
+{
+ [Fact]
+ public async Task CreateAsync_WritesPrefixedKeyWithExpiry()
+ {
+ var db = new Mock();
+ RedisKey key = default;
+ RedisValue value = default;
+ TimeSpan? expiry = null;
+ SetupStringSet(db, (k, v, e) =>
+ {
+ key = k;
+ value = v;
+ expiry = e;
+ });
+
+ var store = new RedisSsoStateStore(CreateMultiplexer(db.Object));
+ await store.CreateAsync(NewState("abc123"), TimeSpan.FromSeconds(300));
+
+ Assert.Equal("jellyfin:sso:oid:state:abc123", key.ToString());
+ Assert.Equal(TimeSpan.FromSeconds(300), expiry);
+ Assert.Contains("abc123", value.ToString(), StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task UpdateAsync_RefreshesExpiry()
+ {
+ var db = new Mock();
+ TimeSpan? expiry = null;
+ SetupStringSet(db, (k, v, e) => expiry = e);
+
+ var store = new RedisSsoStateStore(CreateMultiplexer(db.Object));
+ await store.UpdateAsync(NewState("abc123"), TimeSpan.FromSeconds(120));
+
+ Assert.Equal(TimeSpan.FromSeconds(120), expiry);
+ }
+
+ [Fact]
+ public async Task GetAsync_WhenKeyMissing_ReturnsNull()
+ {
+ var db = new Mock();
+ db.Setup(d => d.StringGetAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(RedisValue.Null);
+
+ var store = new RedisSsoStateStore(CreateMultiplexer(db.Object));
+
+ Assert.Null(await store.GetAsync("missing"));
+ }
+
+ [Fact]
+ public async Task GetAsync_WithoutState_DoesNotTouchRedis()
+ {
+ var db = new Mock(MockBehavior.Strict);
+ var store = new RedisSsoStateStore(CreateMultiplexer(db.Object));
+
+ Assert.Null(await store.GetAsync(null));
+ Assert.Null(await store.GetAsync(string.Empty));
+ }
+
+ [Fact]
+ public async Task Mutations_WrittenByTheCallback_AreVisibleToTheAuthLeg()
+ {
+ var db = new Mock();
+ string stored = null;
+ SetupStringSet(db, (k, v, e) => stored = v.ToString());
+ db.Setup(d => d.StringGetAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(() => stored is null ? RedisValue.Null : (RedisValue)stored);
+ db.Setup(d => d.StringGetDeleteAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(() =>
+ {
+ var payload = stored;
+ stored = null;
+ return payload is null ? RedisValue.Null : (RedisValue)payload;
+ });
+
+ var store = new RedisSsoStateStore(CreateMultiplexer(db.Object));
+ var expiry = TimeSpan.FromSeconds(300);
+
+ // Leg 1: the start endpoint stores a freshly prepared state.
+ await store.CreateAsync(NewState("abc123"), expiry);
+
+ // Leg 2: the callback endpoint validates the response and writes its decision back.
+ var callbackState = await store.GetAsync("abc123");
+ Assert.NotNull(callbackState);
+ Assert.False(callbackState.Valid);
+ callbackState.Valid = true;
+ callbackState.Username = "alice";
+ callbackState.Admin = true;
+ callbackState.AvatarURL = "https://idp.example/alice.png";
+ await store.UpdateAsync(callbackState, expiry);
+
+ // Leg 3: the auth endpoint redeems the state and sees the callback's decision.
+ var authState = await store.ConsumeAsync("abc123");
+ Assert.NotNull(authState);
+ Assert.True(authState.Valid);
+ Assert.Equal("alice", authState.Username);
+ Assert.True(authState.Admin);
+ Assert.Equal("https://idp.example/alice.png", authState.AvatarURL);
+ Assert.Equal("code-verifier-secret", authState.State.CodeVerifier);
+
+ // A replay of the same response finds nothing.
+ Assert.Null(await store.ConsumeAsync("abc123"));
+ }
+
+ [Fact]
+ public async Task ConsumeAsync_UsesAnAtomicGetAndDelete()
+ {
+ var db = new Mock(MockBehavior.Strict);
+ db.Setup(d => d.StringGetDeleteAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(RedisValue.Null);
+
+ var store = new RedisSsoStateStore(CreateMultiplexer(db.Object));
+
+ Assert.Null(await store.ConsumeAsync("abc123"));
+ db.Verify(
+ d => d.StringGetDeleteAsync(
+ It.Is(k => k.ToString() == "jellyfin:sso:oid:state:abc123"),
+ It.IsAny()),
+ Times.Once);
+ }
+
+ [Fact]
+ public async Task ListAsync_ScansPrimariesOnly()
+ {
+ var db = new Mock();
+ db.Setup(d => d.StringGetAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync((RedisValue)SsoStateSerializer.Serialize(NewState("abc123")));
+
+ var primary = CreateServer(isReplica: false, keys: new[] { (RedisKey)"jellyfin:sso:oid:state:abc123" });
+ var replica = CreateServer(isReplica: true, keys: new[] { (RedisKey)"jellyfin:sso:oid:state:abc123" });
+
+ var primaryEndpoint = new DnsEndPoint("primary", 6379);
+ var replicaEndpoint = new DnsEndPoint("replica", 6379);
+
+ var mux = new Mock();
+ mux.Setup(m => m.GetDatabase(It.IsAny(), It.IsAny