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
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 directssh host '… "{{…}}"'. Direct ssh eats inner quotes locally;podman inspect --format '{{…}}'and multi-lineak shell -c "…"only survive inside a quoted heredoc. Authentik runs rootless (podman exec authentik-server …, no sudo). OAuth2Providerhas nopropertiesattribute in 2026.8; scopes are derived fromp.property_mappings. Accessing.propertiesraises and silently aborts the whole-cpayload.ScopeMappingis not importable fromauthentik.core.models. To read scopes: iteratep.property_mappings.all(), thenm.scopemapping.scope_name(reverse one-to-one; the basePropertyMappingrow has noscope_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_KEYand 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 (dropUSE_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 toa. The repo already uses this (open-webui.containerOAUTH_SCOPES). - OKF conformance gate rejects a page frontmatter
timestampin 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.