infra/.claude/reference/authentik-state.md
Viktor Barzin 117b99e28f docs/authentik: document postgres session backend + close out 2026-04-18 post-mortem items
Update `.claude/reference/authentik-state.md`:
  - Add `ProxyProvider.access_token_validity = "weeks=4"` to the Session
    Duration table with the gotcha that the gorilla session store binds
    the value once at outpost startup (rollout restart needed).
  - Replace the "session storage moved to Postgres in 2025.10" note that
    falsely implied the migration was automatic — explain that the
    `Outpost.managed` field gates the postgres path and our outpost
    silently stayed on `FilesystemStore` until 2026-05-10.
  - Document the goauthentik 2026.2.2 service-selector bug
    (service.py:52) and the JSON-patch workaround.
  - Document that the standalone embedded-outpost deployment needs
    `AUTHENTIK_POSTGRESQL__*` env vars injected via JSON patch, plus the
    `app.kubernetes.io/component=server` pod label.
  - Note the "Terraform doesn't expose `Outpost.managed`" assumption
    that holds the `managed=embedded` value in place across applies.

Close out post-mortem `2026-04-18-authentik-outpost-shm-full.md`:
  - P2 codify-in-Terraform: DONE.
  - P3 access_token_validity reduce: DONE-alt (we did the opposite —
    bumped to 4 weeks — because postgres backend mooted the storage
    concern).
  - P3 move-off-embedded-outpost: DONE-alt (postgres backend addresses
    the loss-of-state class on the embedded outpost itself).
2026-05-10 16:28:11 +00:00

9.7 KiB

Authentik Current State

Snapshot of applications, groups, users, and flows. Use authentik skill for management tasks.

Applications (10)

Application Provider Type Auth Flow
Cloudflare Access OAuth2/OIDC explicit consent
Domain wide catch all Proxy (forward auth) implicit consent
Forgejo OAuth2/OIDC explicit consent
Grafana OAuth2/OIDC implicit consent
Headscale OAuth2/OIDC explicit consent
Immich OAuth2/OIDC explicit consent
Kubernetes OAuth2/OIDC (public) implicit consent
linkwarden OAuth2/OIDC explicit consent
Matrix OAuth2/OIDC implicit consent
wrongmove OAuth2/OIDC implicit consent

Groups (9)

Group Parent Superuser Purpose
Allow Login Users -- No Parent group for login-permitted users
authentik Admins -- Yes Full admin access
Headscale Users Allow Login Users No VPN access
Home Server Admins Allow Login Users No Server admin access
Wrongmove Users Allow Login Users No Real-estate app access
kubernetes-admins -- No K8s cluster-admin RBAC
kubernetes-power-users -- No K8s power-user RBAC
kubernetes-namespace-owners -- No K8s namespace-owner RBAC
Task Submitters -- No Task submission access

Users (8 real)

Username Name Type Groups
akadmin authentik Default Admin internal authentik Admins, Home Server Admins, Headscale Users
vbarzin@gmail.com Viktor Barzin internal authentik Admins, Home Server Admins, Wrongmove Users, Headscale Users
emil.barzin@gmail.com Emil Barzin internal Home Server Admins, Headscale Users
ancaelena98@gmail.com Anca Milea external Wrongmove Users, Headscale Users
vabbit81@gmail.com GHEORGHE Milea external Headscale Users
valentinakolevabarzina@gmail.com Valentina internal Headscale Users
anca.r.cristian10@gmail.com -- internal Wrongmove Users
kadir.tugan@gmail.com Kadir internal Wrongmove Users

Login Sources

  • Google (OAuth) -- user matching by identifier
  • GitHub (OAuth) -- user matching by email_link
  • Facebook (OAuth) -- user matching by email_link
  • All sources use invitation-enrollment as enrollment flow (new users require invitation)

Authorization Flows

  • Explicit consent (default-provider-authorization-explicit-consent): Shows consent screen
  • Implicit consent (default-provider-authorization-implicit-consent): Auto-redirects

Invitation Enrollment Flow

Slug: invitation-enrollment | PK: 7d667321-2b02-4e16-8161-148078a8dac1

New users can only sign up via invitation link. Admins generate single-use invite links.

Stages (in order)

Order Stage Type Purpose
10 invitation-validation Invitation Validates ?itoken= parameter, blocks without valid token
20 enrollment-identification Identification Shows social login (Google/GitHub/Facebook) + passkey
30 enrollment-prompt Prompt Collects name and email (pre-filled from social login)
40 enrollment-user-write User Write Creates user in Allow Login Users group
50 enrollment-login User Login Auto-login after signup (policy: invitation-group-assignment adds user to target group from invitation fixed_data.group)

Invitation Management

Script: .claude/scripts/authentik-invite.sh

# Create invitation (single-use, no expiry)
./authentik-invite.sh create "Headscale Users"

# Create invitation with expiry
./authentik-invite.sh create "Wrongmove Users" --days 7

# Add user to group after enrollment
./authentik-invite.sh assign <username> "Headscale Users"

# List pending invitations
./authentik-invite.sh list

Invited users sign up via social login (Google/GitHub/Facebook) or passkey. No username/password enrollment. The target group (e.g. "Headscale Users") is auto-assigned on enrollment via the invitation-group-assignment expression policy. The assign command is available for manual post-enrollment group changes.

Cleanup Log (2026-03-13)

Deleted Flows

  • enrollment-inviation (typo) -- previous invitation attempt
  • headscale-authentication -- not used by any provider
  • headscale-authorization -- not used by any provider
  • default-enrollment-flow -- password-based, unused
  • oauth-enrollment -- replaced by invitation-enrollment

Deleted Stages

  • enrollment-invitation, enrollment-invitation-write (from old invitation flow)
  • invitation (unbound)
  • default-enrollment-prompt-first, default-enrollment-prompt-second (from default enrollment)
  • default-enrollment-user-write, default-enrollment-email-verification, default-enrollment-user-login

Deleted Groups

  • authentik Read-only -- 0 users, unused role

Deleted Policies

  • map github username to email -- unbound
  • Map Google Attributes -- unbound

Deleted Roles

  • authentik Read-only -- no group assignment

Policy Fix (2026-04-06)

Unbound brute-force-protection Policy

The brute-force-protection ReputationPolicy (PK: ac98cb11-31d3-46ab-8883-bf51e6b09a60, check_username=True, check_ip=True, threshold=-5) was bound to 3 authentication flows, causing "Flow does not apply to current user" for all unauthenticated users (no username to evaluate → failure_result=false → flow denied).

Removed bindings from:

  • default-authentication-flow (PK: 34618cf3) — username/password login
  • webauthn (PK: 0b60c2a5) — passkey login
  • default-source-authentication (PK: via policybindingmodel 1a779f24) — Google/GitHub/Facebook OAuth

Policy still exists with 0 bindings. If brute-force protection is needed, bind to the password stage (not the flow level).

Session Duration (2026-05-01)

Pinned via Terraform in stacks/authentik/:

Knob Value Surface Effect
UserLoginStage.session_duration on default-authentication-login weeks=4 authentik_stage_user_login.default_login in authentik_provider.tf Authenticated users stay logged in 4 weeks across browser restarts. No sliding refresh — resets on each login.
ProxyProvider.access_token_validity on Provider for Domain wide catch all weeks=4 authentik_provider_proxy.catchall.access_token_validity in authentik_provider.tf Cookie Max-Age on authentik_proxy_* and expires on rows in authentik_providers_proxy_proxysession. Bumped 2026-05-10 from hours=168. Bumping requires kubectl rollout restart deploy/ak-outpost-authentik-embedded-outpost — the gorilla session store binds the value once at outpost startup; the 5-min provider refresh logs "reusing existing session store" and skips rebuild.
AUTHENTIK_SESSIONS__UNAUTHENTICATED_AGE (server + worker) hours=2 server.env + worker.env in modules/authentik/values.yaml Anonymous Django sessions (bots, healthcheckers, partial flows) are reaped within 2h instead of the 1d default.

Notes:

  • There is no Brand.session_duration; UserLoginStage is the only correct lever for authenticated session lifetime.
  • Embedded outpost session storage: PostgreSQL table authentik_providers_proxy_proxysession in authentik 2025.10+ (PR #16628), but only when IsEmbedded() returns true (i.e. Outpost.managed == "goauthentik.io/outposts/embedded"). Our outpost record had managed=null until 2026-05-10, which silently kept it on the gorilla FilesystemStore at /dev/shm (TMPDIR) and re-exposed the 2026-04-18 mismatched-session-ID class on every pod restart. Fix landed 2026-05-10: see authentik_outpost.embedded in authentik_provider.tf and post-mortem 2026-04-18-authentik-outpost-shm-full.md.
  • The proxy outpost service has a known goauthentik 2026.2.2 bug (internal/outpost/controllers/k8s/service.py:52): for embedded outposts the controller sets the Service selector to app.kubernetes.io/name=authentik (the server pods), not authentik-outpost-proxy. We work around it via a kubernetes_json_patches.service patch on the outpost record (replaces /spec/selector with the outpost's own labels). Without this, endpoints are empty and Traefik forward-auth fails over to the Basic Auth realm Emergency Access.
  • The standalone embedded-outpost deployment needs AUTHENTIK_POSTGRESQL__{HOST,PORT,USER,PASSWORD,NAME} env vars to reach the dbaas cluster — codified via kubernetes_json_patches.deployment envFrom the shared goauthentik Secret. The app.kubernetes.io/component=server pod label is also injected via JSON patch (matches the component:server half of the Service selector that the controller adds for embedded outposts).
  • ProxyProvider.remember_me_offset stays UI-managed via ignore_changes.
  • The Authentik provider's resource schema does not expose the Outpost.managed field. We rely on TF's "write only fields it knows about" semantic: the server-set goauthentik.io/outposts/embedded value is preserved across applies because Terraform never writes managed. Don't change the resource provider schema expectations without verifying this assumption holds.
  • The unauthenticated_age env var is injected via server.env / worker.env (not authentik.sessions.unauthenticated_age) because we set authentik.existingSecret.secretName: goauthentik, which makes the chart skip rendering its own AUTHENTIK_* Secret. The authentik.* value block is therefore inert in this stack — anything new under authentik.* must use the *.env arrays instead. The same applies to the existing authentik.cache.*, authentik.web.*, authentik.worker.* blocks (currently inert; live values come from the orphaned, helm-keep-policy goauthentik Secret created by chart 2025.10.3 before existingSecret was introduced).