docs: Jellyfin LDAP-Auth integration (outpost + plugin) and authentik LDAP rules

This commit is contained in:
Frank
2026-09-01 15:43:04 -07:00
parent f4ef2b81ed
commit 6a67a9067f
3 changed files with 131 additions and 4 deletions
+43 -4
View File
@@ -101,11 +101,50 @@ cgroup regardless of `GroupAdd=keep-groups`.
## Authentication
- **Built-in auth (default):** Per-user libraries, parental controls, max bitrate limits. Manage via Dashboard → Users.
- **Caddy basic auth:** Single login layer. Does not integrate with per-user Jellyfin permissions.
- **LDAP/OIDC:** Available via plugins (LDAP-Auth, Jellyfin SSO). Integrates with Authentik, Keycloak, etc.
- **Built-in auth:** per-user libraries, parental controls, max bitrate
limits. The local `dank` admin account is built-in and its password is
Frank's — never recorded in this repo (the bootstrap-era value no longer
authenticates on the live database).
- **LDAP via Authentik (live 2026-09-01):** the LDAP-Auth plugin
authenticates users against the authentik LDAP outpost on Dankclaw — see
[[Authentik]] for the outpost unit, provider, and RBAC objects.
For Dankserver, built-in auth is recommended. Use Caddy basic auth on top for external access.
### LDAP-Auth plugin (v23.0.0.0, live 2026-09-01)
- **Install from the GitHub releases zip** — `repo.jellyfin.org`'s stable
manifest path 404s here, so fetch
`jellyfin/jellyfin-plugin-ldapauth` release asset
`ldap-authentication_23.0.0.0.zip` from GitHub and unzip into
`config/plugins/LDAP-Auth_23.0.0.0/`. The config lives at
`config/plugins/configurations/LDAP-Auth.xml` — write it directly, then
restart the service.
- **Live config:** server `192.168.0.143:389`, base DN
`dc=franksdank,dc=cloud`, bind DN
`cn=svc-jellyfin-ldap,ou=users,dc=franksdank,dc=cloud`, bind password =
the authentik outpost token (secret — the XML lives on the host only,
never in the repo), search filter
`(memberOf=cn=jellyfin_users,ou=groups,dc=franksdank,dc=cloud)`, admin
filter on `cn=jellyfin_admins,ou=groups,…`, `CreateUsersFromLdap=true`
(auto-create on first login).
- **Attribute separator is a comma.** `LdapSearchAttributes` is
`Split(',')` in the plugin source — a semicolon-separated list
(`uid;cn;mail;displayName`) parses as ONE bogus attribute, the search
silently matches nothing, and the log says `Found no users matching X`.
Use `uid,cn,mail,displayName`.
- **Username attribute is `cn`.** Authentik 2026.x maps `uid` to an opaque
per-user hash, not the username, so `uid=<name>` never matches. Set
`LdapUsernameAttribute=cn` (authentik exposes the username as `cn`).
- **Verify with a throwaway user:** create a test member of
`jellyfin_users` in authentik, then
`POST /Users/AuthenticateByName` with headers `Content-Type:
application/json` **and** `X-Emby-Authorization: MediaBrowser
Client="…", Device="…", DeviceId="…", Version="10.11.0"` — both are
required in 10.11 (missing `X-Emby-Authorization` → 400). 200 + non-admin
policy = LDAP path works. Delete the test user from authentik **and**
Jellyfin afterward.
- **Plugin load lines only show in `journalctl --user -u jellyfin.service`**
(`Loaded plugin: LDAP-Auth 23.0.0.0`) — `podman logs` does not carry the
loader lines in this image.
## Metadata Scraping
@@ -11,6 +11,8 @@ Pi-hole) are not wrapped — they handle their own session.
- **Name:** `auth.franksdank.cloud`
- **Stack:** 3 compose services — `authentik-postgres`, `authentik-server`,
`authentik-worker` (see [Pi-hole](Pi-hole.md) for the LAN-DNS pattern)
- **LDAP outpost:** a 4th, quadlet-deployed unit (`authentik-outpost-ldap`)
serves LDAP for Jellyfin — see the section below.
## Why Authentik
@@ -155,6 +157,51 @@ implementation diverges from upstream in ways that bit us; all verified live:
`grafana-profile-scope`, Python-syntax expressions) and will start emitting
claims the moment the evaluator bug is fixed — no config change needed then.
## LDAP outpost (Jellyfin auth, live 2026-09-01)
A fourth unit, `dankclaw/authentik/quadlet/authentik-outpost-ldap.container`
→ `authentik-outpost-ldap.service`, serves LDAP for Jellyfin's LDAP-Auth
plugin. The image is pinned by its **amd64 single-platform digest**
(`ghcr.io/goauthentik/ldap@sha256:fa33bc8f522113830e1662f4c034352f9eb08e0a7d3da2ddadad9a4b33b4cbf4`):
podman 5.7.0 refuses the
`ghcr.io/goauthentik/ldap:2026.8.0` tag because the tag resolves to a
*nested* manifest list (index → per-platform manifest lists) —
`unexpectedly received a manifest list instead of a manifest for a single
image`. Resolve once with `podman manifest inspect <image> | jq -r
'.manifests[] | select(.platform.architecture=="amd64") | .digest'` and pin
that digest.
- Ports **389** (ldap) and **636** (ldaps) publish on the LAN; Jellyfin on
Dankserver binds `192.168.0.143:389`. The outpost token travels in
`AUTHENTIK_LDAP_OUTPOST_TOKEN` from the host `.env` (git-ignored).
### Authentik-side objects (provisioned via `ak shell` ORM)
| Object | Value |
|---|---|
| LDAP provider | `Jellyfin LDAP` (pk 11), base DN `dc=franksdank,dc=cloud` |
| Bind DN | `cn=svc-jellyfin-ldap,ou=users,dc=franksdank,dc=cloud` |
| Service account | `svc-jellyfin-ldap` — passwordless/unusable; authenticates by token |
| Bind token | intent **`verification`**, non-expiring — its key is the LDAP bind password |
| RBAC role | `svc-jellyfin-ldap-search` carrying object-level permission `authentik_providers_ldap.search_full_directory` on the provider |
| Groups | `jellyfin_users`, `jellyfin_admins` |
| Application | `jellyfin` (slug `jellyfin`) |
Hard-won rules, each verified live:
- **Token intent is load-bearing.** A bind token with `intent=app_password`
is rejected by the flow's password stage with `Invalid password`;
`intent=verification` passes. The outpost's direct binder executes the
provider's authorization flow, and that password stage only accepts
verification-intent tokens as static passwords.
- **Search needs `search_full_directory`.** Without the RBAC grant the
outpost returns only the bind user itself — a `(memberOf=…)` filter then
matches zero entries and the plugin logs `Found no users matching X`.
The permission is object-level on the provider; grant it via an RBAC role
and retest.
- `ak shell` import path for the provider model in 2026.8:
`from authentik.providers.ldap.models import LDAPProvider`.
## Configuration (secrets)
Two secrets live in the host `.env` (git-ignored; placeholders in
@@ -205,5 +252,6 @@ Two secrets live in the host `.env` (git-ignored; placeholders in
- [Caddy](Caddy.md) — the reverse proxy carrying the `:8016`/`:8017` auth-chain listeners
- [Pi-hole](Pi-hole.md) — the LAN DNS that resolves `auth.franksdank.cloud`
- [Jellyfin](../Dankserver/Jellyfin.md) — consumes the LDAP outpost via its LDAP-Auth plugin
- [Network Architecture](../Architecture/Network%20Architecture.md) — the flat naming scheme
- Research: `.docaudit/research/auth.md` — the full provider comparison
+40
View File
@@ -11,6 +11,46 @@ timestamp: 2026-09-14
# Bundle Change Log
## 2026-09-01 — Jellyfin authentication moved to Authentik LDAP
- `/dankclaw/authentik/quadlet/authentik-outpost-ldap.container` (`4ad85fa`,
digest-pinned in `f4ef2b8`) — new `authentik-outpost-ldap.service` on
Dankclaw serving LDAP :389/:636 for Jellyfin. Image
`ghcr.io/goauthentik/ldap` pinned by **amd64 single-platform digest**:
podman 5.7.0 refuses the tag because it resolves to a nested manifest
list ("unexpectedly received a manifest list instead of a manifest for a
single image") — resolve via `podman manifest inspect | jq` and pin.
- Authentik objects provisioned via `ak shell` ORM (live, idempotent
get_or_create): LDAP provider `Jellyfin LDAP`, service account
`svc-jellyfin-ldap` + bind token, groups `jellyfin_users` /
`jellyfin_admins`, application `jellyfin`.
- `/scripts/deploy-remote.sh` (`691da47`) — `authentik-outpost-ldap` added
to `service_is_allowed` (the deploy runner has its own allowlist beyond
deploy-plan.py's).
- Two authentik-side rules proven by live bind tests (`ldapsearch` through
the published port):
1. Bind token **intent must be `verification`** — `app_password` tokens
are rejected by the authorization flow's password stage with
`Invalid password`.
2. Search requires the **`search_full_directory`** object permission on
the provider (granted via RBAC role `svc-jellyfin-ldap-search`);
without it the outpost returns only the bind user, so `memberOf`
filters match zero entries.
- Jellyfin side (host-only, no repo change): LDAP-Auth **23.0.0.0**
installed from the GitHub releases zip (the `repo.jellyfin.org` stable
manifest path 404s), config XML at
`config/plugins/configurations/LDAP-Auth.xml`, username attribute `cn`
(authentik 2026.x maps `uid` to an opaque hash), and — the big one —
**`LdapSearchAttributes` is comma-separated** (`Split(',')` in the plugin
source; semicolons silently produce "Found no users matching X").
End-to-end verified with a throwaway `jellyfin_users` member
(`AuthenticateByName` 200, non-admin policy), then removed from both
authentik and the Jellyfin DB directly (its admin-token cleanup 401'd —
the local `dank` admin password on the live DB is Frank's, not the
bootstrap-era value; notated in the Jellyfin doc).
- Docs: [[Authentik]] gained the LDAP-outpost section (objects table +
rules), [[Jellyfin]] gained the LDAP-Auth plugin section. Docs-only
commit → deploy no-op.
## 2026-09-01 — frank-default-small: two compressed-tensors FP8 swaps fail to load; Huihui restored
- `/scripts/frank-default-small.env`, `/scripts/serve-frank-small.sh`,
`/scripts/frank-default-small.service` (`af6cd72` → `34ab6b4` → reverted