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 <noreply@anthropic.com>
This commit is contained in:
Pascal Linxweiler
2026-07-13 13:07:54 +02:00
commit 2380e97e79
10 changed files with 749 additions and 0 deletions

View File

@@ -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;
/// <summary>
/// OIDC login endpoints.
/// </summary>
[ApiController]
[Route("OidcAuth")]
public class OidcAuthController : ControllerBase
{
private static readonly TimeSpan StateLifetime = TimeSpan.FromMinutes(15);
private static readonly ConcurrentDictionary<string, FlowState> States = new();
private readonly IUserManager _userManager;
private readonly ISessionManager _sessionManager;
private readonly ILogger<OidcAuthController> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="OidcAuthController"/> class.
/// </summary>
/// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
/// <param name="sessionManager">Instance of the <see cref="ISessionManager"/> interface.</param>
/// <param name="logger">Instance of the <see cref="ILogger{OidcAuthController}"/> interface.</param>
public OidcAuthController(IUserManager userManager, ISessionManager sessionManager, ILogger<OidcAuthController> logger)
{
_userManager = userManager;
_sessionManager = sessionManager;
_logger = logger;
}
private static PluginConfiguration Config => Plugin.Instance!.Configuration;
/// <summary>
/// Starts the OIDC login flow by redirecting to the provider.
/// </summary>
/// <returns>A redirect to the provider's authorization endpoint.</returns>
[HttpGet("login")]
[AllowAnonymous]
public async Task<ActionResult> 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);
}
/// <summary>
/// OIDC redirect URI. Exchanges the authorization code and serves a page that
/// finishes the login inside the Jellyfin web client.
/// </summary>
/// <param name="state">The OIDC state parameter.</param>
/// <returns>An HTML page completing the login.</returns>
[HttpGet("callback")]
[AllowAnonymous]
public async Task<ActionResult> 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");
}
/// <summary>
/// Exchanges a validated OIDC flow state for a Jellyfin session. Called by the callback page.
/// </summary>
/// <param name="payload">Device information and the flow state.</param>
/// <returns>The Jellyfin <see cref="AuthenticationResult"/>.</returns>
[HttpPost("auth")]
[AllowAnonymous]
[Produces("application/json")]
public async Task<ActionResult<AuthenticationResult>> 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 = """
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Signing in</title>
<style>body{font-family:sans-serif;background:#101010;color:#eee;display:flex;align-items:center;justify-content:center;height:100vh;margin:0}</style>
</head>
<body>
<p id="msg">Signing in</p>
<script>
(function () {
var data = __PAYLOAD__;
var deviceId = localStorage.getItem('_deviceId2');
if (!deviceId) {
deviceId = (window.crypto && crypto.randomUUID) ? crypto.randomUUID().replace(/-/g, '') : String(Date.now());
localStorage.setItem('_deviceId2', deviceId);
}
fetch('auth', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
state: data.state,
deviceId: deviceId,
deviceName: navigator.userAgent.indexOf('Firefox') !== -1 ? 'Firefox' : 'Browser',
appName: 'Jellyfin Web',
appVersion: '10.10.0'
})
}).then(function (r) {
if (!r.ok) { throw new Error('Auth failed: ' + r.status); }
return r.json();
}).then(function (auth) {
var credentials = { Servers: [{
ManualAddress: window.location.origin,
manualAddressOnly: true,
Id: auth.ServerId,
AccessToken: auth.AccessToken,
UserId: auth.User.Id,
DateLastAccessed: Date.now()
}] };
localStorage.setItem('jellyfin_credentials', JSON.stringify(credentials));
localStorage.setItem('enableAutoLogin', 'true');
window.location.replace(data.webRoot);
}).catch(function (e) {
document.getElementById('msg').textContent = e.message;
});
})();
</script>
</body>
</html>
""";
}
/// <summary>
/// Body of the auth completion request sent by the callback page.
/// </summary>
public class AuthPayload
{
/// <summary>
/// Gets or sets the OIDC flow state.
/// </summary>
public string State { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the Jellyfin device id.
/// </summary>
public string DeviceId { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the device name.
/// </summary>
public string? DeviceName { get; set; }
/// <summary>
/// Gets or sets the app name.
/// </summary>
public string? AppName { get; set; }
/// <summary>
/// Gets or sets the app version.
/// </summary>
public string? AppVersion { get; set; }
}

View File

@@ -0,0 +1,71 @@
using MediaBrowser.Model.Plugins;
namespace Jellyfin.Plugin.OidcAuth.Configuration;
/// <summary>
/// Plugin configuration for OIDC authentication.
/// </summary>
public class PluginConfiguration : BasePluginConfiguration
{
/// <summary>
/// Gets or sets the OIDC issuer URL (authority). Discovery document is fetched from
/// {issuer}/.well-known/openid-configuration.
/// </summary>
public string OidIssuer { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the OIDC client id.
/// </summary>
public string OidClientId { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the OIDC client secret.
/// </summary>
public string OidClientSecret { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the requested scopes, space separated.
/// </summary>
public string OidScopes { get; set; } = "openid profile email";
/// <summary>
/// Gets or sets the claim used as the Jellyfin username.
/// </summary>
public string UsernameClaim { get; set; } = "preferred_username";
/// <summary>
/// Gets or sets the claim containing the user's roles/groups.
/// </summary>
public string RoleClaim { get; set; } = "groups";
/// <summary>
/// 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.
/// </summary>
public string AdminRoles { get; set; } = string.Empty;
/// <summary>
/// Gets or sets a comma separated list of role/group values required to log in.
/// When empty, every authenticated user may log in.
/// </summary>
public string AllowedRoles { get; set; } = string.Empty;
/// <summary>
/// Gets or sets a value indicating whether unknown users are created on first login.
/// </summary>
public bool CreateUsersIfMissing { get; set; } = true;
/// <summary>
/// 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.
/// </summary>
public bool SetRandomPasswordOnCreate { get; set; } = true;
/// <summary>
/// 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).
/// </summary>
public bool DisableEndpointValidation { get; set; }
}

View File

@@ -0,0 +1,120 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>OIDC Auth</title>
</head>
<body>
<div id="OidcAuthConfigPage" data-role="page" class="page type-interior pluginConfigurationPage" data-require="emby-input,emby-button,emby-checkbox">
<div data-role="content">
<div class="content-primary">
<form id="OidcAuthConfigForm">
<div class="verticalSection verticalSection-extrabottompadding">
<div class="sectionTitleContainer flex align-items-center">
<h2 class="sectionTitle">OIDC Auth</h2>
</div>
<div class="inputContainer">
<input is="emby-input" id="OidIssuer" type="url" label="Issuer URL" />
<div class="fieldDescription">Base URL of your OIDC provider, e.g. https://auth.example.com. The discovery document is loaded from &lt;issuer&gt;/.well-known/openid-configuration.</div>
</div>
<div class="inputContainer">
<input is="emby-input" id="OidClientId" type="text" label="Client ID" />
</div>
<div class="inputContainer">
<input is="emby-input" id="OidClientSecret" type="password" label="Client Secret" autocomplete="new-password" />
</div>
<div class="inputContainer">
<input is="emby-input" id="OidScopes" type="text" label="Scopes" />
<div class="fieldDescription">Space separated. Default: openid profile email. Add e.g. "groups" if your provider needs it for the role claim.</div>
</div>
<div class="inputContainer">
<input is="emby-input" id="UsernameClaim" type="text" label="Username claim" />
<div class="fieldDescription">Claim used as Jellyfin username. Default: preferred_username. Falls back to "sub".</div>
</div>
<div class="inputContainer">
<input is="emby-input" id="RoleClaim" type="text" label="Role claim" />
<div class="fieldDescription">Claim containing groups/roles. Default: groups.</div>
</div>
<div class="inputContainer">
<input is="emby-input" id="AllowedRoles" type="text" label="Allowed roles" />
<div class="fieldDescription">Comma separated. Only users with one of these roles may log in. Empty = everyone.</div>
</div>
<div class="inputContainer">
<input is="emby-input" id="AdminRoles" type="text" label="Admin roles" />
<div class="fieldDescription">Comma separated. Users with one of these roles become Jellyfin administrators. Empty = never touch admin flag.</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label">
<input is="emby-checkbox" id="CreateUsersIfMissing" type="checkbox" />
<span>Create users on first login</span>
</label>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label">
<input is="emby-checkbox" id="SetRandomPasswordOnCreate" type="checkbox" />
<span>Set random password for auto-created users (recommended — without it, anyone can log in as them with a blank password)</span>
</label>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label">
<input is="emby-checkbox" id="DisableEndpointValidation" type="checkbox" />
<span>Disable strict endpoint validation (needed for Google and other providers whose endpoints are on a different host than the issuer)</span>
</label>
</div>
<div>
<button is="emby-button" type="submit" class="raised button-submit block emby-button">
<span>Save</span>
</button>
</div>
<div class="fieldDescription" style="margin-top: 1em;">
Login URL: <code>&lt;your jellyfin address&gt;/OidcAuth/login</code>
register <code>&lt;your jellyfin address&gt;/OidcAuth/callback</code> as redirect URI at your provider.
</div>
</div>
</form>
</div>
</div>
<script type="text/javascript">
(function () {
var pluginId = '96badb44-9940-4ff0-befc-5f987159152c';
var textFields = ['OidIssuer', 'OidClientId', 'OidClientSecret', 'OidScopes', 'UsernameClaim', 'RoleClaim', 'AllowedRoles', 'AdminRoles'];
var boolFields = ['CreateUsersIfMissing', 'SetRandomPasswordOnCreate', 'DisableEndpointValidation'];
document.querySelector('#OidcAuthConfigPage').addEventListener('pageshow', function () {
Dashboard.showLoadingMsg();
ApiClient.getPluginConfiguration(pluginId).then(function (config) {
textFields.forEach(function (f) { document.querySelector('#' + f).value = config[f] || ''; });
boolFields.forEach(function (f) { document.querySelector('#' + f).checked = !!config[f]; });
Dashboard.hideLoadingMsg();
});
});
document.querySelector('#OidcAuthConfigForm').addEventListener('submit', function (e) {
e.preventDefault();
Dashboard.showLoadingMsg();
ApiClient.getPluginConfiguration(pluginId).then(function (config) {
textFields.forEach(function (f) { config[f] = document.querySelector('#' + f).value; });
boolFields.forEach(function (f) { config[f] = document.querySelector('#' + f).checked; });
ApiClient.updatePluginConfiguration(pluginId, config).then(function (result) {
Dashboard.processPluginConfigurationUpdateResult(result);
});
});
return false;
});
})();
</script>
</div>
</body>
</html>

View File

@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>Jellyfin.Plugin.OidcAuth</RootNamespace>
<AssemblyVersion>1.0.0.0</AssemblyVersion>
<FileVersion>1.0.0.0</FileVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>disable</ImplicitUsings>
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Jellyfin.Controller" Version="10.10.7" />
<PackageReference Include="IdentityModel.OidcClient" Version="5.2.1" />
</ItemGroup>
<ItemGroup>
<None Remove="Configuration\configPage.html" />
<EmbeddedResource Include="Configuration\configPage.html" />
</ItemGroup>
</Project>

View File

@@ -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;
/// <summary>
/// The OIDC authentication plugin.
/// </summary>
public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
{
/// <summary>
/// Initializes a new instance of the <see cref="Plugin"/> class.
/// </summary>
/// <param name="applicationPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param>
/// <param name="xmlSerializer">Instance of the <see cref="IXmlSerializer"/> interface.</param>
public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer)
: base(applicationPaths, xmlSerializer)
{
Instance = this;
}
/// <summary>
/// Gets the current plugin instance.
/// </summary>
public static Plugin? Instance { get; private set; }
/// <inheritdoc />
public override string Name => "OIDC Auth";
/// <inheritdoc />
public override Guid Id => Guid.Parse("96badb44-9940-4ff0-befc-5f987159152c");
/// <inheritdoc />
public override string Description => "Log in to Jellyfin with an OpenID Connect provider.";
/// <inheritdoc />
public IEnumerable<PluginPageInfo> GetPages()
{
return new[]
{
new PluginPageInfo
{
Name = "OidcAuth",
EmbeddedResourcePath = GetType().Namespace + ".Configuration.configPage.html"
}
};
}
}