#!/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", a standard userinfo API_URL # with no `?api=1`, and 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."