diff --git a/gateway-proxy/gateway_proxy.py b/gateway-proxy/gateway_proxy.py
index e712307..235a8cd 100644
--- a/gateway-proxy/gateway_proxy.py
+++ b/gateway-proxy/gateway_proxy.py
@@ -61,6 +61,72 @@ ACCESS_TOKENS: dict[str, dict] = {}
REFRESH_TOKENS: dict[str, dict] = {}
PENDING_AUTH: dict[str, dict] = {}
+# Browser session store (cookie-based login for /admin and /dashboard)
+GUI_SESSIONS: dict[str, float] = {} # token -> expires_at
+GUI_SESSION_TTL = 8 * 3600 # 8 hours
+
+
+def _create_gui_session() -> str:
+ token = secrets.token_hex(32)
+ GUI_SESSIONS[token] = time.time() + GUI_SESSION_TTL
+ return token
+
+
+def _validate_gui_session(request: Request) -> bool:
+ token = request.cookies.get("mcp_session")
+ if not token:
+ return False
+ expires = GUI_SESSIONS.get(token, 0)
+ if expires < time.time():
+ GUI_SESSIONS.pop(token, None)
+ return False
+ return True
+
+
+GUI_LOGIN_HTML = """
+
+
+
+
+ MCP Gateway — Login
+
+
+
+
+
🔒
+
MCP Gateway
+
Enter your gateway password to continue
+ {error_html}
+
+
+
+"""
+
def _hash(value: str) -> str:
return hashlib.sha256(value.encode()).hexdigest()
@@ -746,6 +812,53 @@ async def handle_mcp(request: Request) -> Response:
})
+# ---------------------------------------------------------------------------
+# GUI Login (password → session cookie for /admin and /dashboard)
+# ---------------------------------------------------------------------------
+
+async def gui_login_get(request: Request) -> HTMLResponse:
+ """GET /gui-login — show login form (redirect here when session missing)."""
+ next_url = request.query_params.get("next", "/admin")
+ page = GUI_LOGIN_HTML.format(error_html="", next_url=html.escape(next_url))
+ return HTMLResponse(page)
+
+
+async def gui_login_post(request: Request) -> Response:
+ """POST /gui-login — validate password, set session cookie, redirect."""
+ form = await request.form()
+ password = form.get("password", "")
+ next_url = form.get("next", "/admin")
+ # Sanitise redirect target — only allow relative paths on this host
+ if not next_url.startswith("/") or next_url.startswith("//"):
+ next_url = "/admin"
+
+ if not GATEWAY_PASSWORD or password != GATEWAY_PASSWORD:
+ error_html = 'Incorrect password. Please try again.
'
+ page = GUI_LOGIN_HTML.format(error_html=error_html, next_url=html.escape(next_url))
+ return HTMLResponse(page, status_code=401)
+
+ session_token = _create_gui_session()
+ response = RedirectResponse(next_url, status_code=303)
+ response.set_cookie(
+ "mcp_session", session_token,
+ max_age=GUI_SESSION_TTL,
+ httponly=True,
+ samesite="lax",
+ secure=True,
+ )
+ return response
+
+
+async def gui_logout(request: Request) -> Response:
+ """GET /gui-logout — clear session cookie."""
+ token = request.cookies.get("mcp_session")
+ if token:
+ GUI_SESSIONS.pop(token, None)
+ response = RedirectResponse("/gui-login?next=/admin", status_code=303)
+ response.delete_cookie("mcp_session")
+ return response
+
+
# ---------------------------------------------------------------------------
# Health / Status
# ---------------------------------------------------------------------------
@@ -827,8 +940,7 @@ async def probe_backend(name: str, url: str) -> dict:
async def dashboard_status(request: Request) -> JSONResponse:
"""Auth-protected endpoint — live-probes all backends."""
- token_info = validate_bearer_token(request)
- if not token_info:
+ if not _validate_gui_session(request) and not validate_bearer_token(request):
return JSONResponse({"error": "unauthorized"}, status_code=401)
probes = await asyncio.gather(
*[probe_backend(name, url) for name, url in BACKENDS.items()],
@@ -982,14 +1094,8 @@ DASHBOARD_HTML = """
async def dashboard(request: Request) -> Response:
- token_info = validate_bearer_token(request)
- if not token_info:
- return Response(
- content='{"error": "unauthorized"}',
- status_code=401,
- media_type="application/json",
- headers={"WWW-Authenticate": f'Bearer resource_metadata="{ISSUER_URL}/.well-known/oauth-protected-resource"'},
- )
+ if not _validate_gui_session(request):
+ return RedirectResponse(f"/gui-login?next=/dashboard", status_code=303)
return Response(DASHBOARD_HTML, media_type="text/html")
@@ -1080,6 +1186,9 @@ routes = [
Route("/status", status, methods=["GET"]),
# Dashboard (no auth required)
+ Route("/gui-login", gui_login_get, methods=["GET"]),
+ Route("/gui-login", gui_login_post, methods=["POST"]),
+ Route("/gui-logout", gui_logout, methods=["GET"]),
Route("/dashboard", dashboard, methods=["GET"]),
Route("/dashboard/status", dashboard_status, methods=["GET"]),
diff --git a/gateway-proxy/user_dashboard_ui.py b/gateway-proxy/user_dashboard_ui.py
index c7f937e..b169d28 100644
--- a/gateway-proxy/user_dashboard_ui.py
+++ b/gateway-proxy/user_dashboard_ui.py
@@ -5,31 +5,8 @@ Provides a comprehensive web interface for managing users and API keys.
Vue 3 compatible.
"""
-import os
-import time
-import hashlib
from starlette.requests import Request
-from starlette.responses import HTMLResponse, JSONResponse
-
-_STATIC_API_KEY = os.environ.get("GATEWAY_STATIC_API_KEY", "")
-
-def _admin_require_auth(request: Request):
- """Returns None if authorized, or a 401 JSONResponse if not."""
- auth_header = request.headers.get("Authorization", "")
- if not auth_header.startswith("Bearer "):
- return JSONResponse({"error": "unauthorized"}, status_code=401)
- token = auth_header[7:]
- if _STATIC_API_KEY and token == _STATIC_API_KEY:
- return None
- try:
- from gateway_proxy import ACCESS_TOKENS
- token_hash = hashlib.sha256(token.encode()).hexdigest()
- info = ACCESS_TOKENS.get(token_hash)
- if info and info["expires_at"] >= time.time():
- return None
- except ImportError:
- pass
- return JSONResponse({"error": "unauthorized"}, status_code=401)
+from starlette.responses import HTMLResponse, RedirectResponse
USER_DASHBOARD_HTML = """
@@ -422,6 +399,8 @@ createApp({
"""
async def user_management_dashboard(request: Request):
- """Serve the user management dashboard. Requires Bearer token auth."""
- if (err := _admin_require_auth(request)): return err
+ """Serve the user management dashboard. Requires GUI session cookie."""
+ from gateway_proxy import _validate_gui_session
+ if not _validate_gui_session(request):
+ return RedirectResponse("/gui-login?next=/admin", status_code=303)
return HTMLResponse(USER_DASHBOARD_HTML)