feat(dashboard): drop token auth, trust oauth2-proxy X-Forwarded-User

This commit is contained in:
Claude
2026-08-22 18:03:33 +00:00
parent c9809bce9b
commit 99255bb167
4 changed files with 141 additions and 203 deletions
+35 -73
View File
@@ -9,13 +9,13 @@ ThreadingHTTPServer shape) that renders three views off the feedback SQLite:
GET /r/<owner>/<name>/<index> one PR's findings
GET /r/<owner>/<name>/<index>/raw raw Markdown body (via Gitea)
GET /static/style.css CSS
GET /login login form
POST /login check token, set cookie
POST /r/<owner>/<name>/edit mutate .pr-review.json (Tasks C+D)
Auth: when `PRAGENT_DASHBOARD_TOKEN` is set, every route except `/login`,
`/static/*`, and `POST /login` requires `Cookie: pragent_dash=<token>`.
Unset → tailnet-only mode, no auth.
Auth: oauth2-proxy fronts this service in-cluster. Every route except
`/static/*` requires the `X-Forwarded-User` header (set by oauth2-proxy
once the user has logged in via Logto). Missing header → 401 +
`WWW-Authenticate: Basic realm="pragent-dashboard"` so oauth2-proxy
intercepts the response.
DB: `PRAGENT_FEEDBACK_DB` points at the SQLite file the webhook server
also writes. Per-request open (SQLite is cheap, no concurrency hazard,
@@ -33,6 +33,7 @@ import datetime
import html
import json
import os
import secrets
import string
import urllib.error
import urllib.parse
@@ -47,13 +48,15 @@ from pilot import dashboard_data
# ---------------------------------------------------------------------------
FEEDBACK_DB = "" # legacy; readers should call _feedback_db()
DASHBOARD_TOKEN = "" # legacy; readers should call _dashboard_token()
PORT = int(os.environ.get("DASHBOARD_PORT", "8081"))
GITEA_API = "" # legacy; readers should call _gitea_api()
BOT_TOKEN = "" # legacy; readers should call _bot_token()
COOKIE_NAME = "pragent_dash"
# CSRF secret for the edit form. Regenerated per process (each Python
# interpreter launch). Behind oauth2-proxy this is enough — only an
# already-authenticated same-tab request can read this and echo it back.
_CSRF_SECRET: str = secrets.token_urlsafe(24)
# ---------------------------------------------------------------------------
@@ -67,10 +70,6 @@ def _feedback_db() -> str:
return os.environ.get("PRAGENT_FEEDBACK_DB", "")
def _dashboard_token() -> str:
return os.environ.get("PRAGENT_DASHBOARD_TOKEN", "")
def _bot_token() -> str:
return os.environ.get("PRAGENT_BOT_TOKEN", "")
@@ -231,18 +230,6 @@ ${findings_table}
""")
_LOGIN = string.Template("""<h2>Login</h2>
${flash}
<form method="post" action="/login">
<label for="token">Dashboard token</label>
<input type="text" id="token" name="token" autofocus>
<div class="row">
<button type="submit">Sign in</button>
</div>
</form>
""")
# ---------------------------------------------------------------------------
# Small helpers
# ---------------------------------------------------------------------------
@@ -418,10 +405,6 @@ def _pr_body(data: dict, *, repo_url: str) -> str:
)
def _login_body(flash: str = "") -> str:
return _LOGIN.substitute(flash=_esc(flash))
def _page(title: str, body: str, *, repos_first: str = "") -> str:
db_status = _feedback_db() or "(no DB configured)"
return _BASE.substitute(
@@ -467,16 +450,14 @@ def _http(method: str, url: str, *, token: str = "", body: dict | None = None,
def _is_authed(headers) -> bool:
"""True when token matches; false when token unset OR cookie absent/wrong."""
tok = _dashboard_token()
if not tok:
return True # no token → tailnet-only mode
cookie = headers.get("Cookie") or ""
for part in cookie.split(";"):
k, _, v = part.strip().partition("=")
if k == COOKIE_NAME and v == tok:
return True
return False
"""True when oauth2-proxy forwarded a verified user.
oauth2-proxy sets `X-Forwarded-User` (and friends) only after a
successful Logto login + email allowlist check. Unauthenticated
requests never see the header, so the dashboard never has to know
about cookies, secrets, or Logto's token shape.
"""
return bool((headers.get("X-Forwarded-User") or "").strip())
# ---------------------------------------------------------------------------
@@ -505,11 +486,10 @@ def _route_repo(owner: str, name: str) -> bytes:
current_model = cfg.get("model", "")
elif err and err != "404":
flash = f"could not read .pr-review.json: {err}"
csrf = _dashboard_token() or "noauth"
body = _repo_body(
data,
repo_url=repo_url,
csrf=csrf,
csrf=_CSRF_SECRET,
current_model=current_model,
current_static_message=current_static_message,
flash=flash,
@@ -549,25 +529,11 @@ def _route_static_css() -> bytes:
return STYLE_CSS.encode()
def _route_login_get() -> bytes:
return _page("login", _login_body()).encode()
def _route_login_post(token: str) -> tuple[int, dict, bytes]:
"""Compare token to env. Sets cookie on match, 401 otherwise."""
expected = _dashboard_token()
if not expected or token != expected:
return 401, {}, b"invalid token"
cookie = f"{COOKIE_NAME}={expected}; HttpOnly; SameSite=Strict; Path=/"
return 302, {"Set-Cookie": cookie, "Location": "/"}, b""
def _route_edit(owner: str, name: str, form: dict) -> tuple[int, dict, bytes]:
"""Mutate .pr-review.json via the Gitea contents API (Tasks C+D)."""
repo_url = f"{owner}/{name}"
csrf = form.get("_csrf", "")
expected_csrf = _dashboard_token() or "noauth"
if csrf != expected_csrf:
if csrf != _CSRF_SECRET:
return 302, {"Location": f"/r/{repo_url}"}, b""
static_message = (form.get("static_message") or "").strip()[:400]
model = (form.get("model") or "").strip()
@@ -682,19 +648,27 @@ class Handler(BaseHTTPRequestHandler):
self.end_headers()
self.wfile.write(body)
def _unauthorized(self) -> None:
"""401 + Basic challenge so oauth2-proxy intercepts and redirects to Logto."""
body = b"unauthorized\n"
self.send_response(401)
self.send_header("Content-Type", "text/plain; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.send_header("WWW-Authenticate", 'Basic realm="pragent-dashboard"')
self.end_headers()
self.wfile.write(body)
# --- GET -----------------------------------------------------------------
def do_GET(self):
path = self.path
# Static is exempt from auth so the login page can load its CSS.
# Static is exempt from auth (also unauthenticated browser fingerprinting
# noise, but it's the same CSS regardless of viewer).
if path == "/static/style.css":
self._send(200, _route_static_css(), content_type="text/css; charset=utf-8")
return
if path == "/login":
self._send(200, _route_login_get())
return
if not _is_authed(self.headers):
self._redirect("/login")
self._unauthorized()
return
if path == "/" or path == "":
@@ -724,17 +698,8 @@ class Handler(BaseHTTPRequestHandler):
def do_POST(self):
path = self.path
# Login POST is exempt from auth (obviously).
if path == "/login":
length = int(self.headers.get("Content-Length", "0") or "0")
raw = self.rfile.read(length) if length else b""
form = urllib.parse.parse_qs(raw.decode("utf-8", errors="replace"))
token = (form.get("token") or [""])[0]
status, extra, body = _route_login_post(token)
self._send(status, body, content_type="text/plain", extra_headers=extra)
return
if not _is_authed(self.headers):
self._redirect("/login")
self._unauthorized()
return
# /r/<owner>/<name>/edit
m = _EDIT_RE.match(path)
@@ -775,10 +740,7 @@ def main() -> int:
if not _feedback_db():
print("pragent-dashboard: WARNING: PRAGENT_FEEDBACK_DB not set; dashboard will be empty",
flush=True)
if _dashboard_token():
print("pragent-dashboard: auth ON (PRAGENT_DASHBOARD_TOKEN set)", flush=True)
else:
print("pragent-dashboard: auth OFF (tailnet-only mode)", flush=True)
print("pragent-dashboard: auth via oauth2-proxy (X-Forwarded-User required)", flush=True)
server = ThreadingHTTPServer(("0.0.0.0", PORT), Handler)
print(f"pragent-dashboard: listening on :{PORT}", flush=True)
try: