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())).Returns(db.Object); + mux.Setup(m => m.GetEndPoints(It.IsAny())).Returns(new EndPoint[] { primaryEndpoint, replicaEndpoint }); + mux.Setup(m => m.GetServer(primaryEndpoint, It.IsAny())).Returns(primary); + mux.Setup(m => m.GetServer(replicaEndpoint, It.IsAny())).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 db, Action capture) + { + db.Setup(d => d.StringSetAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Callback( + (key, value, expiry, keepTtl, when, flags) => capture(key, value, expiry)) + .ReturnsAsync(true); + } + + private static IServer CreateServer(bool isReplica, IReadOnlyList keys) + { + var server = new Mock(); + server.SetupGet(s => s.IsConnected).Returns(true); + server.SetupGet(s => s.IsReplica).Returns(isReplica); + server.Setup(s => s.KeysAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(ToAsyncEnumerable(keys)); + return server.Object; + } + + private static async IAsyncEnumerable ToAsyncEnumerable(IReadOnlyList keys) + { + foreach (var key in keys) + { + yield return key; + } + + await Task.CompletedTask; + } + + private static IConnectionMultiplexer CreateMultiplexer(IDatabase database) + { + var mux = new Mock(); + mux.Setup(m => m.GetDatabase(It.IsAny(), It.IsAny())).Returns(database); + return mux.Object; + } +} diff --git a/tests/SSO-Auth.Tests/SSO-Auth.Tests.csproj b/tests/SSO-Auth.Tests/SSO-Auth.Tests.csproj new file mode 100644 index 0000000..1fda4c3 --- /dev/null +++ b/tests/SSO-Auth.Tests/SSO-Auth.Tests.csproj @@ -0,0 +1,27 @@ + + + + net10.0 + Jellyfin.Plugin.SSO_Auth.Tests + Exe + false + false + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + diff --git a/tests/SSO-Auth.Tests/SSOControllerTests.cs b/tests/SSO-Auth.Tests/SSOControllerTests.cs new file mode 100644 index 0000000..fe821fd --- /dev/null +++ b/tests/SSO-Auth.Tests/SSOControllerTests.cs @@ -0,0 +1,238 @@ +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.AspNetCore.Mvc.Infrastructure; +using Microsoft.Extensions.DependencyInjection; +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(); + 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(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(); + store.Setup(s => s.ListAsync()).ReturnsAsync(new[] { state }); + + var result = await CreateController(store.Object).OidStates(); + + var ok = Assert.IsType(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); + } + + [Fact] + public async Task OidPost_WhenTheStoreIsUnreachable_RejectsTheLoginAndLogsIt() + { + ConfigurePlugin(); + var store = new Mock(); + store.Setup(s => s.GetAsync(It.IsAny())).ThrowsAsync(new InvalidOperationException("valkey is down")); + var logger = new Mock>(); + + var result = await CreateController(store.Object, logger.Object).OidPost("authentik", "abc123"); + + var content = Assert.IsType(result); + Assert.Equal(StatusCodes.Status503ServiceUnavailable, content.StatusCode); + AssertLoggedAnError(logger); + } + + [Fact] + public async Task OidStates_WhenTheStoreIsUnreachable_ReportsItUnavailable() + { + ConfigurePlugin(); + var store = new Mock(); + store.Setup(s => s.ListAsync()).ThrowsAsync(new InvalidOperationException("valkey is down")); + + var result = await CreateController(store.Object).OidStates(); + + var content = Assert.IsType(result); + Assert.Equal(StatusCodes.Status503ServiceUnavailable, content.StatusCode); + } + + [Fact] + public async Task OidAuth_WhenTheStoreIsUnreachable_DoesNotAuthenticate() + { + ConfigurePlugin(); + var store = new Mock(); + store.Setup(s => s.ConsumeAsync(It.IsAny())).ThrowsAsync(new InvalidOperationException("valkey is down")); + var sessionManager = new Mock(); + + var result = await CreateController(store.Object, sessionManager: sessionManager) + .OidAuth("authentik", new AuthResponse { Data = "abc123" }); + + var problem = Assert.IsType(result); + Assert.Equal(StatusCodes.Status503ServiceUnavailable, problem.StatusCode); + sessionManager.VerifyNoOtherCalls(); + } + + [Fact] + public async Task CompleteOidState_WhenTheRoleCheckFailed_DiscardsTheStateInsteadOfExtendingIt() + { + ConfigurePlugin(); + var store = new Mock(); + var timedState = NewState("abc123"); + + var result = await CreateController(store.Object) + .CompleteOidState("authentik", "abc123", timedState, "https://jellyfin.example"); + + var content = Assert.IsType(result); + Assert.Equal(StatusCodes.Status401Unauthorized, content.StatusCode); + store.Verify(s => s.ConsumeAsync("abc123"), Times.Once); + store.Verify(s => s.UpdateAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task CompleteOidState_WhenValid_WritesTheStateBack() + { + ConfigurePlugin(); + var store = new Mock(); + var timedState = NewState("abc123"); + timedState.Valid = true; + + var result = await CreateController(store.Object) + .CompleteOidState("authentik", "abc123", timedState, "https://jellyfin.example"); + + var content = Assert.IsType(result); + Assert.Null(content.StatusCode); + store.Verify(s => s.UpdateAsync(timedState, It.IsAny()), Times.Once); + store.Verify(s => s.ConsumeAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task CompleteOidState_WhenTheWriteBackFails_RejectsTheLogin() + { + ConfigurePlugin(); + var store = new Mock(); + store.Setup(s => s.UpdateAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException("valkey is down")); + var timedState = NewState("abc123"); + timedState.Valid = true; + + var result = await CreateController(store.Object) + .CompleteOidState("authentik", "abc123", timedState, "https://jellyfin.example"); + + var content = Assert.IsType(result); + Assert.Equal(StatusCodes.Status503ServiceUnavailable, content.StatusCode); + } + + private static TimedAuthorizeState NewState(string state) + { + return new TimedAuthorizeState( + new AuthorizeState { State = state, CodeVerifier = "code-verifier-secret" }, + DateTime.UtcNow); + } + + private static void AssertLoggedAnError(Mock> logger) + { + logger.Verify( + l => l.Log( + LogLevel.Error, + It.IsAny(), + It.IsAny(), + It.IsAny(), + (Func)It.IsAny()), + Times.Once); + } + + private static void ConfigurePlugin() + { + var root = Path.Combine(Path.GetTempPath(), "sso-auth-tests", Guid.NewGuid().ToString("N")); + var paths = new Mock(); + paths.SetupGet(p => p.PluginsPath).Returns(root); + paths.SetupGet(p => p.PluginConfigurationsPath).Returns(root); + + var plugin = new SSOPlugin(paths.Object, new Mock().Object); + var configuration = new PluginConfiguration(); + configuration.OidConfigs["authentik"] = new OidConfig { Enabled = true }; + plugin.UpdateConfiguration(configuration); + } + + private static SSOController CreateController( + ISsoStateStore store, + ILogger logger = null, + Mock sessionManager = null) + { + var problemDetailsFactory = new Mock(); + problemDetailsFactory + .Setup(f => f.CreateProblemDetails( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns((HttpContext _, int? status, string title, string type, string detail, string instance) => + new ProblemDetails { Status = status, Title = title, Detail = detail }); + + var services = new ServiceCollection(); + services.AddSingleton(problemDetailsFactory.Object); + + return new SSOController( + logger ?? new Mock>().Object, + new Mock().Object, + (sessionManager ?? new Mock()).Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + store) + { + ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { RequestServices = services.BuildServiceProvider() } + } + }; + } +} diff --git a/tests/SSO-Auth.Tests/SsoAuthServiceRegistratorTests.cs b/tests/SSO-Auth.Tests/SsoAuthServiceRegistratorTests.cs new file mode 100644 index 0000000..4cfcd94 --- /dev/null +++ b/tests/SSO-Auth.Tests/SsoAuthServiceRegistratorTests.cs @@ -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(provider.GetRequiredService()); + } + + [Fact] + public void RegisterServices_WithAMultiplexer_UsesTheSharedStore() + { + var provider = Register(withMultiplexer: true); + + Assert.IsType(provider.GetRequiredService()); + } + + private static ServiceProvider Register(bool withMultiplexer) + { + var loggerFactory = new Mock(); + loggerFactory.Setup(f => f.CreateLogger(It.IsAny())).Returns(new Mock().Object); + + var services = new ServiceCollection(); + services.AddSingleton(loggerFactory.Object); + + if (withMultiplexer) + { + var mux = new Mock(); + mux.Setup(m => m.GetDatabase(It.IsAny(), It.IsAny())).Returns(new Mock().Object); + services.AddSingleton(mux.Object); + } + + new SsoAuthServiceRegistrator().RegisterServices(services, null); + + return services.BuildServiceProvider(); + } +} diff --git a/tests/SSO-Auth.Tests/SsoStateSerializerTests.cs b/tests/SSO-Auth.Tests/SsoStateSerializerTests.cs new file mode 100644 index 0000000..4349112 --- /dev/null +++ b/tests/SSO-Auth.Tests/SsoStateSerializerTests.cs @@ -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 { "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)); + } +}