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 <token> - 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 <token> 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
This commit is contained in:
@@ -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),
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user