forked from mirrors/jellyfin-plugin-sso
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ac1032d85e | |||
| b5245fe666 | |||
| 82e4c338ee | |||
| 040cffe967 | |||
| 8116ef6930 | |||
| e9bc573e7d | |||
| e71df7aa36 | |||
| 5d446c16f2 | |||
| a167d00b73 | |||
| 404be19c2b | |||
| f7d909c77a | |||
| 3dd5110c46 | |||
| 7028cfe11d | |||
| f1e157bac2 |
@@ -0,0 +1,26 @@
|
||||
when:
|
||||
- event: pull_request
|
||||
|
||||
steps:
|
||||
# Restore, build and test in one step so the NuGet cache stays on the step's
|
||||
# own ephemeral storage instead of the workspace volume.
|
||||
- name: build-test
|
||||
image: mcr.microsoft.com/dotnet/sdk:10.0
|
||||
environment:
|
||||
DOTNET_CLI_TELEMETRY_OPTOUT: "1"
|
||||
DOTNET_NOLOGO: "1"
|
||||
commands:
|
||||
- dotnet --info
|
||||
- dotnet restore SSO-Auth.sln
|
||||
- dotnet build SSO-Auth.sln -c Release --no-restore --warnaserror
|
||||
- dotnet test SSO-Auth.sln -c Release --no-build --verbosity minimal
|
||||
backend_options:
|
||||
kubernetes:
|
||||
serviceAccountName: jellyfin-plugin-sso
|
||||
resources:
|
||||
requests:
|
||||
memory: 1Gi
|
||||
cpu: 1
|
||||
limits:
|
||||
memory: 4Gi
|
||||
cpu: 2
|
||||
@@ -0,0 +1,74 @@
|
||||
when:
|
||||
- event: tag
|
||||
ref: refs/tags/v*
|
||||
|
||||
steps:
|
||||
# Everything runs on almalinux9-base: JPRM installs from the artifactapi pypi
|
||||
# remote and the publish below PUTs to artifactapi, both over https against
|
||||
# the internal CA. The Microsoft SDK image does not trust that CA (curl 60),
|
||||
# so the dotnet build has to come to the image that does rather than the
|
||||
# other way round.
|
||||
- name: package
|
||||
image: git.unkin.net/unkin/almalinux9-base:20260606
|
||||
environment:
|
||||
DOTNET_CLI_TELEMETRY_OPTOUT: "1"
|
||||
DOTNET_NOLOGO: "1"
|
||||
commands:
|
||||
- |
|
||||
set -eu
|
||||
VERSION=$$(sed -n 's/^version: *"\([0-9.]*\)".*/\1/p' build.yaml | head -1)
|
||||
if [ "v$$VERSION" != "$$CI_COMMIT_TAG" ]; then
|
||||
echo "tag $$CI_COMMIT_TAG does not match build.yaml version $$VERSION; use make patch/minor/major/hotfix"
|
||||
exit 1
|
||||
fi
|
||||
dnf -y -q install dotnet-sdk-10.0 python3-pip zip
|
||||
pip3 install --quiet --index-url https://artifactapi.k8s.syd1.au.unkin.net/api/v1/remote/pypi/simple "jprm==1.1.0"
|
||||
mkdir -p dist
|
||||
jprm plugin build . --output ./dist --version "$$VERSION" --dotnet-configuration Release --dotnet-framework net10.0
|
||||
ls -l dist
|
||||
backend_options:
|
||||
kubernetes:
|
||||
serviceAccountName: jellyfin-plugin-sso
|
||||
resources:
|
||||
requests:
|
||||
memory: 2Gi
|
||||
cpu: 1
|
||||
limits:
|
||||
memory: 4Gi
|
||||
cpu: 2
|
||||
|
||||
# Uploads are immutable (409 on overwrite) and artifactapi has no HEAD route,
|
||||
# so probe with GET and treat an existing object as a no-op re-run.
|
||||
- name: publish
|
||||
image: git.unkin.net/unkin/almalinux9-base:20260606
|
||||
commands:
|
||||
- |
|
||||
set -eu
|
||||
HOST="https://artifactapi.k8s.syd1.au.unkin.net"
|
||||
REPO="jellyfin-plugins"
|
||||
ZIP=$$(ls dist/sso-authentication_*.zip | head -1)
|
||||
VERSION=$$(echo "$$CI_COMMIT_TAG" | sed 's/^v//')
|
||||
PATH_IN_REPO="unkin/jellyfin-plugin-sso/$$VERSION/$$(basename "$$ZIP")"
|
||||
code=$$(curl -s -o /dev/null -w '%{http_code}' "$$HOST/api/v2/remotes/$$REPO/files/$$PATH_IN_REPO" || true)
|
||||
if [ "$$code" = "200" ]; then
|
||||
echo "$$PATH_IN_REPO already published (HTTP $$code); skipping upload"
|
||||
exit 0
|
||||
fi
|
||||
echo "Uploading $$ZIP to $$REPO/$$PATH_IN_REPO (existence probe returned $$code)"
|
||||
curl -f -X PUT \
|
||||
"$$HOST/api/v2/remotes/$$REPO/files/$$PATH_IN_REPO" \
|
||||
-H "Content-Type: application/zip" \
|
||||
--data-binary @"$$ZIP"
|
||||
echo
|
||||
sha256sum "$$ZIP"
|
||||
depends_on: [package]
|
||||
backend_options:
|
||||
kubernetes:
|
||||
serviceAccountName: jellyfin-plugin-sso
|
||||
resources:
|
||||
requests:
|
||||
memory: 256Mi
|
||||
cpu: 250m
|
||||
limits:
|
||||
memory: 1Gi
|
||||
cpu: 1
|
||||
@@ -0,0 +1,51 @@
|
||||
.PHONY: build test lint clean patch minor major hotfix
|
||||
|
||||
SLN ?= SSO-Auth.sln
|
||||
|
||||
build:
|
||||
dotnet restore $(SLN)
|
||||
dotnet build $(SLN) -c Release --no-restore --warnaserror
|
||||
|
||||
test: build
|
||||
dotnet test $(SLN) -c Release --no-build --verbosity minimal
|
||||
|
||||
lint:
|
||||
dotnet format $(SLN) --verify-no-changes
|
||||
|
||||
clean:
|
||||
dotnet clean $(SLN) -c Release || true
|
||||
rm -rf SSO-Auth/bin SSO-Auth/obj tests/SSO-Auth.Tests/bin tests/SSO-Auth.Tests/obj
|
||||
|
||||
# Jellyfin plugin versions are 4-part (System.Version in the plugin manifest),
|
||||
# so build.yaml holds the version and the release tag must be exactly
|
||||
# v<that version>; .woodpecker/release.yaml fails the build if they diverge.
|
||||
_CUR := $(shell sed -n 's/^version: *"\([0-9.]*\)".*/\1/p' build.yaml | head -1)
|
||||
_A := $(or $(word 1,$(subst ., ,$(_CUR))),0)
|
||||
_B := $(or $(word 2,$(subst ., ,$(_CUR))),0)
|
||||
_C := $(or $(word 3,$(subst ., ,$(_CUR))),0)
|
||||
_D := $(or $(word 4,$(subst ., ,$(_CUR))),0)
|
||||
|
||||
define release
|
||||
@test -n "$(_CUR)" || { echo "cannot parse version from build.yaml"; exit 1; }
|
||||
@test -z "$$(git status --porcelain)" || { echo "working tree is dirty"; exit 1; }
|
||||
@sed -i 's|^version: .*|version: "$(1)"|' build.yaml
|
||||
@sed -i 's|<AssemblyVersion>[^<]*</AssemblyVersion>|<AssemblyVersion>$(1)</AssemblyVersion>|' SSO-Auth/SSO-Auth.csproj
|
||||
@sed -i 's|<FileVersion>[^<]*</FileVersion>|<FileVersion>$(1)</FileVersion>|' SSO-Auth/SSO-Auth.csproj
|
||||
@git commit -qam "release $(1)"
|
||||
@git tag v$(1)
|
||||
@git push origin HEAD v$(1)
|
||||
@echo "Released v$(1)"
|
||||
endef
|
||||
|
||||
major:
|
||||
$(call release,$(shell expr $(_A) + 1).0.0.0)
|
||||
|
||||
minor:
|
||||
$(call release,$(_A).$(shell expr $(_B) + 1).0.0)
|
||||
|
||||
patch:
|
||||
$(call release,$(_A).$(_B).$(shell expr $(_C) + 1).0)
|
||||
|
||||
# Fourth component: a rebuild of an existing patch (upstream's 3.5.2.1 shape).
|
||||
hotfix:
|
||||
$(call release,$(_A).$(_B).$(_C).$(shell expr $(_D) + 1))
|
||||
@@ -22,6 +22,8 @@
|
||||
</a>
|
||||
</p>
|
||||
|
||||
Project archived because I'm tired of working on this after all the years.
|
||||
|
||||
This plugin allows users to sign in through an SSO provider (such as Google, Microsoft, or your own provider). This enables one-click signin.
|
||||
|
||||
https://user-images.githubusercontent.com/17993169/149681516-f93b43f5-fa5c-4c1f-a909-e5414878a864.mp4
|
||||
|
||||
@@ -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
|
||||
|
||||
+133
-60
@@ -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;
|
||||
@@ -28,7 +29,6 @@ using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using SSO_Auth.Lib;
|
||||
|
||||
namespace Jellyfin.Plugin.SSO_Auth.Api;
|
||||
|
||||
@@ -39,6 +39,12 @@ namespace Jellyfin.Plugin.SSO_Auth.Api;
|
||||
[Route("[controller]")]
|
||||
public class SSOController : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private const string StateStoreUnavailable = "Login state is unavailable. Try again.";
|
||||
|
||||
private readonly IUserManager _userManager;
|
||||
private readonly ISessionManager _sessionManager;
|
||||
private readonly IAuthorizationContext _authContext;
|
||||
@@ -48,7 +54,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.
|
||||
@@ -62,6 +74,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,
|
||||
@@ -71,7 +84,8 @@ public class SSOController : ControllerBase
|
||||
ICryptoProvider cryptoProvider,
|
||||
IProviderManager providerManager,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IServerConfigurationManager serverConfigurationManager)
|
||||
IServerConfigurationManager serverConfigurationManager,
|
||||
ISsoStateStore stateStore)
|
||||
{
|
||||
_sessionManager = sessionManager;
|
||||
_userManager = userManager;
|
||||
@@ -82,6 +96,7 @@ public class SSOController : ControllerBase
|
||||
_providerManager = providerManager;
|
||||
_serverConfigurationManager = serverConfigurationManager;
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_stateStore = stateStore;
|
||||
_logger.LogInformation("SSO Controller initialized");
|
||||
}
|
||||
|
||||
@@ -115,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");
|
||||
}
|
||||
@@ -328,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
|
||||
@@ -361,7 +376,6 @@ public class SSOController : ControllerBase
|
||||
[HttpGet("OID/start/{provider}")]
|
||||
public async Task<ActionResult> OidChallenge(string provider, [FromQuery] bool isLinking = false)
|
||||
{
|
||||
Invalidate();
|
||||
OidConfig config;
|
||||
try
|
||||
{
|
||||
@@ -417,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);
|
||||
}
|
||||
|
||||
@@ -488,12 +505,18 @@ 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 listed = await TryStateReadAsync(() => _stateStore.ListAsync(), "list").ConfigureAwait(false);
|
||||
if (!listed.Succeeded)
|
||||
{
|
||||
return ReturnError(StatusCodes.Status503ServiceUnavailable, StateStoreUnavailable);
|
||||
}
|
||||
|
||||
return Ok(listed.Value.Select(SsoStateInfo.FromState).ToList());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -519,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -965,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'");
|
||||
}
|
||||
@@ -1108,7 +1135,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
|
||||
@@ -1120,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!");
|
||||
@@ -1268,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;
|
||||
@@ -1312,6 +1330,61 @@ public class SSOController : ControllerBase
|
||||
}.ToString().TrimEnd('/');
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="provider">The name of the provider.</param>
|
||||
/// <param name="state">The OpenID state value.</param>
|
||||
/// <param name="timedState">The evaluated state.</param>
|
||||
/// <param name="baseUrl">The base URL of the Jellyfin installation.</param>
|
||||
/// <returns>The response to serve to the client.</returns>
|
||||
internal async Task<ActionResult> 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<T>(Func<Task<T>> 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<bool> TryStateWriteAsync(Func<Task> 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();
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<RootNamespace>SSO_Auth</RootNamespace>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="Library.fs" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,5 +0,0 @@
|
||||
namespace SSO_Auth.Lib
|
||||
|
||||
module Say =
|
||||
let hello name =
|
||||
printfn "Hello %s" name
|
||||
+11
-10
@@ -1,10 +1,10 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<RootNamespace>Jellyfin.Plugin.SSO_Auth</RootNamespace>
|
||||
<AssemblyVersion>4.0.0.4</AssemblyVersion>
|
||||
<FileVersion>4.0.0.4</FileVersion>
|
||||
<AssemblyVersion>5.0.0.0</AssemblyVersion>
|
||||
<FileVersion>5.0.0.0</FileVersion>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
@@ -30,10 +30,15 @@
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
<PackageReference Include="Duende.IdentityModel.OidcClient" Version="6.0.1" />
|
||||
<PackageReference Include="Jellyfin.Controller" Version="10.11.0" />
|
||||
<PackageReference Include="Jellyfin.Model" Version="10.11.0" />
|
||||
<PackageReference Include="Jellyfin.Controller" Version="12.0.0" />
|
||||
<PackageReference Include="Jellyfin.Model" Version="12.0.0" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
<PackageReference Include="System.Security.Cryptography.Xml" Version="6.0.1" />
|
||||
<!-- Compile-only: the server supplies this assembly, so this version must track the server's. 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>
|
||||
<InternalsVisibleTo Include="SSO-Auth.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -42,10 +47,6 @@
|
||||
<PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="Lib\Lib.fsproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -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.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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <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
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public const int Capacity = 1024;
|
||||
|
||||
private readonly ConcurrentDictionary<string, Entry> _entries = new ConcurrentDictionary<string, Entry>(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of states currently held.
|
||||
/// </summary>
|
||||
public int Count => _entries.Count;
|
||||
|
||||
/// <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));
|
||||
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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+5
-4
@@ -1,10 +1,10 @@
|
||||
name: "SSO Authentication"
|
||||
guid: "505ce9d1-d916-42fa-86ca-673ef241d7df"
|
||||
imageUrl: "https://raw.githubusercontent.com/9p4/jellyfin-plugin-sso/main/img/logo.png"
|
||||
version: "4.0.0.4"
|
||||
targetAbi: "10.11.0.0"
|
||||
framework: "net9.0"
|
||||
owner: "9p4"
|
||||
version: "5.0.0.0"
|
||||
targetAbi: "12.0.0.0"
|
||||
framework: "net10.0"
|
||||
owner: "unkin"
|
||||
overview: "Authenticate users against an SSO provider."
|
||||
description: |
|
||||
This plugin allows users to sign in through an SSO provider (such as Google, Facebook, or your own provider). This enables one-click signin.
|
||||
@@ -15,6 +15,7 @@ artifacts:
|
||||
- "Duende.IdentityModel.OidcClient.dll"
|
||||
- "Duende.IdentityModel.dll"
|
||||
changelog: |
|
||||
5.0.0.0: Jellyfin 12.0
|
||||
4.0.0.4: Fix security issue in SAML
|
||||
4.0.0.0: Jellyfin 10.11
|
||||
3.5.3.0: Allow for OID-provided avatars, various bugfixes and workarounds
|
||||
|
||||
@@ -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<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);
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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<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() }
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user