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:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
|
||||
104
static/js/app.js
104
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 = `
|
||||
<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);
|
||||
|
||||
Reference in New Issue
Block a user