Files
command-center/agent/managed-skills/authentik-rp-sso-login-diagnosis/SKILL.md
T
Frank 8af40c402b chore(agent): commit the machine tier and the doctrine text the checkout was already running
Landing blocker for #61 and #63: the deployment checkout is 12 commits behind and
`merge --ff-only` refuses while tracked paths are dirty. Discard is not available here
(workspace policy: no `checkout`/`stash` on shared state), so the dirt is committed.

- `agent/APPEND_SYSTEM.md`: the point-of-use / one-work-log / PR-link doctrine, already
  byte-identical to `origin/main` (`cmp` against the trunk blob: no difference). No new
  prose — the commit exists so the trunk merge can proceed.
- `agent/config.yml` and `agent/managed-skills/**`: the machine-live tier the heartbeat
  owns (AGENTS.md § Live-sync tiers).

`ALLOW_SECRETS=1` was used, deliberately and after auditing the staged diff by hand: the
only credential-shaped strings in it are two copies of `AKIA0000000000000EXAMPLE` — the
inert all-zero AWS example id — and they appear inside the two skill pages that teach
that exact placeholder shape. This checkout is still running the pre-#61 hook; the merged
gate (value-scoped placeholder filter, `71601f7`) exempts this value, so the refusal
dies with this landing. No `--no-verify` was used: the gate ran and its verdict is
recorded here.

Refs #61
2026-10-21 01:44:51 -07:00

6.7 KiB

name, description
name description
authentik-rp-sso-login-diagnosis Diagnose/repair a dead OAuth2 relying-party login against authentik (Grafana-style): authorize 500, missing email claim, Grafana /emails fallback. Live probe harness + ak-shell gotchas.

Authentik OAuth2 relying-party (RP) SSO login diagnosis

Use when a vhost authenticates through authentik via the OIDC authorization-code flow (Grafana, Seerr OIDC, any "Sign in with " button) and the login fails — even though the guard-chain e2e (Anubis + outpost) passes green.

Key insight: the edge test lies

The outpost-guard spec (e.g. 13-sso) only proves an authenticated session reaches the app's login page. It never follows the RP OAuth button. So the RP login can be 100% dead and the suite still passes. Always drive the real RP round-trip to get ground truth (harness below).

Symptom → cause map (all verified live on 2026-10-20, grafana)

Signature Root cause Fix
Browser hits …/application/o/authorize/?client_id=… → "Server Error — Powered by authentik" (500), traceback AttributeError: 'NoneType' object has no attribute 'slug' in flows/planner.py provider authorization_flow is NULL (raw ak shell create() leaves it unset) set authorization_flow = default-provider-authorization-implicit-consent
Grafana log logger=oauth.generic_oauth level=error msg="Error getting email address" url=<api_url>/emails … 404/403 the granted scope mapping returns a bare string, not a dict → no email claim emitted fix the ScopeMapping expression to return {"email": user.email, "email_verified": True}
authorize 500 on scope-overlap, log Application requested scopes not configured for a scope like user:email client requested a scope authentik doesn't define (GitLab-ism) client must request authentik's real scopes: openid email profile
Grafana ?api=1 on API URL → GET <…>/userinfo/?api=1/emails → 403 Grafana v13 appends /emails to a ?api=1 URL use the standard …/userinfo/ with no ?api=1

Crucial causal note: Grafana v13 GETs <api_url>/emails for the address ONLY when the token carries no email claim. So the /emails 404 is a symptom of the missing claim, not a Grafana misconfig. Do NOT chase it with USE_ID_TOKEN or attribute paths; fix the claim. USE_ID_TOKEN=true does NOT stop the /emails fetch. Once the claim exists, standard userinfo carries email and no USE_ID_TOKEN is needed.

Live RP-login probe harness (ground truth)

A qa-e2e authentik session storage state exists at tests/e2e/.auth/qa-e2e.json after an e2e run; the e2e node_modules live in the main checkout (tests/e2e/). Throwaway probe:

// node _p.cjs  (run from a checkout with node_modules + .auth)
const { chromium } = require('playwright');
(async () => {
  const b = await chromium.launch({ headless: true });
  const ctx = await b.newContext({ storageState: '.auth/qa-e2e.json',
    userAgent: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36" });
  const p = await ctx.newPage();
  await p.goto('https://<vhost>/', { waitUntil: 'domcontentloaded' });
  for (let i=0;i<30;i++){ const t=await p.locator('body').innerText().catch(()=>''); if(!t.includes('not a bot')) break; await p.waitForTimeout(3000);}
  await p.goto('https://<vhost>/login/generic_oauth', { waitUntil: 'domcontentloaded' });
  await p.waitForTimeout(9000);
  const r = await ctx.request.get('https://<vhost>/api/user');
  console.log(p.url(), r.status(), (await r.text()).slice(0,200));
  await b.close();
})();

Success = final URL on the app host (not /login) AND /api/user 200 with an email. Before the fix it is /login + 401. Move the throwaway to ~/.AGENTTRASH/… after (never rm).

ak-shell census: operational traps (authentik 2026.8)

  • Run it via ssh dankclaw bash -s <<'EOF' … EOF, NEVER direct ssh host '… "{{…}}"'. Direct ssh eats inner quotes locally; podman inspect --format '{{…}}' and multi-line ak shell -c "…" only survive inside a quoted heredoc. Authentik runs rootless (podman exec authentik-server …, no sudo).
  • OAuth2Provider has no properties attribute in 2026.8; scopes are derived from p.property_mappings. Accessing .properties raises and silently aborts the whole -c payload.
  • ScopeMapping is not importable from authentik.core.models. To read scopes: iterate p.property_mappings.all(), then m.scopemapping.scope_name (reverse one-to-one; the base PropertyMapping row has no scope_name).
  • Introspect before guessing: [f.name for f in Model._meta.get_fields()]. Don't trial-and-error attribute names — that wastes turns.
  • Claims-into-id_token field is include_claims_in_id_token.
  • Provider census that works:
from authentik.providers.oauth2.models import OAuth2Provider
for p in OAuth2Provider.objects.all():
    af=p.authorization_flow
    print(p.name, af.slug if af else None)
# email mapping shape (the real bug):
p=OAuth2Provider.objects.filter(name='grafana').first()
for m in p.property_mappings.all():
    print(m.name, getattr(getattr(m,'scopemapping',None),'scope_name',None), repr(getattr(m,'expression','')))

Repair = idempotent script (DB state, not deployable)

Authentik config lives in its DB, not in any repo file; a blueprint re-import or an ak shell re-creation reverts provider fields silently. Ship an idempotent scripts/authentik-<svc>-sso-fix.sh that ssh HOST 'podman exec -i authentik-server ak shell' <<'PYEOF' … PYEOF and re-applies each fact guarded by "already set?" checks. Match the style of scripts/authentik-set-login-session-duration.sh; add a header note "DB state — re-run after a rebuild". Reference it from the service page. Grafana's half (env) lives in grafana.container and deploys normally.

Repo test-seam gotchas this triggers

  • literal-secret seam (observability_quadlet.bats, "no literal secret") globs keys *_SECRET|*_PASSWORD|*_TOKEN|*_API_KEY and demands a ${VAR} value. A boolean env ending in _TOKEN (…_USE_ID_TOKEN=true) is a false positive. Prefer not to set such a key (drop USE_ID_TOKEN); otherwise the seam must be refined (ask the owner before editing a pre-existing test).
  • Quadlet Environment= word-splits on spaces (systemd semantics) — a spaced list must be quoted: Environment="KEY=a b c", else it truncates to a. The repo already uses this (open-webui.container OAUTH_SCOPES).
  • OKF conformance gate rejects a page frontmatter timestamp in the future. The fleet host clock reads UTC (often a day ahead of local "today") — set the timestamp to the repo's today, not the host's UTC date.