diff --git a/dankclaw/observability/quadlet/grafana.container b/dankclaw/observability/quadlet/grafana.container index a790d89..8eefb6f 100644 --- a/dankclaw/observability/quadlet/grafana.container +++ b/dankclaw/observability/quadlet/grafana.container @@ -47,9 +47,23 @@ Environment=GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET=${GRAFANA_OAUTH_CLIENT_SECRET} # client_id query parameter — /application/o//authorize/ does not exist. Environment=GF_AUTH_GENERIC_OAUTH_AUTH_URL=https://auth.franksdank.cloud/application/o/authorize/ Environment=GF_AUTH_GENERIC_OAUTH_TOKEN_URL=https://auth.franksdank.cloud/application/o/token/ -# ?api=1 = introspection-style userinfo: returns custom claims (groups) too. -Environment=GF_AUTH_GENERIC_OAUTH_API_URL=https://auth.franksdank.cloud/application/o/userinfo/?api=1 -Environment=GF_AUTH_GENERIC_OAUTH_ALLOW_SIGN_UP=false +# Read claims from the signed ID token. Grafana v13 always appends /emails to +# API_URL and GETs it as a userinfo endpoint (authentik has no such path and +# 404s it, failing the login). USE_ID_TOKEN makes Grafana take sub/email/name +# from the id_token, which authentik fills from the openid/email/profile scopes. +Environment=GF_AUTH_GENERIC_OAUTH_USE_ID_TOKEN=true +Environment=GF_AUTH_GENERIC_OAUTH_API_URL=https://auth.franksdank.cloud/application/o/userinfo/ +# authentik's scopes are openid/email/profile. `user:email` (a GitLab-ism) is +# not a known scope here; requesting it makes the authorize view crash. +# Quoted: quadlet word-splits Environment=, so an unquoted spaced list +# truncates SCOPES to its first token. +Environment="GF_AUTH_GENERIC_OAUTH_SCOPES=openid email profile" +Environment=GF_AUTH_GENERIC_OAUTH_EMAIL_ATTRIBUTE_PATH=email +Environment=GF_AUTH_GENERIC_OAUTH_LOGIN_ATTRIBUTE_PATH=sub +Environment=GF_AUTH_GENERIC_OAUTH_NAME_ATTRIBUTE_PATH=preferred_username +# First SSO login auto-provisions the user. Safe: unauthenticated traffic never +# reaches this callback — the Anubis -> Authentik guard fronts every request. +Environment=GF_AUTH_GENERIC_OAUTH_ALLOW_SIGN_UP=true Environment=GF_AUTH_GENERIC_OAUTH_AUTO_UPSERT=true Environment=GF_AUTH_GENERIC_OAUTH_ALLOW_ASSIGN_ORG=true Environment=GF_AUTH_GENERIC_OAUTH_ORG_ID=1 diff --git a/scripts/authentik-grafana-sso-fix.sh b/scripts/authentik-grafana-sso-fix.sh new file mode 100755 index 0000000..19077c7 --- /dev/null +++ b/scripts/authentik-grafana-sso-fix.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# Repair the Grafana OAuth2 relying-party SSO on Dankclaw's authentik. +# +# grafana.franksdank.cloud logs in through authentik as an OAuth2 relying party. +# Two DB-side facts (the provider was created by hand via `ak shell`, never +# repo-managed) silently broke that login, and BOTH had previously been +# mis-recorded here as a single "the property-mapping evaluator is broken, so +# userinfo returns only `sub`" myth. They are neither, and the evaluator is fine: +# +# 1. provider.authorization_flow was NULL. The authorize view builds a flow +# planner from it and crashes with +# `AttributeError: 'NoneType' object has no attribute 'slug'` — an HTTP 500 +# "Server Error — Powered by authentik", before any claim is touched. +# Fix: point it at default-provider-authorization-implicit-consent, the +# flow every other provider in this instance already uses. +# 2. The `grafana-email-scope` ScopeMapping's expression was the bare string +# `user.email`. authentik merges a scope mapping's result into the token as +# a MAPPING: a returned dict adds its keys as claims; a returned string is +# keyed under the mapping's NAME ("grafana-email-scope"), so no `email` +# claim is ever emitted. Grafana, finding no email, falls back to +# GET /emails, authentik 404s that path, and the login dies with +# "Login failed: InternalError". Fix: return +# {"email": user.email, "email_verified": True} — the shape the stock +# OpenID 'email' mapping uses. The dict-valued `grafana-profile-scope` +# mapping has always worked, which is the proof the evaluator is fine. +# +# This is authentik DB state. It is NOT in any deployable file: a blueprint +# re-import, or an ak-shell re-creation of the grafana provider, reverts BOTH +# facts and the symptom returns verbatim. Re-run this after any such rebuild. +# Every step is idempotent. +# +# Grafana's own half — SCOPES="openid email profile", USE_ID_TOKEN=true, no +# `?api=1` on the API URL, ALLOW_SIGN_UP=true — lives in +# dankclaw/observability/quadlet/grafana.container and deploys normally. +# +# Usage: authentik-grafana-sso-fix.sh (override host: AUTHENTIK_HOST=…) +set -euo pipefail +HOST="${AUTHENTIK_HOST:-dankclaw}" + +ssh "$HOST" 'podman exec -i authentik-server ak shell' <<'PYEOF' +from authentik.flows.models import Flow +from authentik.providers.oauth2.models import OAuth2Provider + +p = OAuth2Provider.objects.filter(name="grafana").first() +if not p: + raise SystemExit("no OAuth2Provider named 'grafana'") + +# 1. The authorization flow every other provider uses. +want = Flow.objects.filter(slug="default-provider-authorization-implicit-consent").first() +if not want: + raise SystemExit("default-provider-authorization-implicit-consent flow is missing") +if p.authorization_flow_id != want.pk: + p.authorization_flow = want + p.save() + print(f"set grafana.authorization_flow -> {want.slug}") +else: + print(f"grafana.authorization_flow already {want.slug}") + +# 2. The email scope mapping must RETURN A DICT, not a bare string. +m = p.property_mappings.filter(name="grafana-email-scope").first() +if not m: + raise SystemExit("grafana-email-scope mapping is missing") +fixed = 'return {"email": user.email, "email_verified": True}' +if m.expression.strip() != fixed: + m.expression = fixed + m.save() + print("repaired grafana-email-scope -> returns {email, email_verified}") +else: + print("grafana-email-scope expression already correct") + +# 3. The id_token must carry the scope claims; Grafana reads identity from it. +if not p.include_claims_in_id_token: + p.include_claims_in_id_token = True + p.save() + print("enabled include_claims_in_id_token") +else: + print("include_claims_in_id_token already on") +PYEOF + +echo "Done. Verify: sign in at https://grafana.franksdank.cloud via Authentik;" +echo "the round trip must land on / and /api/user must answer 200 with an email."