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
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ NOTION_TOKEN=ntn_...
# Analytics dashboard (optional — protects /analytics and /api/analytics/*)
ANALYTICS_TOKEN=

# MCP OAuth JWT secret — REQUIRED in production (32+ bytes random).
# Generate: openssl rand -hex 32
# If unset in development, an ephemeral secret is generated at startup
# (all issued tokens are invalidated on restart).
MCP_JWT_SECRET=

# Server
PORT=3001
NODE_ENV=development
Expand Down
14 changes: 11 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions src/__tests__/analytics-endpoints.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ vi.mock("../config.js", () => ({
discordBotToken: "",
discordPublicKey: "",
notionToken: "",
mcpJwtSecret: "x".repeat(32),
}),
hasSearchTools: vi.fn().mockReturnValue(false),
hasKnowledgeTools: vi.fn().mockReturnValue(false),
Expand Down Expand Up @@ -296,6 +297,7 @@ describe("analyticsAuth middleware", () => {
discordBotToken: "",
discordPublicKey: "",
notionToken: "",
mcpJwtSecret: "x".repeat(32),
});
const res = mockRes();
const next = vi.fn();
Expand Down Expand Up @@ -329,6 +331,7 @@ describe("analyticsAuth middleware", () => {
discordBotToken: "",
discordPublicKey: "",
notionToken: "",
mcpJwtSecret: "x".repeat(32),
});
const res = mockRes();
const next = vi.fn();
Expand Down
1 change: 1 addition & 0 deletions src/__tests__/analytics-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ vi.mock("../config.js", () => ({
discordBotToken: "",
discordPublicKey: "",
notionToken: "",
mcpJwtSecret: "x".repeat(32),
}),
hasSearchTools: vi.fn().mockReturnValue(false),
hasKnowledgeTools: vi.fn().mockReturnValue(false),
Expand Down
1 change: 1 addition & 0 deletions src/__tests__/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1034,6 +1034,7 @@ describe("config.ts", () => {
process.env.CLONE_DIR = "/tmp/clones";
process.env.GITHUB_TOKEN = "ghp_test";
process.env.GITHUB_WEBHOOK_SECRET = "secret";
process.env.MCP_JWT_SECRET = "x".repeat(64);

mockedExistsSync.mockReturnValue(true);
mockedReadFileSync.mockReturnValue(makeYaml());
Expand Down
193 changes: 193 additions & 0 deletions src/__tests__/oauth-e2e.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
import { describe, it, expect, beforeAll, afterAll, vi } from "vitest";
import express from "express";
import type { Server } from "node:http";
import type { AddressInfo } from "node:net";
import { createHash, randomBytes } from "node:crypto";

// Use a stable secret so the handlers and our verifier both agree.
vi.mock("../config.js", () => ({
getConfig: vi.fn().mockReturnValue({
port: 0,
databaseUrl: "pglite:///tmp/test",
openaiApiKey: "",
githubToken: "",
githubWebhookSecret: "",
nodeEnv: "test",
logLevel: "info",
cloneDir: "/tmp/test",
slackBotToken: "",
slackSigningSecret: "",
discordBotToken: "",
discordPublicKey: "",
notionToken: "",
mcpJwtSecret: "e".repeat(64),
}),
getServerConfig: vi.fn(),
getAnalyticsConfig: vi.fn(),
hasSearchTools: vi.fn().mockReturnValue(false),
hasKnowledgeTools: vi.fn().mockReturnValue(false),
hasCollectTools: vi.fn().mockReturnValue(false),
hasBashSemanticSearch: vi.fn().mockReturnValue(false),
}));

import {
protectedResourceHandler,
authorizationServerHandler,
registerHandler,
authorizeHandler,
tokenHandler,
bearerMiddleware,
type AuthContext,
} from "../oauth/handlers.js";

function base64url(buf: Buffer | string): string {
const b = typeof buf === "string" ? Buffer.from(buf) : buf;
return b
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/g, "");
}

let server: Server;
let baseUrl: string;

beforeAll(async () => {
const app = express();
app.use(express.json());
app.use(express.urlencoded({ extended: false }));

app.get("/.well-known/oauth-protected-resource", protectedResourceHandler);
app.get(
"/.well-known/oauth-authorization-server",
authorizationServerHandler,
);
app.post("/register", registerHandler);
app.get("/authorize", authorizeHandler);
app.post("/token", tokenHandler);

// Stub /mcp that echoes req.auth
app.post(
"/mcp",
bearerMiddleware,
(req: express.Request & { auth?: AuthContext }, res) => {
res.json({ echoed_auth: req.auth ?? null });
},
);

await new Promise<void>((resolve) => {
server = app.listen(0, () => resolve());
});
const addr = server.address() as AddressInfo;
baseUrl = `http://127.0.0.1:${addr.port}`;
});

afterAll(async () => {
await new Promise<void>((resolve, reject) => {
server.close((err) => (err ? reject(err) : resolve()));
});
});

describe("OAuth 2.1 end-to-end ceremonial flow", () => {
it("completes register → authorize → token → /mcp with Bearer", async () => {
// 1. POST /register
const registerRes = await fetch(`${baseUrl}/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
redirect_uris: [`${baseUrl}/cb`],
}),
});
expect(registerRes.status).toBe(201);
const { client_id } = (await registerRes.json()) as { client_id: string };
expect(client_id).toBeTruthy();

// 2. Generate PKCE pair
const verifier = base64url(randomBytes(32));
const challenge = base64url(createHash("sha256").update(verifier).digest());

// 3. GET /authorize
const authorizeUrl = new URL(`${baseUrl}/authorize`);
authorizeUrl.searchParams.set("response_type", "code");
authorizeUrl.searchParams.set("client_id", client_id);
authorizeUrl.searchParams.set("redirect_uri", `${baseUrl}/cb`);
authorizeUrl.searchParams.set("code_challenge", challenge);
authorizeUrl.searchParams.set("code_challenge_method", "S256");
authorizeUrl.searchParams.set("state", "abc");
const authRes = await fetch(authorizeUrl.toString(), {
redirect: "manual",
});
expect(authRes.status).toBe(302);
const location = authRes.headers.get("location");
expect(location).toBeTruthy();
const redirected = new URL(location!);
const code = redirected.searchParams.get("code");
expect(code).toBeTruthy();
expect(redirected.searchParams.get("state")).toBe("abc");

// 4. POST /token (form-encoded)
const form = new URLSearchParams();
form.set("grant_type", "authorization_code");
form.set("code", code!);
form.set("code_verifier", verifier);
form.set("client_id", client_id);
form.set("redirect_uri", `${baseUrl}/cb`);
const tokenRes = await fetch(`${baseUrl}/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: form.toString(),
});
expect(tokenRes.status).toBe(200);
const tokenBody = (await tokenRes.json()) as {
access_token: string;
token_type: string;
expires_in: number;
};
expect(tokenBody.access_token).toBeTruthy();
expect(tokenBody.token_type).toBe("Bearer");
expect(tokenBody.expires_in).toBe(3600);

// 5. POST /mcp with Bearer — should attach req.auth
const mcpRes = await fetch(`${baseUrl}/mcp`, {
method: "POST",
headers: {
Authorization: `Bearer ${tokenBody.access_token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({}),
});
expect(mcpRes.status).toBe(200);
const mcpBody = (await mcpRes.json()) as {
echoed_auth: { sub: string; client_id: string } | null;
};
expect(mcpBody.echoed_auth).toEqual({ sub: "anonymous", client_id });
});

it("/mcp succeeds with no Authorization header (opportunistic)", async () => {
const res = await fetch(`${baseUrl}/mcp`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
expect(res.status).toBe(200);
const body = (await res.json()) as {
echoed_auth: unknown;
};
expect(body.echoed_auth).toBeNull();
});

it("/mcp returns 401 + WWW-Authenticate on garbage token", async () => {
const res = await fetch(`${baseUrl}/mcp`, {
method: "POST",
headers: {
Authorization: "Bearer garbage.token.here",
"Content-Type": "application/json",
},
body: JSON.stringify({}),
});
expect(res.status).toBe(401);
const www = res.headers.get("www-authenticate");
expect(www).toContain('Bearer realm="mcp"');
expect(www).toContain('error="invalid_token"');
});
});
Loading
Loading