feat: sync the profile picture from the OIDC provider
All checks were successful
Build Plugin / build (push) Successful in 1m1s
All checks were successful
Build Plugin / build (push) Successful in 1m1s
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>
This commit is contained in:
@@ -1,14 +1,22 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Security.Claims;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using IdentityModel.OidcClient;
|
using IdentityModel.OidcClient;
|
||||||
using Jellyfin.Data;
|
using Jellyfin.Data;
|
||||||
|
using Jellyfin.Database.Implementations.Entities;
|
||||||
using Jellyfin.Database.Implementations.Enums;
|
using Jellyfin.Database.Implementations.Enums;
|
||||||
using Jellyfin.Plugin.OidcAuth.Configuration;
|
using Jellyfin.Plugin.OidcAuth.Configuration;
|
||||||
|
using MediaBrowser.Common.Net;
|
||||||
using MediaBrowser.Controller.Authentication;
|
using MediaBrowser.Controller.Authentication;
|
||||||
|
using MediaBrowser.Controller.Configuration;
|
||||||
using MediaBrowser.Controller.Library;
|
using MediaBrowser.Controller.Library;
|
||||||
|
using MediaBrowser.Controller.Providers;
|
||||||
using MediaBrowser.Controller.Session;
|
using MediaBrowser.Controller.Session;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
@@ -26,8 +34,24 @@ public class OidcAuthController : ControllerBase
|
|||||||
private static readonly TimeSpan StateLifetime = TimeSpan.FromMinutes(15);
|
private static readonly TimeSpan StateLifetime = TimeSpan.FromMinutes(15);
|
||||||
private static readonly ConcurrentDictionary<string, FlowState> States = new();
|
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 IUserManager _userManager;
|
||||||
private readonly ISessionManager _sessionManager;
|
private readonly ISessionManager _sessionManager;
|
||||||
|
private readonly IProviderManager _providerManager;
|
||||||
|
private readonly IServerConfigurationManager _serverConfigurationManager;
|
||||||
|
private readonly IHttpClientFactory _httpClientFactory;
|
||||||
private readonly ILogger<OidcAuthController> _logger;
|
private readonly ILogger<OidcAuthController> _logger;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -35,11 +59,23 @@ public class OidcAuthController : ControllerBase
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
|
/// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
|
||||||
/// <param name="sessionManager">Instance of the <see cref="ISessionManager"/> 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>
|
/// <param name="logger">Instance of the <see cref="ILogger{OidcAuthController}"/> interface.</param>
|
||||||
public OidcAuthController(IUserManager userManager, ISessionManager sessionManager, ILogger<OidcAuthController> logger)
|
public OidcAuthController(
|
||||||
|
IUserManager userManager,
|
||||||
|
ISessionManager sessionManager,
|
||||||
|
IProviderManager providerManager,
|
||||||
|
IServerConfigurationManager serverConfigurationManager,
|
||||||
|
IHttpClientFactory httpClientFactory,
|
||||||
|
ILogger<OidcAuthController> logger)
|
||||||
{
|
{
|
||||||
_userManager = userManager;
|
_userManager = userManager;
|
||||||
_sessionManager = sessionManager;
|
_sessionManager = sessionManager;
|
||||||
|
_providerManager = providerManager;
|
||||||
|
_serverConfigurationManager = serverConfigurationManager;
|
||||||
|
_httpClientFactory = httpClientFactory;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,6 +191,19 @@ public class OidcAuthController : ControllerBase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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);
|
await _userManager.UpdateUserAsync(user).ConfigureAwait(false);
|
||||||
|
|
||||||
flow.Username = username;
|
flow.Username = username;
|
||||||
@@ -203,6 +252,96 @@ public class OidcAuthController : ControllerBase
|
|||||||
return Ok(authResult);
|
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()
|
private OidcClient CreateClient()
|
||||||
{
|
{
|
||||||
var options = new OidcClientOptions
|
var options = new OidcClientOptions
|
||||||
|
|||||||
@@ -38,6 +38,17 @@ public class PluginConfiguration : BasePluginConfiguration
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public string RoleClaim { get; set; } = "groups";
|
public string RoleClaim { get; set; } = "groups";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the claim holding a URL to the user's profile picture.
|
||||||
|
/// </summary>
|
||||||
|
public string PictureClaim { get; set; } = "picture";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether the Jellyfin profile image is updated
|
||||||
|
/// from the picture claim on every OIDC login.
|
||||||
|
/// </summary>
|
||||||
|
public bool SyncProfilePicture { get; set; } = true;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets a comma separated list of role/group values that grant Jellyfin
|
/// 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.
|
/// administrator rights. When empty, admin status is never modified by the plugin.
|
||||||
|
|||||||
@@ -41,6 +41,11 @@
|
|||||||
<div class="fieldDescription">Claim containing groups/roles. Default: groups.</div>
|
<div class="fieldDescription">Claim containing groups/roles. Default: groups.</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="inputContainer">
|
||||||
|
<input is="emby-input" id="PictureClaim" type="text" label="Picture claim" />
|
||||||
|
<div class="fieldDescription">Claim holding a URL to the user's avatar. Default: picture.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="inputContainer">
|
<div class="inputContainer">
|
||||||
<input is="emby-input" id="AllowedRoles" type="text" label="Allowed roles" />
|
<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 class="fieldDescription">Comma separated. Only users with one of these roles may log in. Empty = everyone.</div>
|
||||||
@@ -58,6 +63,13 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="checkboxContainer checkboxContainer-withDescription">
|
||||||
|
<label class="emby-checkbox-label">
|
||||||
|
<input is="emby-checkbox" id="SyncProfilePicture" type="checkbox" />
|
||||||
|
<span>Sync the profile picture from the provider on every login (overwrites a picture set in Jellyfin)</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="checkboxContainer checkboxContainer-withDescription">
|
<div class="checkboxContainer checkboxContainer-withDescription">
|
||||||
<label class="emby-checkbox-label">
|
<label class="emby-checkbox-label">
|
||||||
<input is="emby-checkbox" id="CreateUsersIfMissing" type="checkbox" />
|
<input is="emby-checkbox" id="CreateUsersIfMissing" type="checkbox" />
|
||||||
@@ -96,8 +108,8 @@
|
|||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
(function () {
|
(function () {
|
||||||
var pluginId = '96badb44-9940-4ff0-befc-5f987159152c';
|
var pluginId = '96badb44-9940-4ff0-befc-5f987159152c';
|
||||||
var textFields = ['OidIssuer', 'OidClientId', 'OidClientSecret', 'OidScopes', 'UsernameClaim', 'RoleClaim', 'AllowedRoles', 'AdminRoles'];
|
var textFields = ['OidIssuer', 'OidClientId', 'OidClientSecret', 'OidScopes', 'UsernameClaim', 'RoleClaim', 'PictureClaim', 'AllowedRoles', 'AdminRoles'];
|
||||||
var boolFields = ['RevokeAdminWithoutRole', 'CreateUsersIfMissing', 'SetRandomPasswordOnCreate', 'DisableEndpointValidation'];
|
var boolFields = ['RevokeAdminWithoutRole', 'SyncProfilePicture', 'CreateUsersIfMissing', 'SetRandomPasswordOnCreate', 'DisableEndpointValidation'];
|
||||||
|
|
||||||
document.querySelector('#OidcAuthConfigPage').addEventListener('pageshow', function () {
|
document.querySelector('#OidcAuthConfigPage').addEventListener('pageshow', function () {
|
||||||
Dashboard.showLoadingMsg();
|
Dashboard.showLoadingMsg();
|
||||||
|
|||||||
Reference in New Issue
Block a user