Add bearer token API authentication

- Auto-generate random 32-char bearer token on first startup, stored in
  secrets.yaml as api.bearerToken
- BearerAuthMiddleware checks Authorization: Bearer <token> on all /api/
  endpoints except /health, /health/reconcile, /events (SSE), and non-API
  paths (frontend static files)
- Development mode (WILD_CENTRAL_ENV=development) skips auth entirely
- Web app ApiClient: add setToken/clearToken/hasToken methods, persist
  token in localStorage, automatically include Authorization header on
  all API requests
- Token can be found in secrets.yaml for CLI/automation use
This commit is contained in:
2026-07-14 12:45:22 +00:00
parent 60f3ca4a3a
commit f5a030fd44
4 changed files with 88 additions and 5 deletions

View File

@@ -21,17 +21,43 @@ interface ErrorResponseBody {
import { getApiBaseUrl } from './config';
const TOKEN_KEY = 'wild-central:token';
export class ApiClient {
private baseUrl: string;
private token: string | null = null;
constructor(baseUrl: string = getApiBaseUrl()) {
this.baseUrl = baseUrl;
// Load token from localStorage on init
try { this.token = localStorage.getItem(TOKEN_KEY); } catch { /* noop */ }
}
getBaseURL(): string {
return this.baseUrl;
}
setToken(token: string) {
this.token = token;
try { localStorage.setItem(TOKEN_KEY, token); } catch { /* noop */ }
}
clearToken() {
this.token = null;
try { localStorage.removeItem(TOKEN_KEY); } catch { /* noop */ }
}
hasToken(): boolean {
return this.token !== null && this.token !== '';
}
private authHeaders(): Record<string, string> {
if (this.token) {
return { 'Authorization': `Bearer ${this.token}` };
}
return {};
}
private async request<T>(
endpoint: string,
options?: RequestInit
@@ -43,6 +69,7 @@ export class ApiClient {
...options,
headers: {
'Content-Type': 'application/json',
...this.authHeaders(),
...options?.headers,
},
});
@@ -104,7 +131,7 @@ export class ApiClient {
async getText(endpoint: string): Promise<string> {
const url = `${this.baseUrl}${endpoint}`;
const response = await fetch(url);
const response = await fetch(url, { headers: this.authHeaders() });
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
@@ -122,7 +149,7 @@ export class ApiClient {
const url = `${this.baseUrl}${endpoint}`;
const response = await fetch(url, {
method: 'PUT',
headers: { 'Content-Type': 'text/plain' },
headers: { 'Content-Type': 'text/plain', ...this.authHeaders() },
body: text,
});