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

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
bin/
obj/
artifacts/
*.zip
*.user
.vs/

View 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

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"
}
};
}
}

92
README.md Normal file
View 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
View 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
View 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"
}
]
}
]