Files
unkin-agent e71df7aa36
ci/woodpecker/pr/ci Pipeline was successful
bound the in-memory state store and guard store failures
Drop a rejected state instead of refreshing its TTL, and turn a store
outage into the plugin's error page rather than an unhandled 500.
2026-09-19 23:05:16 +10:00

239 lines
9.2 KiB
C#

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<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);
}
[Fact]
public async Task OidPost_WhenTheStoreIsUnreachable_RejectsTheLoginAndLogsIt()
{
ConfigurePlugin();
var store = new Mock<ISsoStateStore>();
store.Setup(s => s.GetAsync(It.IsAny<string>())).ThrowsAsync(new InvalidOperationException("valkey is down"));
var logger = new Mock<ILogger<SSOController>>();
var result = await CreateController(store.Object, logger.Object).OidPost("authentik", "abc123");
var content = Assert.IsType<ContentResult>(result);
Assert.Equal(StatusCodes.Status503ServiceUnavailable, content.StatusCode);
AssertLoggedAnError(logger);
}
[Fact]
public async Task OidStates_WhenTheStoreIsUnreachable_ReportsItUnavailable()
{
ConfigurePlugin();
var store = new Mock<ISsoStateStore>();
store.Setup(s => s.ListAsync()).ThrowsAsync(new InvalidOperationException("valkey is down"));
var result = await CreateController(store.Object).OidStates();
var content = Assert.IsType<ContentResult>(result);
Assert.Equal(StatusCodes.Status503ServiceUnavailable, content.StatusCode);
}
[Fact]
public async Task OidAuth_WhenTheStoreIsUnreachable_DoesNotAuthenticate()
{
ConfigurePlugin();
var store = new Mock<ISsoStateStore>();
store.Setup(s => s.ConsumeAsync(It.IsAny<string>())).ThrowsAsync(new InvalidOperationException("valkey is down"));
var sessionManager = new Mock<ISessionManager>();
var result = await CreateController(store.Object, sessionManager: sessionManager)
.OidAuth("authentik", new AuthResponse { Data = "abc123" });
var problem = Assert.IsType<ObjectResult>(result);
Assert.Equal(StatusCodes.Status503ServiceUnavailable, problem.StatusCode);
sessionManager.VerifyNoOtherCalls();
}
[Fact]
public async Task CompleteOidState_WhenTheRoleCheckFailed_DiscardsTheStateInsteadOfExtendingIt()
{
ConfigurePlugin();
var store = new Mock<ISsoStateStore>();
var timedState = NewState("abc123");
var result = await CreateController(store.Object)
.CompleteOidState("authentik", "abc123", timedState, "https://jellyfin.example");
var content = Assert.IsType<ContentResult>(result);
Assert.Equal(StatusCodes.Status401Unauthorized, content.StatusCode);
store.Verify(s => s.ConsumeAsync("abc123"), Times.Once);
store.Verify(s => s.UpdateAsync(It.IsAny<TimedAuthorizeState>(), It.IsAny<TimeSpan>()), Times.Never);
}
[Fact]
public async Task CompleteOidState_WhenValid_WritesTheStateBack()
{
ConfigurePlugin();
var store = new Mock<ISsoStateStore>();
var timedState = NewState("abc123");
timedState.Valid = true;
var result = await CreateController(store.Object)
.CompleteOidState("authentik", "abc123", timedState, "https://jellyfin.example");
var content = Assert.IsType<ContentResult>(result);
Assert.Null(content.StatusCode);
store.Verify(s => s.UpdateAsync(timedState, It.IsAny<TimeSpan>()), Times.Once);
store.Verify(s => s.ConsumeAsync(It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task CompleteOidState_WhenTheWriteBackFails_RejectsTheLogin()
{
ConfigurePlugin();
var store = new Mock<ISsoStateStore>();
store.Setup(s => s.UpdateAsync(It.IsAny<TimedAuthorizeState>(), It.IsAny<TimeSpan>()))
.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<ContentResult>(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<ILogger<SSOController>> logger)
{
logger.Verify(
l => l.Log(
LogLevel.Error,
It.IsAny<EventId>(),
It.IsAny<It.IsAnyType>(),
It.IsAny<Exception>(),
(Func<It.IsAnyType, Exception, string>)It.IsAny<object>()),
Times.Once);
}
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,
ILogger<SSOController> logger = null,
Mock<ISessionManager> sessionManager = null)
{
var problemDetailsFactory = new Mock<ProblemDetailsFactory>();
problemDetailsFactory
.Setup(f => f.CreateProblemDetails(
It.IsAny<HttpContext>(),
It.IsAny<int?>(),
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<string>()))
.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<ILogger<SSOController>>().Object,
new Mock<ILoggerFactory>().Object,
(sessionManager ?? 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 { RequestServices = services.BuildServiceProvider() }
}
};
}
}