From 009fd039a2c6661c6cb2848ce210fa6664b7e01e Mon Sep 17 00:00:00 2001 From: cabbage Date: Fri, 27 Mar 2026 04:28:12 +0800 Subject: [PATCH] feat: optional Bearer-token authentication via API_TOKEN env var Disabled by default (empty API_TOKEN). When set: - All /api/* and /mcp requests require: Authorization: Bearer - Public exemptions: /, /healthz, /static/*, /auth-check - Web UI: pre-flight /auth-check on load; shows token modal if required - Token stored in sessionStorage, sent on every API request - Mid-session 401s re-trigger the token modal - MCP clients must pass the header: Authorization: Bearer Files changed: - app/config.py: api_token field + API_TOKEN env var - app/api/auth.py: Starlette BaseHTTPMiddleware for token enforcement - main.py: register middleware + /auth-check public endpoint - static/js/api.js: token storage, auth header, 401 handler hook - static/js/app.js: auth pre-flight, showTokenModal(), bootstrap() - static/css/components.css: .auth-overlay / .auth-card styles - README.md: API_TOKEN env var docs + MCP client header example --- README.md | 18 +++++++ app/api/auth.py | 67 ++++++++++++++++++++++++ app/config.py | 4 ++ main.py | 9 ++++ static/css/components.css | 80 +++++++++++++++++++++++++++++ static/js/api.js | 36 ++++++++++++- static/js/app.js | 104 +++++++++++++++++++++++++++++++++++--- 7 files changed, 309 insertions(+), 9 deletions(-) create mode 100644 app/api/auth.py diff --git a/README.md b/README.md index 71156aa..09c2789 100644 --- a/README.md +++ b/README.md @@ -492,6 +492,7 @@ Server-Sent Events endpoint for live UI updates. - [x] MCP stateless/stateful mode configurable via `MCP_STATELESS` env var (default `true`). - [x] Per-agent generation counter prevents abandoned (timed-out) coroutines from silently consuming instructions meant for newer calls. - [x] `tests/test_wakeup.py` covers both immediate-wakeup timing and concurrent-call generation safety. + - [x] Optional Bearer-token authentication via `API_TOKEN` env var (disabled by default); web UI prompts for token on first load. - [ ] **Documentation and developer experience** - [x] Document local run instructions. @@ -564,6 +565,7 @@ The server starts on `http://localhost:8000` by default. | `DEFAULT_EMPTY_RESPONSE` | _(empty)_ | Default response when queue is empty | | `AGENT_STALE_AFTER_SECONDS` | `30` | Seconds of inactivity before agent shown as idle | | `MCP_STATELESS` | `true` | `true` for stateless sessions (survives restarts, recommended); `false` for stateful | +| `API_TOKEN` | _(empty)_ | When set, all `/api/*` and `/mcp` requests require `Authorization: Bearer `; web UI prompts for the token on first load | ### Configuring an MCP client (agent) @@ -580,6 +582,22 @@ Point the agent's MCP client to the streamable-HTTP transport: } ``` +If `API_TOKEN` is set, include the token as a request header: + +```json +{ + "mcpServers": { + "local-mcp": { + "url": "http://localhost:8000/mcp", + "transport": "streamable-http", + "headers": { + "Authorization": "Bearer " + } + } + } +} +``` + The agent should call `get_user_request` aggressively and continuously — **do not end the working session**. Every call returns the next pending instruction (if any). When the queue is empty the tool waits up to `wait_seconds` before returning an empty/default response, so the agent should loop and call again. ## 10. Implementation Notes for Future Work diff --git a/app/api/auth.py b/app/api/auth.py new file mode 100644 index 0000000..347d783 --- /dev/null +++ b/app/api/auth.py @@ -0,0 +1,67 @@ +""" +app/api/auth.py +Optional Bearer-token authentication middleware. + +When API_TOKEN is set in the environment, every request to a protected path +must include the header: + + Authorization: Bearer + +Public paths (no auth required even when a token is configured): + GET / (serves index.html — UI bootstrap) + GET /healthz (health check) + GET /static/* (JS / CSS / fonts — UI assets) + GET /auth-check (lets the UI know whether auth is required) + +All other paths — /api/*, /mcp, /docs, /openapi.json — are protected. +""" + +from __future__ import annotations + +import logging + +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import JSONResponse + +logger = logging.getLogger(__name__) + +# Paths that are always accessible without a token +_PUBLIC_EXACT = frozenset({"/", "/healthz", "/auth-check"}) +_PUBLIC_PREFIXES = ("/static/",) + + +class TokenAuthMiddleware(BaseHTTPMiddleware): + def __init__(self, app, token: str) -> None: + super().__init__(app) + self._token = token + if token: + logger.info("Token authentication ENABLED") + else: + logger.info("Token authentication disabled (set API_TOKEN to enable)") + + async def dispatch(self, request: Request, call_next): + # Auth disabled → pass through + if not self._token: + return await call_next(request) + + path = request.url.path + + # Always-public paths + if path in _PUBLIC_EXACT: + return await call_next(request) + for prefix in _PUBLIC_PREFIXES: + if path.startswith(prefix): + return await call_next(request) + + # Validate Bearer token + auth_header = request.headers.get("Authorization", "") + if auth_header == f"Bearer {self._token}": + return await call_next(request) + + logger.warning("Unauthorized request: %s %s", request.method, path) + return JSONResponse( + {"detail": "Unauthorized — provide a valid Bearer token"}, + status_code=401, + ) + diff --git a/app/config.py b/app/config.py index 19262c8..bc24bec 100644 --- a/app/config.py +++ b/app/config.py @@ -33,6 +33,9 @@ class Settings: # Set MCP_STATELESS=false to use stateful sessions (needed for multi-turn MCP flows). mcp_stateless: bool = True + # Authentication — set API_TOKEN env var to enable; empty string disables auth entirely. + api_token: str = "" + def _parse_bool(value: str, default: bool) -> bool: if value.lower() in ("1", "true", "yes", "on"): @@ -54,6 +57,7 @@ def load_settings() -> Settings: agent_stale_after_seconds=int(os.getenv("AGENT_STALE_AFTER_SECONDS", "30")), mcp_server_name=os.getenv("MCP_SERVER_NAME", "local-mcp"), mcp_stateless=_parse_bool(os.getenv("MCP_STATELESS", "true"), default=True), + api_token=os.getenv("API_TOKEN", ""), ) diff --git a/main.py b/main.py index 2160d2c..9952455 100644 --- a/main.py +++ b/main.py @@ -16,6 +16,7 @@ from fastapi.responses import FileResponse, JSONResponse from fastapi.staticfiles import StaticFiles from app.api import routes_config, routes_instructions, routes_status +from app.api.auth import TokenAuthMiddleware from app.config import settings from app.database import init_db from app.logging_setup import configure_logging @@ -60,6 +61,9 @@ def create_app() -> FastAPI: lifespan=lifespan, ) + # --- Token auth middleware (no-op when API_TOKEN is not set) --- + app.add_middleware(TokenAuthMiddleware, token=settings.api_token) + # --- Global exception handler --- @app.exception_handler(Exception) async def global_exception_handler(request: Request, exc: Exception): @@ -69,6 +73,11 @@ def create_app() -> FastAPI: content={"detail": "Internal server error", "error": str(exc)}, ) + # --- Public: lets the UI know whether it needs a token (no auth required) --- + @app.get("/auth-check", include_in_schema=False) + def auth_check(): + return {"auth_required": bool(settings.api_token)} + # --- API routers --- app.include_router(routes_status.router) app.include_router(routes_instructions.router) diff --git a/static/css/components.css b/static/css/components.css index 3165aaa..ebe076e 100644 --- a/static/css/components.css +++ b/static/css/components.css @@ -480,3 +480,83 @@ to { transform: rotate(360deg); } } +/* ── Auth overlay ─────────────────────────────────────────────────────────── */ + +.auth-overlay { + position: fixed; + inset: 0; + z-index: 9000; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.72); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); +} + +.auth-card { + width: 100%; + max-width: 380px; + margin: var(--space-4); + padding: var(--space-8); + background: var(--surface-raised); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: 0 32px 64px rgba(0, 0, 0, 0.48); + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-4); + text-align: center; +} + +.auth-card__icon { + display: flex; + align-items: center; + justify-content: center; + width: 64px; + height: 64px; + border-radius: 50%; + background: color-mix(in srgb, var(--cyan) 10%, transparent); + border: 1px solid color-mix(in srgb, var(--cyan) 25%, transparent); + margin-bottom: var(--space-2); +} + +.auth-card__title { + font-family: var(--font-display); + font-size: var(--text-lg); + font-weight: 700; + color: var(--text-primary); + margin: 0; +} + +.auth-card__desc { + font-size: var(--text-sm); + color: var(--text-muted); + margin: 0; + line-height: 1.6; +} + +.auth-card__desc code { + font-family: var(--font-mono); + font-size: 0.9em; + color: var(--cyan); + background: color-mix(in srgb, var(--cyan) 10%, transparent); + padding: 1px 5px; + border-radius: 3px; +} + +.auth-card__form { + width: 100%; + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +.auth-card__error { + font-size: var(--text-xs); + color: var(--red); + margin: 0; +} + + diff --git a/static/js/api.js b/static/js/api.js index cbd4c95..1952e6f 100644 --- a/static/js/api.js +++ b/static/js/api.js @@ -1,20 +1,52 @@ /** * static/js/api.js * Thin fetch wrappers for all HTTP API endpoints. + * Supports optional Bearer-token authentication. */ const BASE = ''; +const TOKEN_KEY = 'local-mcp-token'; + +// Callback invoked when a 401 is received — set by app.js +let _onUnauthorized = null; + +export function setUnauthorizedHandler(fn) { + _onUnauthorized = fn; +} + +export function getStoredToken() { + return sessionStorage.getItem(TOKEN_KEY) || ''; +} + +export function setStoredToken(token) { + if (token) { + sessionStorage.setItem(TOKEN_KEY, token); + } else { + sessionStorage.removeItem(TOKEN_KEY); + } +} async function request(method, path, body) { const opts = { method, headers: { 'Content-Type': 'application/json' }, }; + + const token = getStoredToken(); + if (token) { + opts.headers['Authorization'] = `Bearer ${token}`; + } + if (body !== undefined) opts.body = JSON.stringify(body); const res = await fetch(BASE + path, opts); if (res.status === 204) return null; + if (res.status === 401) { + if (_onUnauthorized) _onUnauthorized(); + throw new Error('Unauthorized'); + } + const data = await res.json(); if (!res.ok) { const msg = data?.detail || `HTTP ${res.status}`; @@ -27,6 +59,9 @@ export const api = { // Health health: () => request('GET', '/healthz'), + // Auth check (public — no token needed) + authCheck: () => fetch('/auth-check').then(r => r.json()), + // Status status: () => request('GET', '/api/status'), @@ -41,4 +76,3 @@ export const api = { getConfig: () => request('GET', '/api/config'), updateConfig: (patch) => request('PATCH', '/api/config', patch), }; - diff --git a/static/js/app.js b/static/js/app.js index 0740c47..37ddc62 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -4,7 +4,7 @@ * and exports shared utilities like `toast`. */ -import { api } from './api.js'; +import { api, setUnauthorizedHandler, getStoredToken, setStoredToken } from './api.js'; import { state } from './state.js'; import { connectSSE } from './events.js'; import { initInstructions, initComposer, refreshTimestamps } from './instructions.js'; @@ -26,6 +26,73 @@ export function toast(message, type = 'info') { }, 3000); } +// ── Token auth modal ────────────────────────────────────────────────────── + +function showTokenModal(onSuccess) { + // Remove any existing modal + document.getElementById('auth-modal')?.remove(); + + const overlay = document.createElement('div'); + overlay.id = 'auth-modal'; + overlay.className = 'auth-overlay'; + overlay.innerHTML = ` +
+
+ + + + +
+

Authentication Required

+

This server requires a Bearer token.
Enter the value of API_TOKEN to continue.

+
+ + +
+ +
+ `; + + document.body.appendChild(overlay); + + const form = overlay.querySelector('#auth-form'); + const input = overlay.querySelector('#auth-token-input'); + const errorMsg = overlay.querySelector('#auth-error'); + + input.focus(); + + form.addEventListener('submit', async (e) => { + e.preventDefault(); + const token = input.value.trim(); + if (!token) return; + + // Optimistically store and try a quick auth ping + setStoredToken(token); + try { + await api.status(); + overlay.remove(); + onSuccess(); + } catch (err) { + // 401 means wrong token + setStoredToken(''); + input.value = ''; + input.focus(); + errorMsg.style.display = ''; + } + }); +} + // ── Document title badge ────────────────────────────────────────────────── function updateTitle(instructions) { @@ -43,7 +110,6 @@ function initReconnectingIndicator() { serverLed.className = 'led led--amber led--pulse'; serverLed.querySelector('.led__label').textContent = 'Reconnecting…'; } - // The full status update (onopen) will re-set the correct class }); } @@ -92,8 +158,8 @@ function initGlobalSubscriptions() { // ── Init ────────────────────────────────────────────────────────────────── -document.addEventListener('DOMContentLoaded', async () => { - initTheme(); // must run first – sets button icon +async function bootstrap() { + initTheme(); initReconnectingIndicator(); initStatus(); initConfig(); @@ -101,8 +167,30 @@ document.addEventListener('DOMContentLoaded', async () => { initComposer(); initGlobalSubscriptions(); - await loadInitialData(); - connectSSE(); - startPolling(); -}); + // Hook 401 handler so mid-session token expiry shows the modal again + setUnauthorizedHandler(() => { + showTokenModal(async () => { + await loadInitialData(); + connectSSE(); + }); + }); + // Check whether auth is required before doing any authenticated calls + const authInfo = await api.authCheck().catch(() => ({ auth_required: false })); + + if (authInfo.auth_required && !getStoredToken()) { + // Show modal before loading data + showTokenModal(async () => { + await loadInitialData(); + connectSSE(); + startPolling(); + }); + } else { + // No auth, or token already stored — proceed normally + await loadInitialData(); + connectSSE(); + startPolling(); + } +} + +document.addEventListener('DOMContentLoaded', bootstrap);