Skip to content

Commit 31379ac

Browse files
authored
Merge pull request #11 from jeffklassen/fix/trigger-endpoint-auth
fix: require bearer token auth on /trigger endpoint
2 parents bbf89ef + b166979 commit 31379ac

2 files changed

Lines changed: 149 additions & 0 deletions

File tree

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3+
import YAML from "yaml";
4+
import { hashTokenSync } from "../../mcp/config.ts";
5+
import type { McpConfig } from "../../mcp/types.ts";
6+
import { setTriggerDeps, startServer } from "../server.ts";
7+
8+
/**
9+
* Tests that the /trigger endpoint requires bearer token auth
10+
* with operator scope. Closes ghostwright/phantom#9.
11+
*/
12+
describe("/trigger endpoint auth", () => {
13+
const adminToken = "test-trigger-admin-token";
14+
const readToken = "test-trigger-read-token";
15+
const operatorToken = "test-trigger-operator-token";
16+
17+
const mcpConfigPath = "config/mcp.yaml";
18+
let originalMcpYaml: string | null = null;
19+
let server: ReturnType<typeof Bun.serve>;
20+
let baseUrl: string;
21+
22+
beforeAll(() => {
23+
// Back up the existing mcp.yaml so we can restore it after tests
24+
if (existsSync(mcpConfigPath)) {
25+
originalMcpYaml = readFileSync(mcpConfigPath, "utf-8");
26+
}
27+
28+
// Write test tokens to mcp.yaml so loadMcpConfig picks them up
29+
const mcpConfig: McpConfig = {
30+
tokens: [
31+
{ name: "admin", hash: hashTokenSync(adminToken), scopes: ["read", "operator", "admin"] },
32+
{ name: "reader", hash: hashTokenSync(readToken), scopes: ["read"] },
33+
{ name: "operator", hash: hashTokenSync(operatorToken), scopes: ["read", "operator"] },
34+
],
35+
rate_limit: { requests_per_minute: 60, burst: 10 },
36+
};
37+
38+
mkdirSync("config", { recursive: true });
39+
writeFileSync(mcpConfigPath, YAML.stringify(mcpConfig), "utf-8");
40+
41+
// Start server with a random port
42+
server = startServer({ name: "test", port: 0, role: "base" } as never, Date.now());
43+
baseUrl = `http://localhost:${server.port}`;
44+
45+
// Wire trigger deps with a mock runtime
46+
setTriggerDeps({
47+
runtime: {
48+
handleMessage: async () => ({
49+
text: "ok",
50+
cost: { totalUsd: 0 },
51+
durationMs: 0,
52+
}),
53+
} as never,
54+
});
55+
});
56+
57+
afterAll(() => {
58+
server?.stop(true);
59+
// Restore the original mcp.yaml
60+
if (originalMcpYaml !== null) {
61+
writeFileSync(mcpConfigPath, originalMcpYaml, "utf-8");
62+
}
63+
});
64+
65+
const triggerBody = JSON.stringify({ task: "hello" });
66+
67+
test("rejects request with no Authorization header", async () => {
68+
const res = await fetch(`${baseUrl}/trigger`, {
69+
method: "POST",
70+
headers: { "Content-Type": "application/json" },
71+
body: triggerBody,
72+
});
73+
expect(res.status).toBe(401);
74+
const json = (await res.json()) as { status: string; message: string };
75+
expect(json.message).toContain("Missing");
76+
});
77+
78+
test("rejects request with invalid token", async () => {
79+
const res = await fetch(`${baseUrl}/trigger`, {
80+
method: "POST",
81+
headers: {
82+
"Content-Type": "application/json",
83+
Authorization: "Bearer wrong-token",
84+
},
85+
body: triggerBody,
86+
});
87+
expect(res.status).toBe(401);
88+
});
89+
90+
test("rejects read-only token (insufficient scope)", async () => {
91+
const res = await fetch(`${baseUrl}/trigger`, {
92+
method: "POST",
93+
headers: {
94+
"Content-Type": "application/json",
95+
Authorization: `Bearer ${readToken}`,
96+
},
97+
body: triggerBody,
98+
});
99+
expect(res.status).toBe(403);
100+
const json = (await res.json()) as { status: string; message: string };
101+
expect(json.message).toContain("operator");
102+
});
103+
104+
test("accepts operator token", async () => {
105+
const res = await fetch(`${baseUrl}/trigger`, {
106+
method: "POST",
107+
headers: {
108+
"Content-Type": "application/json",
109+
Authorization: `Bearer ${operatorToken}`,
110+
},
111+
body: triggerBody,
112+
});
113+
expect(res.status).toBe(200);
114+
const json = (await res.json()) as { status: string };
115+
expect(json.status).toBe("ok");
116+
});
117+
118+
test("accepts admin token", async () => {
119+
const res = await fetch(`${baseUrl}/trigger`, {
120+
method: "POST",
121+
headers: {
122+
"Content-Type": "application/json",
123+
Authorization: `Bearer ${adminToken}`,
124+
},
125+
body: triggerBody,
126+
});
127+
expect(res.status).toBe(200);
128+
});
129+
});

src/core/server.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import type { AgentRuntime } from "../agent/runtime.ts";
22
import type { SlackChannel } from "../channels/slack.ts";
33
import type { PhantomConfig } from "../config/types.ts";
4+
import { AuthMiddleware } from "../mcp/auth.ts";
5+
import { loadMcpConfig } from "../mcp/config.ts";
46
import type { PhantomMcpServer } from "../mcp/server.ts";
57
import type { MemoryHealth } from "../memory/types.ts";
68
import { handleUiRequest } from "../ui/serve.ts";
@@ -67,7 +69,12 @@ export function setTriggerDeps(deps: TriggerDeps): void {
6769
triggerDeps = deps;
6870
}
6971

72+
let triggerAuth: AuthMiddleware | null = null;
73+
7074
export function startServer(config: PhantomConfig, startedAt: number): ReturnType<typeof Bun.serve> {
75+
const mcpConfig = loadMcpConfig();
76+
triggerAuth = new AuthMiddleware(mcpConfig);
77+
7178
const server = Bun.serve({
7279
port: config.port,
7380
async fetch(req) {
@@ -142,6 +149,19 @@ export function startServer(config: PhantomConfig, startedAt: number): ReturnTyp
142149
}
143150

144151
async function handleTrigger(req: Request): Promise<Response> {
152+
if (!triggerAuth) {
153+
return Response.json({ status: "error", message: "Auth not initialized" }, { status: 503 });
154+
}
155+
156+
const auth = await triggerAuth.authenticate(req);
157+
if (!auth.authenticated) {
158+
return Response.json({ status: "error", message: auth.error }, { status: 401 });
159+
}
160+
161+
if (!triggerAuth.hasScope(auth, "operator")) {
162+
return Response.json({ status: "error", message: "Insufficient scope: operator required" }, { status: 403 });
163+
}
164+
145165
if (!triggerDeps) {
146166
return Response.json({ status: "error", message: "Trigger not configured" }, { status: 503 });
147167
}

0 commit comments

Comments
 (0)