feat: API HTTP authentifiée (chat + review) (v0.20.0)

registerApi : login rate-limité → JWT, puis Bearer obligatoire sur
/api/chat, /api/review (+approve/refuse), /api/state. CORS restreint +
rate-limit. Réutilise ChatService + ReviewService. API activée seulement
si auth configurée (apiAuth). 65 tests (5 via inject), 0 vuln.

Palier de risque : privilégié (surface exposée) — montée seulement si
auth présente ; non exposée tant que .env non renseigné.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Kantin-Petit
2026-06-23 02:13:03 +02:00
parent e322ed1167
commit 26debf2fe0
7 changed files with 331 additions and 8 deletions
+11
View File
@@ -6,6 +6,17 @@ incompatibles. Chaque ligne renvoie à un commit dédié (un artefact = un commi
## [Unreleased]
## [0.20.0] — 2026-06-23
### Added
- `src/api/routes.ts` : API HTTP (`registerApi`) — `POST /api/auth/login`
(rate-limit serré), JWT Bearer sur le reste, `POST /api/chat`, `GET /api/review`,
`POST /api/review/:id/{approve,refuse}`, `GET /api/state`. CORS restreint +
rate-limit global. Réutilise `ChatService` + `ReviewService`.
- Config auth API (`CHLOVA_ADMIN_USER/_PASSWORD_HASH/_TOTP_SECRET/_JWT_SECRET`,
`CHLOVA_WEB_ORIGIN`) + helper `apiAuth()` ; API activée seulement si configurée.
- Câblage `index.ts` (API montée si auth présente). Deps `@fastify/cors`,
`@fastify/rate-limit` épinglées. 5 tests API (inject). 65 tests, 0 vuln.
## [0.19.0] — 2026-06-23 — début Phase 4 (UI : auth + service chat)
### Added
- `src/api/auth.ts` : auth surface exposée — mot de passe scrypt (`node:crypto`),
+70 -2
View File
@@ -8,10 +8,12 @@
"name": "chlova-orchestrator",
"version": "0.1.0",
"dependencies": {
"@fastify/cors": "^11.2.0",
"@fastify/rate-limit": "^11.0.0",
"@modelcontextprotocol/sdk": "1.29.0",
"fastify": "5.8.5",
"jose": "^6.2.3",
"otplib": "^13.4.1",
"jose": "6.2.3",
"otplib": "13.4.1",
"pino": "10.3.1",
"zod": "3.24.1"
},
@@ -522,6 +524,26 @@
"fast-uri": "^3.0.0"
}
},
"node_modules/@fastify/cors": {
"version": "11.2.0",
"resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-11.2.0.tgz",
"integrity": "sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"fastify-plugin": "^5.0.0",
"toad-cache": "^3.7.0"
}
},
"node_modules/@fastify/error": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz",
@@ -612,6 +634,27 @@
"ipaddr.js": "^2.1.0"
}
},
"node_modules/@fastify/rate-limit": {
"version": "11.0.0",
"resolved": "https://registry.npmjs.org/@fastify/rate-limit/-/rate-limit-11.0.0.tgz",
"integrity": "sha512-kCs+G59SitZw9TL/ekFe+MrzXk20dEp6zPAM8WEZjFl5Ubvv5ksTbEXYr4jGlBwWAKn78q+NFsj5CN75zXLjaw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"@lukeed/ms": "^2.0.2",
"fastify-plugin": "^5.0.0",
"toad-cache": "^3.7.0"
}
},
"node_modules/@hono/node-server": {
"version": "1.19.14",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
@@ -631,6 +674,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/@lukeed/ms": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz",
"integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/@modelcontextprotocol/sdk": {
"version": "1.29.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
@@ -1867,6 +1919,22 @@
"toad-cache": "^3.7.0"
}
},
"node_modules/fastify-plugin": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz",
"integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT"
},
"node_modules/fastq": {
"version": "1.20.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
+2
View File
@@ -16,6 +16,8 @@
"test:watch": "vitest"
},
"dependencies": {
"@fastify/cors": "11.2.0",
"@fastify/rate-limit": "11.0.0",
"@modelcontextprotocol/sdk": "1.29.0",
"fastify": "5.8.5",
"jose": "6.2.3",
+86
View File
@@ -0,0 +1,86 @@
import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
import cors from "@fastify/cors";
import rateLimit from "@fastify/rate-limit";
import type { ChatService } from "../agent/chat-service.js";
import type { ReviewService } from "../gatekeeper/review.js";
import { login, verifyJwt, type AuthConfig } from "./auth.js";
/**
* API HTTP de la surface exposée (Phase 4). JWT obligatoire sauf /api/auth/login.
* Réutilise ChatService (même comportement que Telegram) et ReviewService.
*
* Sécurité : login rate-limité (anti brute-force), JWT Bearer sur tout le reste,
* CORS restreint à l'origine du SPA (dev) — same-origin en prod.
*/
export interface ApiDeps {
auth: AuthConfig;
chat: ChatService;
review: ReviewService | null;
state: () => Record<string, unknown>;
webOrigin?: string | undefined;
}
export async function registerApi(app: FastifyInstance, deps: ApiDeps): Promise<void> {
await app.register(cors, {
origin: deps.webOrigin ?? false,
methods: ["GET", "POST"],
allowedHeaders: ["content-type", "authorization"],
});
await app.register(rateLimit, { max: 120, timeWindow: "1 minute" });
// Garde JWT : 401 si Bearer absent/invalide.
const requireAuth = async (req: FastifyRequest, reply: FastifyReply): Promise<void> => {
const header = req.headers.authorization ?? "";
const token = header.startsWith("Bearer ") ? header.slice(7) : "";
const sub = token ? await verifyJwt(token, deps.auth.jwtSecret) : null;
if (!sub) {
await reply.code(401).send({ error: "non autorisé" });
}
};
// ── Login (rate-limit serré, anti brute-force) ──────────────────────────
app.post(
"/api/auth/login",
{ config: { rateLimit: { max: 5, timeWindow: "1 minute" } } },
async (req, reply) => {
const body = (req.body ?? {}) as Record<string, unknown>;
const user = String(body.user ?? "");
const password = String(body.password ?? "");
const totp = String(body.totp ?? "");
const token = await login(deps.auth, { user, password, totp });
if (!token) return reply.code(401).send({ error: "identifiants invalides" });
return { token };
},
);
// ── Chat ────────────────────────────────────────────────────────────────
app.post("/api/chat", { preHandler: requireAuth }, async (req, reply) => {
const body = (req.body ?? {}) as Record<string, unknown>;
const message = String(body.message ?? "").trim();
if (!message) return reply.code(400).send({ error: "message vide" });
const replyText = await deps.chat.handle("api:owner", message);
return { reply: replyText };
});
// ── Review ────────────────────────────────────────────────────────────────
app.get("/api/review", { preHandler: requireAuth }, async () => {
return { assets: deps.review ? deps.review.listPending() : [] };
});
const decide = (action: "approve" | "refuse") =>
async (req: FastifyRequest, reply: FastifyReply) => {
if (!deps.review) return reply.code(409).send({ error: "review indisponible (Phase 1)" });
const id = (req.params as { id: string }).id;
try {
const asset = action === "approve" ? deps.review.approve(id) : deps.review.refuse(id);
return { asset };
} catch {
return reply.code(404).send({ error: "asset inconnu" });
}
};
app.post("/api/review/:id/approve", { preHandler: requireAuth }, decide("approve"));
app.post("/api/review/:id/refuse", { preHandler: requireAuth }, decide("refuse"));
// ── État ────────────────────────────────────────────────────────────────
app.get("/api/state", { preHandler: requireAuth }, async () => deps.state());
}
+35
View File
@@ -67,6 +67,17 @@ const schema = z.object({
(v) => (typeof v === "string" && v.length > 0 ? v : undefined),
z.string().url().optional(),
),
// API/UI (Phase 4) — surface exposée, login fort. L'API n'est activée que si
// ces 4 valeurs sont présentes (apiAuth()). Tout optionnel sinon.
adminUser: z.string().optional(),
adminPasswordHash: z.string().optional(), // SECRET — hash scrypt (cf. scripts/hash-password)
totpSecret: z.string().optional(), // SECRET
jwtSecret: z.string().optional(), // SECRET
webOrigin: z.preprocess(
(v) => (typeof v === "string" && v.length > 0 ? v : undefined),
z.string().url().optional(),
), // origine CORS autorisée (dev) ; en prod le SPA est servi en same-origin
});
export type Config = z.infer<typeof schema>;
@@ -78,6 +89,9 @@ const SECRET_KEYS = new Set<keyof Config>([
"portainerMcpAuthToken",
"telegramBotToken",
"alertWebhookUrl",
"adminPasswordHash",
"totpSecret",
"jwtSecret",
]);
export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
@@ -97,6 +111,11 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
telegramAllowedUserIds: env.TELEGRAM_ALLOWED_USER_IDS,
dbPath: env.CHLOVA_DB_PATH,
alertWebhookUrl: env.ALERT_WEBHOOK_URL,
adminUser: env.CHLOVA_ADMIN_USER,
adminPasswordHash: env.CHLOVA_ADMIN_PASSWORD_HASH,
totpSecret: env.CHLOVA_TOTP_SECRET,
jwtSecret: env.CHLOVA_JWT_SECRET,
webOrigin: env.CHLOVA_WEB_ORIGIN,
});
if (!parsed.success) {
@@ -130,6 +149,22 @@ export function assertReadOnlyPhase(cfg: Config): void {
}
}
/**
* Config d'auth de l'API si les 4 valeurs requises sont présentes, sinon null
* (API/UI désactivée — surface non exposée).
*/
export function apiAuth(cfg: Config): import("./api/auth.js").AuthConfig | null {
if (cfg.adminUser && cfg.adminPasswordHash && cfg.totpSecret && cfg.jwtSecret) {
return {
adminUser: cfg.adminUser,
adminPasswordHash: cfg.adminPasswordHash,
totpSecret: cfg.totpSecret,
jwtSecret: cfg.jwtSecret,
};
}
return null;
}
/** Vue de la config sûre pour les logs : secrets masqués. */
export function redactedConfig(cfg: Config): Record<string, unknown> {
const out: Record<string, unknown> = {};
+25 -6
View File
@@ -1,5 +1,6 @@
import Fastify from "fastify";
import { loadConfig, assertReadOnlyPhase, redactedConfig } from "./config.js";
import Fastify, { type FastifyBaseLogger } from "fastify";
import { loadConfig, assertReadOnlyPhase, redactedConfig, apiAuth } from "./config.js";
import { registerApi } from "./api/routes.js";
import { createLogger } from "./audit/log.js";
import { OllamaClient } from "./llm/ollama.js";
import { McpRegistry } from "./mcp/registry.js";
@@ -103,12 +104,30 @@ async function main(): Promise<void> {
);
// ── Healthcheck interne (jamais publié) ─────────────────────────────────
const app = Fastify({ loggerInstance: logger });
app.get("/health", async () => ({
status: "ok",
// pino Logger est plus étroit que FastifyBaseLogger ; cast pour aligner le
// type d'instance avec registerApi (comportement runtime identique).
const app = Fastify({ loggerInstance: logger as unknown as FastifyBaseLogger });
const stateOf = () => ({
phase: cfg.phase === 2 ? "2-write-review" : "1-readonly",
tools: tools.length,
}));
});
app.get("/health", async () => ({ status: "ok", ...stateOf() }));
// API/UI : activée seulement si l'auth est configurée (surface exposée).
const auth = apiAuth(cfg);
if (auth) {
await registerApi(app, {
auth,
chat,
review,
state: stateOf,
webOrigin: cfg.webOrigin,
});
logger.info("API/UI activée (auth configurée)");
} else {
logger.info("API/UI désactivée (auth non configurée) — surface Telegram seule");
}
await app.listen({ host: "0.0.0.0", port: 8080 });
logger.info({ port: 8080 }, "healthcheck interne prêt");
+102
View File
@@ -0,0 +1,102 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import Fastify, { type FastifyInstance } from "fastify";
import { generateSync, generateSecret } from "otplib";
import { registerApi } from "../src/api/routes.js";
import { hashPassword, type AuthConfig } from "../src/api/auth.js";
import { ChatService } from "../src/agent/chat-service.js";
import { ReviewService } from "../src/gatekeeper/review.js";
import { AssetRepository } from "../src/gatekeeper/repository.js";
import { createAsset } from "../src/gatekeeper/assets.js";
import { createLogger } from "../src/audit/log.js";
const log = createLogger("silent");
const auth: AuthConfig = {
adminUser: "kantin",
adminPasswordHash: hashPassword("pw"),
totpSecret: generateSecret(),
jwtSecret: "jwt-secret-suffisamment-long-pour-hs256",
};
// ChatService stub : pas de vrai LLM.
const chatStub = {
handle: async (_actor: string, text: string) => `echo:${text}`,
} as unknown as ChatService;
let app: FastifyInstance;
let repo: AssetRepository;
beforeEach(async () => {
repo = new AssetRepository(":memory:");
const review = new ReviewService(repo, log);
app = Fastify();
await registerApi(app, { auth, chat: chatStub, review, state: () => ({ phase: "2-write-review", tools: 3 }) });
await app.ready();
});
afterEach(async () => {
await app.close();
repo.close();
});
async function tokenOf(): Promise<string> {
const res = await app.inject({
method: "POST",
url: "/api/auth/login",
payload: { user: "kantin", password: "pw", totp: generateSync({ secret: auth.totpSecret }) },
});
return res.json().token as string;
}
describe("API auth", () => {
it("login renvoie un token avec bons facteurs", async () => {
const res = await app.inject({
method: "POST",
url: "/api/auth/login",
payload: { user: "kantin", password: "pw", totp: generateSync({ secret: auth.totpSecret }) },
});
expect(res.statusCode).toBe(200);
expect(res.json().token).toBeTruthy();
});
it("login 401 avec mauvais mot de passe", async () => {
const res = await app.inject({
method: "POST",
url: "/api/auth/login",
payload: { user: "kantin", password: "bad", totp: generateSync({ secret: auth.totpSecret }) },
});
expect(res.statusCode).toBe(401);
});
it("/api/chat refuse sans token (401)", async () => {
const res = await app.inject({ method: "POST", url: "/api/chat", payload: { message: "salut" } });
expect(res.statusCode).toBe(401);
});
it("/api/chat répond avec token", async () => {
const token = await tokenOf();
const res = await app.inject({
method: "POST",
url: "/api/chat",
headers: { authorization: `Bearer ${token}` },
payload: { message: "salut" },
});
expect(res.statusCode).toBe(200);
expect(res.json().reply).toBe("echo:salut");
});
});
describe("API review", () => {
it("liste, approuve et refuse", async () => {
repo.create(createAsset({ id: "tool:x", type: "tool", version: "1.0.0", riskTier: "privileged" }));
const token = await tokenOf();
const auth_h = { authorization: `Bearer ${token}` };
const list = await app.inject({ method: "GET", url: "/api/review", headers: auth_h });
expect(list.json().assets).toHaveLength(1);
const ok = await app.inject({ method: "POST", url: "/api/review/tool:x/approve", headers: auth_h });
expect(ok.json().asset.status).toBe("approuvé");
const missing = await app.inject({ method: "POST", url: "/api/review/nope/refuse", headers: auth_h });
expect(missing.statusCode).toBe(404);
});
});