commit 2380e97e79a692d87ccceb199c13f3a3c2a88da0 Author: Pascal Linxweiler Date: Mon Jul 13 13:07:54 2026 +0200 feat: initial Jellyfin OIDC auth plugin OpenID Connect SSO for Jellyfin 10.10 (net8.0). Authorization code flow with PKCE via IdentityModel.OidcClient, automatic user creation with random password, role based admin/access mapping, plugin repo manifest. Co-Authored-By: Claude Fable 5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d0cd06d --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +bin/ +obj/ +artifacts/ +*.zip +*.user +.vs/ diff --git a/Jellyfin.Plugin.OidcAuth.sln b/Jellyfin.Plugin.OidcAuth.sln new file mode 100644 index 0000000..b82d964 --- /dev/null +++ b/Jellyfin.Plugin.OidcAuth.sln @@ -0,0 +1,22 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Plugin.OidcAuth", "Jellyfin.Plugin.OidcAuth\Jellyfin.Plugin.OidcAuth.csproj", "{B7C3AC62-209A-4895-9FBA-8A3BEBF0E9E7}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {B7C3AC62-209A-4895-9FBA-8A3BEBF0E9E7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B7C3AC62-209A-4895-9FBA-8A3BEBF0E9E7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B7C3AC62-209A-4895-9FBA-8A3BEBF0E9E7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B7C3AC62-209A-4895-9FBA-8A3BEBF0E9E7}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/Jellyfin.Plugin.OidcAuth/Api/OidcAuthController.cs b/Jellyfin.Plugin.OidcAuth/Api/OidcAuthController.cs new file mode 100644 index 0000000..e954f37 --- /dev/null +++ b/Jellyfin.Plugin.OidcAuth/Api/OidcAuthController.cs @@ -0,0 +1,323 @@ +using System; +using System.Collections.Concurrent; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using IdentityModel.OidcClient; +using Jellyfin.Data.Enums; +using Jellyfin.Plugin.OidcAuth.Configuration; +using MediaBrowser.Controller.Authentication; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Session; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Plugin.OidcAuth.Api; + +/// +/// OIDC login endpoints. +/// +[ApiController] +[Route("OidcAuth")] +public class OidcAuthController : ControllerBase +{ + private static readonly TimeSpan StateLifetime = TimeSpan.FromMinutes(15); + private static readonly ConcurrentDictionary States = new(); + + private readonly IUserManager _userManager; + private readonly ISessionManager _sessionManager; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + public OidcAuthController(IUserManager userManager, ISessionManager sessionManager, ILogger logger) + { + _userManager = userManager; + _sessionManager = sessionManager; + _logger = logger; + } + + private static PluginConfiguration Config => Plugin.Instance!.Configuration; + + /// + /// Starts the OIDC login flow by redirecting to the provider. + /// + /// A redirect to the provider's authorization endpoint. + [HttpGet("login")] + [AllowAnonymous] + public async Task Login() + { + if (string.IsNullOrWhiteSpace(Config.OidIssuer) || string.IsNullOrWhiteSpace(Config.OidClientId)) + { + return StatusCode(500, "OIDC Auth plugin is not configured."); + } + + CleanupStates(); + + var client = CreateClient(); + var state = await client.PrepareLoginAsync().ConfigureAwait(false); + States[state.State] = new FlowState(state); + + return Redirect(state.StartUrl); + } + + /// + /// OIDC redirect URI. Exchanges the authorization code and serves a page that + /// finishes the login inside the Jellyfin web client. + /// + /// The OIDC state parameter. + /// An HTML page completing the login. + [HttpGet("callback")] + [AllowAnonymous] + public async Task Callback([FromQuery] string state) + { + if (string.IsNullOrEmpty(state) || !States.TryGetValue(state, out var flow)) + { + return BadRequest("Unknown or expired login state. Start again at /OidcAuth/login."); + } + + var client = CreateClient(); + var result = await client.ProcessResponseAsync(Request.QueryString.Value, flow.AuthorizeState).ConfigureAwait(false); + if (result.IsError) + { + States.TryRemove(state, out _); + _logger.LogWarning("OIDC login failed: {Error}", result.Error); + return BadRequest($"OIDC login failed: {result.Error}"); + } + + var username = result.User.FindFirst(Config.UsernameClaim)?.Value + ?? result.User.FindFirst("sub")?.Value; + if (string.IsNullOrWhiteSpace(username)) + { + States.TryRemove(state, out _); + return BadRequest($"OIDC login failed: no '{Config.UsernameClaim}' or 'sub' claim in token."); + } + + var roles = result.User.FindAll(Config.RoleClaim).Select(c => c.Value).ToArray(); + + var allowedRoles = SplitCsv(Config.AllowedRoles); + if (allowedRoles.Length > 0 && !roles.Intersect(allowedRoles, StringComparer.Ordinal).Any()) + { + States.TryRemove(state, out _); + _logger.LogWarning("OIDC user {Username} denied: no allowed role. Roles: {Roles}", username, string.Join(",", roles)); + return StatusCode(403, "You are not allowed to access this Jellyfin server."); + } + + var user = _userManager.GetUserByName(username); + if (user is null) + { + if (!Config.CreateUsersIfMissing) + { + States.TryRemove(state, out _); + return StatusCode(403, $"No Jellyfin user '{username}' exists and automatic creation is disabled."); + } + + _logger.LogInformation("Creating Jellyfin user {Username} from OIDC login", username); + user = await _userManager.CreateUserAsync(username).ConfigureAwait(false); + user.SetPermission(PermissionKind.EnableAllFolders, true); + + if (Config.SetRandomPasswordOnCreate) + { + // Without a password Jellyfin accepts a blank password for this user + // from any client. Users can set their own later in the web profile. + await _userManager.ChangePassword( + user, + Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32))) + .ConfigureAwait(false); + } + } + + var adminRoles = SplitCsv(Config.AdminRoles); + if (adminRoles.Length > 0) + { + user.SetPermission(PermissionKind.IsAdministrator, roles.Intersect(adminRoles, StringComparer.Ordinal).Any()); + } + + await _userManager.UpdateUserAsync(user).ConfigureAwait(false); + + flow.Username = username; + flow.Validated = true; + + var payload = JsonSerializer.Serialize(new { state, webRoot = $"{Request.PathBase}/web/" }); + return Content(CallbackPage.Replace("__PAYLOAD__", payload, StringComparison.Ordinal), "text/html"); + } + + /// + /// Exchanges a validated OIDC flow state for a Jellyfin session. Called by the callback page. + /// + /// Device information and the flow state. + /// The Jellyfin . + [HttpPost("auth")] + [AllowAnonymous] + [Produces("application/json")] + public async Task> Auth([FromBody] AuthPayload payload) + { + if (string.IsNullOrEmpty(payload.State) + || !States.TryRemove(payload.State, out var flow) + || !flow.Validated + || flow.Username is null + || flow.Created + StateLifetime < DateTime.UtcNow) + { + return BadRequest("Unknown or expired login state."); + } + + var user = _userManager.GetUserByName(flow.Username); + if (user is null) + { + return BadRequest("User no longer exists."); + } + + var authResult = await _sessionManager.AuthenticateDirect(new AuthenticationRequest + { + App = string.IsNullOrWhiteSpace(payload.AppName) ? "Jellyfin Web" : payload.AppName, + AppVersion = string.IsNullOrWhiteSpace(payload.AppVersion) ? "1.0.0" : payload.AppVersion, + DeviceId = string.IsNullOrWhiteSpace(payload.DeviceId) ? Guid.NewGuid().ToString("N") : payload.DeviceId, + DeviceName = string.IsNullOrWhiteSpace(payload.DeviceName) ? "OIDC Login" : payload.DeviceName, + UserId = user.Id, + Username = user.Username, + RemoteEndPoint = HttpContext.Connection.RemoteIpAddress?.ToString() + }).ConfigureAwait(false); + + return Ok(authResult); + } + + private OidcClient CreateClient() + { + var options = new OidcClientOptions + { + Authority = Config.OidIssuer, + ClientId = Config.OidClientId, + ClientSecret = string.IsNullOrEmpty(Config.OidClientSecret) ? null : Config.OidClientSecret, + RedirectUri = $"{Request.Scheme}://{Request.Host}{Request.PathBase}/OidcAuth/callback", + Scope = Config.OidScopes + }; + + if (Config.DisableEndpointValidation) + { + options.Policy.Discovery.ValidateEndpoints = false; + } + + return new OidcClient(options); + } + + private static string[] SplitCsv(string value) + { + return value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + } + + private static void CleanupStates() + { + var cutoff = DateTime.UtcNow - StateLifetime; + foreach (var entry in States) + { + if (entry.Value.Created < cutoff) + { + States.TryRemove(entry.Key, out _); + } + } + } + + private sealed class FlowState + { + public FlowState(AuthorizeState authorizeState) + { + AuthorizeState = authorizeState; + } + + public AuthorizeState AuthorizeState { get; } + + public DateTime Created { get; } = DateTime.UtcNow; + + public bool Validated { get; set; } + + public string? Username { get; set; } + } + + private const string CallbackPage = """ + + + + +Signing in… + + + +

Signing in…

+ + + +"""; +} + +/// +/// Body of the auth completion request sent by the callback page. +/// +public class AuthPayload +{ + /// + /// Gets or sets the OIDC flow state. + /// + public string State { get; set; } = string.Empty; + + /// + /// Gets or sets the Jellyfin device id. + /// + public string DeviceId { get; set; } = string.Empty; + + /// + /// Gets or sets the device name. + /// + public string? DeviceName { get; set; } + + /// + /// Gets or sets the app name. + /// + public string? AppName { get; set; } + + /// + /// Gets or sets the app version. + /// + public string? AppVersion { get; set; } +} diff --git a/Jellyfin.Plugin.OidcAuth/Configuration/PluginConfiguration.cs b/Jellyfin.Plugin.OidcAuth/Configuration/PluginConfiguration.cs new file mode 100644 index 0000000..fd2c321 --- /dev/null +++ b/Jellyfin.Plugin.OidcAuth/Configuration/PluginConfiguration.cs @@ -0,0 +1,71 @@ +using MediaBrowser.Model.Plugins; + +namespace Jellyfin.Plugin.OidcAuth.Configuration; + +/// +/// Plugin configuration for OIDC authentication. +/// +public class PluginConfiguration : BasePluginConfiguration +{ + /// + /// Gets or sets the OIDC issuer URL (authority). Discovery document is fetched from + /// {issuer}/.well-known/openid-configuration. + /// + public string OidIssuer { get; set; } = string.Empty; + + /// + /// Gets or sets the OIDC client id. + /// + public string OidClientId { get; set; } = string.Empty; + + /// + /// Gets or sets the OIDC client secret. + /// + public string OidClientSecret { get; set; } = string.Empty; + + /// + /// Gets or sets the requested scopes, space separated. + /// + public string OidScopes { get; set; } = "openid profile email"; + + /// + /// Gets or sets the claim used as the Jellyfin username. + /// + public string UsernameClaim { get; set; } = "preferred_username"; + + /// + /// Gets or sets the claim containing the user's roles/groups. + /// + public string RoleClaim { get; set; } = "groups"; + + /// + /// Gets or sets a comma separated list of role/group values that grant Jellyfin + /// administrator rights. When empty, admin status is never modified by the plugin. + /// + public string AdminRoles { get; set; } = string.Empty; + + /// + /// Gets or sets a comma separated list of role/group values required to log in. + /// When empty, every authenticated user may log in. + /// + public string AllowedRoles { get; set; } = string.Empty; + + /// + /// Gets or sets a value indicating whether unknown users are created on first login. + /// + public bool CreateUsersIfMissing { get; set; } = true; + + /// + /// Gets or sets a value indicating whether auto-created users get a random password. + /// Without one, Jellyfin accepts a blank password for them from any client. + /// Users can still set their own password in the web profile for TV/native logins, + /// or use Quick Connect. + /// + public bool SetRandomPasswordOnCreate { get; set; } = true; + + /// + /// Gets or sets a value indicating whether strict discovery endpoint validation is disabled. + /// Required for providers whose endpoints live on a different host than the issuer (e.g. Google). + /// + public bool DisableEndpointValidation { get; set; } +} diff --git a/Jellyfin.Plugin.OidcAuth/Configuration/configPage.html b/Jellyfin.Plugin.OidcAuth/Configuration/configPage.html new file mode 100644 index 0000000..f19b2f4 --- /dev/null +++ b/Jellyfin.Plugin.OidcAuth/Configuration/configPage.html @@ -0,0 +1,120 @@ + + + + OIDC Auth + + +
+
+
+
+
+
+

OIDC Auth

+
+ +
+ +
Base URL of your OIDC provider, e.g. https://auth.example.com. The discovery document is loaded from <issuer>/.well-known/openid-configuration.
+
+ +
+ +
+ +
+ +
+ +
+ +
Space separated. Default: openid profile email. Add e.g. "groups" if your provider needs it for the role claim.
+
+ +
+ +
Claim used as Jellyfin username. Default: preferred_username. Falls back to "sub".
+
+ +
+ +
Claim containing groups/roles. Default: groups.
+
+ +
+ +
Comma separated. Only users with one of these roles may log in. Empty = everyone.
+
+ +
+ +
Comma separated. Users with one of these roles become Jellyfin administrators. Empty = never touch admin flag.
+
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ Login URL: <your jellyfin address>/OidcAuth/login — + register <your jellyfin address>/OidcAuth/callback as redirect URI at your provider. +
+
+
+
+
+ +
+ + diff --git a/Jellyfin.Plugin.OidcAuth/Jellyfin.Plugin.OidcAuth.csproj b/Jellyfin.Plugin.OidcAuth/Jellyfin.Plugin.OidcAuth.csproj new file mode 100644 index 0000000..39d525a --- /dev/null +++ b/Jellyfin.Plugin.OidcAuth/Jellyfin.Plugin.OidcAuth.csproj @@ -0,0 +1,23 @@ + + + + net8.0 + Jellyfin.Plugin.OidcAuth + 1.0.0.0 + 1.0.0.0 + enable + disable + true + + + + + + + + + + + + + diff --git a/Jellyfin.Plugin.OidcAuth/Plugin.cs b/Jellyfin.Plugin.OidcAuth/Plugin.cs new file mode 100644 index 0000000..0a1dbfe --- /dev/null +++ b/Jellyfin.Plugin.OidcAuth/Plugin.cs @@ -0,0 +1,53 @@ +using System; +using System.Collections.Generic; +using Jellyfin.Plugin.OidcAuth.Configuration; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Common.Plugins; +using MediaBrowser.Model.Plugins; +using MediaBrowser.Model.Serialization; + +namespace Jellyfin.Plugin.OidcAuth; + +/// +/// The OIDC authentication plugin. +/// +public class Plugin : BasePlugin, IHasWebPages +{ + /// + /// Initializes a new instance of the class. + /// + /// Instance of the interface. + /// Instance of the interface. + public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer) + : base(applicationPaths, xmlSerializer) + { + Instance = this; + } + + /// + /// Gets the current plugin instance. + /// + public static Plugin? Instance { get; private set; } + + /// + public override string Name => "OIDC Auth"; + + /// + public override Guid Id => Guid.Parse("96badb44-9940-4ff0-befc-5f987159152c"); + + /// + public override string Description => "Log in to Jellyfin with an OpenID Connect provider."; + + /// + public IEnumerable GetPages() + { + return new[] + { + new PluginPageInfo + { + Name = "OidcAuth", + EmbeddedResourcePath = GetType().Namespace + ".Configuration.configPage.html" + } + }; + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..2fd355e --- /dev/null +++ b/README.md @@ -0,0 +1,92 @@ +# Jellyfin OIDC Auth Plugin + +Log in to Jellyfin with any OpenID Connect provider (Authelia, Authentik, Keycloak, Pocket ID, Google, ...). + +## How it works + +1. User opens `https:///OidcAuth/login`. +2. Plugin redirects to your provider (authorization code flow + PKCE via `IdentityModel.OidcClient`). +3. Provider redirects back to `https:///OidcAuth/callback`; the plugin exchanges the code, validates the tokens, and maps claims to a Jellyfin user (creating it if configured). +4. A small callback page creates a Jellyfin session (`ISessionManager.AuthenticateDirect`), writes the credentials into the web client's local storage, and redirects to `/web/`. + +## Build + +```bash +dotnet publish Jellyfin.Plugin.OidcAuth --configuration Release --output artifacts +``` + +## Install via plugin repository (recommended) + +1. In Jellyfin: Dashboard → Plugins → Repositories → **+** +2. Repository URL: + + ``` + https://raw.githubusercontent.com/YOUR_GITHUB_USER/jellyfinoidc/main/manifest.json + ``` + +3. Catalog → Authentication → **OIDC Auth** → Install → restart Jellyfin. + +Updates show up in the catalog automatically when a new version is added to `manifest.json`. + +## Install manually + +Copy these files from `artifacts/` into a new folder in Jellyfin's plugin directory +(e.g. `config/plugins/OidcAuth/`), then restart Jellyfin: + +- `Jellyfin.Plugin.OidcAuth.dll` +- `IdentityModel.OidcClient.dll` +- `IdentityModel.dll` + +Requires Jellyfin 10.10.x. + +## Configure + +At your OIDC provider, create a **confidential web client** with redirect URI: + +``` +https:///OidcAuth/callback +``` + +Then in Jellyfin: Dashboard → Plugins → OIDC Auth: + +| Setting | Meaning | +|---|---| +| Issuer URL | Provider base URL; discovery doc must exist at `/.well-known/openid-configuration` | +| Client ID / Secret | From your provider | +| Scopes | Default `openid profile email`; add `groups` if roles come via that scope | +| Username claim | Default `preferred_username`, falls back to `sub` | +| Role claim | Default `groups` | +| Allowed roles | CSV; empty = every authenticated user may log in | +| Admin roles | CSV; empty = plugin never touches the admin flag | +| Create users on first login | On by default; new users get access to all libraries | +| Set random password for auto-created users | On by default. Without it, Jellyfin accepts a **blank password** for these users from any client — convenient on a LAN-only server, dangerous on an exposed one | +| Disable strict endpoint validation | Enable for Google and other providers whose endpoints are on a different host than the issuer | + +## Login button on the web login page + +Jellyfin does not let plugins modify the login page. Add a button via +Dashboard → General → Branding → Login disclaimer: + +```html +Sign in with SSO +``` + +## TV and native clients + +The plugin does not change or disable Jellyfin's normal password login, so TV apps +(Android TV, Roku, Swiftfin, Kodi, ...) keep working exactly as before. For users +that exist only through OIDC, three options: + +1. **Quick Connect** (recommended): enable it in Dashboard → General. On the TV choose + Quick Connect, then approve the code from the web client (where you're logged in via OIDC). +2. **Set a password**: after the first OIDC login, the user sets a password in their + web profile and uses that on the TV. +3. **Blank password** (LAN-only setups): turn off "Set random password for auto-created + users" — TV login then works with username + empty password. Do **not** do this on a + server reachable from the internet. + +## Notes / limitations + +- The OIDC browser flow itself works for the **web client** (and anything that can open a browser and reuse the resulting session token). Native clients that only show the username/password form can't use this flow directly — see the TV section above. +- Behind a reverse proxy, make sure Jellyfin sees the external scheme/host (`X-Forwarded-Proto`, `X-Forwarded-Host` — Jellyfin's "Known proxies" setting), otherwise the computed redirect URI won't match the one registered at the provider. +- Password login stays enabled; this plugin adds an additional login path. diff --git a/build.yaml b/build.yaml new file mode 100644 index 0000000..a6c868d --- /dev/null +++ b/build.yaml @@ -0,0 +1,19 @@ +--- +name: "OIDC Auth" +guid: "96badb44-9940-4ff0-befc-5f987159152c" +version: "1.0.0.0" +targetAbi: "10.10.0.0" +framework: "net8.0" +owner: "pascallinxweiler" +overview: "Log in to Jellyfin with an OpenID Connect provider" +description: > + Adds OpenID Connect (OIDC) single sign-on to Jellyfin. Supports any + spec-compliant provider (Authelia, Authentik, Keycloak, Pocket ID, Google, ...), + automatic user creation, and role based admin/access mapping. +category: "Authentication" +artifacts: + - "Jellyfin.Plugin.OidcAuth.dll" + - "IdentityModel.OidcClient.dll" + - "IdentityModel.dll" +changelog: | + Initial release. diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..bf95f60 --- /dev/null +++ b/manifest.json @@ -0,0 +1,20 @@ +[ + { + "guid": "96badb44-9940-4ff0-befc-5f987159152c", + "name": "OIDC Auth", + "description": "Adds OpenID Connect (OIDC) single sign-on to Jellyfin. Supports any spec-compliant provider (Authelia, Authentik, Keycloak, Pocket ID, Google, ...), automatic user creation, and role based admin/access mapping.", + "overview": "Log in to Jellyfin with an OpenID Connect provider", + "owner": "YOUR_GITHUB_USER", + "category": "Authentication", + "versions": [ + { + "version": "1.0.0.0", + "changelog": "Initial release.", + "targetAbi": "10.10.0.0", + "sourceUrl": "https://github.com/YOUR_GITHUB_USER/jellyfinoidc/releases/download/v1.0.0.0/oidc-auth_1.0.0.0.zip", + "checksum": "178db5af78c0a5d7cb0d1910338f5fde", + "timestamp": "2026-07-13T00:00:00Z" + } + ] + } +]