forked from mirrors/jellyfin-plugin-sso
e71df7aa36
ci/woodpecker/pr/ci Pipeline was successful
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.
239 lines
9.2 KiB
C#
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() }
|
|
}
|
|
};
|
|
}
|
|
}
|