jellyfin.md (9433B)
1 # Jellyfin 2 3 We document the setup of [Jellyfin](https://jellyfin.org) running at [jellyfin.piribauer.ch](https://jellyfin.piribauer.ch), our media server for films. 4 5 Authentication is delegated to Authentik, but unlike the other apps on the hub, Jellyfin does not support Authentik's forward-auth proxy flow — it needs its own login form. 6 The official `jellyfin-plugin-sso` (OIDC) plugin is archived and unmaintained, so we follow Authentik's documented alternative instead: an **LDAP outpost** that exposes Authentik's user database over LDAP, paired with Jellyfin's own LDAP Authentication plugin. 7 Jellyfin's login page stays exactly as it is; the plugin validates the entered username/password against the LDAP outpost in the background. There is no redirect to Authentik's login screen. 8 9 We assume Docker, nginx, and Authentik are already running. 10 11 ## Storage 12 13 Media lives on a dedicated external HDD, mounted at `/mnt/media`, kept separate from the restic-backed `/home/julian` tree so the (large, replaceable) media library is never included in backups. 14 15 ```console 16 # mkfs.ext4 /dev/sda1 17 # mkdir -p /mnt/media 18 # mount /dev/sda1 /mnt/media 19 ``` 20 21 An entry in `/etc/fstab` makes the mount persist across reboots. 22 23 ## Docker Compose 24 25 `/home/julian/jellyfin/docker-compose.yml`: 26 27 ```yaml 28 services: 29 jellyfin: 30 image: docker.io/jellyfin/jellyfin:latest 31 container_name: jellyfin 32 restart: unless-stopped 33 user: "1000:1000" 34 volumes: 35 - ./config:/config 36 - ./cache:/cache 37 - /mnt/media:/media:ro 38 ports: 39 - "127.0.0.1:8096:8096" 40 networks: 41 default: {} 42 authentik_default: {} 43 44 networks: 45 authentik_default: 46 external: true 47 ``` 48 49 Media is mounted read-only at the container path `/media` — not `/mnt/media`, which only exists on the host. 50 Jellyfin is additionally attached to Authentik's `authentik_default` docker network so it can reach the LDAP outpost by its container name, without exposing anything extra on the host. 51 52 ```console 53 $ cd /home/julian/jellyfin 54 $ docker compose up -d 55 ``` 56 57 ## Nginx 58 59 ```nginx 60 server { 61 server_name jellyfin.piribauer.ch; 62 63 location / { 64 proxy_pass http://127.0.0.1:8096; 65 proxy_http_version 1.1; 66 proxy_set_header Upgrade $http_upgrade; 67 proxy_set_header Connection "upgrade"; 68 proxy_set_header Host $host; 69 proxy_set_header X-Real-IP $remote_addr; 70 proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 71 proxy_set_header X-Forwarded-Proto $scheme; 72 proxy_buffering off; 73 } 74 75 listen 443 ssl; 76 ssl_certificate /etc/letsencrypt/live/piribauer.ch/fullchain.pem; 77 ssl_certificate_key /etc/letsencrypt/live/piribauer.ch/privkey.pem; 78 include /etc/letsencrypt/options-ssl-nginx.conf; 79 ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; 80 } 81 ``` 82 83 **Certbot warning:** `piribauer.ch` uses a single certificate shared across every subdomain (one SAN list). Adding a new site with 84 85 ```console 86 # certbot --nginx -d jellyfin.piribauer.ch --cert-name piribauer.ch --expand 87 ``` 88 89 only works safely if **every** existing domain is also passed with `-d`. Omitting any of them causes certbot to *replace* the certificate rather than expand it, silently dropping the missing domains from the SAN list and breaking TLS for those subdomains — with no error at request time until a client actually notices the hostname mismatch. Always re-run with the full `-d` list for all subdomains (`piribauer.ch`, `www.piribauer.ch`, `auth`, `mail`, `www.mail`, `git`, `hub`, `calendar`, `familytree`, `technotes`, `jellyfin`) when expanding. 90 91 ## First run 92 93 On first start we complete Jellyfin's setup wizard at `https://jellyfin.piribauer.ch`, which creates an initial local admin account, and add a library: 94 95 - Content type: Movies 96 - Folder: `/media` (the container path, not `/mnt/media`) 97 98 ## Authentik — LDAP outpost 99 100 Authentik's LDAP outpost runs as an extra service in the existing Authentik compose stack, `/home/julian/authentik/docker-compose.yml`: 101 102 ```yaml 103 ldap-outpost: 104 image: ghcr.io/goauthentik/ldap:2024.12.3 105 container_name: authentik-ldap-outpost 106 restart: unless-stopped 107 environment: 108 AUTHENTIK_HOST: http://server:9000 109 AUTHENTIK_TOKEN: ${LDAP_OUTPOST_TOKEN} 110 depends_on: 111 server: 112 condition: service_healthy 113 ``` 114 115 `AUTHENTIK_HOST` uses the Compose service name `server`, resolved via Docker's internal DNS — not the container's display name. 116 `LDAP_OUTPOST_TOKEN` is the outpost's token, generated when the outpost is created in the Authentik admin UI (**Applications → Outposts**) and stored in `/home/julian/authentik/.env`. 117 118 We configure, in the Authentik admin UI: 119 120 - A service account (e.g. `jellyfin_ldap`) used only to bind and search — never to log in as a person. 121 - Two groups: `jellyfin_users` (who can log into Jellyfin) and `jellyfin_admins` (reserved for future automatic admin grants, currently unused — see below). 122 - An **LDAP Provider** with `base_dn: dc=piribauer,dc=ch` and bind mode `direct`. 123 - A dedicated **authorization flow** for the provider, rather than reusing the default one. The stock `default-provider-authorization-implicit-consent` flow requires an authenticated Django session (`authentication: require_authenticated`), which an LDAP direct bind never has — using it throws `FlowNonApplicableException`. The custom flow needs `authentication: require_outpost` and at least two stages, an **Identification Stage** followed by a **User Login Stage** (a flow with zero stages throws `EmptyFlowException`, and a login stage with nothing preceding it never gets a `pending_user` and denies access). 124 - An **Application** bound to the provider. We leave it *without* any policy bindings — LDAP direct-bind requests run as `AnonymousUser`, and evaluating user/group policy bindings against `AnonymousUser` crashes the outpost. Access control instead comes from the LDAP search filter on the Jellyfin side (see below). 125 - The **Outpost** itself, with the LDAP provider assigned to it. 126 127 ## Jellyfin — LDAP Authentication plugin 128 129 Installed from Jellyfin's plugin catalog (**Dashboard → Plugins → Catalog → LDAP Authentication**), then configured under **Dashboard → Plugins → LDAP Authentication**: 130 131 - LDAP server: `authentik-ldap-outpost`, port `389`, no encryption (traffic stays inside the docker network) 132 - Bind DN: `cn=jellyfin_ldap,ou=service-accounts,ou=goauthentik.io,dc=piribauer,dc=ch` 133 134 Service accounts live under `ou=service-accounts,ou=goauthentik.io`, not `ou=users` like regular people — the exact path is visible on the user's object via the Authentik API (`path` field). 135 - Base DN: `dc=piribauer,dc=ch` 136 - User search filter: restricts to members of `jellyfin_users`, e.g. `(&(memberOf=cn=jellyfin_users,ou=groups,dc=piribauer,dc=ch)(|(uid={username})(cn={username})))` 137 - Admin filter: references `jellyfin_admins` (inert until someone is added to that group) 138 139 ## Pointing an existing local account at LDAP 140 141 If a local Jellyfin account already exists (e.g. created by the setup wizard) and should authenticate via LDAP instead of its local password, there's no need to delete and recreate it — just repoint its auth provider: 142 143 ```console 144 $ curl -H "Authorization: MediaBrowser Token=<api-key>" \ 145 http://127.0.0.1:8096/Users/<user-id> 146 ``` 147 148 Take the returned `Policy` object, set `AuthenticationProviderId` to `Jellyfin.Plugin.LDAP_Auth.LdapAuthenticationProviderPlugin`, and POST it back to `/Users/<user-id>/Policy`. 149 150 ## Known issue: username casing 151 152 If the local Jellyfin account's username differs from the LDAP username only in case (e.g. local `Julian` vs. LDAP `julian`), login fails with an HTTP 400 and the server log shows: 153 154 ``` 155 System.ArgumentException: The new and old names must be different. 156 at Jellyfin.Server.Implementations.Users.UserManager.RenameUser(...) 157 at Jellyfin.Plugin.LDAP_Auth.LdapAuthenticationProviderPlugin.Authenticate(...) 158 ``` 159 160 The LDAP plugin syncs the local username to match LDAP's casing on every login, but Jellyfin's rename check treats the two names as identical (case-insensitively) and refuses the "no-op" rename — even though the case actually differs. This affects a direct API/UI rename attempt just as much as the plugin's own sync. 161 162 The fix is a two-step rename through an intermediate name, so no step is a case-only change: 163 164 ```console 165 $ curl -X POST -H "Authorization: MediaBrowser Token=<api-key>" -H "Content-Type: application/json" \ 166 -d '{"Name": "julian_tmp", ...}' http://127.0.0.1:8096/Users/<user-id> 167 $ curl -X POST -H "Authorization: MediaBrowser Token=<api-key>" -H "Content-Type: application/json" \ 168 -d '{"Name": "julian", ...}' http://127.0.0.1:8096/Users/<user-id> 169 ``` 170 171 (each `...` is the full user object as returned by `GET /Users/<user-id>`, with only `Name` changed). Once the local username exactly matches LDAP's casing, the plugin has nothing left to rename and login proceeds normally. 172 173 ## Granting admin rights 174 175 `jellyfin_admins` is wired into the LDAP plugin's admin filter but currently has no members, so admin rights aren't granted automatically. Until that group is populated, admin rights are set directly: 176 177 ```console 178 $ curl -X POST -H "Authorization: MediaBrowser Token=<api-key>" -H "Content-Type: application/json" \ 179 -d '{"IsAdministrator": true, ...}' http://127.0.0.1:8096/Users/<user-id>/Policy 180 ```