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,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>