Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 45dc748a84 | |||
| b5a0bd79a3 | |||
| fda59741b5 | |||
| 55c94f7d84 | |||
|
|
ceeef92150 |
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
41
README.md
41
README.md
@@ -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
|
||||
|
||||
@@ -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
BIN
dist/oidc-auth_1.0.4.0.zip
vendored
Normal file
Binary file not shown.
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user