feat: add dashboard token auth for android clients

This commit is contained in:
ejclaw
2026-05-25 03:27:49 +09:00
parent 8ef0f5630a
commit fe106645f9
8 changed files with 242 additions and 25 deletions

View File

@@ -46,6 +46,7 @@ describe('config/env loading', () => {
delete process.env.WEB_DASHBOARD_HOST;
delete process.env.WEB_DASHBOARD_PORT;
delete process.env.WEB_DASHBOARD_STATIC_DIR;
delete process.env.WEB_DASHBOARD_TOKEN;
delete process.env.SESSION_COMMAND_USER_IDS;
vi.resetModules();
});
@@ -175,12 +176,14 @@ describe('config/env loading', () => {
expect(config.WEB_DASHBOARD.staticDir).toBe(
path.resolve(tempRoot, 'apps', 'dashboard', 'dist'),
);
expect(config.WEB_DASHBOARD.token).toBe('');
vi.resetModules();
process.env.WEB_DASHBOARD_ENABLED = 'true';
process.env.WEB_DASHBOARD_HOST = '0.0.0.0';
process.env.WEB_DASHBOARD_PORT = '9001';
process.env.WEB_DASHBOARD_STATIC_DIR = './custom-dashboard-dist';
process.env.WEB_DASHBOARD_TOKEN = 'mobile-secret';
config = await import('./config.js');
expect(config.WEB_DASHBOARD).toEqual({
@@ -188,6 +191,7 @@ describe('config/env loading', () => {
host: '0.0.0.0',
port: 9001,
staticDir: path.resolve(tempRoot, 'custom-dashboard-dist'),
token: 'mobile-secret',
});
});

View File

@@ -274,6 +274,7 @@ export function loadConfig(): AppConfig {
readNonEmptyText('WEB_DASHBOARD_STATIC_DIR') ??
path.join(projectRoot, 'apps', 'dashboard', 'dist'),
),
token: readNonEmptyText('WEB_DASHBOARD_TOKEN') ?? '',
},
codexWarmup: {
enabled: readBoolean('CODEX_WARMUP_ENABLED', false),

View File

@@ -83,6 +83,7 @@ export interface AppConfig {
host: string;
port: number;
staticDir: string;
token: string;
};
codexWarmup: {
enabled: boolean;

View File

@@ -0,0 +1,72 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import { afterEach, describe, expect, it } from 'vitest';
import { createWebDashboardHandler } from './web-dashboard-server.js';
const tempDirs: string[] = [];
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
describe('web dashboard API auth', () => {
it('requires a bearer token for API routes when dashboard auth is configured', async () => {
const handler = createWebDashboardHandler({
authToken: 'mobile-secret',
readStatusSnapshots: () => [],
getTasks: () => [],
getPairedTasks: () => [],
startBackgroundCacheRefresh: false,
});
const missing = await handler(new Request('http://localhost/api/health'));
expect(missing.status).toBe(401);
expect(missing.headers.get('www-authenticate')).toBe('Bearer');
const wrong = await handler(
new Request('http://localhost/api/health', {
headers: { authorization: 'Bearer wrong-secret' },
}),
);
expect(wrong.status).toBe(401);
const ok = await handler(
new Request('http://localhost/api/health', {
headers: { authorization: 'Bearer mobile-secret' },
}),
);
expect(ok.status).toBe(200);
await expect(ok.json()).resolves.toEqual({ ok: true });
});
it('accepts the mobile token header and leaves static assets readable', async () => {
const staticDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'ejclaw-dashboard-auth-'),
);
tempDirs.push(staticDir);
fs.writeFileSync(path.join(staticDir, 'index.html'), '<main>ok</main>');
const handler = createWebDashboardHandler({
authToken: 'mobile-secret',
staticDir,
readStatusSnapshots: () => [],
getTasks: () => [],
startBackgroundCacheRefresh: false,
});
const asset = await handler(new Request('http://localhost/'));
expect(asset.status).toBe(200);
await expect(asset.text()).resolves.toContain('<main>ok</main>');
const api = await handler(
new Request('http://localhost/api/health', {
headers: { 'x-ejclaw-dashboard-token': 'mobile-secret' },
}),
);
expect(api.status).toBe(200);
});
});

View File

@@ -1,6 +1,7 @@
import fs from 'fs';
import path from 'path';
import { execFileSync } from 'child_process';
import { timingSafeEqual } from 'crypto';
import { WEB_DASHBOARD } from './config.js';
import {
@@ -67,6 +68,12 @@ const MANAGED_SERVICE_CALLER_FALLBACK_MESSAGE =
const UNIT_NOT_FOUND_PATTERN =
/(Unit .* not found|Could not find the requested service|not-found)/i;
type JsonResponse = (
value: unknown,
init?: ResponseInit,
request?: Request,
) => Response;
export type { ServiceRestartRecord } from './web-dashboard-service-routes.js';
export interface WebDashboardHandlerOptions {
@@ -138,6 +145,7 @@ export interface WebDashboardHandlerOptions {
enqueueMessageCheck?: (chatJid: string, groupFolder: string) => void;
nudgeScheduler?: () => void;
restartServiceStack?: () => string[];
authToken?: string;
now?: () => string;
}
@@ -236,6 +244,43 @@ function serveStaticFile(staticDir: string, pathname: string): Response {
});
}
function extractDashboardAuthToken(request: Request): string {
const authorization = request.headers.get('authorization') ?? '';
const bearer = authorization.match(/^Bearer\s+(.+)$/i);
if (bearer) return bearer[1]!.trim();
return request.headers.get('x-ejclaw-dashboard-token')?.trim() ?? '';
}
function safeTokenEquals(actual: string, expected: string): boolean {
const actualBytes = Buffer.from(actual);
const expectedBytes = Buffer.from(expected);
if (actualBytes.length !== expectedBytes.length) return false;
return timingSafeEqual(actualBytes, expectedBytes);
}
function isDashboardApiAuthorized(request: Request, token: string): boolean {
if (!token) return true;
const actual = extractDashboardAuthToken(request);
return Boolean(actual) && safeTokenEquals(actual, token);
}
function rejectUnauthorizedApi(
url: URL,
request: Request,
token: string,
jsonResponse: JsonResponse,
): Response | null {
if (!url.pathname.startsWith('/api/')) return null;
if (isDashboardApiAuthorized(request, token)) return null;
return jsonResponse(
{ error: 'Unauthorized' },
{
status: 401,
headers: { 'www-authenticate': 'Bearer' },
},
);
}
function isRoot(): boolean {
return typeof process.getuid === 'function' && process.getuid() === 0;
}
@@ -329,7 +374,7 @@ export function createWebDashboardHandler(
const restartServiceStack =
opts.restartServiceStack ?? restartEjclawStackServices;
const statusMaxAgeMs = opts.statusMaxAgeMs ?? DEFAULT_STATUS_MAX_AGE_MS;
const authToken = opts.authToken ?? WEB_DASHBOARD.token;
const roomsTimelineDeps: RoomsTimelineRouteDependencies = {
statusMaxAgeMs,
readSnapshots,
@@ -342,19 +387,22 @@ export function createWebDashboardHandler(
loadRecentDeliveredWorkItemsForChat,
loadRecentChatMessages,
};
if (opts.startBackgroundCacheRefresh !== false) {
startRoomsTimelineCacheRefresh(roomsTimelineDeps);
}
const rememberRoomMessageId = createRoomMessageIdCache();
const inboxDismissTracker = createInboxDismissTracker();
const recentServiceRestarts: ServiceRestartRecord[] = [];
const activeServiceRestartTargets = new Set<string>();
return async (request: Request): Promise<Response> => {
const url = new URL(request.url);
const authFailure = rejectUnauthorizedApi(
url,
request,
authToken,
jsonResponse,
);
if (authFailure) return authFailure;
const scheduledTaskRoute = await handleScheduledTaskRoute({
url,
request,
@@ -368,7 +416,6 @@ export function createWebDashboardHandler(
now: opts.now,
});
if (scheduledTaskRoute) return scheduledTaskRoute;
const inboxActionRoute = await handleInboxActionRoute({
url,
request,
@@ -384,7 +431,6 @@ export function createWebDashboardHandler(
now: opts.now,
});
if (inboxActionRoute) return inboxActionRoute;
const roomMessageRoute = await handleRoomMessageRoute({
url,
request,
@@ -398,7 +444,6 @@ export function createWebDashboardHandler(
now: opts.now,
});
if (roomMessageRoute) return roomMessageRoute;
const roomTimelineRoute = handleRoomTimelineRoute({
url,
request,
@@ -406,7 +451,6 @@ export function createWebDashboardHandler(
...roomsTimelineDeps,
});
if (roomTimelineRoute) return roomTimelineRoute;
const serviceRoute = await handleServiceRoute({
url,
request,
@@ -417,18 +461,15 @@ export function createWebDashboardHandler(
now: opts.now,
});
if (serviceRoute) return serviceRoute;
const settingsRoute = await handleSettingsRoute({
url,
request,
jsonResponse,
});
if (settingsRoute) return settingsRoute;
if (request.method !== 'GET' && request.method !== 'HEAD') {
return jsonResponse({ error: 'Method not allowed' }, { status: 405 });
}
const simpleGetRoute = handleSimpleGetRoute({
url,
statusMaxAgeMs,
@@ -437,7 +478,6 @@ export function createWebDashboardHandler(
jsonResponse,
});
if (simpleGetRoute) return simpleGetRoute;
const overviewRoute = handleOverviewRoute({
url,
jsonResponse,
@@ -450,17 +490,14 @@ export function createWebDashboardHandler(
now: opts.now,
});
if (overviewRoute) return overviewRoute;
if (url.pathname.startsWith('/api/')) {
return jsonResponse({ error: 'Not found' }, { status: 404 });
}
if (!opts.staticDir) {
return new Response('Dashboard static directory is not configured', {
status: 404,
});
}
return serveStaticFile(opts.staticDir, url.pathname);
};
}

View File

@@ -21,18 +21,18 @@ import {
resolveWorkspaceInstallCommand,
} from './workspace-package-manager.js';
describe('workspace package manager helpers', () => {
let tempRoot: string;
let tempRoot: string;
beforeEach(() => {
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-pkgmgr-'));
execFileSyncMock.mockReset();
});
beforeEach(() => {
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-pkgmgr-'));
execFileSyncMock.mockReset();
});
afterEach(() => {
fs.rmSync(tempRoot, { recursive: true, force: true });
});
afterEach(() => {
fs.rmSync(tempRoot, { recursive: true, force: true });
});
describe('workspace package manager detection', () => {
it('detects package managers from packageManager field and lockfiles', () => {
const pnpmRepo = path.join(tempRoot, 'pnpm');
fs.mkdirSync(pnpmRepo, { recursive: true });
@@ -110,7 +110,9 @@ describe('workspace package manager helpers', () => {
commandText: 'bun run build',
});
});
});
describe('workspace dependency installs', () => {
it('installs dependencies once and tracks install fingerprint', () => {
const repoDir = path.join(tempRoot, 'repo');
fs.mkdirSync(repoDir, { recursive: true });
@@ -312,7 +314,9 @@ describe('workspace package manager helpers', () => {
expect(execFileSyncMock).toHaveBeenCalledTimes(2);
expect(hasInstalledNodeModules(repoDir)).toBe(true);
});
});
describe('workspace package manager environment', () => {
it('disables corepack project specs only for lockfile-selected pnpm workspaces under a conflicting ancestor', () => {
const parentDir = path.join(tempRoot, 'parent');
fs.mkdirSync(parentDir, { recursive: true });