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.
This commit is contained in:
unkin-agent
2026-09-19 22:47:43 +10:00
parent 404be19c2b
commit a167d00b73
16 changed files with 1071 additions and 72 deletions
+1 -1
View File
@@ -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)
+28 -22
View File
@@ -1,22 +1,28 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
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
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{C30A5CFB-B27E-4E83-9E96-1E0362B36748}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{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
EndGlobalSection
EndGlobal
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
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
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{C30A5CFB-B27E-4E83-9E96-1E0362B36748}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{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
+45 -49
View File
@@ -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;
@@ -47,7 +48,13 @@ public class SSOController : ControllerBase
private readonly IProviderManager _providerManager;
private readonly IServerConfigurationManager _serverConfigurationManager;
private readonly IHttpClientFactory _httpClientFactory;
private static readonly IDictionary<string, TimedAuthorizeState> StateManager = new Dictionary<string, TimedAuthorizeState>();
private readonly ISsoStateStore _stateStore;
/// <summary>
/// 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.
/// </summary>
private static readonly TimeSpan StateExpiry = TimeSpan.FromSeconds(300);
/// <summary>
/// Initializes a new instance of the <see cref="SSOController"/> class.
@@ -61,6 +68,7 @@ public class SSOController : ControllerBase
/// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param>
/// <param name="httpClientFactory">Instance of the <see cref="IHttpClientFactory"/> interface.</param>
/// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
/// <param name="stateStore">Instance of the <see cref="ISsoStateStore"/> interface.</param>
public SSOController(
ILogger<SSOController> logger,
ILoggerFactory loggerFactory,
@@ -70,7 +78,8 @@ public class SSOController : ControllerBase
ICryptoProvider cryptoProvider,
IProviderManager providerManager,
IHttpClientFactory httpClientFactory,
IServerConfigurationManager serverConfigurationManager)
IServerConfigurationManager serverConfigurationManager,
ISsoStateStore stateStore)
{
_sessionManager = sessionManager;
_userManager = userManager;
@@ -81,6 +90,7 @@ public class SSOController : ControllerBase
_providerManager = providerManager;
_serverConfigurationManager = serverConfigurationManager;
_httpClientFactory = httpClientFactory;
_stateStore = stateStore;
_logger.LogInformation("SSO Controller initialized");
}
@@ -114,7 +124,8 @@ public class SSOController : ControllerBase
return BadRequest("Missing state");
}
if (!StateManager.TryGetValue(state, out var timedState))
var timedState = await _stateStore.GetAsync(state).ConfigureAwait(false);
if (timedState is null)
{
return BadRequest("Invalid or expired state");
}
@@ -329,6 +340,8 @@ public class SSOController : ControllerBase
bool isLinking = timedState.IsLinking;
await _stateStore.UpdateAsync(timedState, StateExpiry).ConfigureAwait(false);
if (timedState.Valid)
{
_logger.LogInformation($"Is request linking: {isLinking}");
@@ -360,7 +373,6 @@ public class SSOController : ControllerBase
[HttpGet("OID/start/{provider}")]
public async Task<ActionResult> OidChallenge(string provider, [FromQuery] bool isLinking = false)
{
Invalidate();
OidConfig config;
try
{
@@ -416,10 +428,10 @@ 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 };
await _stateStore.CreateAsync(timedState, StateExpiry).ConfigureAwait(false);
return Redirect(state.StartUrl);
}
@@ -487,12 +499,13 @@ public class SSOController : ControllerBase
/// <summary>
/// This is a debug endpoint to list all running OpenID flows. Requires administrator privileges.
/// </summary>
/// <returns>The list of OpenID flows in progress.</returns>
/// <returns>The list of OpenID flows in progress, without their PKCE code verifiers.</returns>
[Authorize(Policy = Policies.RequiresElevation)]
[HttpGet("OID/States")]
public ActionResult OidStates()
public async Task<ActionResult> OidStates()
{
return Ok(StateManager);
var states = await _stateStore.ListAsync().ConfigureAwait(false);
return Ok(states.Select(SsoStateInfo.FromState).ToList());
}
/// <summary>
@@ -518,27 +531,25 @@ 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 timedState = await _stateStore.ConsumeAsync(response.Data).ConfigureAwait(false);
if (timedState is not null && timedState.Valid)
{
if (kvp.Value.State.State.Equals(response.Data) && kvp.Value.Valid)
{
Guid userId = await CreateCanonicalLinkAndUserIfNotExist("oid", provider, kvp.Value.Username);
Guid userId = await CreateCanonicalLinkAndUserIfNotExist("oid", provider, timedState.Username);
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 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 +975,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 +1118,7 @@ public class SSOController : ControllerBase
/// <returns>JSON for the client to populate information with.</returns>
[Consumes(MediaTypeNames.Application.Json)]
[Produces(MediaTypeNames.Application.Json)]
private ActionResult OidLink(string provider, Guid jellyfinUserId, AuthResponse response)
private async Task<ActionResult> OidLink(string provider, Guid jellyfinUserId, AuthResponse response)
{
OidConfig config;
try
@@ -1119,13 +1130,10 @@ public class SSOController : ControllerBase
return BadRequest("No matching provider found");
}
foreach (var kvp in StateManager)
var timedState = await _stateStore.GetAsync(response.Data).ConfigureAwait(false);
if (timedState is not null && timedState.Valid)
{
if (kvp.Value.State.State.Equals(response.Data) && kvp.Value.Valid)
{
string providerUserId = kvp.Value.Username;
return CreateCanonicalLink("oid", provider, jellyfinUserId, providerUserId);
}
return CreateCanonicalLink("oid", provider, jellyfinUserId, timedState.Username);
}
return Problem("Something went wrong!");
@@ -1267,18 +1275,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;
+2
View File
@@ -33,6 +33,8 @@
<PackageReference Include="Jellyfin.Controller" Version="12.0.0" />
<PackageReference Include="Jellyfin.Model" Version="12.0.0" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<!-- Compile-only: the server supplies this assembly; shipping it would load a second copy into the plugin load context. -->
<PackageReference Include="StackExchange.Redis" Version="2.13.17" ExcludeAssets="runtime" Private="false" />
</ItemGroup>
<ItemGroup>
+44
View File
@@ -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;
/// <summary>
/// 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.
/// </summary>
public class SsoAuthServiceRegistrator : IPluginServiceRegistrator
{
/// <inheritdoc />
public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost)
{
ArgumentNullException.ThrowIfNull(serviceCollection);
serviceCollection.AddSingleton<ISsoStateStore>(sp =>
{
var logger = sp.GetRequiredService<ILoggerFactory>().CreateLogger<SsoAuthServiceRegistrator>();
var multiplexer = sp.GetService<IConnectionMultiplexer>();
if (multiplexer is null)
{
logger.LogInformation(
"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);
});
}
}
+48
View File
@@ -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;
/// <summary>
/// Stores the in-flight OpenID authorization states of a login or linking flow.
/// </summary>
public interface ISsoStateStore
{
/// <summary>
/// Stores a newly prepared authorization state.
/// </summary>
/// <param name="state">The state to store, keyed on its OpenID state value.</param>
/// <param name="expiry">How long the state remains usable.</param>
/// <returns>A task representing the write.</returns>
Task CreateAsync(TimedAuthorizeState state, TimeSpan expiry);
/// <summary>
/// Reads an authorization state without removing it.
/// </summary>
/// <param name="state">The OpenID state value.</param>
/// <returns>The stored state, or <c>null</c> when it is unknown or expired.</returns>
Task<TimedAuthorizeState> GetAsync(string state);
/// <summary>
/// Persists mutations made to an authorization state and refreshes its expiry.
/// </summary>
/// <param name="state">The state to persist, keyed on its OpenID state value.</param>
/// <param name="expiry">How long the state remains usable.</param>
/// <returns>A task representing the write.</returns>
Task UpdateAsync(TimedAuthorizeState state, TimeSpan expiry);
/// <summary>
/// Reads an authorization state and removes it in the same operation, so it can only be redeemed once.
/// </summary>
/// <param name="state">The OpenID state value.</param>
/// <returns>The stored state, or <c>null</c> when it is unknown, expired or already redeemed.</returns>
Task<TimedAuthorizeState> ConsumeAsync(string state);
/// <summary>
/// Lists every authorization state currently in flight.
/// </summary>
/// <returns>The states in flight.</returns>
Task<IReadOnlyList<TimedAuthorizeState>> ListAsync();
}
+95
View File
@@ -0,0 +1,95 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Threading.Tasks;
using Jellyfin.Plugin.SSO_Auth.Api;
namespace Jellyfin.Plugin.SSO_Auth.State;
/// <summary>
/// A process-local <see cref="ISsoStateStore"/> used when the server has no shared cache configured.
/// States are held serialized so that this store and <see cref="RedisSsoStateStore"/> behave identically:
/// a mutation is only visible to a later leg of the flow once it has been written back.
/// </summary>
public sealed class InMemorySsoStateStore : ISsoStateStore
{
private readonly ConcurrentDictionary<string, Entry> _entries = new ConcurrentDictionary<string, Entry>(StringComparer.Ordinal);
/// <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 Task<TimedAuthorizeState> GetAsync(string state)
{
if (string.IsNullOrEmpty(state) || !_entries.TryGetValue(state, out var entry))
{
return Task.FromResult<TimedAuthorizeState>(null);
}
if (IsExpired(entry))
{
_entries.TryRemove(state, out _);
return Task.FromResult<TimedAuthorizeState>(null);
}
return Task.FromResult(SsoStateSerializer.Deserialize(entry.Payload));
}
/// <inheritdoc />
public Task<TimedAuthorizeState> ConsumeAsync(string state)
{
if (string.IsNullOrEmpty(state) || !_entries.TryRemove(state, out var entry) || IsExpired(entry))
{
return Task.FromResult<TimedAuthorizeState>(null);
}
return Task.FromResult(SsoStateSerializer.Deserialize(entry.Payload));
}
/// <inheritdoc />
public Task<IReadOnlyList<TimedAuthorizeState>> ListAsync()
{
var states = new List<TimedAuthorizeState>();
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<IReadOnlyList<TimedAuthorizeState>>(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));
return Task.CompletedTask;
}
private sealed record Entry(string Payload, DateTimeOffset ExpiresAt);
}
+115
View File
@@ -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;
/// <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);
}
}
+92
View File
@@ -0,0 +1,92 @@
using System;
using System.Collections.Generic;
using Jellyfin.Plugin.SSO_Auth.Api;
namespace Jellyfin.Plugin.SSO_Auth.State;
/// <summary>
/// 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.
/// </summary>
public class SsoStateInfo
{
/// <summary>
/// Gets or sets the OpenID state value.
/// </summary>
public string State { get; set; }
/// <summary>
/// Gets or sets the redirect URI the provider calls back to.
/// </summary>
public string RedirectUri { get; set; }
/// <summary>
/// Gets or sets when the flow started.
/// </summary>
public DateTime Created { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the flow has been validated.
/// </summary>
public bool Valid { get; set; }
/// <summary>
/// Gets or sets the user tied to the flow.
/// </summary>
public string Username { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the user is an administrator.
/// </summary>
public bool Admin { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the flow links an existing account.
/// </summary>
public bool IsLinking { get; set; }
/// <summary>
/// Gets or sets the folders the user is allowed access to.
/// </summary>
public IReadOnlyList<string> Folders { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the user is allowed to view live TV.
/// </summary>
public bool EnableLiveTv { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the user is allowed to manage live TV.
/// </summary>
public bool EnableLiveTvManagement { get; set; }
/// <summary>
/// Gets or sets the user avatar url.
/// </summary>
public string AvatarURL { get; set; }
/// <summary>
/// Projects a stored state onto its redacted view.
/// </summary>
/// <param name="state">The stored state.</param>
/// <returns>The redacted view.</returns>
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
};
}
}
+36
View File
@@ -0,0 +1,36 @@
using Jellyfin.Plugin.SSO_Auth.Api;
using Newtonsoft.Json;
namespace Jellyfin.Plugin.SSO_Auth.State;
/// <summary>
/// Converts <see cref="TimedAuthorizeState"/> to and from the representation held by a state store.
/// </summary>
public static class SsoStateSerializer
{
private static readonly JsonSerializerSettings Settings = new JsonSerializerSettings
{
NullValueHandling = NullValueHandling.Include,
DateTimeZoneHandling = DateTimeZoneHandling.RoundtripKind
};
/// <summary>
/// Serializes an authorization state.
/// </summary>
/// <param name="state">The state to serialize.</param>
/// <returns>The serialized state.</returns>
public static string Serialize(TimedAuthorizeState state)
{
return JsonConvert.SerializeObject(state, Settings);
}
/// <summary>
/// Deserializes an authorization state.
/// </summary>
/// <param name="payload">The serialized state, which may be null or empty.</param>
/// <returns>The state, or <c>null</c> when there is nothing to deserialize.</returns>
public static TimedAuthorizeState Deserialize(string payload)
{
return string.IsNullOrEmpty(payload) ? null : JsonConvert.DeserializeObject<TimedAuthorizeState>(payload, Settings);
}
}
@@ -0,0 +1,88 @@
using System;
using System.Collections.Generic;
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<string> { "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);
}
private static TimedAuthorizeState NewState(string state)
{
return new TimedAuthorizeState(
new AuthorizeState { State = state, CodeVerifier = "code-verifier-secret" },
DateTime.UtcNow);
}
}
@@ -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<IDatabase>();
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<IDatabase>();
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<IDatabase>();
db.Setup(d => d.StringGetAsync(It.IsAny<RedisKey>(), It.IsAny<CommandFlags>()))
.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<IDatabase>(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<IDatabase>();
string stored = null;
SetupStringSet(db, (k, v, e) => stored = v.ToString());
db.Setup(d => d.StringGetAsync(It.IsAny<RedisKey>(), It.IsAny<CommandFlags>()))
.ReturnsAsync(() => stored is null ? RedisValue.Null : (RedisValue)stored);
db.Setup(d => d.StringGetDeleteAsync(It.IsAny<RedisKey>(), It.IsAny<CommandFlags>()))
.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<IDatabase>(MockBehavior.Strict);
db.Setup(d => d.StringGetDeleteAsync(It.IsAny<RedisKey>(), It.IsAny<CommandFlags>()))
.ReturnsAsync(RedisValue.Null);
var store = new RedisSsoStateStore(CreateMultiplexer(db.Object));
Assert.Null(await store.ConsumeAsync("abc123"));
db.Verify(
d => d.StringGetDeleteAsync(
It.Is<RedisKey>(k => k.ToString() == "jellyfin:sso:oid:state:abc123"),
It.IsAny<CommandFlags>()),
Times.Once);
}
[Fact]
public async Task ListAsync_ScansPrimariesOnly()
{
var db = new Mock<IDatabase>();
db.Setup(d => d.StringGetAsync(It.IsAny<RedisKey>(), It.IsAny<CommandFlags>()))
.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<IConnectionMultiplexer>();
mux.Setup(m => m.GetDatabase(It.IsAny<int>(), It.IsAny<object>())).Returns(db.Object);
mux.Setup(m => m.GetEndPoints(It.IsAny<bool>())).Returns(new EndPoint[] { primaryEndpoint, replicaEndpoint });
mux.Setup(m => m.GetServer(primaryEndpoint, It.IsAny<object>())).Returns(primary);
mux.Setup(m => m.GetServer(replicaEndpoint, It.IsAny<object>())).Returns(replica);
var states = await new RedisSsoStateStore(mux.Object).ListAsync();
Assert.Single(states);
Assert.Equal("abc123", states[0].State.State);
}
private static TimedAuthorizeState NewState(string state)
{
return new TimedAuthorizeState(
new AuthorizeState
{
State = state,
StartUrl = "https://idp.example/authorize",
RedirectUri = "https://jellyfin.example/sso/OID/redirect/authentik",
CodeVerifier = "code-verifier-secret"
},
DateTime.UtcNow);
}
private static void SetupStringSet(Mock<IDatabase> db, Action<RedisKey, RedisValue, TimeSpan?> capture)
{
db.Setup(d => d.StringSetAsync(
It.IsAny<RedisKey>(),
It.IsAny<RedisValue>(),
It.IsAny<TimeSpan?>(),
It.IsAny<bool>(),
It.IsAny<When>(),
It.IsAny<CommandFlags>()))
.Callback<RedisKey, RedisValue, TimeSpan?, bool, When, CommandFlags>(
(key, value, expiry, keepTtl, when, flags) => capture(key, value, expiry))
.ReturnsAsync(true);
}
private static IServer CreateServer(bool isReplica, IReadOnlyList<RedisKey> keys)
{
var server = new Mock<IServer>();
server.SetupGet(s => s.IsConnected).Returns(true);
server.SetupGet(s => s.IsReplica).Returns(isReplica);
server.Setup(s => s.KeysAsync(
It.IsAny<int>(),
It.IsAny<RedisValue>(),
It.IsAny<int>(),
It.IsAny<long>(),
It.IsAny<int>(),
It.IsAny<CommandFlags>()))
.Returns(ToAsyncEnumerable(keys));
return server.Object;
}
private static async IAsyncEnumerable<RedisKey> ToAsyncEnumerable(IReadOnlyList<RedisKey> keys)
{
foreach (var key in keys)
{
yield return key;
}
await Task.CompletedTask;
}
private static IConnectionMultiplexer CreateMultiplexer(IDatabase database)
{
var mux = new Mock<IConnectionMultiplexer>();
mux.Setup(m => m.GetDatabase(It.IsAny<int>(), It.IsAny<object>())).Returns(database);
return mux.Object;
}
}
@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<RootNamespace>Jellyfin.Plugin.SSO_Auth.Tests</RootNamespace>
<OutputType>Exe</OutputType>
<IsPackable>false</IsPackable>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.9.0" />
<PackageReference Include="Moq" Version="4.18.4" />
<PackageReference Include="StackExchange.Redis" Version="2.13.17" />
<PackageReference Include="xunit.v3" Version="3.2.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\SSO-Auth\SSO-Auth.csproj" />
</ItemGroup>
</Project>
+102
View File
@@ -0,0 +1,102 @@
using System;
using System.IO;
using System.Net.Http;
using System.Threading.Tasks;
using Duende.IdentityModel.OidcClient;
using Jellyfin.Plugin.SSO_Auth.Api;
using Jellyfin.Plugin.SSO_Auth.Config;
using Jellyfin.Plugin.SSO_Auth.State;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Net;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Controller.Session;
using MediaBrowser.Model.Cryptography;
using MediaBrowser.Model.Serialization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Moq;
using Newtonsoft.Json;
using Xunit;
namespace Jellyfin.Plugin.SSO_Auth.Tests;
public class SSOControllerTests
{
[Fact]
public async Task OidPost_WhenTheStateIsUnknownToTheStore_ReportsAnExpiredState()
{
ConfigurePlugin();
var store = new Mock<ISsoStateStore>();
store.Setup(s => s.GetAsync("state-from-another-pod")).ReturnsAsync((TimedAuthorizeState)null);
var result = await CreateController(store.Object).OidPost("authentik", "state-from-another-pod");
var badRequest = Assert.IsType<BadRequestObjectResult>(result);
Assert.Equal("Invalid or expired state", badRequest.Value);
}
[Fact]
public async Task OidStates_DoesNotExposeTheCodeVerifier()
{
ConfigurePlugin();
var state = new TimedAuthorizeState(
new AuthorizeState
{
State = "abc123",
StartUrl = "https://idp.example/authorize?code_challenge=abc",
RedirectUri = "https://jellyfin.example/sso/OID/redirect/authentik",
CodeVerifier = "code-verifier-secret"
},
DateTime.UtcNow)
{
Username = "alice"
};
var store = new Mock<ISsoStateStore>();
store.Setup(s => s.ListAsync()).ReturnsAsync(new[] { state });
var result = await CreateController(store.Object).OidStates();
var ok = Assert.IsType<OkObjectResult>(result);
var body = JsonConvert.SerializeObject(ok.Value);
Assert.DoesNotContain("code-verifier-secret", body, StringComparison.Ordinal);
Assert.DoesNotContain("CodeVerifier", body, StringComparison.OrdinalIgnoreCase);
Assert.Contains("abc123", body, StringComparison.Ordinal);
Assert.Contains("alice", body, StringComparison.Ordinal);
}
private static void ConfigurePlugin()
{
var root = Path.Combine(Path.GetTempPath(), "sso-auth-tests", Guid.NewGuid().ToString("N"));
var paths = new Mock<IApplicationPaths>();
paths.SetupGet(p => p.PluginsPath).Returns(root);
paths.SetupGet(p => p.PluginConfigurationsPath).Returns(root);
var plugin = new SSOPlugin(paths.Object, new Mock<IXmlSerializer>().Object);
var configuration = new PluginConfiguration();
configuration.OidConfigs["authentik"] = new OidConfig { Enabled = true };
plugin.UpdateConfiguration(configuration);
}
private static SSOController CreateController(ISsoStateStore store)
{
return new SSOController(
new Mock<ILogger<SSOController>>().Object,
new Mock<ILoggerFactory>().Object,
new Mock<ISessionManager>().Object,
new Mock<IUserManager>().Object,
new Mock<IAuthorizationContext>().Object,
new Mock<ICryptoProvider>().Object,
new Mock<IProviderManager>().Object,
new Mock<IHttpClientFactory>().Object,
new Mock<IServerConfigurationManager>().Object,
store)
{
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
};
}
}
@@ -0,0 +1,47 @@
using Jellyfin.Plugin.SSO_Auth.State;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Moq;
using StackExchange.Redis;
using Xunit;
namespace Jellyfin.Plugin.SSO_Auth.Tests;
public class SsoAuthServiceRegistratorTests
{
[Fact]
public void RegisterServices_WithoutAMultiplexer_FallsBackToTheInMemoryStore()
{
var provider = Register(withMultiplexer: false);
Assert.IsType<InMemorySsoStateStore>(provider.GetRequiredService<ISsoStateStore>());
}
[Fact]
public void RegisterServices_WithAMultiplexer_UsesTheSharedStore()
{
var provider = Register(withMultiplexer: true);
Assert.IsType<RedisSsoStateStore>(provider.GetRequiredService<ISsoStateStore>());
}
private static ServiceProvider Register(bool withMultiplexer)
{
var loggerFactory = new Mock<ILoggerFactory>();
loggerFactory.Setup(f => f.CreateLogger(It.IsAny<string>())).Returns(new Mock<ILogger>().Object);
var services = new ServiceCollection();
services.AddSingleton(loggerFactory.Object);
if (withMultiplexer)
{
var mux = new Mock<IConnectionMultiplexer>();
mux.Setup(m => m.GetDatabase(It.IsAny<int>(), It.IsAny<object>())).Returns(new Mock<IDatabase>().Object);
services.AddSingleton(mux.Object);
}
new SsoAuthServiceRegistrator().RegisterServices(services, null);
return services.BuildServiceProvider();
}
}
@@ -0,0 +1,82 @@
using System;
using System.Collections.Generic;
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 SsoStateSerializerTests
{
[Fact]
public void RoundTrip_PreservesEveryFieldTheFlowDependsOn()
{
var original = new TimedAuthorizeState(
new AuthorizeState
{
State = "state-value",
StartUrl = "https://idp.example/authorize?code_challenge=abc",
RedirectUri = "https://jellyfin.example/sso/OID/redirect/authentik",
CodeVerifier = "code-verifier-secret"
},
new DateTime(2026, 9, 19, 10, 30, 0, DateTimeKind.Utc))
{
Valid = true,
Username = "alice",
Admin = true,
IsLinking = true,
Folders = new List<string> { "movies", "shows" },
EnableLiveTv = true,
EnableLiveTvManagement = true,
AvatarURL = "https://idp.example/avatar/alice.png"
};
var restored = SsoStateSerializer.Deserialize(SsoStateSerializer.Serialize(original));
Assert.NotNull(restored);
Assert.Equal("state-value", restored.State.State);
Assert.Equal("https://idp.example/authorize?code_challenge=abc", restored.State.StartUrl);
Assert.Equal("https://jellyfin.example/sso/OID/redirect/authentik", restored.State.RedirectUri);
// The code verifier is required to complete the token exchange on the pod that handles the callback.
Assert.Equal("code-verifier-secret", restored.State.CodeVerifier);
Assert.Equal(original.Created, restored.Created);
Assert.True(restored.Valid);
Assert.Equal("alice", restored.Username);
Assert.True(restored.Admin);
Assert.True(restored.IsLinking);
Assert.Equal(new[] { "movies", "shows" }, restored.Folders);
Assert.True(restored.EnableLiveTv);
Assert.True(restored.EnableLiveTvManagement);
Assert.Equal("https://idp.example/avatar/alice.png", restored.AvatarURL);
}
[Fact]
public void RoundTrip_PreservesNulls()
{
var original = new TimedAuthorizeState(
new AuthorizeState { State = "state-value", CodeVerifier = null, RedirectUri = null, StartUrl = null },
DateTime.UtcNow);
var restored = SsoStateSerializer.Deserialize(SsoStateSerializer.Serialize(original));
Assert.NotNull(restored);
Assert.Equal("state-value", restored.State.State);
Assert.Null(restored.State.CodeVerifier);
Assert.Null(restored.State.RedirectUri);
Assert.Null(restored.Username);
Assert.Null(restored.AvatarURL);
Assert.Null(restored.Folders);
Assert.False(restored.Valid);
}
[Theory]
[InlineData(null)]
[InlineData("")]
public void Deserialize_WithoutPayload_ReturnsNull(string payload)
{
Assert.Null(SsoStateSerializer.Deserialize(payload));
}
}