Files
jellyfinsso/Jellyfin.Plugin.OidcAuth/Api/OidcAuthController.cs
Pascal Linxweiler 7dc79bf003 fix: admin role mapping grants only, revoke behind opt-in
SSO login for an existing admin whose token lacked the admin role was
silently demoting the account, locking admins out of the dashboard.
Missing admin role now leaves the flag alone unless
RevokeAdminWithoutRole is enabled.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 14:44:07 +02:00

341 lines
12 KiB
C#

using System;
using System.Collections.Concurrent;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using IdentityModel.OidcClient;
using Jellyfin.Data;
using Jellyfin.Database.Implementations.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();
_logger.LogInformation(
"OIDC login for {Username}. Claims: {Claims}. Roles from claim '{RoleClaim}': [{Roles}]",
username,
string.Join("; ", result.User.Claims.Select(c => $"{c.Type}={c.Value}")),
Config.RoleClaim,
string.Join(", ", roles));
var allowedRoles = SplitCsv(Config.AllowedRoles);
if (allowedRoles.Length > 0 && !roles.Intersect(allowedRoles, StringComparer.OrdinalIgnoreCase).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.Id,
Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32)))
.ConfigureAwait(false);
}
}
var adminRoles = SplitCsv(Config.AdminRoles);
if (adminRoles.Length > 0)
{
var hasAdminRole = roles.Intersect(adminRoles, StringComparer.OrdinalIgnoreCase).Any();
_logger.LogInformation("OIDC admin mapping for {Username}: admin roles [{AdminRoles}] => hasAdminRole={HasAdminRole}", username, Config.AdminRoles, hasAdminRole);
if (hasAdminRole)
{
user.SetPermission(PermissionKind.IsAdministrator, true);
}
else if (Config.RevokeAdminWithoutRole)
{
user.SetPermission(PermissionKind.IsAdministrator, false);
}
}
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; }
}