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:
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; }
|
||||
}
|
||||
Reference in New Issue
Block a user