dumpd /me reports the decoded discord_id; the gateway page shows it on hover

The quickest way to confirm the Vanguard profile scope mapping is live, without echoing the raw token: the
browser never sees X-Authentik-Jwt, so printing it would hand the user a bearer token for the proxy provider.
Decoding is display-only and never feeds an authorisation decision. Verified against eight inputs including a
numeric claim, a missing claim, a non-token and malformed base64.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-11-03 15:13:27 -06:00
parent ba110747b7
commit 12760a94c3
3 changed files with 28 additions and 2 deletions

View File

@@ -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 `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 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. 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. 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 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. you hold the bot role; `https://live.HOST/me/stream-key` shows your username.

View File

@@ -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); 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; $('who').textContent = d.name && d.name !== d.user ? `${d.name} (${d.user})` : d.user;
$('pills').innerHTML = d.groups.map(g => `<span class="pill">${g}</span>`).join('') || '<span class="pill">no groups yet — ask an admin for a role</span>'; $('pills').innerHTML = d.groups.map(g => `<span class="pill">${g}</span>`).join('') || '<span class="pill">no groups yet — ask an admin for a role</span>';
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 has = g => d.groups.includes(g);
const show = (id, on) => $(id).classList.toggle('hidden', !on); 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) // sections appear only for what this account can actually do (the gates check these same groups)

View File

@@ -46,6 +46,27 @@ TZ = ZoneInfo(os.environ.get("DUMP_TZ", "America/New_York"))
NIGHT_START = time(17, 0) # 5 pm NIGHT_START = time(17, 0) # 5 pm
NIGHT_END = time(2, 0) # 2 am next day 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): def identity(headers):
"""(username, is_admin) from the headers Caddy sets after Authentik; (None, False) if absent. """(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.""" 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"}) 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()}) 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, 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"): if self.path.startswith("/me/clips"):
who, _ = identity(self.headers) who, _ = identity(self.headers)
if not who: return self._json(401, {"error": "not authenticated"}) if not who: return self._json(401, {"error": "not authenticated"})