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