diff --git a/.env.example b/.env.example index 506ac28..22d1d72 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,7 @@ APP_ID= PRIVATE_KEY= WEBHOOK_SECRET= MARKETPLACE_WEBHOOK_SECRET= +ADMIN_API_TOKEN= AZURE_OPENAI_API_KEY=your_azure_openai_api_key AZURE_OPENAI_BASE_URL=https://your-resource.cognitiveservices.azure.com/openai/v1 AZURE_OPENAI_DEPLOYMENT=Kimi-K2.6 diff --git a/src/index.ts b/src/index.ts index 585cc6c..da45fb9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,7 @@ import { registerEventHandlers } from "./events/index.js"; import { env } from "./lib/env.js"; import { logger } from "./lib/logger.js"; import { queries, saveInstallation } from "./lib/db.js"; +import { requireAdminApiToken } from "./lib/adminAuth.js"; registerEventHandlers(githubApp); @@ -73,7 +74,7 @@ server.post("/api/github/marketplace", async (c) => { return c.json({ ok: true }); }); -server.post("/api/installations/sync", async (c) => { +server.post("/api/installations/sync", requireAdminApiToken, async (c) => { try { let synced = 0; @@ -116,7 +117,7 @@ server.post("/api/installations/sync", async (c) => { } }); -server.get("/api/installations", (c) => { +server.get("/api/installations", requireAdminApiToken, (c) => { const all = c.req.query("all") === "true"; const rows = all ? queries.getAllInstallations.all() diff --git a/src/lib/__tests__/adminAuth.test.ts b/src/lib/__tests__/adminAuth.test.ts new file mode 100644 index 0000000..5aa4f58 --- /dev/null +++ b/src/lib/__tests__/adminAuth.test.ts @@ -0,0 +1,61 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { Hono } from "hono"; +import { isAuthorizedBearer, requireAdminApiToken } from "../adminAuth.js"; + +function protectedApp() { + const app = new Hono(); + app.get("/protected", requireAdminApiToken, (c) => c.json({ ok: true })); + return app; +} + +describe("admin API auth", () => { + afterEach(() => { + delete process.env.ADMIN_API_TOKEN; + }); + + it("authorizes exact bearer tokens", () => { + expect(isAuthorizedBearer("Bearer secret-token", "secret-token")).toBe(true); + expect(isAuthorizedBearer("bearer secret-token", "secret-token")).toBe(true); + }); + + it("rejects missing, malformed, and incorrect bearer tokens", () => { + expect(isAuthorizedBearer(undefined, "secret-token")).toBe(false); + expect(isAuthorizedBearer("Basic secret-token", "secret-token")).toBe(false); + expect(isAuthorizedBearer("Bearer wrong-token", "secret-token")).toBe(false); + expect(isAuthorizedBearer("Bearer secret-token", "")).toBe(false); + }); + + it("fails closed when the admin token is not configured", async () => { + const res = await protectedApp().request("/protected", { + headers: { authorization: "Bearer anything" }, + }); + + expect(res.status).toBe(503); + await expect(res.json()).resolves.toEqual({ + error: "admin_api_token_not_configured", + }); + }); + + it("rejects unauthenticated requests with a bearer challenge", async () => { + process.env.ADMIN_API_TOKEN = "secret-token"; + + const res = await protectedApp().request("/protected"); + + expect(res.status).toBe(401); + expect(res.headers.get("www-authenticate")).toBe( + 'Bearer realm="superagent-admin"', + ); + await expect(res.json()).resolves.toEqual({ error: "unauthorized" }); + }); + + it("allows requests with the configured admin token", async () => { + process.env.ADMIN_API_TOKEN = "secret-token"; + + const res = await protectedApp().request("/protected", { + headers: { authorization: "Bearer secret-token" }, + }); + + expect(res.status).toBe(200); + await expect(res.json()).resolves.toEqual({ ok: true }); + }); +}); diff --git a/src/lib/adminAuth.ts b/src/lib/adminAuth.ts new file mode 100644 index 0000000..bf28313 --- /dev/null +++ b/src/lib/adminAuth.ts @@ -0,0 +1,35 @@ +import { createHash, timingSafeEqual } from "node:crypto"; +import type { MiddlewareHandler } from "hono"; +import { env } from "./env.js"; +import { logger } from "./logger.js"; + +function sha256(value: string): Buffer { + return createHash("sha256").update(value).digest(); +} + +export function isAuthorizedBearer( + authorizationHeader: string | undefined, + expectedToken: string, +): boolean { + if (!expectedToken) return false; + + const match = authorizationHeader?.match(/^Bearer\s+(.+)$/i); + if (!match) return false; + + return timingSafeEqual(sha256(match[1]), sha256(expectedToken)); +} + +export const requireAdminApiToken: MiddlewareHandler = async (c, next) => { + const adminApiToken = env.adminApiToken; + if (!adminApiToken) { + logger.error("ADMIN_API_TOKEN is required for installation API routes"); + return c.json({ error: "admin_api_token_not_configured" }, 503); + } + + if (!isAuthorizedBearer(c.req.header("authorization"), adminApiToken)) { + c.header("WWW-Authenticate", 'Bearer realm="superagent-admin"'); + return c.json({ error: "unauthorized" }, 401); + } + + await next(); +}; diff --git a/src/lib/env.ts b/src/lib/env.ts index d791034..f97e79c 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -20,6 +20,9 @@ export const env = { get marketplaceWebhookSecret() { return process.env.MARKETPLACE_WEBHOOK_SECRET ?? ""; }, + get adminApiToken() { + return process.env.ADMIN_API_TOKEN ?? ""; + }, get logLevel() { return process.env.LOG_LEVEL ?? "info"; },