docs(jellyfin,authentik): the LDAP admin filter is inert; the auth door lives in Users #143
No files matched your search
@@ -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
|
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.
|
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
|
Jellyfin has no native external-auth, so it authenticates users through the
|
||||||
**LDAP-Auth plugin** (`jellyfin-plugin-ldapauth`) against an [[Authentik]] LDAP
|
**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.
|
publishing `389`/`636` on the Dankclaw host for cross-host binds.
|
||||||
- Service account `svc-jellyfin-ldap` (DN
|
- Service account `svc-jellyfin-ldap` (DN
|
||||||
`cn=svc-jellyfin-ldap,ou=users,dc=franksdank,dc=cloud`).
|
`cn=svc-jellyfin-ldap,ou=users,dc=franksdank,dc=cloud`).
|
||||||
- Access groups `jellyfin_users` (search filter
|
- Access group `jellyfin_users`: the plugin's search filter is
|
||||||
`(memberOf=cn=jellyfin_users,ou=groups,dc=franksdank,dc=cloud)`) and
|
`(memberOf=cn=jellyfin_users,ou=groups,dc=franksdank,dc=cloud)`, so a user
|
||||||
`jellyfin_admins` (admin filter `(cn=jellyfin_admins)`). Add a user to
|
who is not in that group is **invisible** to the plugin and the login fails
|
||||||
`jellyfin_users` to grant them Jellyfin access; `jellyfin_admins` grants
|
as "Wrong username or password". Add members with
|
||||||
the Jellyfin admin role.
|
`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):
|
**Jellyfin side** (plugin config, set via admin → Plugins → LDAP-Auth):
|
||||||
- Server `192.168.0.143:389` (plain LDAP over the trusted LAN), Base DN
|
- 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
|
`dc=franksdank,dc=cloud`, bind user = the service-account DN, search
|
||||||
attributes `uid,cn,mail,displayName`, uid attr `uid`, username attr `cn`
|
attributes `uid,cn,mail,displayName`, uid attr `uid`, username attr `cn`
|
||||||
`CreateUsersFromLdap=true`, all folders enabled.
|
`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
|
**Gotcha — the bind credential is a PASSWORD, not a token.** The LDAP bind is
|
||||||
verified by authentik's default authentication flow (standard-password
|
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
|
service-account password is stored only in the Jellyfin plugin config (the
|
||||||
`LdapBindPassword` field) — it is never committed to this repo.
|
`LdapBindPassword` field) — it is never committed to this repo.
|
||||||
|
|
||||||
**Verification (2026-09-25):** `ldapsearch` bind + the `jellyfin_users`
|
**Verification (re-taken 2026-11-04, from Dankserver):** the container reaches
|
||||||
search both succeed from Dankclaw; a real user (`alex`, in `jellyfin_users`)
|
`192.168.0.143:389`; the service-account bind plus the `jellyfin_users` search
|
||||||
logged into the web UI with their authentik password and the plugin created
|
return every member; LDAP-Auth 23.0.0.0 loads on Jellyfin 12.4; the `alex` row
|
||||||
the matching Jellyfin user.
|
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
|
**Gotcha — the login endpoint changed in Jellyfin 12.1.** The legacy
|
||||||
`POST /Users/Auth/Password` route (and its `LegacyEmbyRouteRewriteMiddleware`)
|
`POST /Users/Auth/Password` route (and its `LegacyEmbyRouteRewriteMiddleware`)
|
||||||
was removed; probing it now returns a silent 401 with no plugin log, which
|
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 |
|
| Object | Value |
|
||||||
|---|---|
|
|---|---|
|
||||||
| LDAP provider | `Jellyfin LDAP` (pk 11), base DN `dc=franksdank,dc=cloud`, `search_mode=direct`, `bind_mode=direct` |
|
| 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)* |
|
| 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 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)* |
|
| 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 — an earlier revision recorded `intent=verification`; the live value is `app_password` |
|
| 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`) |
|
| 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`) |
|
| 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) |
|
| 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
|
- **Token intent is moot for Jellyfin.** The earlier argument (a bind token with
|
||||||
revision asserted that a bind token with `intent=app_password` is rejected by
|
`intent=app_password` is rejected by the flow's password stage, only
|
||||||
the flow's password stage with `Invalid password` and only
|
`intent=verification` passes) never applied here: LDAP-Auth sends the service
|
||||||
`intent=verification` passes. The live token for `svc-jellyfin-ldap` is
|
account's **password** from `LdapBindPassword`, not a token. A live bind plus a
|
||||||
`app_password` (`authentik_core_token`, 2026-10-18), so either the rule is
|
`memberOf=cn=jellyfin_users` search (11 entries) succeeded from Dankserver on
|
||||||
wrong or the current Jellyfin bind path is not that token. Nothing observable
|
2026-11-04. Leave the token row alone.
|
||||||
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.
|
|
||||||
- **Search needs `search_full_directory`.** Without the RBAC grant the
|
- **Search needs `search_full_directory`.** Without the RBAC grant the
|
||||||
outpost returns only the bind user itself — a `(memberOf=…)` filter then
|
outpost returns only the bind user itself — a `(memberOf=…)` filter then
|
||||||
matches zero entries and the plugin logs `Found no users matching X`.
|
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
|
The permission is object-level on the provider; grant it via an RBAC role
|
||||||
and retest.
|
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:
|
- `ak shell` import path for the provider model in 2026.8:
|
||||||
`from authentik.providers.ldap.models import LDAPProvider`.
|
`from authentik.providers.ldap.models import LDAPProvider` (verified inside a
|
||||||
- (unverified 2026-10-18: importing it outside a configured Django shell fails
|
live `ak shell`, 2026-11-04 — it only fails outside a configured Django shell).
|
||||||
with `ImproperlyConfigured`, and no `ak shell` was driven against the live
|
- **`ak shell` field names, 2026.8:** `Event` is keyed by `created` and `action`
|
||||||
auth service for this check)*
|
— 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)
|
## 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`
|
The second outpost — `dankclaw/authentik/quadlet/authentik-outpost-ldap.container`
|
||||||
→ `authentik-outpost-ldap.service`, one of the five units in this stack —
|
→ `authentik-outpost-ldap.service`, one of the five units in this stack —
|
||||||
serves LDAP for Jellyfin's LDAP-Auth plugin (`enabled`=generated, `active`
|
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
|
`Restart=always`, container up, re-checked 2026-11-04). **The image pin has one
|
||||||
single-platform digest**
|
owner: the `Image=` line of
|
||||||
`ghcr.io/goauthentik/ldap@sha256:2e7b259eb8c37faac916946ddf48ec681ee9d3a9f724fe8521bf161973f13cf9`
|
`dankclaw/authentik/quadlet/authentik-outpost-ldap.container`** — never restate
|
||||||
(`dankclaw/authentik/quadlet/authentik-outpost-ldap.container:26`, matching the
|
the digest here, because every pin bump turns this page into a lie (the digest
|
||||||
running image in `podman ps`, 2026-10-18). The digest in this page before 2026-10-18
|
cited here went stale twice: `…fa33bc8f…`, then `…2e7b259e…`). The reason a
|
||||||
(`…fa33bc8f…`) is superseded. The reason for pinning a digest at all: podman
|
digest is pinned at all: podman 5.7.0 refuses the plain tag because it resolves
|
||||||
5.7.0 refuses the plain tag because it resolves to a *nested* manifest list
|
to a *nested* manifest list (index → per-platform manifest lists) —
|
||||||
(index → per-platform manifest lists) — `unexpectedly received a manifest list
|
`unexpectedly received a manifest list instead of a manifest for a single image`
|
||||||
instead of a manifest for a single image` — first hit on `:2026.8.0`, still the
|
— first hit on `:2026.8.0` and still the rule. Resolve once with
|
||||||
rule at the current `2026.8.3` pin. Resolve once with `podman manifest inspect
|
`podman manifest inspect <image> | jq -r '.manifests[] |
|
||||||
<image> | jq -r '.manifests[] | select(.platform.architecture=="amd64") |
|
select(.platform.architecture=="amd64") | .digest'` and pin that digest.
|
||||||
.digest'` and pin that digest.
|
|
||||||
|
|
||||||
- Ports **389** (ldap) and **636** (ldaps) publish on the LAN
|
- Ports **389** (ldap) and **636** (ldaps) publish on the LAN
|
||||||
(`PublishPort=389:3389`, `636:6636`
|
(`PublishPort=389:3389`, `636:6636`
|
||||||
|
|||||||
Reference in new issue
Block a user