Compare commits

...

38 Commits

Author SHA1 Message Date
benvin ac1032d85e Merge pull request 'publish the plugin zip to artifactapi on tag' (#4) from benvin/release-pipeline into main
ci/woodpecker/tag/release Pipeline was successful
Reviewed-on: #4
2026-09-19 23:52:47 +10:00
unkin-agent b5245fe666 Merge remote-tracking branch 'origin/main' into benvin/release-pipeline
ci/woodpecker/pr/ci Pipeline was successful
2026-09-19 23:45:15 +10:00
unkin-agent 82e4c338ee publish the plugin zip to artifactapi on tag
Add .woodpecker/release.yaml building the JPRM zip and uploading it to the
jellyfin-plugins repo, and teach the bump targets the plugin's 4-part version.
2026-09-19 23:43:50 +10:00
benvin 040cffe967 Merge pull request 'Back the SSO OAuth state with the shared cache' (#3) from benvin/valkey-oauth-state into main
Reviewed-on: #3
2026-09-19 23:38:48 +10:00
benvin 8116ef6930 Merge pull request 'Target the Jellyfin 12.0 plugin ABI' (#2) from benvin/jellyfin-12-abi into main
Reviewed-on: #2
2026-09-19 23:37:18 +10:00
unkin-agent e9bc573e7d snapshot the state store atomically before evicting
ci/woodpecker/pr/ci Pipeline was successful
LINQ buffering of a ConcurrentDictionary is not atomic, so a concurrent write made Evict() throw and fail the login.
2026-09-19 23:23:26 +10:00
unkin-agent e71df7aa36 bound the in-memory state store and guard store failures
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.
2026-09-19 23:05:16 +10:00
unkin-agent 5d446c16f2 keep solution file line endings
ci/woodpecker/pr/ci Pipeline was successful
2026-09-19 22:49:18 +10:00
unkin-agent a167d00b73 store OAuth state in the shared cache
ci/woodpecker/pr/ci Pipeline was successful
Keys each in-flight OpenID flow in the server's Valkey/Redis connection so a login
can start on one replica and finish on another. Falls back to an in-memory store
when no multiplexer is registered.
2026-09-19 22:47:43 +10:00
unkin-agent 404be19c2b target the Jellyfin 12.0 plugin ABI
ci/woodpecker/pr/ci Pipeline was successful
Move the plugin to net10.0 against Jellyfin.Controller/Model 12.0.0 and
declare targetAbi 12.0.0.0.

Drop the dead Lib F# project and the System.Security.Cryptography.Xml
reference, now provided by the net10.0 shared framework.
2026-09-19 22:34:04 +10:00
benvin f7d909c77a Merge pull request 'Add Woodpecker CI and Makefile' (#1) from benvin/ci into main
Reviewed-on: #1
2026-09-19 22:23:10 +10:00
unkin-agent 3dd5110c46 run CI on pull requests only
ci/woodpecker/pr/ci Pipeline was successful
2026-09-19 22:20:14 +10:00
unkin-agent 7028cfe11d add woodpecker CI and Makefile
ci/woodpecker/push/ci Pipeline was successful
Build and test the plugin on every push and pull request, and give
local builds the same commands plus the house version-bump targets.
2026-09-19 17:48:09 +10:00
Ersei Saggi f1e157bac2 I'm tired of this grandpa 2026-05-11 21:52:38 -07:00
Ersei Saggi 83deeb253c Validate SAML responses 2026-03-19 20:11:02 -07:00
Ersei Saggi ca187a7b5e Make prettier happy 2026-02-22 11:04:40 -08:00
9p4 05213dc1ee Merge pull request #349 from mandos21/main
Bugfix: Prevent KeyNotFoundExceptions in OidPost
2026-02-11 20:11:01 -08:00
9p4 f7a0c94452 Merge pull request #318 from ForsakenRei/ForsakenRei-patch-1
Update README dead link, add warning for permission overwritten
2026-02-11 07:51:22 -08:00
mandos21 475048a32e Fixed KeyNotFoundExceptions in PostOid flow 2026-02-02 21:20:49 -05:00
Matt DeGenaro 781e07b2ce Update role mapping information in configPage.html
Clarified default role mapping for Keycloak and Authelia.
2026-02-02 20:27:15 -05:00
9p4 fed7e764a8 Merge pull request #347 from Joker9944/kanidm-config-example
Add kanidm config steps
2026-02-01 10:29:53 -08:00
Joker9944 f0def6fd8d Add kanidm config steps 2026-02-01 10:12:00 +01:00
9p4 8e128932c4 Merge pull request #343 from jon4hz/fix-invalidate-session-immediately
fix: invalidate state immediately after auth
2026-01-14 11:39:57 -08:00
jon4hz 5b3d70d328 fix: invalidate state immediately after auth 2026-01-14 20:36:04 +01:00
Ersei Saggi be26670e1f Make prettier happy 2026-01-12 17:48:12 -05:00
Ersei Saggi 547eabf55f Invalidate tokens after authentication
In certain cases, users who had their access revoked could log back in.
2026-01-12 17:46:02 -05:00
Ersei Saggi 48d75325b5 Use Jellyfin's HttpClientFactory
This should respect proxy settings and set a proper user-agent
2026-01-12 17:35:29 -05:00
Ersei Saggi cdce0e583d Add loggerFactory to XML comment
Makes CI happy
2025-12-21 02:41:32 -05:00
Ersei Saggi 0e897f922f Allow not loading profile information from user info endpoint 2025-12-18 15:14:34 -05:00
Ersei Saggi afbab1073e Add logging for OIDC library 2025-12-18 15:14:32 -05:00
しぐれ 87425aae36 doc: Add warning for permission overwritten 2025-11-13 21:36:10 -05:00
しぐれ 9be9a1fed8 doc: fix dead OpenID link in README 2025-11-13 21:25:29 -05:00
9p4 3b47851131 Merge pull request #312 from mhlas7/main
Add Pocket ID config steps
2025-11-13 09:37:31 -05:00
mhlas7 337ea0ba04 Add Pocket ID to the readme 2025-10-26 14:24:54 -07:00
mhlas7 c37e3e3a71 Add Pocket ID config steps 2025-10-26 12:45:21 -07:00
Ersei Saggi 8f86ce5101 Prepare for 4.0.0.3 2025-10-21 10:37:39 -04:00
Ersei Saggi d2c77db404 Ensure that disablePushedAuthorization is set to true for authelia 2025-10-21 00:48:35 -04:00
Ersei Saggi fc3976dcc8 Update download artifact too 2025-10-20 22:48:00 -04:00
26 changed files with 1768 additions and 109 deletions
+1 -1
View File
@@ -18,7 +18,7 @@ jobs:
- build
steps:
- name: Download Artifact
uses: actions/download-artifact@v2.1.0
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
with:
name: build-artifact
- name: Prepare GitHub Release assets
+26
View File
@@ -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
+74
View File
@@ -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
+51
View File
@@ -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))
+5 -1
View File
@@ -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
@@ -46,11 +48,13 @@ This is 100% alpha software! PRs are welcome to improve the code.
- authentik
- Keycloak
- OIDC & SAML
- Pocket ID
- Kanidm
- Google OpenID: Works, but usernames are all numeric
## Supported Protocols
- [OpenID](https://openid.net/what-is-openid/)
- [OpenID](https://openid.net/developers/how-connect-works/)
- [SAML](https://www.cloudflare.com/learning/access-management/what-is-saml/)
## Security
+6
View File
@@ -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
+256 -73
View File
@@ -4,6 +4,7 @@ using System.IO;
using System.Linq;
using System.Net.Http;
using System.Net.Mime;
using System.Reflection;
using System.Security.Cryptography;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
@@ -13,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;
@@ -27,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;
@@ -38,41 +39,64 @@ 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;
private readonly ILogger<SSOController> _logger;
private readonly ILoggerFactory _loggerFactory;
private readonly ICryptoProvider _cryptoProvider;
private readonly IProviderManager _providerManager;
private readonly IServerConfigurationManager _serverConfigurationManager;
private static readonly IDictionary<string, TimedAuthorizeState> StateManager = new Dictionary<string, TimedAuthorizeState>();
private readonly IHttpClientFactory _httpClientFactory;
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.
/// </summary>
/// <param name="logger">Instance of the <see cref="ILogger{SSOController}"/> interface.</param>
/// <param name="loggerFactory">Instance of the <see cref="ILoggerFactory"/> interface.</param>
/// <param name="sessionManager">Instance of the <see cref="ISessionManager"/> interface.</param>
/// <param name="authContext">Instance of the <see cref="IAuthorizationContext"/> interface.</param>
/// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
/// <param name="cryptoProvider">Instance of the <see cref="ICryptoProvider"/> interface.</param>
/// <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,
ISessionManager sessionManager,
IUserManager userManager,
IAuthorizationContext authContext,
ICryptoProvider cryptoProvider,
IProviderManager providerManager,
IServerConfigurationManager serverConfigurationManager)
IHttpClientFactory httpClientFactory,
IServerConfigurationManager serverConfigurationManager,
ISsoStateStore stateStore)
{
_sessionManager = sessionManager;
_userManager = userManager;
_authContext = authContext;
_cryptoProvider = cryptoProvider;
_logger = logger;
_loggerFactory = loggerFactory;
_providerManager = providerManager;
_serverConfigurationManager = serverConfigurationManager;
_httpClientFactory = httpClientFactory;
_stateStore = stateStore;
_logger.LogInformation("SSO Controller initialized");
}
@@ -101,6 +125,23 @@ public class SSOController : ControllerBase
if (config.Enabled)
{
if (string.IsNullOrEmpty(state))
{
return BadRequest("Missing state");
}
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");
}
var scopes = config.OidScopes == null ? new string[2] : config.OidScopes;
var options = new OidcClientOptions
{
@@ -110,6 +151,17 @@ public class SSOController : ControllerBase
RedirectUri = GetRequestBase(config.SchemeOverride, config.PortOverride) + $"/sso/OID/{(Request.Path.Value.Contains("/start/", StringComparison.InvariantCultureIgnoreCase) ? "redirect" : "r")}/" + provider,
Scope = string.Join(" ", scopes.Prepend("openid profile")),
DisablePushedAuthorization = config.DisablePushedAuthorization,
LoggerFactory = _loggerFactory,
LoadProfile = !config.DoNotLoadProfile,
HttpClientFactory = o =>
{
var client = _httpClientFactory.CreateClient();
System.Reflection.Assembly assembly = System.Reflection.Assembly.GetExecutingAssembly();
System.Diagnostics.FileVersionInfo fvi = System.Diagnostics.FileVersionInfo.GetVersionInfo(assembly.Location);
string version = fvi.FileVersion;
client.DefaultRequestHeaders.UserAgent.ParseAdd($"Jellyfin-Plugin-SSO-Auth +{version} (https://github.com/9p4/jellyfin-plugin-sso)");
return client;
}
};
var oidEndpointUri = new Uri(config.OidEndpoint?.Trim());
options.Policy.Discovery.AdditionalEndpointBaseAddresses.Add(oidEndpointUri.GetLeftPart(UriPartial.Authority));
@@ -117,7 +169,7 @@ public class SSOController : ControllerBase
options.Policy.Discovery.RequireHttps = !config.DisableHttps;
options.Policy.Discovery.ValidateIssuerName = !config.DoNotValidateIssuerName;
var oidcClient = new OidcClient(options);
var currentState = StateManager[state].State;
var currentState = timedState.State;
var result = await oidcClient.ProcessResponseAsync(Request.QueryString.Value, currentState).ConfigureAwait(false);
if (result.IsError)
@@ -127,19 +179,19 @@ public class SSOController : ControllerBase
if (!config.EnableFolderRoles && config.EnabledFolders != null)
{
StateManager[state].Folders = new List<string>(config.EnabledFolders);
timedState.Folders = new List<string>(config.EnabledFolders);
}
else
{
StateManager[state].Folders = new List<string>();
timedState.Folders = new List<string>();
}
StateManager[state].EnableLiveTv = config.EnableLiveTv;
StateManager[state].EnableLiveTvManagement = config.EnableLiveTvManagement;
timedState.EnableLiveTv = config.EnableLiveTv;
timedState.EnableLiveTvManagement = config.EnableLiveTvManagement;
if (config.AvatarUrlFormat is not null)
{
StateManager[state].AvatarURL = result.User.Claims.Aggregate(
timedState.AvatarURL = result.User.Claims.Aggregate(
config.AvatarUrlFormat,
(s, claim) => s.Contains($"@{{{claim.Type}}}") ? s.Replace($"@{{{claim.Type}}}", claim.Value) : s);
}
@@ -148,10 +200,10 @@ public class SSOController : ControllerBase
{
if (claim.Type == (config.DefaultUsernameClaim?.Trim() ?? "preferred_username"))
{
StateManager[state].Username = claim.Value;
timedState.Username = claim.Value;
if (config.Roles == null || config.Roles.Length == 0)
{
StateManager[state].Valid = true;
timedState.Valid = true;
}
}
@@ -177,14 +229,40 @@ public class SSOController : ControllerBase
{
// We recursively traverse through the JSON data for the roles and parse it
var json = JsonConvert.DeserializeObject<IDictionary<string, object>>(claim.Value);
for (int i = 1; i < segments.Length - 1; i++)
if (json is null)
{
var segment = segments[i];
json = (json[segment] as JObject).ToObject<IDictionary<string, object>>();
roles = new List<string>();
}
else
{
bool missingSegment = false;
for (int i = 1; i < segments.Length - 1; i++)
{
var segment = segments[i];
if (!json.TryGetValue(segment, out var nextToken) || nextToken is not JObject nextObject)
{
missingSegment = true;
break;
}
// The final step is to take the JSON and turn it from a dictionary into a string
roles = (json[segments[^1]] as JArray).ToObject<List<string>>();
json = nextObject.ToObject<IDictionary<string, object>>();
if (json is null)
{
missingSegment = true;
break;
}
}
if (missingSegment || !json.TryGetValue(segments[^1], out var rolesToken) || rolesToken is not JArray rolesArray)
{
roles = new List<string>();
}
else
{
// The final step is to take the JSON and turn it from a dictionary into a string
roles = rolesArray.ToObject<List<string>>();
}
}
}
foreach (string role in roles)
@@ -196,7 +274,7 @@ public class SSOController : ControllerBase
{
if (role.Equals(validRoles))
{
StateManager[state].Valid = true;
timedState.Valid = true;
}
}
}
@@ -208,7 +286,7 @@ public class SSOController : ControllerBase
{
if (role.Equals(validAdminRoles))
{
StateManager[state].Admin = true;
timedState.Admin = true;
}
}
}
@@ -220,7 +298,7 @@ public class SSOController : ControllerBase
{
if (role.Equals(folderRoleMap.Role?.Trim()))
{
StateManager[state].Folders.AddRange(folderRoleMap.Folders);
timedState.Folders.AddRange(folderRoleMap.Folders);
}
}
}
@@ -234,7 +312,7 @@ public class SSOController : ControllerBase
{
if (role.Equals(validLiveTvRoles))
{
StateManager[state].EnableLiveTv = true;
timedState.EnableLiveTv = true;
}
}
}
@@ -246,7 +324,7 @@ public class SSOController : ControllerBase
{
if (role.Equals(validLiveTvManagementRoles))
{
StateManager[state].EnableLiveTvManagement = true;
timedState.EnableLiveTvManagement = true;
}
}
}
@@ -257,38 +335,31 @@ public class SSOController : ControllerBase
}
// If the provider doesn't support the preferred username claim, then use the sub claim
if (!StateManager[state].Valid)
if (!timedState.Valid)
{
foreach (var claim in result.User.Claims)
{
if (claim.Type == "sub")
{
StateManager[state].Username = claim.Value;
timedState.Username = claim.Value;
if (config.Roles.Length == 0)
{
StateManager[state].Valid = true;
timedState.Valid = true;
}
}
}
}
bool isLinking = StateManager[state].IsLinking;
if (StateManager[state].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}",
StateManager[state].Username,
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
@@ -305,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
{
@@ -335,6 +405,18 @@ public class SSOController : ControllerBase
RedirectUri = redirectUri,
Scope = string.Join(" ", config.OidScopes.Prepend("openid profile")),
DisablePushedAuthorization = config.DisablePushedAuthorization,
LoggerFactory = _loggerFactory,
LoadProfile = !config.DoNotLoadProfile,
HttpClientFactory = o =>
{
var client = _httpClientFactory.CreateClient();
System.Reflection.Assembly assembly = System.Reflection.Assembly.GetExecutingAssembly();
System.Diagnostics.FileVersionInfo fvi = System.Diagnostics.FileVersionInfo.GetVersionInfo(assembly.Location);
string version = fvi.FileVersion;
client.DefaultRequestHeaders.UserAgent.ParseAdd($"Jellyfin-Plugin-SSO-Auth +{version} (https://github.com/9p4/jellyfin-plugin-sso)");
return client;
}
};
var oidEndpointUri = new Uri(config.OidEndpoint?.Trim());
options.Policy.Discovery.AdditionalEndpointBaseAddresses.Add(oidEndpointUri.GetLeftPart(UriPartial.Authority));
@@ -349,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);
}
@@ -420,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>
@@ -451,16 +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);
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);
}
}
@@ -499,6 +605,11 @@ public class SSOController : ControllerBase
{
var samlResponse = new Response(config.SamlCertificate, Request.Form["SAMLResponse"]);
if (!samlResponse.IsValid())
{
return Problem("Invalid SAML signature");
}
bool valid = false;
// If no roles are configured, don't use RBAC
@@ -657,6 +768,12 @@ public class SSOController : ControllerBase
bool liveTv = config.EnableLiveTv;
bool liveTvManagement = config.EnableLiveTvManagement;
var samlResponse = new Response(config.SamlCertificate, response.Data);
if (!samlResponse.IsValid())
{
return Problem("Invalid SAML signature");
}
List<string> folders;
if (!config.EnableFolderRoles && config.EnabledFolders != null)
{
@@ -722,7 +839,17 @@ public class SSOController : ControllerBase
Guid userId = await CreateCanonicalLinkAndUserIfNotExist("saml", provider, samlResponse.GetNameID());
var authenticationResult = await Authenticate(userId, isAdmin, config.EnableAuthorization, config.EnableAllFolders, folders.ToArray(), liveTv, liveTvManagement, response, config.DefaultProvider?.Trim(), null)
var authenticationResult = await Authenticate(
userId,
isAdmin,
config.EnableAuthorization,
config.EnableAllFolders,
folders.ToArray(),
liveTv,
liveTvManagement,
response,
config.DefaultProvider?.Trim(),
null)
.ConfigureAwait(false);
return Ok(authenticationResult);
}
@@ -865,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'");
}
@@ -985,7 +1112,11 @@ public class SSOController : ControllerBase
}
var samlResponse = new Response(config.SamlCertificate, response.Data);
// TODO: Does saml response require further validation?
if (!samlResponse.IsValid())
{
return Problem("Invalid SAML signature");
}
string providerUserId = samlResponse.GetNameID();
@@ -1004,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
@@ -1016,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!");
@@ -1095,7 +1229,13 @@ public class SSOController : ControllerBase
{
try
{
using var client = new HttpClient();
using var client = _httpClientFactory.CreateClient();
System.Reflection.Assembly assembly = System.Reflection.Assembly.GetExecutingAssembly();
System.Diagnostics.FileVersionInfo fvi = System.Diagnostics.FileVersionInfo.GetVersionInfo(assembly.Location);
string version = fvi.FileVersion;
client.DefaultRequestHeaders.UserAgent.ParseAdd($"Jellyfin-Plugin-SSO-Auth +{version} (https://github.com/9p4/jellyfin-plugin-sso)");
var avatarResponse = await client.GetAsync(avatarUrl);
if (!avatarResponse.Content.Headers.TryGetValues("content-type", out var contentTypeList))
@@ -1158,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;
@@ -1202,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();
+5
View File
@@ -327,6 +327,11 @@ public class OidConfig
/// Gets or sets a value indicating whether the OpenID issuer name is validated.
/// </summary>
public bool DoNotValidateIssuerName { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the UserInfo endpoint is used to get profile data.
/// </summary>
public bool DoNotLoadProfile { get; set; }
}
/// <summary>
+20 -1
View File
@@ -480,7 +480,9 @@
list of strings from the OIDC server.
<br />
For Keycloak, it is <code>realm_access.roles</code> by
default.
default for realm roles. For client roles, it is
<code>resource_access.&gt;clientId&lt;.roles</code>
(e.g. resource_access.jellyfin.roles)
<br />
For Authelia, it is <code>groups</code>
</div>
@@ -637,6 +639,23 @@
<span>Do Not Validate OpenID Issuer Name (Insecure)</span>
</label>
</div>
<div
class="checkboxContainer checkboxContainer-withDescription"
>
<label>
<input
is="emby-checkbox"
id="DoNotLoadProfile"
name="DoNotLoadProfile"
type="checkbox"
class="sso-toggle"
/>
<span>Do Not Load Profile Information</span>
</label>
<div class="fieldDescription checkboxFieldDescription">
May be required for Cloudflare OpenID
</div>
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="RoleClaim"
-13
View File
@@ -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>
-5
View File
@@ -1,5 +0,0 @@
namespace SSO_Auth.Lib
module Say =
let hello name =
printfn "Hello %s" name
+11 -10
View File
@@ -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.0</AssemblyVersion>
<FileVersion>4.0.0.0</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>
+44
View File
@@ -0,0 +1,44 @@
using System;
using Jellyfin.Plugin.SSO_Auth.State;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Plugins;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using StackExchange.Redis;
namespace Jellyfin.Plugin.SSO_Auth;
/// <summary>
/// Registers the plugin's services. The OAuth state store is backed by the shared cache the server
/// already connects to when one is configured, which is what lets a login start on one server instance
/// and be completed on another.
/// </summary>
public class SsoAuthServiceRegistrator : IPluginServiceRegistrator
{
/// <inheritdoc />
public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost)
{
ArgumentNullException.ThrowIfNull(serviceCollection);
serviceCollection.AddSingleton<ISsoStateStore>(sp =>
{
var logger = sp.GetRequiredService<ILoggerFactory>().CreateLogger<SsoAuthServiceRegistrator>();
var multiplexer = sp.GetService<IConnectionMultiplexer>();
if (multiplexer is null)
{
logger.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);
});
}
}
+48
View File
@@ -0,0 +1,48 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Jellyfin.Plugin.SSO_Auth.Api;
namespace Jellyfin.Plugin.SSO_Auth.State;
/// <summary>
/// Stores the in-flight OpenID authorization states of a login or linking flow.
/// </summary>
public interface ISsoStateStore
{
/// <summary>
/// Stores a newly prepared authorization state.
/// </summary>
/// <param name="state">The state to store, keyed on its OpenID state value.</param>
/// <param name="expiry">How long the state remains usable.</param>
/// <returns>A task representing the write.</returns>
Task CreateAsync(TimedAuthorizeState state, TimeSpan expiry);
/// <summary>
/// Reads an authorization state without removing it.
/// </summary>
/// <param name="state">The OpenID state value.</param>
/// <returns>The stored state, or <c>null</c> when it is unknown or expired.</returns>
Task<TimedAuthorizeState> GetAsync(string state);
/// <summary>
/// Persists mutations made to an authorization state and refreshes its expiry.
/// </summary>
/// <param name="state">The state to persist, keyed on its OpenID state value.</param>
/// <param name="expiry">How long the state remains usable.</param>
/// <returns>A task representing the write.</returns>
Task UpdateAsync(TimedAuthorizeState state, TimeSpan expiry);
/// <summary>
/// Reads an authorization state and removes it in the same operation, so it can only be redeemed once.
/// </summary>
/// <param name="state">The OpenID state value.</param>
/// <returns>The stored state, or <c>null</c> when it is unknown, expired or already redeemed.</returns>
Task<TimedAuthorizeState> ConsumeAsync(string state);
/// <summary>
/// Lists every authorization state currently in flight.
/// </summary>
/// <returns>The states in flight.</returns>
Task<IReadOnlyList<TimedAuthorizeState>> ListAsync();
}
+134
View File
@@ -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);
}
+115
View File
@@ -0,0 +1,115 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Jellyfin.Plugin.SSO_Auth.Api;
using StackExchange.Redis;
namespace Jellyfin.Plugin.SSO_Auth.State;
/// <summary>
/// A Redis/Valkey backed <see cref="ISsoStateStore"/>. Every login-path operation addresses a single
/// key so the store works unchanged against a cluster-mode deployment, and expiry is a key TTL rather
/// than a sweep, so a state times out even if no further login ever happens.
/// </summary>
public sealed class RedisSsoStateStore : ISsoStateStore
{
/// <summary>
/// The prefix every OpenID state key is stored under.
/// </summary>
public const string KeyPrefix = "jellyfin:sso:oid:state:";
private readonly IConnectionMultiplexer _redis;
private readonly IDatabase _database;
/// <summary>
/// Initializes a new instance of the <see cref="RedisSsoStateStore"/> class.
/// </summary>
/// <param name="redis">The connection multiplexer shared with the server.</param>
public RedisSsoStateStore(IConnectionMultiplexer redis)
{
ArgumentNullException.ThrowIfNull(redis);
_redis = redis;
_database = redis.GetDatabase();
}
/// <inheritdoc />
public Task CreateAsync(TimedAuthorizeState state, TimeSpan expiry)
{
return WriteAsync(state, expiry);
}
/// <inheritdoc />
public Task UpdateAsync(TimedAuthorizeState state, TimeSpan expiry)
{
return WriteAsync(state, expiry);
}
/// <inheritdoc />
public async Task<TimedAuthorizeState> GetAsync(string state)
{
if (string.IsNullOrEmpty(state))
{
return null;
}
var payload = await _database.StringGetAsync(KeyFor(state)).ConfigureAwait(false);
return SsoStateSerializer.Deserialize(payload);
}
/// <inheritdoc />
public async Task<TimedAuthorizeState> ConsumeAsync(string state)
{
if (string.IsNullOrEmpty(state))
{
return null;
}
var payload = await _database.StringGetDeleteAsync(KeyFor(state)).ConfigureAwait(false);
return SsoStateSerializer.Deserialize(payload);
}
/// <inheritdoc />
public async Task<IReadOnlyList<TimedAuthorizeState>> ListAsync()
{
var states = new List<TimedAuthorizeState>();
foreach (var endpoint in _redis.GetEndPoints())
{
var server = _redis.GetServer(endpoint);
if (!server.IsConnected || server.IsReplica)
{
continue;
}
await foreach (var key in server.KeysAsync(pattern: KeyPrefix + "*").ConfigureAwait(false))
{
var parsed = SsoStateSerializer.Deserialize(await _database.StringGetAsync(key).ConfigureAwait(false));
if (parsed is not null)
{
states.Add(parsed);
}
}
}
return states;
}
private static RedisKey KeyFor(string state)
{
return KeyPrefix + state;
}
private Task WriteAsync(TimedAuthorizeState state, TimeSpan expiry)
{
ArgumentNullException.ThrowIfNull(state);
return _database.StringSetAsync(
KeyFor(state.State.State),
SsoStateSerializer.Serialize(state),
expiry,
keepTtl: false,
when: When.Always,
flags: CommandFlags.None);
}
}
+92
View File
@@ -0,0 +1,92 @@
using System;
using System.Collections.Generic;
using Jellyfin.Plugin.SSO_Auth.Api;
namespace Jellyfin.Plugin.SSO_Auth.State;
/// <summary>
/// A redacted view of an in-flight authorization state. The PKCE code verifier and the authorization
/// URL derived from it are deliberately omitted so that listing flows cannot leak them.
/// </summary>
public class SsoStateInfo
{
/// <summary>
/// Gets or sets the OpenID state value.
/// </summary>
public string State { get; set; }
/// <summary>
/// Gets or sets the redirect URI the provider calls back to.
/// </summary>
public string RedirectUri { get; set; }
/// <summary>
/// Gets or sets when the flow started.
/// </summary>
public DateTime Created { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the flow has been validated.
/// </summary>
public bool Valid { get; set; }
/// <summary>
/// Gets or sets the user tied to the flow.
/// </summary>
public string Username { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the user is an administrator.
/// </summary>
public bool Admin { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the flow links an existing account.
/// </summary>
public bool IsLinking { get; set; }
/// <summary>
/// Gets or sets the folders the user is allowed access to.
/// </summary>
public IReadOnlyList<string> Folders { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the user is allowed to view live TV.
/// </summary>
public bool EnableLiveTv { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the user is allowed to manage live TV.
/// </summary>
public bool EnableLiveTvManagement { get; set; }
/// <summary>
/// Gets or sets the user avatar url.
/// </summary>
public string AvatarURL { get; set; }
/// <summary>
/// Projects a stored state onto its redacted view.
/// </summary>
/// <param name="state">The stored state.</param>
/// <returns>The redacted view.</returns>
public static SsoStateInfo FromState(TimedAuthorizeState state)
{
ArgumentNullException.ThrowIfNull(state);
return new SsoStateInfo
{
State = state.State?.State,
RedirectUri = state.State?.RedirectUri,
Created = state.Created,
Valid = state.Valid,
Username = state.Username,
Admin = state.Admin,
IsLinking = state.IsLinking,
Folders = state.Folders,
EnableLiveTv = state.EnableLiveTv,
EnableLiveTvManagement = state.EnableLiveTvManagement,
AvatarURL = state.AvatarURL
};
}
}
+36
View File
@@ -0,0 +1,36 @@
using Jellyfin.Plugin.SSO_Auth.Api;
using Newtonsoft.Json;
namespace Jellyfin.Plugin.SSO_Auth.State;
/// <summary>
/// Converts <see cref="TimedAuthorizeState"/> to and from the representation held by a state store.
/// </summary>
public static class SsoStateSerializer
{
private static readonly JsonSerializerSettings Settings = new JsonSerializerSettings
{
NullValueHandling = NullValueHandling.Include,
DateTimeZoneHandling = DateTimeZoneHandling.RoundtripKind
};
/// <summary>
/// Serializes an authorization state.
/// </summary>
/// <param name="state">The state to serialize.</param>
/// <returns>The serialized state.</returns>
public static string Serialize(TimedAuthorizeState state)
{
return JsonConvert.SerializeObject(state, Settings);
}
/// <summary>
/// Deserializes an authorization state.
/// </summary>
/// <param name="payload">The serialized state, which may be null or empty.</param>
/// <returns>The state, or <c>null</c> when there is nothing to deserialize.</returns>
public static TimedAuthorizeState Deserialize(string payload)
{
return string.IsNullOrEmpty(payload) ? null : JsonConvert.DeserializeObject<TimedAuthorizeState>(payload, Settings);
}
}
+6 -4
View File
@@ -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.0"
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,8 @@ 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
3.5.2.4: Updates for Jellyfin 10.9
+82 -1
View File
@@ -2,6 +2,8 @@
This plugin has been tested to work against various providers, though not all providers provide support for all of this plugins' features.
❗ Before you proceed, make sure you have another admin account if you are going to link SSO provider to the only admin account on the server, permission might get overwritten (see [#212](https://github.com/9p4/jellyfin-plugin-sso/issues/212)).
## TOC / Tested Providers:
This section is broken into providers that support Role-Based Access Control (RBAC), and those that do not
@@ -10,8 +12,9 @@ This section is broken into providers that support Role-Based Access Control (RB
- ✅ [Authelia](#authelia)
- ✅ [authentik](#authentik)
- [✅ Keycloak](#keycloak-oidc)
- ✅ [Keycloak](#keycloak-oidc)
- Both [OIDC](#keycloak-oidc) & [SAML](#keycloak-saml)
- ✅ [Pocket ID](#pocket-id)
### No RBAC Support
@@ -88,6 +91,7 @@ authelia:
OidSecret: <redacted>
RoleClaim: groups
OidScopes: ["groups"]
DisablePushedAuthorization: true
```
## authentik
@@ -222,3 +226,80 @@ keycloak:
SamlClientId: <same-as-in-keycloak>
SamlCertificate: <copied-from-xml-file>
```
## Pocket ID
A simple and easy-to-use OIDC provider that allows users to authenticate with their passkeys to your services.
### Pocket ID Config
1. Login to you Pocket ID admin account
1. Go to `Administration -> OCID Clients`
1. Click `Add OCID Client`
1. Give the client a name e.g. `Jellyfin`
1. Set the `Clent Launch URL` to your Jellyfin endpoint
1. Set the callbak url to `https://jellyfin.example.com/sso/OID/redirect/pocketid`. The `pocketid` part must match the `Name of OpenID Provider` in the Jellyfin SSO provider
1. (optional) Enable PKCE if Jellyfin is an https endpoint
1. (optional) Set a logo
1. (optional) Set `Allowed User Groups`
### Jellyfin's Config
```yaml
pocketid:
OidEndpoint: https://pocketid.example.com/.well-known/openid-configuration
OidClientId: <pocket-id-client-id>
OidSecret: <pocket-id-secret>
EnableAuthorization: true # (optional) If you want Jellyfin to read group permissions from pocket id
RoleClaim: groups # (optional) If you want Jellyfin to be able to read group assignments from pocket id
AdminRoles: admin # (optional) The pocket id group which will give a user Jellyfin admin privilges
Roles: users # (optional) The pocket id group which will give a user Jellyfin access
AvatarUrlFormat: @{picture} # (optional) This will pull each users pocket id photo into Jellyfin
```
## Kanidm
Kanidm is a modern and simple identity management platform written in rust.
### Kanidm Config
```shell
kanidm system oauth2 create jellyfin "Jellyfin" https://jellyfin.example.com/
# Set this to drop the trailing @idm.example.com in usernames
kanidm system oauth2 prefer-short-username jellyfin
kanidm system oauth2 add-redirect-url jellyfin https://jellyfin.example.com/sso/OID/redirect/kanidm
kanidm system oauth2 add-redirect-url jellyfin https://jellyfin.example.com/sso/OID/r/kanidm
# Optionally setup groups for Jellyfin
kanidm group create jellyfin_admins
kanidm group create jellyfin_users
kanidm system oauth2 update-scope-map jellyfin jellyfin_admins openid profile groups
kanidm system oauth2 update-scope-map jellyfin jellyfin_users openid profile groups
```
Get the secret used in the Jellyfin config with `kanidm system oauth2 show-basic-secret jellyfin`.
### Jellyfin's Config
```yaml
kanidm:
OidEndpoint: https://idm.example.com/oauth2/openid/jellyfin/
OidClientId: jellyfin
OidSecret: <kanidm-secret>
# (optional) If you want Jellyfin to read group permissions from kanidm
EnableAuthorization: true
OidScopes:
- groups
RoleClaim: groups
AdminsRoles:
- jellyfin_admins@idm.example.com
Roles:
- jellyfin_users@idm.example.com
# If in your setup admin accounts aren't members of the users group you need to add the admins group to roles as well
- jellyfin_admins@idm.example.com
# (optional) If you want the name attribute instead of the spn attribute as username
DefaultUsernameClaim: preferred_username
```
@@ -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>
+238
View File
@@ -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));
}
}