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
114 lines
6.7 KiB
Markdown
114 lines
6.7 KiB
Markdown
---
|
|
name: authentik-rp-sso-login-diagnosis
|
|
description: "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 <authentik>" 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:
|
|
|
|
```js
|
|
// 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.
|