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:
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
bin/
|
||||||
|
obj/
|
||||||
|
artifacts/
|
||||||
|
*.zip
|
||||||
|
*.user
|
||||||
|
.vs/
|
||||||
22
Jellyfin.Plugin.OidcAuth.sln
Normal file
22
Jellyfin.Plugin.OidcAuth.sln
Normal file
@@ -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
|
||||||
323
Jellyfin.Plugin.OidcAuth/Api/OidcAuthController.cs
Normal file
323
Jellyfin.Plugin.OidcAuth/Api/OidcAuthController.cs
Normal 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; }
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
120
Jellyfin.Plugin.OidcAuth/Configuration/configPage.html
Normal file
120
Jellyfin.Plugin.OidcAuth/Configuration/configPage.html
Normal 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 <issuer>/.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><your jellyfin address>/OidcAuth/login</code> —
|
||||||
|
register <code><your jellyfin address>/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>
|
||||||
23
Jellyfin.Plugin.OidcAuth/Jellyfin.Plugin.OidcAuth.csproj
Normal file
23
Jellyfin.Plugin.OidcAuth/Jellyfin.Plugin.OidcAuth.csproj
Normal 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>
|
||||||
53
Jellyfin.Plugin.OidcAuth/Plugin.cs
Normal file
53
Jellyfin.Plugin.OidcAuth/Plugin.cs
Normal 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"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
92
README.md
Normal file
92
README.md
Normal file
@@ -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://<jellyfin>/OidcAuth/login`.
|
||||||
|
2. Plugin redirects to your provider (authorization code flow + PKCE via `IdentityModel.OidcClient`).
|
||||||
|
3. Provider redirects back to `https://<jellyfin>/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://<jellyfin>/OidcAuth/callback
|
||||||
|
```
|
||||||
|
|
||||||
|
Then in Jellyfin: Dashboard → Plugins → OIDC Auth:
|
||||||
|
|
||||||
|
| Setting | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| Issuer URL | Provider base URL; discovery doc must exist at `<issuer>/.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
|
||||||
|
<a is="emby-linkbutton" class="raised block emby-button" href="/OidcAuth/login">Sign in with SSO</a>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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.
|
||||||
19
build.yaml
Normal file
19
build.yaml
Normal file
@@ -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.
|
||||||
20
manifest.json
Normal file
20
manifest.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user