feat(mcp): per-user auth for the embedded /mcp endpoint

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>
This commit is contained in:
claude code agent 227
2026-06-20 07:19:31 +03:00
parent c8af637654
commit 4a00dfc3b2
10 changed files with 1436 additions and 83 deletions

View File

@@ -7,12 +7,30 @@ import { createDocmostMcpServer } from "./index.js";
* embedding host (the gitmost NestJS server) bridges its raw Node req/res into
* `handleRequest`. One McpServer + transport is created per MCP session and
* kept alive between requests, keyed by the `mcp-session-id` header.
*
* `config` is EITHER a static `DocmostMcpConfig` (back-compat: stdio + the env
* service account, unchanged) OR a `McpConfigResolver` run once per session at
* `initialize` to bind that session to the request's identity.
*/
export function createMcpHttpHandler(config) {
export function createMcpHttpHandler(config, options = {}) {
// One transport (and one McpServer) per MCP session, keyed by session id.
const transports = {};
// Last activity timestamp per session id, used for idle eviction.
const lastSeen = {};
// Anti-session-fixation: the opaque identity key bound to each session at
// initialize. A later request for that session whose key differs is rejected.
const sessionIdentity = {};
// Write a JSON-RPC error and end the response. Used for the 400/401 paths so
// every early rejection is a well-formed JSON-RPC error, not a torn response.
const sendJsonRpcError = (res, statusCode, code, message) => {
res.statusCode = statusCode;
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({
jsonrpc: "2.0",
error: { code, message },
id: null,
}));
};
// Idle session TTL (ms): a session with no activity for this long is evicted.
// Defaults to 30 min; overridable via MCP_SESSION_IDLE_MS.
const idleTtlMs = (() => {
@@ -29,6 +47,7 @@ export function createMcpHttpHandler(config) {
if (now - (lastSeen[sid] ?? 0) > idleTtlMs) {
void transports[sid].close();
delete lastSeen[sid];
delete sessionIdentity[sid];
}
}
}, sweepIntervalMs);
@@ -41,16 +60,23 @@ export function createMcpHttpHandler(config) {
// A new session may only be created by an initialize request without a
// session id.
if (sessionId || !isInitializeRequest(parsedBody)) {
res.statusCode = 400;
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({
jsonrpc: "2.0",
error: {
code: -32000,
message: "Bad Request: no valid session ID provided",
},
id: null,
}));
sendJsonRpcError(res, 400, -32000, "Bad Request: no valid session ID provided");
return;
}
// Resolve the per-session config from the request (per-user identity) when
// a resolver was supplied; otherwise use the static config unchanged. The
// resolver may throw (e.g. bad credentials) — surface a clean 401, never
// a created session.
let sessionConfig;
let identity;
try {
sessionConfig =
typeof config === "function" ? await config(req) : config;
if (options.identify)
identity = await options.identify(req);
}
catch (err) {
sendJsonRpcError(res, 401, -32001, err instanceof Error ? err.message : "Unauthorized");
return;
}
transport = new StreamableHTTPServerTransport({
@@ -58,31 +84,46 @@ export function createMcpHttpHandler(config) {
onsessioninitialized: (sid) => {
transports[sid] = transport;
lastSeen[sid] = Date.now();
// Bind the resolved identity to the new session id for anti-fixation.
if (identity !== undefined)
sessionIdentity[sid] = identity;
},
});
transport.onclose = () => {
const sid = transport.sessionId;
if (sid && transports[sid])
delete transports[sid];
if (sid)
delete sessionIdentity[sid];
};
const server = createDocmostMcpServer(config);
const server = createDocmostMcpServer(sessionConfig);
await server.connect(transport);
await transport.handleRequest(req, res, parsedBody);
return;
}
if (!transport) {
res.statusCode = 400;
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({
jsonrpc: "2.0",
error: {
code: -32000,
message: "Bad Request: no valid session ID provided",
},
id: null,
}));
sendJsonRpcError(res, 400, -32000, "Bad Request: no valid session ID provided");
return;
}
// Anti-session-fixation: a request reusing an existing session id must
// present credentials/token that resolve to the SAME identity bound at
// initialize, otherwise reject with 401. This prevents hijacking another
// user's established session by replaying its session id with different
// credentials.
if (options.identify && sessionId && sessionId in sessionIdentity) {
let presented;
try {
presented = await options.identify(req);
}
catch (err) {
sendJsonRpcError(res, 401, -32001, err instanceof Error ? err.message : "Unauthorized");
return;
}
if (presented !== sessionIdentity[sessionId]) {
sendJsonRpcError(res, 401, -32001, "Credentials do not match the user that owns this MCP session.");
return;
}
}
// Routing to an existing transport: refresh its idle timestamp.
if (sessionId)
lastSeen[sessionId] = Date.now();

View File

@@ -4,17 +4,71 @@ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
import { createDocmostMcpServer, DocmostMcpConfig } from "./index.js";
/**
* Per-request config resolver. Run ONCE per MCP session, at the `initialize`
* POST, so the session's DocmostClient is bound to that request's identity
* (e.g. the HTTP-Basic user the embedding host validated). Back-compat: a plain
* `DocmostMcpConfig` object is still accepted (stdio + service account), in
* which case the resolver branch is never taken.
*/
export type McpConfigResolver = (
req: IncomingMessage,
) => DocmostMcpConfig | Promise<DocmostMcpConfig>;
/**
* Optional anti-session-fixation hook. When supplied, it is called on EVERY
* request (init and subsequent) to derive an opaque identity key for the
* presented credentials/token. The key resolved at session init is bound to the
* `mcp-session-id`; a later request whose key differs is rejected with 401, so
* a caller cannot hijack another user's established session by reusing its
* session id with different credentials. The key is opaque to this package (the
* embedding host decides what identity means, e.g. the user's `sub`/email), so
* the package stays generic. Throwing here surfaces as a 401 as well.
*/
export interface McpHttpOptions {
identify?: (req: IncomingMessage) => string | Promise<string>;
}
/**
* Build a stateful Streamable-HTTP handler for the Docmost MCP server. The
* embedding host (the gitmost NestJS server) bridges its raw Node req/res into
* `handleRequest`. One McpServer + transport is created per MCP session and
* kept alive between requests, keyed by the `mcp-session-id` header.
*
* `config` is EITHER a static `DocmostMcpConfig` (back-compat: stdio + the env
* service account, unchanged) OR a `McpConfigResolver` run once per session at
* `initialize` to bind that session to the request's identity.
*/
export function createMcpHttpHandler(config: DocmostMcpConfig) {
export function createMcpHttpHandler(
config: DocmostMcpConfig | McpConfigResolver,
options: McpHttpOptions = {},
) {
// One transport (and one McpServer) per MCP session, keyed by session id.
const transports: Record<string, StreamableHTTPServerTransport> = {};
// Last activity timestamp per session id, used for idle eviction.
const lastSeen: Record<string, number> = {};
// Anti-session-fixation: the opaque identity key bound to each session at
// initialize. A later request for that session whose key differs is rejected.
const sessionIdentity: Record<string, string> = {};
// Write a JSON-RPC error and end the response. Used for the 400/401 paths so
// every early rejection is a well-formed JSON-RPC error, not a torn response.
const sendJsonRpcError = (
res: ServerResponse,
statusCode: number,
code: number,
message: string,
): void => {
res.statusCode = statusCode;
res.setHeader("Content-Type", "application/json");
res.end(
JSON.stringify({
jsonrpc: "2.0",
error: { code, message },
id: null,
}),
);
};
// Idle session TTL (ms): a session with no activity for this long is evicted.
// Defaults to 30 min; overridable via MCP_SESSION_IDLE_MS.
@@ -33,6 +87,7 @@ export function createMcpHttpHandler(config: DocmostMcpConfig) {
if (now - (lastSeen[sid] ?? 0) > idleTtlMs) {
void transports[sid].close();
delete lastSeen[sid];
delete sessionIdentity[sid];
}
}
}, sweepIntervalMs);
@@ -51,17 +106,30 @@ export function createMcpHttpHandler(config: DocmostMcpConfig) {
// A new session may only be created by an initialize request without a
// session id.
if (sessionId || !isInitializeRequest(parsedBody)) {
res.statusCode = 400;
res.setHeader("Content-Type", "application/json");
res.end(
JSON.stringify({
jsonrpc: "2.0",
error: {
code: -32000,
message: "Bad Request: no valid session ID provided",
},
id: null,
}),
sendJsonRpcError(
res,
400,
-32000,
"Bad Request: no valid session ID provided",
);
return;
}
// Resolve the per-session config from the request (per-user identity) when
// a resolver was supplied; otherwise use the static config unchanged. The
// resolver may throw (e.g. bad credentials) — surface a clean 401, never
// a created session.
let sessionConfig: DocmostMcpConfig;
let identity: string | undefined;
try {
sessionConfig =
typeof config === "function" ? await config(req) : config;
if (options.identify) identity = await options.identify(req);
} catch (err) {
sendJsonRpcError(
res,
401,
-32001,
err instanceof Error ? err.message : "Unauthorized",
);
return;
}
@@ -70,33 +138,60 @@ export function createMcpHttpHandler(config: DocmostMcpConfig) {
onsessioninitialized: (sid: string) => {
transports[sid] = transport!;
lastSeen[sid] = Date.now();
// Bind the resolved identity to the new session id for anti-fixation.
if (identity !== undefined) sessionIdentity[sid] = identity;
},
});
transport.onclose = () => {
const sid = transport!.sessionId;
if (sid && transports[sid]) delete transports[sid];
if (sid) delete sessionIdentity[sid];
};
const server = createDocmostMcpServer(config);
const server = createDocmostMcpServer(sessionConfig);
await server.connect(transport);
await transport.handleRequest(req, res, parsedBody);
return;
}
if (!transport) {
res.statusCode = 400;
res.setHeader("Content-Type", "application/json");
res.end(
JSON.stringify({
jsonrpc: "2.0",
error: {
code: -32000,
message: "Bad Request: no valid session ID provided",
},
id: null,
}),
sendJsonRpcError(
res,
400,
-32000,
"Bad Request: no valid session ID provided",
);
return;
}
// Anti-session-fixation: a request reusing an existing session id must
// present credentials/token that resolve to the SAME identity bound at
// initialize, otherwise reject with 401. This prevents hijacking another
// user's established session by replaying its session id with different
// credentials.
if (options.identify && sessionId && sessionId in sessionIdentity) {
let presented: string;
try {
presented = await options.identify(req);
} catch (err) {
sendJsonRpcError(
res,
401,
-32001,
err instanceof Error ? err.message : "Unauthorized",
);
return;
}
if (presented !== sessionIdentity[sessionId]) {
sendJsonRpcError(
res,
401,
-32001,
"Credentials do not match the user that owns this MCP session.",
);
return;
}
}
// Routing to an existing transport: refresh its idle timestamp.
if (sessionId) lastSeen[sessionId] = Date.now();
await transport.handleRequest(req, res, parsedBody);

View File

@@ -0,0 +1,234 @@
// 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);
});