Ops-dashboard/ops-agent/src/auth.ts
Scrum4Me Agent 92d450609c feat(auth): shared-secret auth web-app → ops-agent
- ops-agent/src/auth.ts: constant-time compare via timingSafeEqual to prevent timing attacks; store secret as Buffer
- ops-agent/src/index.ts + ops-agent.service: bind on 127.0.0.1:3099 (was 4242, per plan)
- app/api/agent/[...path]/route.ts: Next.js proxy route that verifies ops_session cookie then forwards requests to agent with Authorization: Bearer <secret>
- .env.example + deploy/ops-dashboard.env.example: add OPS_AGENT_SECRET and OPS_AGENT_URL
- README.md: rotation procedure for the shared secret

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 17:22:37 +02:00

32 lines
1.1 KiB
TypeScript

import fs from 'fs';
import { timingSafeEqual } from 'crypto';
import { FastifyRequest, FastifyReply } from 'fastify';
const SECRET_PATH = process.env.OPS_AGENT_SECRET_PATH ?? '/etc/ops-agent/secret';
let secretBuf: Buffer | null = null;
export function loadSecret(): void {
if (!fs.existsSync(SECRET_PATH)) {
console.warn(`[auth] Secret file not found at ${SECRET_PATH} — auth disabled`);
return;
}
const stat = fs.statSync(SECRET_PATH);
if ((stat.mode & 0o177) !== 0) {
console.warn(`[auth] Warning: ${SECRET_PATH} has loose permissions — expected 0640`);
}
secretBuf = Buffer.from(fs.readFileSync(SECRET_PATH, 'utf8').trim());
}
export async function authHook(req: FastifyRequest, reply: FastifyReply): Promise<void> {
if (secretBuf === null) return; // auth disabled
const header = req.headers['authorization'] ?? '';
const token = header.startsWith('Bearer ') ? header.slice(7) : '';
const tokenBuf = Buffer.from(token);
const valid =
tokenBuf.length === secretBuf.length && timingSafeEqual(tokenBuf, secretBuf);
if (!valid) {
await reply.status(401).send({ error: 'unauthorized' });
}
}