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

@@ -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"})