A proxy provider built by hand in `ak shell` never runs ProxyProvider.set_oauth_defaults() — the REST serializer does, the ORM does not — so the row looks fully configured while redirect_uris is empty (they are derived from external_host by _get_callback_url()), grant_types is [], and authorization_flow is NULL. Each one kills a guarded login in a different, unrelated-looking way, all before any credential is requested: authentik's own "Redirect URI Error" page, then a bodiless HTTP 400 from /application/o/authorize/ once the URIs are filled by hand, and a 500 NoneType.slug in the flow planner. abs-proxy (abs.franksdank.cloud) hit the first two on 2026-11-03. The script calls the vendor's own set_oauth_defaults() and points the authorization flow at default-provider-authorization-implicit-consent for every proxy provider, idempotently; a provider that already matches is untouched, so a deliberate deviation is never flattened. `--check` is the census (exit 1 on drift, writes nothing) for gate use. Applied live 2026-11-03: abs-proxy fixed, the other ten (REST-created) already compliant, census now reports drift: 0. This is authentik DB state and no deployable file carries it — a blueprint re-import or an ak-shell re-creation reverts it and the symptom returns, so the law and the failure signatures are recorded on the outpost page as provisioning rule 3.
250 lines
15 KiB
Markdown
250 lines
15 KiB
Markdown
---
|
|
type: reference
|
|
description: "Outpost, proxy-provider, OAuth2, LDAP and flow configuration for the homelab's Authentik SSO."
|
|
tags:
|
|
- infrastructure
|
|
- authentik
|
|
timestamp: 2026-10-19
|
|
---
|
|
|
|
# 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 `SELECT`s, 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 `abs-proxy`, 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 `SELECT`s 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
|