Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions src/api/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import express, { type Request, type Response, type NextFunction } from "express";

/**
* Bearer-token gate for dashboard mutating API routes.
* Set DASHBOARD_API_TOKEN in the environment; when unset, auth is disabled (dev only).
*/
export function requireDashboardAuth(req: Request, res: Response, next: NextFunction): void {
const expected = process.env.DASHBOARD_API_TOKEN?.trim();
if (!expected) {
next();
return;
}
const header = req.headers.authorization ?? "";
const prefix = "Bearer ";
if (!header.startsWith(prefix)) {
res.status(401).json({ error: "dashboard authentication required" });
return;
}
const token = header.slice(prefix.length).trim();
if (token.length !== expected.length || !timingSafeEqual(token, expected)) {
res.status(401).json({ error: "dashboard authentication required" });
return;
}
next();
}

function timingSafeEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false;
let mismatch = 0;
for (let i = 0; i < a.length; i++) {
mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i);
}
return mismatch === 0;
}

export function installDashboardAuth(app: express.Application): void {
app.use("/api/approvals", requireDashboardAuth);
app.post("/api/approvals/:id/decide", requireDashboardAuth);
}
7 changes: 6 additions & 1 deletion src/api/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import express from "express";
import { WebSocketServer, WebSocket } from "ws";

import { newTask, type CompanyServices, type TaskState } from "../core";
import { installDashboardAuth } from "./auth";

const __dirname = dirname(fileURLToPath(import.meta.url));
const PUBLIC_DIR = resolve(__dirname, "../dashboard/public");
Expand All @@ -19,6 +20,7 @@ export interface HttpServerHandles {
export function createHttpServer(services: CompanyServices): HttpServerHandles {
const app = express();
app.use(express.json());
installDashboardAuth(app);

// Suppress the browser's default favicon request (no asset shipped).
app.get("/favicon.ico", (_req, res) => {
Expand Down Expand Up @@ -83,11 +85,14 @@ export function createHttpServer(services: CompanyServices): HttpServerHandles {
const id = req.params.id;
const body = req.body ?? {};
const decision = body.decision;
const by = typeof body.by === "string" ? body.by : "human";
let by = typeof body.by === "string" ? body.by : "human";
if (decision !== "approved" && decision !== "rejected") {
res.status(400).json({ error: "decision must be 'approved' or 'rejected'" });
return;
}
if (process.env.DASHBOARD_API_TOKEN?.trim()) {
by = "dashboard-operator";
}
const approval = services.policy.decide(id, decision, by);
if (!approval) {
res.status(404).json({ error: "approval not found" });
Expand Down
Loading