From 70be24e9d5126e7b13d3ad009f45bbafcf54fcec Mon Sep 17 00:00:00 2001 From: Frank Date: Wed, 4 Nov 2026 12:25:57 -0800 Subject: [PATCH] docs(jellyfin,authentik): the LDAP admin filter is inert; the auth door lives in Users Diagnosed 'authentik login does not work in Jellyfin' 2026-11-04: nothing was broken. The Jellyfin admin account 'dank' is a LOCAL-provider account, and the operator's authentik identity (akadmin) was not in jellyfin_users, so the plugin search found nothing and Jellyfin fell back to local auth - same UI message, opposite fix. akadmin is now in jellyfin_users (directory data, DB-owned). - Jellyfin: the admin-filter law (DN match, so a group-OU base grants nothing; memberOf shape is the working one), the AuthenticationProviderId diagnostic, re-verified LDAP path (bind + memberOf search from Dankserver). - Authentik Outpost Reference: settle the token-intent argument (the plugin sends a password, never a token), verify the bind DN live, and record the instruments/traps: the outpost journal never shows a bind, Event is keyed by created/action, Outpost has no apps, and piping ak shell steals its heredoc. - Authentik: stop restating the LDAP outpost digest - the unit file owns it. --- serverAppConfigs-docs/Dankserver/Jellyfin.md | 51 +++++++++++++++---- .../Authentik Outpost Reference.md | 48 ++++++++++------- .../Infrastructure/Authentik.md | 23 ++++----- 3 files changed, 81 insertions(+), 41 deletions(-) diff --git a/serverAppConfigs-docs/Dankserver/Jellyfin.md b/serverAppConfigs-docs/Dankserver/Jellyfin.md index 0754b68..2a77bf6 100644 --- a/serverAppConfigs-docs/Dankserver/Jellyfin.md +++ b/serverAppConfigs-docs/Dankserver/Jellyfin.md @@ -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 diff --git a/serverAppConfigs-docs/Infrastructure/Authentik Outpost Reference.md b/serverAppConfigs-docs/Infrastructure/Authentik Outpost Reference.md index 6bbb7fa..9d021ce 100644 --- a/serverAppConfigs-docs/Infrastructure/Authentik Outpost Reference.md +++ b/serverAppConfigs-docs/Infrastructure/Authentik Outpost Reference.md @@ -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) diff --git a/serverAppConfigs-docs/Infrastructure/Authentik.md b/serverAppConfigs-docs/Infrastructure/Authentik.md index 5dea029..019855a 100644 --- a/serverAppConfigs-docs/Infrastructure/Authentik.md +++ b/serverAppConfigs-docs/Infrastructure/Authentik.md @@ -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 - | 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 | 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`