`abs.` was a three-letter abbreviation of the container; the library it serves is /mnt/entropy/Audiobooks, and `audiobooks.` says what the door is. Renamed in one commit on purpose: the pihole seam fails the suite if a Caddyfile vhost has no addn-hosts record, so any split would leave a red commit, and the `abs` name was never deployed at the IdP with this fix, so nothing has to keep answering it — no alias, no redirect, no stale URI. Moves together: the Caddyfile site block, its matcher (@abs -> @audiobooks), the LAN record, ABS_EXTRA_BASES in authentik-audiobookshelf-sso.sh (that script REPLACES the provider's redirect-URI list, so the old origin's callback leaves the RP when it is next run), the homepage admin tile, the guard spec's GUARDED list and leak marker, and the three vault pages that name the door. authentik's provider row follows in the deploy window (external_host + name), which re-derives its redirect_uris through scripts/authentik-proxy-provider-defaults.sh; the Application row keeps its own name, "Audiobookshelf Guard" — it names the app, not the vhost.
15 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 (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 |
| 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):
- Token intent: contested, do not trust either value blindly. An earlier
revision asserted that a bind token with
intent=app_passwordis rejected by the flow's password stage withInvalid passwordand onlyintent=verificationpasses. The live token forsvc-jellyfin-ldapisapp_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-minuteinitialized direct binder/Update providersconfig 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 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. ak shellimport 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 noak shellwas driven against the live auth service for this check)*
- (unverified 2026-10-18: importing it outside a configured Django shell fails
with
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