- C# 64.7%
- HTML 18.9%
- Shell 16.1%
- Dockerfile 0.3%
|
All checks were successful
CI / verify (push) Successful in 59s
Reviewed-on: #21 |
||
|---|---|---|
| .gitea/workflows | ||
| scripts | ||
| src/Jellyfin.Plugin.Sso | ||
| tests/Jellyfin.Plugin.Sso.Tests | ||
| .gitignore | ||
| build.yaml | ||
| Containerfile | ||
| Directory.Build.props | ||
| Jellyfin.Plugin.Sso.sln | ||
| manifest.json | ||
| README.md | ||
| renovate.json | ||
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
- 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. - 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 unknownkid),iss,aud, expiry (2 minute skew) andnonce. - 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 injellyfin_credentialsand opens/web/.
Every failure shows the same generic page; details go to the Jellyfin log only.
Install
From the plugin repository (recommended)
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
httptoken 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/loginlists 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/providerslists 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 forcestarget="_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 inlineClientSecretis 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.Modeland 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,targetAbiinbuild.yaml, runscripts/smoke.shagainst the new server, and loosen the rule inrenovate.json.- The SDK stays on 9.0.x because the plugin targets
net9.0. SHELLCHECK_VERSIONinci.ymlis not managed: itsSHELLCHECK_SHA256cannot be recomputed by Renovate, so bump both by hand.- Setup: the workflows need a
RENOVATE_TOKENrepository secret holding the token of a dedicated account with write access to this repository only (repository and issue scopes), not a personal token. An optionalGITHUB_COM_TOKENsecret (a github.com token with no scopes) avoids rate limits when looking up actions.
Cutting a release
- Raise the version in
build.yamlandAssemblyVersionin the plugin.csproj;scripts/package.shrefuses to build when they differ, or when the published DLLs differ frombuild.yamlartifacts. - Merge to
main, tag the merge commit with the bare version, novprefix (git tag -a <x.y.z> <commit>), and push the tag. .gitea/workflows/release.ymltakes over (it uses the sameRENOVATE_TOKENsecret):scripts/release.shrefuses tags that do not matchbuild.yamlor are not onmain, builds the zip, creates the release with the zip andSHA256SUMS, and opens a pull request adding the version tomanifest.jsonusing 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.