diff --git a/Jellyfin.Plugin.OidcAuth/Api/OidcAuthController.cs b/Jellyfin.Plugin.OidcAuth/Api/OidcAuthController.cs index 80962f1..b9d6304 100644 --- a/Jellyfin.Plugin.OidcAuth/Api/OidcAuthController.cs +++ b/Jellyfin.Plugin.OidcAuth/Api/OidcAuthController.cs @@ -1,14 +1,22 @@ 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; @@ -26,8 +34,24 @@ 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; /// @@ -35,11 +59,23 @@ public class OidcAuthController : ControllerBase /// /// 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, ILogger logger) + 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; } @@ -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); flow.Username = username; @@ -203,6 +252,96 @@ public class OidcAuthController : ControllerBase 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 diff --git a/Jellyfin.Plugin.OidcAuth/Configuration/PluginConfiguration.cs b/Jellyfin.Plugin.OidcAuth/Configuration/PluginConfiguration.cs index 521cc64..105e14a 100644 --- a/Jellyfin.Plugin.OidcAuth/Configuration/PluginConfiguration.cs +++ b/Jellyfin.Plugin.OidcAuth/Configuration/PluginConfiguration.cs @@ -38,6 +38,17 @@ public class PluginConfiguration : BasePluginConfiguration /// public string RoleClaim { get; set; } = "groups"; + /// + /// Gets or sets the claim holding a URL to the user's profile picture. + /// + public string PictureClaim { get; set; } = "picture"; + + /// + /// Gets or sets a value indicating whether the Jellyfin profile image is updated + /// from the picture claim on every OIDC login. + /// + public bool SyncProfilePicture { get; set; } = true; + /// /// 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. diff --git a/Jellyfin.Plugin.OidcAuth/Configuration/configPage.html b/Jellyfin.Plugin.OidcAuth/Configuration/configPage.html index 39445c0..b89d734 100644 --- a/Jellyfin.Plugin.OidcAuth/Configuration/configPage.html +++ b/Jellyfin.Plugin.OidcAuth/Configuration/configPage.html @@ -41,6 +41,11 @@
Claim containing groups/roles. Default: groups.
+
+ +
Claim holding a URL to the user's avatar. Default: picture.
+
+
Comma separated. Only users with one of these roles may log in. Empty = everyone.
@@ -58,6 +63,13 @@
+
+ +
+