5 Commits

Author SHA1 Message Date
45dc748a84 docs: match the login button to the theme's button classes
Use the same classes the login page puts on its own buttons so the SSO link
picks up the theme styling, and note that copying a real button's markup does
not work: a button cannot navigate without an href and DOMPurify removes
onclick. Warn against copying btnQuick, which is the Quick Connect binding
hook rather than styling.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-29 16:01:32 +02:00
b5a0bd79a3 docs: fix the login button snippet
The documented snippet relied on is="emby-linkbutton", but Jellyfin renders the
login disclaimer through markdown-it and then DOMPurify, which strips the is
attribute. The anchor therefore never became a button component and rendered as
a plain link.

Drop the attribute, keep the classes so the theme styles the button, and
document the Custom CSS the disclaimer needs: .disclaimerContainer is not a
block by default, so a "block" button cannot stretch to the width of the other
login buttons, and the anchor colour rule outranks .emby-button.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-29 15:48:30 +02:00
fda59741b5 feat: sync the profile picture from the OIDC provider
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>
2026-07-29 15:41:51 +02:00
55c94f7d84 fix: read the changelog from the annotated tag, not the commit
All checks were successful
Build Plugin / build (push) Successful in 1m1s
actions/checkout leaves refs/tags/<tag> as a lightweight ref pointing straight
at the commit, so `git tag -l --format='%(contents)'` fell through to the commit
message. v1.0.4.0 shipped with the entire commit message as its changelog.

Re-fetch the tag ref so the annotated object is present, and only read a message
when the ref really is a tag object. Use subject+body instead of %(contents) so
a signature would not end up in the changelog, and fall back to build.yaml when
the tag is lightweight or its message is empty.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-29 15:29:12 +02:00
gitea-actions
ceeef92150 release: v1.0.4.0 2026-07-29 13:26:22 +00:00
9 changed files with 236 additions and 12 deletions

View File

@@ -33,10 +33,29 @@ jobs:
set -euo pipefail
echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
# Annotated tag message becomes the changelog; build.yaml is the fallback.
# checkout leaves behind a lightweight ref pointing at the commit, and
# %(contents) on one of those yields the commit message. Re-fetch the
# real object so an annotated tag's message is what we read.
git fetch --force origin \
"refs/tags/${GITHUB_REF_NAME}:refs/tags/${GITHUB_REF_NAME}" || true
CHANGELOG=""
if [ "$(git cat-file -t "refs/tags/${GITHUB_REF_NAME}" 2>/dev/null || true)" = "tag" ]; then
# subject+body rather than %(contents) so a signature is left out.
CHANGELOG="$(git for-each-ref \
--format='%(contents:subject)%0a%0a%(contents:body)' \
"refs/tags/${GITHUB_REF_NAME}")"
fi
# Lightweight tag, or an annotated tag with an empty message.
if [ -z "$(printf '%s' "$CHANGELOG" | tr -d '[:space:]')" ]; then
echo "no annotated tag message; falling back to build.yaml"
CHANGELOG="$(python -c "import yaml; print(yaml.safe_load(open('build.yaml')).get('changelog','').strip())")"
fi
{
echo "changelog<<CHANGELOG_EOF"
git tag -l --format='%(contents)' "$GITHUB_REF_NAME"
printf '%s\n' "$CHANGELOG"
echo "CHANGELOG_EOF"
} >> "$GITHUB_OUTPUT"

View File

@@ -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<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>
@@ -35,11 +59,23 @@ public class OidcAuthController : ControllerBase
/// </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, ILogger<OidcAuthController> logger)
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;
}
@@ -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);
}
/// <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

View File

@@ -38,6 +38,17 @@ public class PluginConfiguration : BasePluginConfiguration
/// </summary>
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>
/// 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.

View File

@@ -41,6 +41,11 @@
<div class="fieldDescription">Claim containing groups/roles. Default: groups.</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">
<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>
@@ -58,6 +63,13 @@
</label>
</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">
<label class="emby-checkbox-label">
<input is="emby-checkbox" id="CreateUsersIfMissing" type="checkbox" />
@@ -96,8 +108,8 @@
<script type="text/javascript">
(function () {
var pluginId = '96badb44-9940-4ff0-befc-5f987159152c';
var textFields = ['OidIssuer', 'OidClientId', 'OidClientSecret', 'OidScopes', 'UsernameClaim', 'RoleClaim', 'AllowedRoles', 'AdminRoles'];
var boolFields = ['RevokeAdminWithoutRole', 'CreateUsersIfMissing', 'SetRandomPasswordOnCreate', 'DisableEndpointValidation'];
var textFields = ['OidIssuer', 'OidClientId', 'OidClientSecret', 'OidScopes', 'UsernameClaim', 'RoleClaim', 'PictureClaim', 'AllowedRoles', 'AdminRoles'];
var boolFields = ['RevokeAdminWithoutRole', 'SyncProfilePicture', 'CreateUsersIfMissing', 'SetRandomPasswordOnCreate', 'DisableEndpointValidation'];
document.querySelector('#OidcAuthConfigPage').addEventListener('pageshow', function () {
Dashboard.showLoadingMsg();

View File

@@ -5,9 +5,9 @@
<RootNamespace>Jellyfin.Plugin.OidcAuth</RootNamespace>
<!-- Kept in sync with build.yaml by scripts/build_plugin.py; the release
workflow overrides these with the version from the git tag. -->
<Version>1.0.3.0</Version>
<AssemblyVersion>1.0.3.0</AssemblyVersion>
<FileVersion>1.0.3.0</FileVersion>
<Version>1.0.4.0</Version>
<AssemblyVersion>1.0.4.0</AssemblyVersion>
<FileVersion>1.0.4.0</FileVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>disable</ImplicitUsings>
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>

View File

@@ -89,13 +89,48 @@ Then in Jellyfin: Dashboard → Plugins → OIDC Auth:
## Login button on the web login page
Jellyfin does not let plugins modify the login page. Add a button via
Dashboard → General → Branding → Login disclaimer:
Jellyfin does not let plugins modify the login page. The login disclaimer is the
only injection point, and it needs a little CSS to look like a real button.
Dashboard → General → Branding → **Login disclaimer**:
```html
<a is="emby-linkbutton" class="raised block emby-button" href="/OidcAuth/login">Sign in with SSO</a>
<a href="/OidcAuth/login" class="raised cancel block emby-button"><span>Sign in with SSO</span></a>
```
`cancel` gives the grey look of the Quick Connect button; swap it for
`button-submit` to match the blue Sign In button. Do not copy `btnQuick` off the
Quick Connect button — that class is what the login page binds Quick Connect to,
not styling.
Dashboard → General → Branding → **Custom CSS**:
```css
.disclaimerContainer {
display: block;
}
a.raised.emby-button {
padding: 0.9em 1em;
color: inherit !important;
text-decoration: none;
}
```
Both pieces are needed. Jellyfin renders the disclaimer through markdown-it and
then DOMPurify, which strips the `is="emby-linkbutton"` attribute that would
normally turn the anchor into a button component — so the styling has to come
from the classes instead. `.disclaimerContainer` is not a block by default,
which is what otherwise leaves the link shrink-wrapped and centred instead of
matching the width of the buttons above it.
It has to be an anchor. Copying the markup of a real login button does not work:
`<button>` cannot navigate without an `href`, and DOMPurify removes `onclick`
along with `is`, so the result renders correctly and does nothing when clicked.
Styling via the theme's own classes rather than inline styles keeps the button
correct when the theme changes, since `.raised` is defined per theme.
## TV and native clients
The plugin does not change or disable Jellyfin's normal password login, so TV apps

View File

@@ -1,7 +1,7 @@
---
name: "OIDC Auth"
guid: "96badb44-9940-4ff0-befc-5f987159152c"
version: "1.0.3.0"
version: "1.0.4.0"
targetAbi: "10.11.0.0"
framework: "net9.0"
owner: "pascallinxweiler"

BIN
dist/oidc-auth_1.0.4.0.zip vendored Normal file

Binary file not shown.

View File

@@ -7,6 +7,14 @@
"owner": "pascallinxweiler",
"category": "Authentication",
"versions": [
{
"version": "1.0.4.0",
"changelog": "fix: make the release workflow build and package a real Jellyfin plugin\n\nThe release workflow was copied from another project and could not succeed on\nany tag: it installed the .NET 8 SDK for a net9.0 project, published a\nJellyfinSso.csproj that does not exist, called a missing update_manifest.py,\nand zipped the entire publish tree instead of the three DLLs declared in\nbuild.yaml.\n\nReplace it with a tag-driven release:\n\n- Build with the .NET 9 SDK, injecting the tag version via -p:Version,\n -p:AssemblyVersion and -p:FileVersion so the assembly no longer carries a\n hardcoded 1.0.3.0.\n- Package via scripts/build_plugin.py, which emits the JPRM layout Jellyfin\n expects: the build.yaml artifacts plus a generated meta.json. The previous\n zips shipped without meta.json, which only worked for repository installs\n because Jellyfin synthesises one from the folder name.\n- Update manifest.json via scripts/update_manifest.py, keeping build.yaml as\n the single source of truth for plugin metadata.\n- Commit the zip and manifest to main and attach the zip to the Gitea release.\n Pushing uses the injected token, falling back to a RELEASE_TOKEN secret when\n the built-in one lacks write access.\n\nAlso fix the published download URLs. manifest.json and the README pointed at\npascallinxweiler/jellyfinplugin, which 404s; the repository is jellyfinsso, so\nthe plugin repository could not be added to Jellyfin and no version could be\ndownloaded. The workflow now derives the URL from GITHUB_REPOSITORY.\n\nAdd a build workflow so main and pull requests are compiled and packaged.\n\nCo-Authored-By: Claude <noreply@anthropic.com>",
"targetAbi": "10.11.0.0",
"sourceUrl": "https://git.linxweiler.xyz/pascallinxweiler/jellyfinsso/raw/branch/main/dist/oidc-auth_1.0.4.0.zip",
"checksum": "afcf05fb5d8737273fdc9ca2195853d6",
"timestamp": "2026-07-29T13:26:22Z"
},
{
"version": "1.0.3.0",
"changelog": "Admin role mapping now only grants admin by default. New opt-in setting 'Also revoke admin when the admin role is missing' restores the old revoking behavior.",