docs(jellyfin,authentik): the LDAP admin filter is inert; the auth door lives in Users #143

Merged
dank merged 1 commits from docs/jellyfin-ldap-sso-laws into main 2026-11-04 12:27:01 -08:00
3 changed files with 81 additions and 41 deletions

No files matched your search

+41 -10
View File
@@ -188,7 +188,7 @@ Client access paths and the public-exposure policy (auth wall required before
any public vhost; Plex-style claim tokens do not exist here — Jellyfin uses its
own users/API keys) follow the fleet [[Authentik]]/[[Anubis]] chain pattern.
## Authentik LDAP authentication (2026-09-25)
## Authentik LDAP authentication (live since 2026-09-25, re-verified 2026-11-04)
Jellyfin has no native external-auth, so it authenticates users through the
**LDAP-Auth plugin** (`jellyfin-plugin-ldapauth`) against an [[Authentik]] LDAP
@@ -204,17 +204,35 @@ user to verify the password.
publishing `389`/`636` on the Dankclaw host for cross-host binds.
- Service account `svc-jellyfin-ldap` (DN
`cn=svc-jellyfin-ldap,ou=users,dc=franksdank,dc=cloud`).
- Access groups `jellyfin_users` (search filter
`(memberOf=cn=jellyfin_users,ou=groups,dc=franksdank,dc=cloud)`) and
`jellyfin_admins` (admin filter `(cn=jellyfin_admins)`). Add a user to
`jellyfin_users` to grant them Jellyfin access; `jellyfin_admins` grants
the Jellyfin admin role.
- Access group `jellyfin_users`: the plugin's search filter is
`(memberOf=cn=jellyfin_users,ou=groups,dc=franksdank,dc=cloud)`, so a user
who is not in that group is **invisible** to the plugin and the login fails
as "Wrong username or password". Add members with
`Group.objects.get(name="jellyfin_users").users.add(user)` in `ak shell`
(directory data — the DB owns it, not this repo).
`jellyfin_admins` is **inert as configured** — see the admin-filter law.
**Jellyfin side** (plugin config, set via admin → Plugins → LDAP-Auth):
- Server `192.168.0.143:389` (plain LDAP over the trusted LAN), Base DN
`dc=franksdank,dc=cloud`, bind user = the service-account DN, search
attributes `uid,cn,mail,displayName`, uid attr `uid`, username attr `cn`
`CreateUsersFromLdap=true`, all folders enabled.
- Admin base `ou=groups,dc=franksdank,dc=cloud`, admin filter
`(cn=jellyfin_admins)`, `EnableLdapAdminFilterMemberUid=false`.
**Law — the admin filter grants nothing unless it can return the user's own
DN.** With `EnableLdapAdminFilterMemberUid=false` the plugin searches
`LdapAdminBaseDn` with `LdapAdminFilter` and sets the admin permission only when
a returned entry's DN equals the bound user's DN
(`LDAP-Auth/LDAPAuthenticationProviderPlugin.cs`). A group-OU base returns the
*group* entry, whose DN never matches, so **no LDAP user can ever become a
Jellyfin admin** — measured 2026-11-04: the search answers
`cn=jellyfin_admins,ou=groups,…` for every login. Working shape if group-granted
admins are ever wanted: base `dc=franksdank,dc=cloud` +
`(memberOf=cn=jellyfin_admins,ou=groups,dc=franksdank,dc=cloud)`. Deliberate
today: the server's own admin account stays **local**, which is the break-glass
door when authentik or the outpost is down. A *malformed* admin filter is not
inert — the plugin throws for every login, so this knob is all-or-nothing.
**Gotcha — the bind credential is a PASSWORD, not a token.** The LDAP bind is
verified by authentik's default authentication flow (standard-password
@@ -224,10 +242,23 @@ password and the bind fails with `flow error password: Invalid`. The
service-account password is stored only in the Jellyfin plugin config (the
`LdapBindPassword` field) — it is never committed to this repo.
**Verification (2026-09-25):** `ldapsearch` bind + the `jellyfin_users`
search both succeed from Dankclaw; a real user (`alex`, in `jellyfin_users`)
logged into the web UI with their authentik password and the plugin created
the matching Jellyfin user.
**Verification (re-taken 2026-11-04, from Dankserver):** the container reaches
`192.168.0.143:389`; the service-account bind plus the `jellyfin_users` search
return every member; LDAP-Auth 23.0.0.0 loads on Jellyfin 12.4; the `alex` row
still carries
`AuthenticationProviderId=Jellyfin.Plugin.LDAP_Auth.LdapAuthenticationProviderPlugin`.
**Law — "the authentik login doesn't work" has two opposite causes; read the
Users table before touching authentik.** Copy `config/data/jellyfin.db` with its
`-wal`/`-shm` and read it read-only (never sqlite the live file):
`Users.AuthenticationProviderId` names the door — `…Users.DefaultAuthentication
Provider` = local password, `…LDAP_Auth.LdapAuthenticationProviderPlugin` =
authentik. Because the LDAP search is scoped to `jellyfin_users`, a name that
exists only as a local account is rejected by the plugin and Jellyfin then
silently falls back to the local provider: same UI message, opposite fix. The
instrument for "did a bind ever reach authentik" is the **event registry**, never
the outpost journal — see [[Authentik Outpost Reference]].
**Gotcha — the login endpoint changed in Jellyfin 12.1.** The legacy
`POST /Users/Auth/Password` route (and its `LegacyEmbyRouteRewriteMiddleware`)
was removed; probing it now returns a silent 401 with no plugin log, which
@@ -148,36 +148,46 @@ Read from the live database (read-only `SELECT`s against the
| Object | Value |
|---|---|
| LDAP provider | `Jellyfin LDAP` (pk 11), base DN `dc=franksdank,dc=cloud`, `search_mode=direct`, `bind_mode=direct` |
| Bind DN | `cn=svc-jellyfin-ldap,ou=users,dc=franksdank,dc=cloud` *(unverified 2026-10-18: base DN confirmed in the DB, the `ou=users` path was not probed — an LDAP bind needs the bind secret)* |
| Service account | `svc-jellyfin-ldap` (user pk 8). **It is not passwordless**: its password field is a real hash, not Django's `!`-prefixed unusable marker (`starts_with(password,'!')` → `f`) — an earlier revision's "passwordless/unusable" claim is wrong, which matters because an account with a usable password can sign in through the ordinary password flow *(unverified 2026-10-18: whether anyone holds that password)* |
| Bind token | exactly one row for pk 8, **`intent=app_password`**, non-expiring — an earlier revision recorded `intent=verification`; the live value is `app_password` |
| Bind DN | `cn=svc-jellyfin-ldap,ou=users,dc=franksdank,dc=cloud` — **verified by a live bind 2026-11-04** (the bind and the `memberOf` search both succeed, so the `ou=users` path is right) |
| Service account | `svc-jellyfin-ldap` (user pk 8). It has a usable password and authenticates through the default flow — that password is held by Jellyfin in the plugin config (`LdapBindPassword`) and is what the bind actually uses (live bind 2026-11-04) |
| Bind token | exactly one row for pk 8, **`intent=app_password`**, non-expiring — and **unused by Jellyfin**, which has no token field (see the rule below) |
| RBAC role | `svc-jellyfin-ldap-search`, carrying exactly one object-level permission `authentik_providers_ldap.search_full_directory` (verified via `guardian_roleobjectpermission`) |
| Groups | `jellyfin_users`, `jellyfin_admins` (both exist in `authentik_core_group`) |
| Application | name `Jellyfin`, **slug `jellyfin-ldap`** (an earlier revision said slug `jellyfin`, which does not exist) |
Hard-won rules (the first is now contested by the live database):
Hard-won rules:
- **Token intent: contested, do not trust either value blindly.** An earlier
revision asserted that a bind token with `intent=app_password` is rejected by
the flow's password stage with `Invalid password` and only
`intent=verification` passes. The live token for `svc-jellyfin-ldap` is
`app_password` (`authentik_core_token`, 2026-10-18), so either the rule is
wrong or the current Jellyfin bind path is not that token. Nothing observable
settles it today: the LDAP outpost's retained log holds only its 5-minute
`initialized direct binder` / `Update providers` config cycles and no bind
activity *(unverified 2026-10-18: no bind was performed — reproducing it
requires the bind secret)*. Re-verify with a real bind before changing the
token's intent either way.
- **Token intent is moot for Jellyfin.** The earlier argument (a bind token with
`intent=app_password` is rejected by the flow's password stage, only
`intent=verification` passes) never applied here: LDAP-Auth sends the service
account's **password** from `LdapBindPassword`, not a token. A live bind plus a
`memberOf=cn=jellyfin_users` search (11 entries) succeeded from Dankserver on
2026-11-04. Leave the token row alone.
- **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.
- **The outpost log never shows a bind.** Its retained journal is only the
5-minute `initialized direct binder` / `Update providers` config cycle; the
absence of a bind line is not evidence of anything. Instruments that do answer:
`ldapsearch` with the plugin's own DN, password and filter, and the authentik
event registry (`login` / `login_failed` with `client_ip` from the podman
gateway, since the published port SNATs).
- `ak shell` import path for the provider model in 2026.8:
`from authentik.providers.ldap.models import LDAPProvider`.
- (unverified 2026-10-18: importing it outside a configured Django shell fails
with `ImproperlyConfigured`, and no `ak shell` was driven against the live
auth service for this check)*
`from authentik.providers.ldap.models import LDAPProvider` (verified inside a
live `ak shell`, 2026-11-04 — it only fails outside a configured Django shell).
- **`ak shell` field names, 2026.8:** `Event` is keyed by `created` and `action`
— there is no `timestamp` and no `type`; `Outpost` has `providers` but **no
`apps`**. Introspect first: `[f.name for f in Model._meta.get_fields()]`.
- **Never pipe `ak shell` inside a heredoc.** `podman exec -i … ak shell | grep
-v '^{'` binds the heredoc to `grep` (the redirection belongs to the LAST
element of the pipeline), so the shell gets no stdin and prints nothing at all.
And `2>/dev/null` on `ak shell` eats the traceback, which looks identical to "no
rows". Clean pattern: have the script write its results to a file and read that
file back —
`podman exec -i authentik-server ak shell >/dev/null 2>/dev/null <<'PY' … open("/tmp/x.txt","w") … PY`
then `podman exec authentik-server cat /tmp/x.txt`.
## Self-serve user-settings flow (password change)
@@ -145,18 +145,17 @@ relying-party quirks are reference lookup: [[Authentik Outpost Reference]].
The second outpost — `dankclaw/authentik/quadlet/authentik-outpost-ldap.container`
→ `authentik-outpost-ldap.service`, one of the five units in this stack —
serves LDAP for Jellyfin's LDAP-Auth plugin (`enabled`=generated, `active`
`Restart=always`, container up, 2026-10-18). The image is pinned by its **amd64
single-platform digest**
`ghcr.io/goauthentik/ldap@sha256:2e7b259eb8c37faac916946ddf48ec681ee9d3a9f724fe8521bf161973f13cf9`
(`dankclaw/authentik/quadlet/authentik-outpost-ldap.container:26`, matching the
running image in `podman ps`, 2026-10-18). The digest in this page before 2026-10-18
(`…fa33bc8f…`) is superseded. The reason for pinning a digest at all: podman
5.7.0 refuses the plain tag because it resolves to a *nested* manifest list
(index → per-platform manifest lists) — `unexpectedly received a manifest list
instead of a manifest for a single image` — first hit on `:2026.8.0`, still the
rule at the current `2026.8.3` pin. Resolve once with `podman manifest inspect
<image> | jq -r '.manifests[] | select(.platform.architecture=="amd64") |
.digest'` and pin that digest.
`Restart=always`, container up, re-checked 2026-11-04). **The image pin has one
owner: the `Image=` line of
`dankclaw/authentik/quadlet/authentik-outpost-ldap.container`** — never restate
the digest here, because every pin bump turns this page into a lie (the digest
cited here went stale twice: `…fa33bc8f…`, then `…2e7b259e…`). The reason a
digest is pinned at all: podman 5.7.0 refuses the plain tag because it resolves
to a *nested* manifest list (index → per-platform manifest lists) —
`unexpectedly received a manifest list instead of a manifest for a single image`
— first hit on `:2026.8.0` and still the rule. 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
(`PublishPort=389:3389`, `636:6636`