security: require auth on all admin/dashboard/user routes

/admin, /dashboard, /dashboard/status, and all /users/* and /keys/*
endpoints were publicly accessible with no authentication, exposing
user management, API key generation, and backend topology to anyone.

- /dashboard and /dashboard/status now require Bearer token
- /admin (user management UI) now requires Bearer token
- All /users/* and /keys/revoke routes now require Bearer token
- /health scrubbed of sensitive fields (token counts, client counts)
- /linkedin/* left public (required for OAuth callback flow)

Auth checks use GATEWAY_STATIC_API_KEY or valid OAuth access tokens,
consistent with the existing /mcp and /status endpoints.
This commit is contained in:
Zac Gaetano 2026-03-31 23:32:11 -04:00
parent c387d80d1b
commit a1a6ef137a
3 changed files with 76 additions and 8 deletions

View file

@ -753,11 +753,7 @@ async def handle_mcp(request: Request) -> Response:
async def health(request: Request) -> JSONResponse: async def health(request: Request) -> JSONResponse:
return JSONResponse({ return JSONResponse({
"status": "healthy", "status": "healthy",
"backends": len(BACKENDS),
"tools": len(TOOL_DEFINITIONS),
"oauth": "enabled", "oauth": "enabled",
"active_tokens": len(ACCESS_TOKENS),
"registered_clients": len(REGISTERED_CLIENTS),
}) })
@ -830,7 +826,10 @@ async def probe_backend(name: str, url: str) -> dict:
async def dashboard_status(request: Request) -> JSONResponse: async def dashboard_status(request: Request) -> JSONResponse:
"""Public endpoint — no auth required — live-probes all backends.""" """Auth-protected endpoint — live-probes all backends."""
token_info = validate_bearer_token(request)
if not token_info:
return JSONResponse({"error": "unauthorized"}, status_code=401)
probes = await asyncio.gather( probes = await asyncio.gather(
*[probe_backend(name, url) for name, url in BACKENDS.items()], *[probe_backend(name, url) for name, url in BACKENDS.items()],
return_exceptions=True return_exceptions=True
@ -983,6 +982,14 @@ DASHBOARD_HTML = """<!DOCTYPE html>
async def dashboard(request: Request) -> Response: 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"'},
)
return Response(DASHBOARD_HTML, media_type="text/html") return Response(DASHBOARD_HTML, media_type="text/html")

View file

@ -5,7 +5,31 @@ Provides a comprehensive web interface for managing users and API keys.
Vue 3 compatible. Vue 3 compatible.
""" """
from starlette.responses import HTMLResponse 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)
USER_DASHBOARD_HTML = """ USER_DASHBOARD_HTML = """
@ -397,6 +421,7 @@ createApp({
</html> </html>
""" """
async def user_management_dashboard(request): async def user_management_dashboard(request: Request):
"""Serve the user management dashboard.""" """Serve the user management dashboard. Requires Bearer token auth."""
if (err := _admin_require_auth(request)): return err
return HTMLResponse(USER_DASHBOARD_HTML) return HTMLResponse(USER_DASHBOARD_HTML)

View file

@ -2,16 +2,45 @@
User Management Routes for MCP Gateway User Management Routes for MCP Gateway
====================================== ======================================
REST endpoints for user, API key, and MCP access control management. REST endpoints for user, API key, and MCP access control management.
All routes require a valid Bearer token (static API key or OAuth token).
""" """
import json import json
import os
import time
import hashlib
from starlette.requests import Request from starlette.requests import Request
from starlette.responses import JSONResponse from starlette.responses import JSONResponse
from user_management import user_manager from user_management import user_manager
STATIC_API_KEY = os.environ.get("GATEWAY_STATIC_API_KEY", "")
def _hash(token: str) -> str:
return hashlib.sha256(token.encode()).hexdigest()
def _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
# Import live token store from gateway_proxy to validate OAuth tokens
try:
from gateway_proxy import ACCESS_TOKENS
token_hash = _hash(token)
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)
async def create_user(request: Request) -> JSONResponse: async def create_user(request: Request) -> JSONResponse:
"""POST /users — Create a new user.""" """POST /users — Create a new user."""
if (err := _require_auth(request)): return err
try: try:
body = await request.json() body = await request.json()
username = body.get('username', '').strip() username = body.get('username', '').strip()
@ -40,6 +69,7 @@ async def create_user(request: Request) -> JSONResponse:
async def list_users(request: Request) -> JSONResponse: async def list_users(request: Request) -> JSONResponse:
"""GET /users — List all users.""" """GET /users — List all users."""
if (err := _require_auth(request)): return err
try: try:
users = user_manager.list_users() users = user_manager.list_users()
return JSONResponse({ return JSONResponse({
@ -53,6 +83,7 @@ async def list_users(request: Request) -> JSONResponse:
async def get_user(request: Request) -> JSONResponse: async def get_user(request: Request) -> JSONResponse:
"""GET /users/{username} — Get user details.""" """GET /users/{username} — Get user details."""
if (err := _require_auth(request)): return err
try: try:
username = request.path_params.get('username') username = request.path_params.get('username')
users = {u['username']: u for u in user_manager.list_users()} users = {u['username']: u for u in user_manager.list_users()}
@ -75,6 +106,7 @@ async def get_user(request: Request) -> JSONResponse:
async def delete_user(request: Request) -> JSONResponse: async def delete_user(request: Request) -> JSONResponse:
"""DELETE /users/{username} — Delete a user.""" """DELETE /users/{username} — Delete a user."""
if (err := _require_auth(request)): return err
try: try:
username = request.path_params.get('username') username = request.path_params.get('username')
user_manager.delete_user(username) user_manager.delete_user(username)
@ -88,6 +120,7 @@ async def delete_user(request: Request) -> JSONResponse:
async def toggle_user(request: Request) -> JSONResponse: async def toggle_user(request: Request) -> JSONResponse:
"""PATCH /users/{username}/enable — Enable/disable a user.""" """PATCH /users/{username}/enable — Enable/disable a user."""
if (err := _require_auth(request)): return err
try: try:
username = request.path_params.get('username') username = request.path_params.get('username')
body = await request.json() body = await request.json()
@ -109,6 +142,7 @@ async def toggle_user(request: Request) -> JSONResponse:
async def generate_api_key(request: Request) -> JSONResponse: async def generate_api_key(request: Request) -> JSONResponse:
"""POST /users/{username}/keys — Generate a new API key.""" """POST /users/{username}/keys — Generate a new API key."""
if (err := _require_auth(request)): return err
try: try:
username = request.path_params.get('username') username = request.path_params.get('username')
body = await request.json() body = await request.json()
@ -132,6 +166,7 @@ async def generate_api_key(request: Request) -> JSONResponse:
async def revoke_api_key(request: Request) -> JSONResponse: async def revoke_api_key(request: Request) -> JSONResponse:
"""DELETE /keys/{key_hash} — Revoke an API key.""" """DELETE /keys/{key_hash} — Revoke an API key."""
if (err := _require_auth(request)): return err
try: try:
key_hash = request.path_params.get('key_hash') key_hash = request.path_params.get('key_hash')
# In practice, we'd need to reconstruct the hash or pass the full key # In practice, we'd need to reconstruct the hash or pass the full key
@ -155,6 +190,7 @@ async def revoke_api_key(request: Request) -> JSONResponse:
async def set_mcp_access(request: Request) -> JSONResponse: async def set_mcp_access(request: Request) -> JSONResponse:
"""PUT /users/{username}/mcp-access — Configure MCP access for a user.""" """PUT /users/{username}/mcp-access — Configure MCP access for a user."""
if (err := _require_auth(request)): return err
try: try:
username = request.path_params.get('username') username = request.path_params.get('username')
body = await request.json() body = await request.json()