No description
  • C# 64.7%
  • HTML 18.9%
  • Shell 16.1%
  • Dockerfile 0.3%
Find a file
2026-11-02 16:07:09 +00:00
.gitea/workflows chore(deps): update ghcr.io/renovatebot/renovate docker tag to v44.199.2 2026-11-02 16:02:20 +00:00
scripts chore(deps): update ghcr.io/navikt/mock-oauth2-server docker tag to v6.1.4 2026-11-02 04:01:14 +00:00
src/Jellyfin.Plugin.Sso chore(release): bump version to 1.2.1 (#14) 2026-10-17 02:58:51 +00:00
tests/Jellyfin.Plugin.Sso.Tests fix: accept the full discovery URL as the authority (#13) 2026-10-17 02:55:04 +00:00
.gitignore feat: add OIDC SSO plugin (#1) 2026-10-16 21:50:38 +00:00
build.yaml chore(release): bump version to 1.2.1 (#14) 2026-10-17 02:58:51 +00:00
Containerfile chore(deps): update mcr.microsoft.com/dotnet/sdk docker tag to v9.0.320 2026-10-26 04:00:50 +00:00
Directory.Build.props feat: add OIDC SSO plugin (#1) 2026-10-16 21:50:38 +00:00
Jellyfin.Plugin.Sso.sln feat: add OIDC SSO plugin (#1) 2026-10-16 21:50:38 +00:00
manifest.json chore(release): add 1.2.1 to the plugin manifest (#15) 2026-10-17 03:05:12 +00:00
README.md fix: accept the full discovery URL as the authority (#13) 2026-10-17 02:55:04 +00:00
renovate.json ci: add Renovate and tag-driven releases (#6) 2026-10-16 23:07:23 +00:00

Jellyfin OIDC SSO plugin

Sign in to Jellyfin with any OpenID Connect provider (authorization code flow with PKCE). Targets Jellyfin 10.11.x (net9.0, Jellyfin.Controller 10.11.11).

How it works

  1. A login button sends the browser to GET /sso/oidc/{provider}/start. The plugin creates a single-use state, nonce and PKCE verifier (10 minute lifetime), binds the state to the browser with a cookie and redirects to the provider.
  2. The provider redirects to GET /sso/oidc/{provider}/callback. The plugin checks the state and cookie, exchanges the code (PKCE, plus the client secret when one is configured), and validates the id_token: signature (keys from the provider's JWKS, refreshed once on an unknown kid), iss, aud, expiry (2 minute skew) and nonce.
  3. The plugin finds or creates the Jellyfin user and answers with a small page holding a one-time login code (60 second lifetime). The page posts it to POST /sso/oidc/{provider}/auth, receives a Jellyfin session, stores it in jellyfin_credentials and opens /web/.

Every failure shows the same generic page; details go to the Jellyfin log only.

Install

In Jellyfin: Dashboard > Plugins > Repositories > add, with this URL:

https://git.enclaveis.com/Enclave-Information-Systems/jellyfin-sso-plugin/raw/branch/main/manifest.json

Then open Catalog, install "OIDC SSO", and restart Jellyfin. Updates arrive through the catalog. The server must be able to reach git.enclaveis.com, and the repository must stay publicly readable. Jellyfin checks the download against the manifest's MD5, which catches corrupt downloads but not tampering.

Manual

Download jellyfin-plugin-sso_<version>.zip from the repository's Releases page, verify it against SHA256SUMS, and extract it into <jellyfin config>/plugins/SSO_<version>/. Restart Jellyfin.

Identity provider setup

  • Client type: confidential, authorization code flow, PKCE S256.
  • Redirect URI: <PublicBaseUrl>/sso/oidc/<Name>/callback (exact match).
  • Scopes: openid profile, plus whatever supplies your username and role claims.
  • Signing key: the provider must sign id_tokens with an asymmetric key (RS256, ES256, ...) and publish it at its jwks_uri. Tokens signed with the client secret (HS256) are rejected. Authentik signs with HS256 and publishes an empty key set until you select a Signing Key on the provider (Provider > Advanced protocol settings > Signing Key); Test provider reports this case.

Configuration

Dashboard > Plugins > OIDC SSO. Each provider is a card with labelled fields and help text; the table below explains every field.

  • The page shows the redirect URI to register at your identity provider (updated as you type, with a copy button) and validates the input before saving.
  • Test provider on each card fetches the provider's discovery document and checks the client secret setup, without signing anyone in. It reports the issuer, endpoints and signing keys, and flags problems such as an unreachable authority, no signing keys, a missing PKCE S256, or an http token endpoint.
  • Configurations saved by earlier versions open in the form unchanged, and saving keeps user links.
  • A stored client secret is never displayed: leave the field empty to keep it, type to replace it, or tick Remove the stored client secret. Roles are entered comma separated.
Field Meaning
Name Used in URLs. Matched exactly.
Enabled Provider is usable only when true.
Authority Issuer URL, for example https://auth.example.com/application/o/<app-slug>/ for Authentik; discovery is read from <Authority>/.well-known/openid-configuration. Pasting the full discovery URL is accepted too (Test provider notes it). https is required unless the authority itself is configured as http.
ClientId OAuth client id.
ClientSecretFile Path to a file holding the client secret (for example a mounted container or systemd secret). Read on every login, so rotation needs no restart. A trailing newline is ignored. Preferred over ClientSecret.
ClientSecret Client secret stored inline. Plain text in the plugin XML. Setting both this and ClientSecretFile is rejected. Leave both empty for a public client that relies on PKCE alone (the provider must allow it).
Scopes Space separated. Default openid profile.
PublicBaseUrl Externally reachable Jellyfin URL including any base path. The redirect URI is built from this, never from request headers.
UsernameClaim Claim used as Jellyfin username. Default preferred_username.
RoleClaim Claim holding roles/groups (string or array).
AllowedRoles Roles allowed to sign in. Empty allows everyone. Matching is case-sensitive.
AdminRoles Roles that grant administrator. When non-empty the plugin sets the admin flag on every login, so removing the role revokes it. When empty the admin flag is not touched.
AutoCreateUsers Create unknown users on first login. Created users get a random password so the normal login form stays closed to them.
LinkExistingByUsername Let an unlinked login take over an existing local user with the same name. Off by default: anyone who controls a username at the provider could otherwise take over that local account.
EnableAllFolders Library access for created users.

Users are bound to the provider sub claim after the first login; later logins use that link even if the username claim changes.

Signing in

  • Sign-in address: <public address>/sso/oidc/login lists the enabled providers as buttons and, with exactly one enabled provider, goes straight to it. Bookmark it or link to it; the configuration page shows the address ready to copy.
  • Direct address per provider: <public address>/sso/oidc/<Name>/start. GET /sso/oidc/providers lists the enabled names.
  • Link on the Jellyfin login page: paste the snippet from the configuration page, for example <a href="https://jf.example.com/sso/oidc/login">Sign in with SSO</a>, under Dashboard > General > Branding > Login disclaimer. Jellyfin shows it as a link below the sign-in form and opens it in a new tab (it forces target="_blank" on links there, checked on 10.11.11). Scripts in the disclaimer are removed, so the plugin cannot add a button to the form itself.

Security notes

  • Prefer ClientSecretFile: the secret then stays out of the plugin XML. An inline ClientSecret is stored in plain text in the plugin's XML configuration, and Jellyfin's configuration API returns it to administrators (the form never displays it); restrict access to the Jellyfin config directory. Jellyfin administrators can already install plugins, so they can read any file the server can read; treat the admin role accordingly. The Test provider endpoint is administrator-only.
  • State, nonce, PKCE verifier and login codes are single use and held in memory; a restart cancels logins in progress.
  • A disabled Jellyfin user cannot sign in via SSO.

Building

All tooling runs in containers (Containerfile, pinned .NET SDK):

podman build -t jf-sso-dev -f Containerfile .
podman run --rm --userns=keep-id -v "$PWD":/work -v jf-sso-nuget:/nuget -w /work jf-sso-dev \
  dotnet test -warnaserror
podman run --rm --userns=keep-id -v "$PWD":/work -v jf-sso-nuget:/nuget -w /work jf-sso-dev \
  dotnet format --verify-no-changes
scripts/package.sh   # writes artifacts/jellyfin-plugin-sso_<version>.zip and artifacts/SHA256SUMS

Smoke test and upgrading Jellyfin

scripts/smoke.sh builds the plugin and runs it in a throwaway podman pod against a real Jellyfin and a mock OIDC provider (pinned images, overridable with JF_IMAGE, IDP_IMAGE, JF_PORT, IDP_PORT). It exercises sign-in with browser-length device ids, role and admin mapping, replay and CSRF rejection, refusals, secret-file handling, the sign-in page and the administrator-only provider test, and checks that the installed jellyfin-web still contains the jellyfin_credentials keys the callback page writes. The exit status is the number of failed checks. The configuration page itself is not covered by the script or by CI; it was checked by hand in a browser against Jellyfin 10.11.11.

Run it against a new server version before supporting it:

JF_IMAGE=docker.io/jellyfin/jellyfin:<version> scripts/smoke.sh

A failure on a jellyfin-web references ... line means the web client changed its credential storage format and the page rendered by SsoController.CompletionPage must be updated.

Continuous integration

.gitea/workflows/ci.yml runs on every pull request to main and on pushes to main, inside the same pinned SDK image as the Containerfile: dotnet build -warnaserror, dotnet format --verify-no-changes, dotnet test, shellcheck (pinned, checksum-verified) on scripts/*.sh, scripts/package.sh, and a structural check of manifest.json. The smoke test is not part of CI because it needs podman; run it locally before merging changes that touch the login flow or the supported Jellyfin version.

Dependency updates

.gitea/workflows/renovate.yml runs a pinned, self-hosted Renovate every Monday (and on demand from the Actions tab) and opens pull requests for NuGet packages, the .NET SDK image, the Renovate image, GitHub actions and the smoke-test images; renovate.json holds the rules. Pull requests go through the normal CI gate, and nothing is merged automatically.

  • Jellyfin.Controller, Jellyfin.Model and the smoke-test Jellyfin image are tied to the server ABI. They only move within 10.11.x, together. Supporting a new Jellyfin version is a deliberate change: update the packages, targetAbi in build.yaml, run scripts/smoke.sh against the new server, and loosen the rule in renovate.json.
  • The SDK stays on 9.0.x because the plugin targets net9.0.
  • SHELLCHECK_VERSION in ci.yml is not managed: its SHELLCHECK_SHA256 cannot be recomputed by Renovate, so bump both by hand.
  • Setup: the workflows need a RENOVATE_TOKEN repository secret holding the token of a dedicated account with write access to this repository only (repository and issue scopes), not a personal token. An optional GITHUB_COM_TOKEN secret (a github.com token with no scopes) avoids rate limits when looking up actions.

Cutting a release

  1. Raise the version in build.yaml and AssemblyVersion in the plugin .csproj; scripts/package.sh refuses to build when they differ, or when the published DLLs differ from build.yaml artifacts.
  2. Merge to main, tag the merge commit with the bare version, no v prefix (git tag -a <x.y.z> <commit>), and push the tag.
  3. .gitea/workflows/release.yml takes over (it uses the same RENOVATE_TOKEN secret): scripts/release.sh refuses tags that do not match build.yaml or are not on main, builds the zip, creates the release with the zip and SHA256SUMS, and opens a pull request adding the version to manifest.json using the checksum of the attached zip (a rebuild would not be byte-identical). Merge that pull request once CI passes; the catalog only sees the new version after that. Older manifest entries stay so existing installs can still resolve them.

To run the steps by hand, use scripts/package.sh, attach the zip and SHA256SUMS to a release, then run scripts/manifest.sh <x.y.z> <the attached zip> and merge the updated manifest.json through a pull request.