diff --git a/src/beaver_gateway/frontends/admin/frontend.py b/src/beaver_gateway/frontends/admin/frontend.py index 2408d05..5dd480d 100644 --- a/src/beaver_gateway/frontends/admin/frontend.py +++ b/src/beaver_gateway/frontends/admin/frontend.py @@ -32,6 +32,7 @@ import secrets import time from collections import deque from typing import TYPE_CHECKING, Annotated, Any +from urllib.parse import urlsplit import itsdangerous from fastapi import FastAPI, Form, HTTPException, Request, status @@ -98,9 +99,34 @@ __all__ = ["AdminFrontend"] class AdminFrontend(Frontend): """FastAPI app behind ``/login`` / ``/tokens`` / ``/audit`` / ``/``.""" - def __init__(self, *, host: str = "0.0.0.0", port: int = 8002) -> None: # noqa: S104 + def __init__( + self, + *, + host: str = "0.0.0.0", # noqa: S104 + port: int = 8002, + public_base_url: str | None = None, + ) -> None: self.host = host self.port = port + # The reverse proxy in front (Caddy `handle_path /admin/*`, nginx + # `location /admin/`, …) may strip a path prefix before forwarding. + # If it does, the admin's self-emitted URLs (redirects, form + # actions, HTMX `hx-post`, nav links, JS `fetch`, the session + # cookie's `Path`) need that prefix back on or the browser will + # walk off the mounted subtree. We derive the prefix from + # ``public_base_url``'s path component: + # + # public_base_url=https://api.example.com/admin + # → url_prefix = "/admin" + # → links rendered as `/admin/login`, cookie scoped to /admin + # + # Empty / unset = root-mounted; the historical no-proxy behaviour. + if public_base_url: + self.public_base_url = public_base_url.rstrip("/") + self.url_prefix = urlsplit(self.public_base_url).path.rstrip("/") + else: + self.public_base_url = None + self.url_prefix = "" self._runtime: GatewayRuntime | None = None self._app: FastAPI | None = None @@ -136,16 +162,27 @@ class AdminFrontend(Frontend): # self-state. Splitting the function would just trade size for # bookkeeping — same total surface, harder to follow. templates = _build_template_env() + url_prefix = self.url_prefix signer = itsdangerous.URLSafeTimedSerializer( runtime.session_secret, salt=SESSION_SALT ) login_limit = _LoginRateLimit( max_attempts=LOGIN_MAX_ATTEMPTS, window=LOGIN_WINDOW_SECONDS ) + # Cookies scoped to ``url_prefix`` (or ``/`` at the root) so a + # single domain can host multiple gateways without cookie cross- + # talk. Browsers ignore a Set-Cookie / delete-cookie whose Path + # doesn't match, so the matching path also has to flow into + # ``_set_session_cookie`` *and* the logout `delete_cookie`. + cookie_path = url_prefix or "/" app = FastAPI(title="beaver-gateway / admin", docs_url=None, redoc_url=None) def render(name: str, **ctx: Any) -> str: - return templates.get_template(name).render(**ctx) + # ``p`` is the prefix Jinja templates prepend to every + # internal path (``{{ p }}/login``, ``{{ p }}/tokens`` …). + # Injected here rather than on ``templates.globals`` because + # ty types the globals dict against Jinja's built-ins. + return templates.get_template(name).render(p=url_prefix, **ctx) # ---- login ---- @@ -155,7 +192,9 @@ class AdminFrontend(Frontend): # carried in a one-shot query param so a failed POST can # redirect back here without leaking creds in the URL. if _current_user(request, signer): - return RedirectResponse("/", status_code=status.HTTP_303_SEE_OTHER) + return RedirectResponse( + f"{url_prefix}/", status_code=status.HTTP_303_SEE_OTHER + ) error = request.query_params.get("error") return HTMLResponse(render("login.html", error=error)) @@ -204,24 +243,31 @@ class AdminFrontend(Frontend): # the form on refresh. Status-303 forces GET on the # follow-up regardless of the original method. return RedirectResponse( - "/login?error=invalid+credentials", + f"{url_prefix}/login?error=invalid+credentials", status_code=status.HTTP_303_SEE_OTHER, ) login_limit.clear(ip) csrf = secrets.token_urlsafe(24) cookie = signer.dumps({"user": username, "csrf": csrf}) - response = RedirectResponse("/", status_code=status.HTTP_303_SEE_OTHER) - _set_session_cookie(response, cookie) + response = RedirectResponse( + f"{url_prefix}/", status_code=status.HTTP_303_SEE_OTHER + ) + _set_session_cookie(response, cookie, path=cookie_path) _log.info("admin login ok: user=%s ip=%s", username, ip) await audit.log(runtime, actor=f"admin:{username}", kind="login_ok", ip=ip) return response @app.post("/logout") async def logout(request: Request) -> Response: - session = _require_session(request, signer) + session = _require_session(request, signer, url_prefix=url_prefix) await _require_csrf(request, session) - response = RedirectResponse("/login", status_code=status.HTTP_303_SEE_OTHER) - response.delete_cookie(SESSION_COOKIE) + response = RedirectResponse( + f"{url_prefix}/login", status_code=status.HTTP_303_SEE_OTHER + ) + # `path` must match the Set-Cookie path, otherwise the + # browser silently keeps the cookie and re-login looks fine + # but the next request re-uses the stale session. + response.delete_cookie(SESSION_COOKIE, path=cookie_path) await audit.log(runtime, actor=f"admin:{session['user']}", kind="logout") return response @@ -229,7 +275,7 @@ class AdminFrontend(Frontend): @app.get("/", response_class=HTMLResponse) async def dashboard(request: Request) -> Response: - session = _require_session(request, signer) + session = _require_session(request, signer, url_prefix=url_prefix) async with runtime.db.session() as db_session: audit = await list_audit_records(db_session, limit=AUDIT_PAGE_SIZE) tokens = await list_tokens(db_session, include_revoked=False) @@ -251,7 +297,7 @@ class AdminFrontend(Frontend): @app.get("/tokens", response_class=HTMLResponse) async def tokens_page(request: Request) -> Response: - session = _require_session(request, signer) + session = _require_session(request, signer, url_prefix=url_prefix) include_revoked = request.query_params.get("include_revoked") == "1" async with runtime.db.session() as db_session: tokens = await list_tokens(db_session, include_revoked=include_revoked) @@ -272,7 +318,7 @@ class AdminFrontend(Frontend): name: Annotated[str, Form(...)], scope: Annotated[str, Form(...)], ) -> Response: - session = _require_session(request, signer) + session = _require_session(request, signer, url_prefix=url_prefix) await _require_csrf(request, session) name = name.strip() if not name: @@ -324,7 +370,7 @@ class AdminFrontend(Frontend): @app.post("/tokens/{token_id}/revoke", response_class=HTMLResponse) async def tokens_revoke(request: Request, token_id: int) -> Response: - session = _require_session(request, signer) + session = _require_session(request, signer, url_prefix=url_prefix) await _require_csrf(request, session) async with runtime.db.session() as db_session: ok = await revoke_token(db_session, token_id=token_id) @@ -356,7 +402,7 @@ class AdminFrontend(Frontend): @app.get("/audit", response_class=HTMLResponse) async def audit_page(request: Request) -> Response: - session = _require_session(request, signer) + session = _require_session(request, signer, url_prefix=url_prefix) before_raw = request.query_params.get("before") before_id: int | None = None if before_raw and before_raw.isdigit(): @@ -390,7 +436,7 @@ class AdminFrontend(Frontend): # ``source="admin_chat"`` in the detail. @app.get("/chat", response_class=HTMLResponse) async def chat_page(request: Request) -> Response: - session = _require_session(request, signer) + session = _require_session(request, signer, url_prefix=url_prefix) available = [ a for a in runtime.agents if runtime.backends.get(a.name) is not None ] @@ -405,7 +451,7 @@ class AdminFrontend(Frontend): @app.post("/chat/send") async def chat_send(request: Request) -> Response: - session = _require_session(request, signer) + session = _require_session(request, signer, url_prefix=url_prefix) submitted = request.headers.get("x-csrf-token") if not isinstance(submitted, str) or not hmac.compare_digest( submitted, session["csrf"] @@ -651,7 +697,7 @@ async def _sse_events_and_broadcast( _log.exception("turn_log_handler raised; continuing") -def _set_session_cookie(response: Response, value: str) -> None: +def _set_session_cookie(response: Response, value: str, *, path: str = "/") -> None: # ``samesite=lax`` keeps the cookie out of cross-site POSTs but # follows top-level navigation; ``httponly`` keeps it out of JS; # ``secure`` is gated on the deployment scheme — toggled by reverse @@ -664,7 +710,7 @@ def _set_session_cookie(response: Response, value: str) -> None: max_age=SESSION_MAX_AGE, httponly=True, samesite="lax", - path="/", + path=path, ) @@ -688,14 +734,19 @@ def _current_user( def _require_session( - request: Request, signer: itsdangerous.URLSafeTimedSerializer + request: Request, + signer: itsdangerous.URLSafeTimedSerializer, + *, + url_prefix: str = "", ) -> dict[str, Any]: session = _current_user(request, signer) if session is None: # GET endpoints want a redirect (so the browser walks the user # to the login form), not a JSON 401. Mutating endpoints will # still trip CSRF below, so the redirect is harmless for those. - raise HTTPException(status.HTTP_303_SEE_OTHER, headers={"Location": "/login"}) + raise HTTPException( + status.HTTP_303_SEE_OTHER, headers={"Location": f"{url_prefix}/login"} + ) return session diff --git a/src/beaver_gateway/frontends/admin/templates/_layout.html b/src/beaver_gateway/frontends/admin/templates/_layout.html index ca1e248..5084416 100644 --- a/src/beaver_gateway/frontends/admin/templates/_layout.html +++ b/src/beaver_gateway/frontends/admin/templates/_layout.html @@ -185,13 +185,13 @@