The vault claimed the fork property-mapping evaluator was broken and userinfo returned only sub, so email could never reach Grafana (outcome unverified). That was a misdiagnosis: the real causes were the NULL authorization_flow (the NoneType.slug crash) and the email scope mapping returning a bare string rather than a dict. Record the four verified causes and fixes, point to the repair script and the new landing spec, and drop the unverified caveat.
240 lines
14 KiB
Markdown
240 lines
14 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
|
|
|
|
Two provisioning rules, each verified live by an outage:
|
|
|
|
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).
|
|
|
|
## 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".
|
|
- **Claims ride in the id_token.** `include_claims_in_id_token` is on for this
|
|
provider and Grafana reads identity from it (`USE_ID_TOKEN=true`).
|
|
- **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 + 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
|