using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Security.Claims;
using System.Text.Json;
using System.Threading.Tasks;
using IdentityModel.OidcClient;
using Jellyfin.Data;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Database.Implementations.Enums;
using Jellyfin.Plugin.OidcAuth.Configuration;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller.Authentication;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Controller.Session;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.OidcAuth.Api;
///
/// OIDC login endpoints.
///
[ApiController]
[Route("OidcAuth")]
public class OidcAuthController : ControllerBase
{
private static readonly TimeSpan StateLifetime = TimeSpan.FromMinutes(15);
private static readonly ConcurrentDictionary States = new();
///
/// Profile pictures larger than this are ignored rather than written to disk.
///
private const long MaxProfilePictureBytes = 5 * 1024 * 1024;
private static readonly Dictionary ProfilePictureExtensions = new(StringComparer.OrdinalIgnoreCase)
{
["image/jpeg"] = ".jpg",
["image/png"] = ".png",
["image/gif"] = ".gif",
["image/webp"] = ".webp"
};
private readonly IUserManager _userManager;
private readonly ISessionManager _sessionManager;
private readonly IProviderManager _providerManager;
private readonly IServerConfigurationManager _serverConfigurationManager;
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger _logger;
///
/// Initializes a new instance of the class.
///
/// Instance of the interface.
/// Instance of the interface.
/// Instance of the interface.
/// Instance of the interface.
/// Instance of the interface.
/// Instance of the interface.
public OidcAuthController(
IUserManager userManager,
ISessionManager sessionManager,
IProviderManager providerManager,
IServerConfigurationManager serverConfigurationManager,
IHttpClientFactory httpClientFactory,
ILogger logger)
{
_userManager = userManager;
_sessionManager = sessionManager;
_providerManager = providerManager;
_serverConfigurationManager = serverConfigurationManager;
_httpClientFactory = httpClientFactory;
_logger = logger;
}
private static PluginConfiguration Config => Plugin.Instance!.Configuration;
///
/// Starts the OIDC login flow by redirecting to the provider.
///
/// A redirect to the provider's authorization endpoint.
[HttpGet("login")]
[AllowAnonymous]
public async Task 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);
}
///
/// OIDC redirect URI. Exchanges the authorization code and serves a page that
/// finishes the login inside the Jellyfin web client.
///
/// The OIDC state parameter.
/// An HTML page completing the login.
[HttpGet("callback")]
[AllowAnonymous]
public async Task 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);
}
}
if (Config.SyncProfilePicture)
{
// A broken or unreachable picture must never keep the user out.
try
{
await SyncProfilePictureAsync(user, result.User).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not sync the profile picture for {Username}", username);
}
}
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");
}
///
/// Exchanges a validated OIDC flow state for a Jellyfin session. Called by the callback page.
///
/// Device information and the flow state.
/// The Jellyfin .
[HttpPost("auth")]
[AllowAnonymous]
[Produces("application/json")]
public async Task> 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);
}
///
/// Copies the provider's profile picture onto the Jellyfin user. The claim holds a
/// URL (Pocket ID points at /api/users/{id}/profile-picture.png), so it is fetched
/// anonymously — the access token is deliberately not sent to an address that
/// ultimately comes from a token payload.
///
private async Task SyncProfilePictureAsync(User user, ClaimsPrincipal principal)
{
var claim = principal.FindFirst(Config.PictureClaim)?.Value;
if (string.IsNullOrWhiteSpace(claim))
{
return;
}
if (!Uri.TryCreate(claim, UriKind.Absolute, out var uri)
|| (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
{
_logger.LogWarning("Ignoring profile picture for {Username}: '{Claim}' is not an http(s) URL", user.Username, claim);
return;
}
var client = _httpClientFactory.CreateClient(NamedClient.Default);
using var response = await client.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning(
"Ignoring profile picture for {Username}: {Uri} returned {Status}",
user.Username,
uri,
(int)response.StatusCode);
return;
}
var mimeType = response.Content.Headers.ContentType?.MediaType;
if (mimeType is null || !ProfilePictureExtensions.TryGetValue(mimeType, out var extension))
{
_logger.LogWarning(
"Ignoring profile picture for {Username}: unsupported content type '{MimeType}'",
user.Username,
mimeType);
return;
}
if (response.Content.Headers.ContentLength > MaxProfilePictureBytes)
{
_logger.LogWarning(
"Ignoring profile picture for {Username}: {Length} bytes exceeds the {Limit} byte limit",
user.Username,
response.Content.Headers.ContentLength,
MaxProfilePictureBytes);
return;
}
var image = await response.Content.ReadAsByteArrayAsync().ConfigureAwait(false);
if (image.Length == 0 || image.Length > MaxProfilePictureBytes)
{
_logger.LogWarning("Ignoring profile picture for {Username}: {Length} bytes", user.Username, image.Length);
return;
}
var path = Path.Combine(
_serverConfigurationManager.ApplicationPaths.UserConfigurationDirectoryPath,
user.Username,
"profile" + extension);
// Rewriting an identical image on every login would churn the image cache.
if (user.ProfileImage is not null
&& string.Equals(user.ProfileImage.Path, path, StringComparison.Ordinal)
&& System.IO.File.Exists(path))
{
var current = await System.IO.File.ReadAllBytesAsync(path).ConfigureAwait(false);
if (image.AsSpan().SequenceEqual(current))
{
return;
}
}
if (user.ProfileImage is not null)
{
await _userManager.ClearProfileImageAsync(user).ConfigureAwait(false);
}
user.ProfileImage = new ImageInfo(path);
using var stream = new MemoryStream(image, false);
await _providerManager.SaveImage(stream, mimeType, path).ConfigureAwait(false);
_logger.LogInformation("Updated the profile picture for {Username} from {Uri}", user.Username, uri);
}
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 = """
Signing in…
Signing in…
""";
}
///
/// Body of the auth completion request sent by the callback page.
///
public class AuthPayload
{
///
/// Gets or sets the OIDC flow state.
///
public string State { get; set; } = string.Empty;
///
/// Gets or sets the Jellyfin device id.
///
public string DeviceId { get; set; } = string.Empty;
///
/// Gets or sets the device name.
///
public string? DeviceName { get; set; }
///
/// Gets or sets the app name.
///
public string? AppName { get; set; }
///
/// Gets or sets the app version.
///
public string? AppVersion { get; set; }
}