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.
16 KiB
type, description, tags, timestamp
| type | description | tags | timestamp | ||
|---|---|---|---|---|---|
| reference | Outpost, proxy-provider, OAuth2, LDAP and flow configuration for the homelab's Authentik SSO. |
|
2026-11-03 |
Authentik Outpost Reference
Configuration lookup for the Authentik deployment: the live proxy-provider inventory behind the guarded vhosts, the provisioning rules the embedded outpost enforces, this fork's OAuth2 relying-party quirks, the LDAP provider objects, the self-serve user-settings flow, and the live database shape. What the service is, how it is deployed, wired and operated is Authentik; the three core containers are Authentik Processes.
Live proxy-provider inventory
Live provider inventory (read from the authentik-postgres container with
read-only SELECTs, 2026-10-18). Nine proxy providers, all attached to outpost
7e39655b-58d3-4fe6-a80a-513f31051217 ("authentik Embedded Outpost", 9
members in authentik_outposts_outpost_providers), all with
internal_host = http://caddy:8017, all carrying the same three flow
UUIDs (default-authentication-flow
default-provider-authorization-implicit-consent
default-provider-invalidation-flow), and each backed by an Application
(the slug column below):
| Provider pk | name | external_host | app slug |
|---|---|---|---|
| 1 | embedded-outpost | https://www.franksdank.cloud |
caddy |
| 2 | search-proxy | https://search.franksdank.cloud |
search |
| 4 | prometheus-proxy | https://prometheus.franksdank.cloud |
prometheus |
| 5 | romm-proxy | https://romm.franksdank.cloud |
romm |
| 6 | files-proxy | https://files.franksdank.cloud |
files |
| 7 | loki-proxy | https://loki.franksdank.cloud/ |
loki |
| 8 | grafana-proxy | https://grafana.franksdank.cloud/ |
grafana-guard |
| 10 | seerr-proxy | https://seerr.franksdank.cloud/ |
seerr-guard |
| 13 | marreta-proxy | https://marreta.franksdank.cloud |
marreta |
That is exactly the Caddyfile's guarded set (dankclaw/Caddyfile:283-423), so
the lockstep rule in Authentik holds today. Two corrections against the 2026-09-02 table:
pk 13 (marreta-proxy) was added when Marreta went onto the chain, and
pk 3 (hermes-proxy) no longer exists at all — select … where id=3
returns zero rows, so it is deleted, not "dormant"; its vhost survived but is
exempt-by-construction (dankclaw/Caddyfile:179-191). OAuth2 relying parties,
not proxy providers, so the proxy outpost does not deserialize them: pk 9
grafana (grant_types = {authorization_code}), pk 12 Seerr OIDC
(grant_types = {} — empty, i.e. still in the state the token-endpoint rule
below rejects), and pk 14 Open WebUI
(grant_types = {authorization_code,refresh_token}). Trailing slashes on
external_host (the runbook-recipe style) and bare origins both intercept
correctly — the outpost matches on hostname.
Provisioning rules for proxy providers
Three provisioning rules, each verified live by an outage or a dead login:
- All three flows are required. A ProxyProvider created via raw API/ORM
with any of
authentication_flow/authorization_flow/invalidation_flownull wedges the embedded outpost's config fetch: the Rust side rejects the whole config JSON (serde:invalid type: null, expected a formatted UUID string), logsFailed to fetch outpost from API, retrying in 3 secondsforever, and everything behind the outpost dies at the next outpost reload — including hosts whose providers were fine (verified 2026-09-02 when three providers provisioned without any flows took down every guarded vhost after a server restart). Backfill all three flows (copy from pk 1:default-authentication-flowdefault-provider-authorization-implicit-consent(the implicit-consent authorization flow,default-invalidation-flow) and the retry loop recovers on its next 3-second cycle. The silent killer: while the outpost process keeps its last good in-memory config, everything works — the outage only surfaces on restart/reload. - Every proxy provider needs an attached Application. The outpost
resolves the post-login redirect through the provider's application; a
provider with no app completes the login (authentik session established)
but bounces to authentik's own user UI (
/if/user/#/library) instead of the app. No error is logged anywhere — the SSO "works" and the app never appears. Create the Application withprovider_idset (the UI does this automatically; raw API/ORM drives must do it explicitly). - A raw ORM create skips the OAuth defaults. The REST serializer calls
ProxyProvider.set_oauth_defaults();ProxyProvider.objects.create()does not. The row then looks fully configured — external_host, internal_host, application bound, outpost claimed — whileredirect_urisis empty (those URIs are derived fromexternal_hostby_get_callback_url) andgrant_typesis[]. Both kill the guard before any credential, in this order: authentik renders its own "Redirect URI Error" page, and once the URIs are filled by hand the next wall is a bodiless HTTP 400 out of/application/o/authorize/(both measured on the Audiobookshelf guard provider, 2026-11-03). Census and repair in one idempotent pass, all providers at once:scripts/authentik-proxy-provider-defaults.sh;--checkprints the census and exits 1 on drift, and a provider that already matches is left alone.
OAuth2 relying-party quirks (this fork; corrected live 2026-10-20)
The Grafana RP provider (grafana, pk 9, confidential client) was created and
verified end-to-end via ak shell ORM drives. This fork's OAuth2 provider
implementation diverges from upstream in ways that bit us; all verified live:
- Application row required.
OAuth2Provider.objects.create()does not create the single-table-inheritanceApplicationrow; without it the flow executor can never complete an authorize request. Create both rows. grant_typesdefaults to empty. Until the provider'sgrant_typescontains"authorization_code"(enum value with underscore), the token endpoint rejects every exchange withinvalid_request.- Token endpoint is form-encoded + Basic-auth only. A JSON body makes
parse_token_requestsee nogrant_type→unsupported_grant_typePOST-body client secrets are not accepted — authenticate the confidential client withAuthorization: Basic base64(client_id:client_secret). - Provider resolution is by
client_idquery param on the global/application/o/authorize/route; per-slug authorize paths do not exist in this build. Token/userinfo live at/application/o/token/and/application/o/userinfo/— do not append?api=1to a Grafana API URL. - NULL
authorization_flow→ authorize 500,AttributeError: 'NoneType' object has no attribute 'slug'. A provider created via rawak shellcreate()leavesauthorization_flowunset; the authorize view then crashes building the flow planner (flows/planner.py) and returns "Server Error — Powered by authentik" before any claim is built. This was previously mis-attributed to a broken evaluator; the crash is the missing flow. Fix: setauthorization_flow = default-provider-authorization-implicit-consent(the flow every other provider in this instance already uses). - A scope mapping that returns a non-dict emits no claim. authentik merges a
granted scope mapping's evaluated result into the token as a mapping: a
dict contributes its keys as claims; a bare string is keyed under the
mapping's NAME, so nothing usable appears.
grafana-email-scopereturned the bare stringuser.email, so noemailclaim landed — the evaluator was never broken (the dict-valuedgrafana-profile-scopemapping has always delivered, and the stock OpenID mappings return dicts). Fix:return {"email": user.email, "email_verified": True}. With no email, Grafana v13 falls back to GET<api_url>/emails; authentik 404s it → "Login failed: InternalError". - Grafana reads identity from the standard userinfo endpoint (
API_URLwith no?api=1); noUSE_ID_TOKENis needed once theemailclaim exists. - Net (2026-10-20): the evaluator is fine; email/profile claims reach RP
clients. Both IdP fixes are idempotent in
scripts/authentik-grafana-sso-fix.sh(re-run after an ak-shell re-creation of the provider or a blueprint import, which revert them). Grafana's half plus the verified outcome (Viewer,login = <sub hex>, email present) are in Grafana.
LDAP provider objects (provisioned via ak shell ORM)
The LDAP outpost's own deployment — unit, image digest, published ports, token env var — is in Authentik. The objects it serves:
Read from the live database (read-only SELECTs against the
authentik-postgres container, 2026-10-18):
| 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 — 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:
- Token intent is moot for Jellyfin. The earlier argument (a bind token with
intent=app_passwordis rejected by the flow's password stage, onlyintent=verificationpasses) never applied here: LDAP-Auth sends the service account's password fromLdapBindPassword, not a token. A live bind plus amemberOf=cn=jellyfin_userssearch (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 logsFound 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 providersconfig cycle; the absence of a bind line is not evidence of anything. Instruments that do answer:ldapsearchwith the plugin's own DN, password and filter, and the authentik event registry (login/login_failedwithclient_ipfrom the podman gateway, since the published port SNATs). ak shellimport path for the provider model in 2026.8:from authentik.providers.ldap.models import LDAPProvider(verified inside a liveak shell, 2026-11-04 — it only fails outside a configured Django shell).ak shellfield names, 2026.8:Eventis keyed bycreatedandaction— there is notimestampand notype;Outposthasprovidersbut noapps. Introspect first:[f.name for f in Model._meta.get_fields()].- Never pipe
ak shellinside a heredoc.podman exec -i … ak shell | grep -v '^{'binds the heredoc togrep(the redirection belongs to the LAST element of the pipeline), so the shell gets no stdin and prints nothing at all. And2>/dev/nullonak shelleats 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") … PYthenpodman exec authentik-server cat /tmp/x.txt.
Self-serve user-settings flow (password change)
Users change their own account details through the default-user-settings-flow
(https://auth.franksdank.cloud/if/flow/default-user-settings-flow/) — the
target of the homepage footer's "account" link. It is a stage_configuration
designated flow, so it renders every bound ConfigurableStage for the logged-in
user.
Stages, in execution order (the FlowStageBinding.order field, not the M2M
insertion order):
| order | stage | type | what it does |
|---|---|---|---|
| 20 | default-user-settings |
PromptStage |
name / email / locale + password |
| 100 | default-user-settings-write |
UserWriteStage |
writes the prompt data to the user |
Password change is a prompt field, not a stage. The UserWriteStage
writes whatever the preceding PromptStage collected: for the key password
it calls user.set_password(value) (stages/user_write/stage.py). So the fix
is a password field on the default-user-settings prompt
(field_key=password, type=password, required=False, order 204) — NOT a
PasswordStage. A PasswordStage is an authentication stage: its
validate_password runs authenticate() against the backends to check the
current password; it never sets a new one. (A PasswordStage was added on
2026-09-25 and removed the same day for exactly this reason.)
Two ORM gotchas: Flow.stages.add() fails on this through model
(Can't bulk create a multi-table inherited model — FlowStageBinding is
MTI), so create the FlowStageBinding row directly with an explicit order
and a prompt field is a Prompt row linked through PromptStageField
(prompt_stage + prompt; the order comes from Prompt.order).
A blank password must not set an empty password. set_password("") would
make the account log in with no password, so a validation policy
(default-user-settings-no-empty-password, bound to the prompt
PromptStageValidationPolicy) denies the form when prompt_data["password"]
is "" and allows it when set. Verified with the PolicyEvaluator
(empty → deny, set → allow, absent → allow) and end-to-end by driving
UserWriteStageView.update_user on a throwaway user (new password valid, old
invalid).
Live database shape
For live DB truth prefer the postgres container, and it works without any
credential — the local socket is trusted, so a read-only audit is just
podman exec -i authentik-postgres psql -U authentik -d authentik -At -f -
with SQL on stdin (quoting SQL through ssh+zsh otherwise fights you). Verify
before guessing: 2026.x renamed both tables and columns, and the models are
MTI all the way down — authentik_core_provider holds id, name and the
three *_flow_id columns; the proxy table is
authentik_providers_proxy_proxyprovider (doubled proxy) and its PK column
really is oauth2provider_ptr_id (proxy providers inherit the OAuth2
table in 2026.8), the LDAP table is
authentik_providers_ldap_ldapprovider with PK provider_ptr_id
authentik_core_application attaches to a provider through provider_id
(its PK is policybindingmodel_ptr_id, not id), and authentik_outposts_outpost
is keyed by uuid. So Provider.objects.filter(type=…) raising FieldError
is the same story from the ORM side: type lives in the subclass tables.
Never select secret columns (client_secret, key, password) into a log.
Cross-references
- Authentik — what the service is, its deployment, wiring, operations and failure modes
- Authentik Processes — the postgres, server and worker containers
- Caddy — the reverse proxy carrying the
:8016/:8017auth-chain listeners - Jellyfin — consumes the LDAP outpost via its LDAP-Auth plugin
- Grafana — guarded vhost and OAuth2 relying party of this provider