The embedded MCP server acted as a single service account; now each /mcp session authenticates as the current user, so tools run under that user's CASL and edits attribute to them. - HTTP Basic (chosen path): Authorization: Basic email:password, validated server-side via AuthService; the session carries the issued user JWT (not the raw password). Password may contain ':' (split on first only). - Bearer fallback: Authorization: Bearer <access JWT>, verified as ACCESS and additionally checked for an active session + non-disabled user (matching JwtStrategy), so revoked/disabled users are rejected. - Service account stays as an optional fallback (no creds + env configured). - packages/mcp createMcpHttpHandler accepts a per-request config resolver (back-compat: static config / stdio unchanged); identity is bound to the mcp-session-id at init and re-validated from the caller's own credentials on every request (anti session-fixation: a guessed session id can't be reused without matching creds). - A full login (session + audit) happens only once at session init; later requests re-verify credentials via a new non-side-effecting AuthService.verifyUserCredentials (no session/audit spam). - Failed-login limiter (5/60s, keyed per-IP, per-IP+email, and per-email so IP rotation can't brute one account) since direct login bypasses the controller throttler. Only real credential failures count. - MCP_TOKEN shared guard moved off Authorization to an X-MCP-Token header (timing-safe compare); credsConfigured 503 gate replaced by a clear 401. - No secrets logged; all auth resolved before res.hijack() so failures return clean 401 JSON. .env.example marks the service account optional. Implements docs/backlog/mcp-per-user-auth.md (variant L). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
235 lines
8.3 KiB
JavaScript
235 lines
8.3 KiB
JavaScript
// Unit tests for createMcpHttpHandler's config-resolver + anti-fixation hook
|
|
// (http.ts). These assert the wrapper contract WITHOUT depending on the MCP
|
|
// SDK's full initialize handshake succeeding:
|
|
// - a STATIC config is still accepted (back-compat: stdio / service account)
|
|
// and never invokes a resolver;
|
|
// - a RESOLVER is accepted and is invoked exactly once on a session-init POST;
|
|
// - the resolver/identify path runs BEFORE the transport, so a thrown
|
|
// resolver error surfaces as a clean 401 and no session is created.
|
|
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { Readable } from "node:stream";
|
|
import { createMcpHttpHandler } from "../../build/http.js";
|
|
|
|
// A minimal initialize JSON-RPC request body (isInitializeRequest checks
|
|
// method === "initialize" + jsonrpc + an object params with protocolVersion).
|
|
const INIT_BODY = {
|
|
jsonrpc: "2.0",
|
|
id: 1,
|
|
method: "initialize",
|
|
params: {
|
|
protocolVersion: "2025-03-26",
|
|
capabilities: {},
|
|
clientInfo: { name: "test", version: "0.0.0" },
|
|
},
|
|
};
|
|
|
|
// Fake Node req: a readable stream is fine; we pass parsedBody explicitly so the
|
|
// transport never reads the stream, and our resolver short-circuits before that.
|
|
function makeReq({ method = "POST", headers = {} } = {}) {
|
|
const req = new Readable({ read() {} });
|
|
req.method = method;
|
|
req.headers = headers;
|
|
req.push(null);
|
|
return req;
|
|
}
|
|
|
|
// Fake Node res capturing statusCode + body, mimicking just what http.ts uses.
|
|
function makeRes() {
|
|
const chunks = [];
|
|
return {
|
|
statusCode: 200,
|
|
headers: {},
|
|
headersSent: false,
|
|
setHeader(k, v) {
|
|
this.headers[k.toLowerCase()] = v;
|
|
},
|
|
end(data) {
|
|
if (data) chunks.push(data);
|
|
this.headersSent = true;
|
|
this.ended = true;
|
|
},
|
|
body() {
|
|
return chunks.join("");
|
|
},
|
|
};
|
|
}
|
|
|
|
test("static config is accepted and never calls a resolver (back-compat)", async () => {
|
|
// A static config object — the stdio / service-account path. A NON-initialize
|
|
// POST with no session id must hit the 400 branch deterministically, proving
|
|
// the static handler is wired and no resolver is consulted.
|
|
const handler = createMcpHttpHandler({
|
|
apiUrl: "http://127.0.0.1:3000/api",
|
|
email: "svc@example.com",
|
|
password: "secret",
|
|
});
|
|
const req = makeReq({ method: "POST", headers: {} });
|
|
const res = makeRes();
|
|
await handler.handleRequest(req, res, { jsonrpc: "2.0", method: "ping", id: 9 });
|
|
assert.equal(res.statusCode, 400);
|
|
assert.match(res.body(), /no valid session ID/);
|
|
});
|
|
|
|
test("resolver is invoked exactly once on a session-init POST", async () => {
|
|
let calls = 0;
|
|
const handler = createMcpHttpHandler((req) => {
|
|
calls += 1;
|
|
// Throw a sentinel so we observe invocation without driving the full
|
|
// SDK handshake; http.ts turns a resolver throw into a clean 401.
|
|
throw new Error("sentinel-from-resolver");
|
|
});
|
|
const req = makeReq({ method: "POST", headers: {} });
|
|
const res = makeRes();
|
|
await handler.handleRequest(req, res, INIT_BODY);
|
|
assert.equal(calls, 1, "resolver must be called exactly once per init");
|
|
assert.equal(res.statusCode, 401);
|
|
assert.match(res.body(), /sentinel-from-resolver/);
|
|
});
|
|
|
|
test("resolver is NOT invoked for a non-init POST without a session id", async () => {
|
|
let calls = 0;
|
|
const handler = createMcpHttpHandler(() => {
|
|
calls += 1;
|
|
return { apiUrl: "http://127.0.0.1:3000/api", getToken: async () => "t" };
|
|
});
|
|
const req = makeReq({ method: "POST", headers: {} });
|
|
const res = makeRes();
|
|
await handler.handleRequest(req, res, { jsonrpc: "2.0", method: "ping", id: 2 });
|
|
assert.equal(calls, 0);
|
|
assert.equal(res.statusCode, 400);
|
|
});
|
|
|
|
test("identify hook throwing on init surfaces as a clean 401", async () => {
|
|
const handler = createMcpHttpHandler(
|
|
() => ({ apiUrl: "http://127.0.0.1:3000/api", getToken: async () => "t" }),
|
|
{
|
|
identify: () => {
|
|
throw new Error("bad-identity");
|
|
},
|
|
},
|
|
);
|
|
const req = makeReq({ method: "POST", headers: {} });
|
|
const res = makeRes();
|
|
await handler.handleRequest(req, res, INIT_BODY);
|
|
assert.equal(res.statusCode, 401);
|
|
assert.match(res.body(), /bad-identity/);
|
|
});
|
|
|
|
// Drive a REAL initialize handshake (over a loopback http server so the SDK's
|
|
// StreamableHTTPServerTransport gets genuine Node req/res objects), capture the
|
|
// assigned mcp-session-id, then replay subsequent requests to exercise the
|
|
// anti-fixation identify comparison: the SAME identity is accepted (routed to
|
|
// the transport), a DIFFERENT identity is rejected 401, and crucially the
|
|
// per-session config RESOLVER is consulted only ONCE (at init), never on a
|
|
// subsequent request — proving subsequent requests do not re-mint the config.
|
|
test("subsequent request: SAME identity routes through, DIFFERENT identity is 401, resolver runs once", async () => {
|
|
const http = await import("node:http");
|
|
|
|
let resolverCalls = 0;
|
|
let currentIdentity = "user-a";
|
|
const handler = createMcpHttpHandler(
|
|
() => {
|
|
resolverCalls += 1;
|
|
return { apiUrl: "http://127.0.0.1:3000/api", getToken: async () => "t" };
|
|
},
|
|
{ identify: () => currentIdentity },
|
|
);
|
|
|
|
// Loopback server: every request is bridged into the MCP handler with its body
|
|
// parsed from JSON, exactly like the embedding host does.
|
|
const server = http.createServer((req, res) => {
|
|
let raw = "";
|
|
req.on("data", (c) => (raw += c));
|
|
req.on("end", () => {
|
|
const body = raw ? JSON.parse(raw) : undefined;
|
|
handler.handleRequest(req, res, body).catch(() => {
|
|
if (!res.headersSent) {
|
|
res.statusCode = 500;
|
|
res.end();
|
|
}
|
|
});
|
|
});
|
|
});
|
|
await new Promise((r) => server.listen(0, "127.0.0.1", r));
|
|
const { port } = server.address();
|
|
|
|
const call = (headers, body) =>
|
|
new Promise((resolve) => {
|
|
const r = http.request(
|
|
{
|
|
host: "127.0.0.1",
|
|
port,
|
|
method: "POST",
|
|
path: "/mcp",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Accept: "application/json, text/event-stream",
|
|
...headers,
|
|
},
|
|
},
|
|
(resp) => {
|
|
let data = "";
|
|
resp.on("data", (c) => (data += c));
|
|
resp.on("end", () =>
|
|
resolve({
|
|
statusCode: resp.statusCode,
|
|
sessionId: resp.headers["mcp-session-id"],
|
|
body: data,
|
|
}),
|
|
);
|
|
},
|
|
);
|
|
r.end(JSON.stringify(body));
|
|
});
|
|
|
|
try {
|
|
// 1) Establish a session via a real initialize POST (identity = user-a).
|
|
const init = await call({}, INIT_BODY);
|
|
assert.equal(resolverCalls, 1, "resolver runs exactly once at init");
|
|
const sid = init.sessionId;
|
|
assert.ok(sid, "initialize must assign an mcp-session-id");
|
|
|
|
// 2) Subsequent request, SAME identity: not a 401, resolver NOT re-run.
|
|
const ok = await call(
|
|
{ "mcp-session-id": sid },
|
|
{ jsonrpc: "2.0", method: "ping", id: 5 },
|
|
);
|
|
assert.notEqual(ok.statusCode, 401, "same identity must not be rejected");
|
|
assert.equal(resolverCalls, 1, "resolver is NOT re-run on a subsequent request");
|
|
|
|
// 3) Subsequent request, DIFFERENT identity: rejected 401 (anti-fixation).
|
|
currentIdentity = "user-b";
|
|
const bad = await call(
|
|
{ "mcp-session-id": sid },
|
|
{ jsonrpc: "2.0", method: "ping", id: 6 },
|
|
);
|
|
assert.equal(bad.statusCode, 401, "different identity hijack is rejected");
|
|
assert.match(bad.body, /do not match the user/);
|
|
assert.equal(resolverCalls, 1, "still no resolver re-run on the rejected request");
|
|
} finally {
|
|
await new Promise((r) => server.close(r));
|
|
}
|
|
});
|
|
|
|
test("unknown existing session id (non-init, with session header) is 400", async () => {
|
|
// A request carrying a session id that was never established must not consult
|
|
// the resolver or identify hook — it is a plain 400 (no valid session).
|
|
let calls = 0;
|
|
const handler = createMcpHttpHandler(
|
|
() => {
|
|
calls += 1;
|
|
return { apiUrl: "http://127.0.0.1:3000/api", getToken: async () => "t" };
|
|
},
|
|
{ identify: () => "x" },
|
|
);
|
|
const req = makeReq({
|
|
method: "POST",
|
|
headers: { "mcp-session-id": "does-not-exist" },
|
|
});
|
|
const res = makeRes();
|
|
await handler.handleRequest(req, res, { jsonrpc: "2.0", method: "ping", id: 3 });
|
|
assert.equal(res.statusCode, 400);
|
|
assert.equal(calls, 0);
|
|
});
|