Files
serverAppConfigs/serverAppConfigs-docs/Infrastructure/Authentik Outpost Reference.md
T
Frank 23ad0ca317 chore(abs): name the Audiobookshelf door audiobooks.franksdank.cloud
`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.
2026-11-03 01:23:54 -08:00

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.
infrastructure
authentik
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:

  1. All three flows are required. A ProxyProvider created via raw API/ORM with any of authentication_flow/authorization_flow/invalidation_flow null wedges the embedded outpost's config fetch: the Rust side rejects the whole config JSON (serde: invalid type: null, expected a formatted UUID string), logs Failed to fetch outpost from API, retrying in 3 seconds forever, 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-flow default-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.
  2. 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 with provider_id set (the UI does this automatically; raw API/ORM drives must do it explicitly).
  3. 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 — while redirect_uris is empty (those URIs are derived from external_host by _get_callback_url) and grant_types is []. 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; --check prints 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-inheritance Application row; without it the flow executor can never complete an authorize request. Create both rows.
  • grant_types defaults to empty. Until the provider's grant_types contains "authorization_code" (enum value with underscore), the token endpoint rejects every exchange with invalid_request.
  • Token endpoint is form-encoded + Basic-auth only. A JSON body makes parse_token_request see no grant_type → unsupported_grant_type POST-body client secrets are not accepted — authenticate the confidential client with Authorization: Basic base64(client_id:client_secret).
  • Provider resolution is by client_id query 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=1 to a Grafana API URL.
  • NULL authorization_flow → authorize 500, AttributeError: 'NoneType' object has no attribute 'slug'. A provider created via raw ak shell create() leaves authorization_flow unset; 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: set authorization_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-scope returned the bare string user.email, so no email claim landed — the evaluator was never broken (the dict-valued grafana-profile-scope mapping 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_URL with no ?api=1); no USE_ID_TOKEN is needed once the email claim 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_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.
  • 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.
  • 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)*

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/:8017 auth-chain listeners
  • Jellyfin — consumes the LDAP outpost via its LDAP-Auth plugin
  • Grafana — guarded vhost and OAuth2 relying party of this provider