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:
2026-03-27 04:28:12 +08:00
parent 1cc75afe87
commit 009fd039a2
7 changed files with 309 additions and 9 deletions

View File

@@ -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),
};

View File

@@ -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 = `
<div class="auth-card fade-in">
<div class="auth-card__icon">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="var(--cyan)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
</svg>
</div>
<h2 class="auth-card__title">Authentication Required</h2>
<p class="auth-card__desc">This server requires a Bearer token.<br>Enter the value of <code>API_TOKEN</code> to continue.</p>
<form id="auth-form" class="auth-card__form">
<input
id="auth-token-input"
type="password"
class="input"
placeholder="Enter your API token…"
autocomplete="current-password"
spellcheck="false"
autofocus
/>
<button type="submit" class="btn btn--primary" style="width:100%">
<svg class="icon" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>
Unlock
</button>
</form>
<p id="auth-error" class="auth-card__error" style="display:none">Incorrect token — try again.</p>
</div>
`;
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);