Files
jellyfinsso/Jellyfin.Plugin.OidcAuth/Api/OidcAuthController.cs
Pascal Linxweiler fda59741b5
All checks were successful
Build Plugin / build (push) Successful in 1m1s
feat: sync the profile picture from the OIDC provider
Pocket ID exposes an avatar through the standard picture claim, pointing at
/api/users/{id}/profile-picture.png. On login the plugin now fetches that URL
and writes it to the Jellyfin user's profile image, so SSO users get their
avatar without setting one by hand.

The picture is fetched anonymously. The access token is deliberately not sent,
because the address ultimately originates from a token payload and must not be
treated as a trusted destination for credentials. Non-http(s) URLs, non-image
content types and anything over 5 MB are rejected, and an identical image is
not rewritten so the image cache is left alone. Any failure is logged and
swallowed: a broken avatar must never block a login.

Controlled by the new "Sync the profile picture" setting, on by default, with
the claim name configurable for providers that do not use "picture".

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-29 15:41:51 +02:00

480 lines
18 KiB
C#

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;
/// <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();
/// <summary>
/// Profile pictures larger than this are ignored rather than written to disk.
/// </summary>
private const long MaxProfilePictureBytes = 5 * 1024 * 1024;
private static readonly Dictionary<string, string> 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<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="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param>
/// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
/// <param name="httpClientFactory">Instance of the <see cref="IHttpClientFactory"/> interface.</param>
/// <param name="logger">Instance of the <see cref="ILogger{OidcAuthController}"/> interface.</param>
public OidcAuthController(
IUserManager userManager,
ISessionManager sessionManager,
IProviderManager providerManager,
IServerConfigurationManager serverConfigurationManager,
IHttpClientFactory httpClientFactory,
ILogger<OidcAuthController> logger)
{
_userManager = userManager;
_sessionManager = sessionManager;
_providerManager = providerManager;
_serverConfigurationManager = serverConfigurationManager;
_httpClientFactory = httpClientFactory;
_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);
}
}
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");
}
/// <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);
}
/// <summary>
/// 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.
/// </summary>
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 = """
<!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; }
}