diff --git a/README.md b/README.md
index 9a42e61..99d92c9 100644
--- a/README.md
+++ b/README.md
@@ -414,7 +414,10 @@ Design: **Authentik authenticates once for the whole domain; Caddy authorizes pe
`default-provider-invalidation-flow` so that also ends the Authentik login: every logout then forces a full
Discord re-login on the next visit (and picks up group changes). Discord itself may approve silently if
you are still logged into Discord in that browser.
-5. Debug: `https://HOST/whoami` prints the username/groups headers exactly as the apps receive them.
+5. Debug: `https://HOST/whoami` prints the username/groups headers exactly as the apps receive them, and
+ `https://HOST/me` adds the `discord_id` claim decoded from `X-Authentik-Jwt` — the quickest check that the
+ `Vanguard profile` scope mapping is live. The raw token is deliberately never echoed: the browser does not
+ see that header today, and printing it would hand the user a bearer token they otherwise could not read.
Group changes in Authentik apply on the next login — log out at `https://auth.HOST/` and back in.
6. Test in a private window: `https://live.HOST/` → Discord login → portal; `https://bot.HOST/` → 403 unless
you hold the bot role; `https://live.HOST/me/stream-key` shows your username.
diff --git a/about/index.html b/about/index.html
index 43598df..cdab28c 100644
--- a/about/index.html
+++ b/about/index.html
@@ -185,6 +185,7 @@ $('l-clips').href = sub('clips') + '/'; document.querySelectorAll('a.l-clips').f
const r = await fetch('/me', {credentials:'same-origin'}); const d = await r.json(); if (!r.ok) throw new Error(d.error || r.status);
$('who').textContent = d.name && d.name !== d.user ? `${d.name} (${d.user})` : d.user;
$('pills').innerHTML = d.groups.map(g => `${g}`).join('') || 'no groups yet — ask an admin for a role';
+ if (d.discord_id) $('who').title = 'Discord ID ' + d.discord_id; // apps identify you by this, not by your name
const has = g => d.groups.includes(g);
const show = (id, on) => $(id).classList.toggle('hidden', !on);
// sections appear only for what this account can actually do (the gates check these same groups)
diff --git a/bin/dumpd.py b/bin/dumpd.py
index d3bf3c0..1ce3243 100755
--- a/bin/dumpd.py
+++ b/bin/dumpd.py
@@ -46,6 +46,27 @@ TZ = ZoneInfo(os.environ.get("DUMP_TZ", "America/New_York"))
NIGHT_START = time(17, 0) # 5 pm
NIGHT_END = time(2, 0) # 2 am next day
+AUTH_JWT_HEADER = os.environ.get("AUTH_JWT_HEADER", "X-Authentik-Jwt")
+
+def proxy_claim(headers, name):
+ """One claim out of the token the proxy injects, or None.
+
+ Authentik's proxy puts its signed token in X-Authentik-Jwt; a scope mapping on the provider adds the claims
+ we care about (authentik/vanguard-profile-scope-mapping.py supplies discord_id). The payload is only read,
+ never trusted beyond what the sibling headers already assert: all of them come from the same proxy, which is
+ the only thing that can reach this port. Returned for display, never for an authorisation decision here.
+ """
+ token = (headers.get(AUTH_JWT_HEADER) or "").strip()
+ if token.count(".") != 2:
+ return None
+ body = token.split(".")[1]
+ try:
+ claims = json.loads(base64.urlsafe_b64decode(body + "=" * (-len(body) % 4)))
+ except (ValueError, TypeError):
+ return None
+ value = claims.get(name)
+ return str(value) if value not in (None, "") else None
+
def identity(headers):
"""(username, is_admin) from the headers Caddy sets after Authentik; (None, False) if absent.
dumpd binds to loopback only, so these headers can only come from Caddy."""
@@ -440,7 +461,8 @@ class H(BaseHTTPRequestHandler):
if not who: return self._json(401, {"error": "not authenticated"})
groups = sorted({g.strip() for g in re.split(r"[|,]", self.headers.get(AUTH_GROUPS_HEADER) or "") if g.strip()})
return self._json(200, {"user": who, "groups": groups, "admin": is_admin,
- "name": self.headers.get("X-Authentik-Name") or who, "email": self.headers.get("X-Authentik-Email") or ""})
+ "name": self.headers.get("X-Authentik-Name") or who, "email": self.headers.get("X-Authentik-Email") or "",
+ "discord_id": proxy_claim(self.headers, "discord_id")})
if self.path.startswith("/me/clips"):
who, _ = identity(self.headers)
if not who: return self._json(401, {"error": "not authenticated"})