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
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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()
Expand Down
61 changes: 61 additions & 0 deletions src/lib/__tests__/adminAuth.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
});
});
35 changes: 35 additions & 0 deletions src/lib/adminAuth.ts
Original file line number Diff line number Diff line change
@@ -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();
};
3 changes: 3 additions & 0 deletions src/lib/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
},
Expand Down
Loading