Merge develop into fix/html-embed-hardening (#46)
Some checks failed
Test / test (pull_request) Has been cancelled

Resolve the html-embed.spec.ts conflict as a union: both #46 and #49 (already in
develop) added different test cases to the same file. Keep all of them —
stripHtmlEmbedNodes gets #46's root-node case plus develop's deeply-nested,
non-object and empty-content cases; #46's collectHtmlEmbedSources and
stripDisallowedHtmlEmbedNodes suites and develop's hasHtmlEmbedNode suite all
kept; imports unioned. No production code conflicted.

Full suite green: server 651, client (16 files), editor-ext 56, mcp 247.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
claude_code
2026-06-21 01:59:22 +03:00
127 changed files with 12089 additions and 769 deletions

View File

@@ -58,4 +58,26 @@ describe('describeProviderError', () => {
// 'e | response body: ' + 300 chars + '…'
expect(out.length).toBeLessThan('e | response body: '.length + 305);
});
it('uses the fallback for a numeric or boolean (non-object, non-string) error', () => {
// typeof number / boolean is neither 'object' nor a non-empty 'string', so
// the early branch returns the fallback verbatim.
expect(describeProviderError(500, 'AI stream error')).toBe('AI stream error');
expect(describeProviderError(0, 'AI stream error')).toBe('AI stream error');
expect(describeProviderError(true)).toBe('Unknown error');
expect(describeProviderError(false, 'fb')).toBe('fb');
});
it('statusCode present but message undefined => "<code>:" with no trailing space', () => {
// `${code}: ${undefined ?? ''}`.trim() collapses to just "<code>:".
expect(describeProviderError({ statusCode: 503 })).toBe('503:');
// The trailing space after the colon is trimmed away.
expect(describeProviderError({ statusCode: 503 }).endsWith(': ')).toBe(false);
});
it('object with neither message nor statusCode nor body => fallback', () => {
expect(describeProviderError({}, 'AI stream error')).toBe('AI stream error');
// An object carrying only unrelated keys is still treated as message-less.
expect(describeProviderError({ foo: 'bar' } as never)).toBe('Unknown error');
});
});

View File

@@ -171,4 +171,117 @@ describe('AiService.getChatModel role model override', () => {
expect(aiProviderCredentialsRepo.find).not.toHaveBeenCalled();
expect(secretBox.decryptSecret).not.toHaveBeenCalled();
});
/**
* Build a service whose workspace driver is ollama (no apiKey, with a baseUrl).
* Complements makeService (which configures openai) for the same-driver and
* not-configured ollama cases.
*/
function makeOllamaService(over: { baseUrl?: string } = {}) {
const aiSettings = {
resolve: jest.fn().mockResolvedValue({
driver: 'ollama',
chatModel: 'llama3',
apiKey: undefined,
baseUrl: over.baseUrl ?? 'http://localhost:11434/v1',
}),
};
const aiProviderCredentialsRepo = { find: jest.fn() };
const secretBox = { decryptSecret: jest.fn() };
const service = new AiService(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
aiSettings as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
aiProviderCredentialsRepo as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
secretBox as any,
);
return { service, aiSettings, aiProviderCredentialsRepo, secretBox };
}
it('same-driver ollama override (workspace driver=ollama): reuses the workspace ollama baseUrl, no creds lookup/decrypt', async () => {
// Workspace driver IS ollama. A role that overrides to ollama (same driver)
// legitimately reuses the workspace's configured ollama endpoint — it must
// NOT hit the cross-driver 503 path, NOT query ai_provider_credentials, and
// NOT decrypt anything (ollama needs no key).
const { service, aiProviderCredentialsRepo, secretBox } = makeOllamaService();
const model = await service.getChatModel('ws-1', {
driver: 'ollama',
chatModel: 'llama3.1',
roleName: 'Local',
});
expect(model).toBeDefined();
expect(aiProviderCredentialsRepo.find).not.toHaveBeenCalled();
expect(secretBox.decryptSecret).not.toHaveBeenCalled();
});
it('chatModel-only override on an ollama workspace: reuses the workspace ollama baseUrl, no creds lookup', async () => {
// No override.driver on an ollama workspace => the workspace ollama driver +
// baseUrl are reused; no creds lookup, no decrypt (the cheap public-share
// model-only override path against an ollama workspace).
const { service, aiProviderCredentialsRepo, secretBox } = makeOllamaService();
const model = await service.getChatModel('ws-1', { chatModel: 'mistral' });
expect(model).toBeDefined();
expect(aiProviderCredentialsRepo.find).not.toHaveBeenCalled();
expect(secretBox.decryptSecret).not.toHaveBeenCalled();
});
it('blank chatModel guard: workspace has a driver but a blank chatModel and no override chatModel => AiNotConfiguredException', async () => {
// cfg.driver passes the first guard, but cfg.chatModel is blank and the
// override carries no chatModel, so the effective chatModel is empty.
const aiSettings = {
resolve: jest.fn().mockResolvedValue({
driver: 'openai',
chatModel: '',
apiKey: 'workspace-key',
baseUrl: undefined,
}),
};
const aiProviderCredentialsRepo = { find: jest.fn() };
const secretBox = { decryptSecret: jest.fn() };
const service = new AiService(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
aiSettings as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
aiProviderCredentialsRepo as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
secretBox as any,
);
await expect(
// Override has only a roleName, no chatModel to fill the blank.
service.getChatModel('ws-1', { roleName: 'Writer' }),
).rejects.toBeInstanceOf(AiNotConfiguredException);
});
it('non-ollama driver with a missing apiKey => AiNotConfiguredException', async () => {
// Workspace is openai (non-ollama) with a model but NO apiKey: the combined
// `driver !== ollama && !apiKey` guard must 503.
const aiSettings = {
resolve: jest.fn().mockResolvedValue({
driver: 'openai',
chatModel: 'gpt-4o-mini',
apiKey: undefined,
baseUrl: undefined,
}),
};
const aiProviderCredentialsRepo = { find: jest.fn() };
const secretBox = { decryptSecret: jest.fn() };
const service = new AiService(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
aiSettings as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
aiProviderCredentialsRepo as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
secretBox as any,
);
await expect(service.getChatModel('ws-1')).rejects.toBeInstanceOf(
AiNotConfiguredException,
);
});
});

View File

@@ -5,6 +5,7 @@
// the Authorization header.
import { UnauthorizedException } from '@nestjs/common';
import { timingSafeEqual } from 'node:crypto';
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
import { JwtType } from '../../core/auth/dto/jwt-payload';
import { CREDENTIALS_MISMATCH_MESSAGE } from '../../core/auth/auth.constants';
@@ -291,6 +292,14 @@ export interface BearerVerifyDeps {
workspaceId?: string;
sessionId?: string;
}>;
// The workspace id of THIS MCP instance, when the caller can resolve it (the
// community build is single-workspace, so McpService passes its default
// workspace's id). When provided, the token's `workspaceId` claim MUST equal
// it, mirroring JwtStrategy's `req.raw.workspaceId !== payload.workspaceId`
// guard so a valid ACCESS token from a DIFFERENT workspace cannot be replayed
// against this instance in a multi-workspace deployment. Optional so callers /
// tests that genuinely cannot resolve an instance workspace are unchanged.
expectedWorkspaceId?: string;
// Load the user (or undefined) for the disabled check.
findUser: (
sub: string,
@@ -321,6 +330,19 @@ export async function verifyBearerAccess(
throw new UnauthorizedException(generic);
}
// Bind the token to THIS instance's workspace (mirrors JwtStrategy). When the
// caller resolved an instance workspace id, a token whose `workspaceId` claim
// points at another workspace is rejected, so a valid ACCESS token minted in
// workspace B cannot be replayed against an MCP instance serving workspace A.
// In the single-workspace community build expectedWorkspaceId equals the only
// workspace, so this is a no-op there; it only bites a multi-workspace deploy.
if (
deps.expectedWorkspaceId &&
payload.workspaceId !== deps.expectedWorkspaceId
) {
throw new UnauthorizedException(generic);
}
const user = await deps.findUser(payload.sub, payload.workspaceId);
if (!user || user.deactivatedAt || user.deletedAt) {
throw new UnauthorizedException(generic);
@@ -342,21 +364,129 @@ export async function verifyBearerAccess(
/**
* Detect a genuine JSON-RPC `initialize` request from an already-parsed body.
* Mirrors the @modelcontextprotocol/sdk `isInitializeRequest` signal that
* packages/mcp/src/http.ts uses to decide whether to mint a session, but
* framework/SDK-free so it is unit-testable and usable from the CommonJS
* McpService. An initialize request is a single JSON-RPC object whose `method`
* is exactly 'initialize'; a batch (array) body is never an initialize request.
* Delegates to the @modelcontextprotocol/sdk `isInitializeRequest` predicate —
* the SAME predicate packages/mcp/src/http.ts uses to decide whether to mint a
* session — so the session-minting side (this server) and the session-creating
* side (http.ts) agree EXACTLY on what counts as an initialize request. The SDK
* predicate validates the full InitializeRequest shape (jsonrpc, id, method ===
* 'initialize', params incl. protocolVersion); a bare `{ method: 'initialize' }`
* with no params, a batch (array) body, etc. are NOT initialize requests.
*
* This is the second half of the session-INIT decision: `isSessionInit` is
* (no `mcp-session-id` header) AND `isInitializeRequestBody(body)`. Using it
* ensures the side-effecting login() (user_sessions insert + USER_LOGIN audit +
* lastLoginAt) only runs for a real initialize, never for an arbitrary
* header-less request that http.ts will subsequently 400.
* (no `mcp-session-id` header) AND `isInitializeRequestBody(body)`. Matching the
* SDK predicate exactly ensures the side-effecting login() (user_sessions insert
* + USER_LOGIN audit + lastLoginAt) only runs for a request http.ts will also
* accept as an initialize — never for an arbitrary header-less request that
* http.ts would subsequently 400 (which would otherwise spam the audit log /
* grow user_sessions without ever creating an MCP session).
*/
export function isInitializeRequestBody(body: unknown): boolean {
if (!body || typeof body !== 'object' || Array.isArray(body)) return false;
return (body as { method?: unknown }).method === 'initialize';
return isInitializeRequest(body);
}
/**
* The outcome of McpService.handle's pre-hijack gauntlet, as a pure value the
* caller acts on. Either send a JSON error with a fixed status (`respond`), or
* proceed to hijack the response and delegate to the MCP transport (`hijack`).
* Keeping this a pure decision (no FastifyReply, no res.hijack) makes the
* status/body mapping unit-testable, and guarantees no error path can leak the
* password or Authorization header — the body is only ever a fixed string or the
* UnauthorizedException's own message.
*/
export type McpHandleDecision =
| { kind: 'respond'; status: number; body: { error: string } }
| { kind: 'hijack' };
/**
* Pure mapping of McpService.handle's auth/enablement gauntlet to a response
* decision. Precedence mirrors handle():
* 1. shared X-MCP-Token mismatch -> 401 {error:'Unauthorized'} (no hijack).
* 2. workspace MCP disabled -> 403 {error:'MCP is disabled ...'}.
* 3. resolveSessionConfig threw:
* - an UnauthorizedException -> 401 with err.message (a SPECIFIC reason;
* never the password/header — the message is the only thing surfaced).
* - any other error -> 500 generic 'Internal server error'.
* 4. otherwise (auth resolved) -> hijack and delegate to the transport.
*/
export function mapAuthResultToResponse(input: {
sharedTokenOk: boolean;
enabled: boolean;
error?: unknown;
}): McpHandleDecision {
if (!input.sharedTokenOk) {
return { kind: 'respond', status: 401, body: { error: 'Unauthorized' } };
}
if (!input.enabled) {
return {
kind: 'respond',
status: 403,
body: { error: 'MCP is disabled for this workspace' },
};
}
if (input.error !== undefined) {
if (input.error instanceof UnauthorizedException) {
return {
kind: 'respond',
status: 401,
body: { error: input.error.message },
};
}
return {
kind: 'respond',
status: 500,
body: { error: 'Internal server error' },
};
}
return { kind: 'hijack' };
}
// Result of the EE MFA module's requirement check for the Basic gate. Both
// flags absent/false means MFA does not block the password login.
export interface BasicGateMfaResult {
userHasMfa?: boolean;
requiresMfaSetup?: boolean;
}
/**
* Pure decision logic for the /mcp HTTP-Basic pre-token gate, replicating EXACTLY
* what AuthController.login enforces before issuing a token, so the Basic path is
* not an SSO/MFA bypass. Framework-free (no ModuleRef, no on-disk EE MFA module)
* so the SSO/MFA decision is unit-testable in isolation:
*
* - `ssoEnforced` true -> throw Unauthorized ("enforced SSO"); a password
* login is not allowed on an SSO-enforced workspace.
* - otherwise, `mfa` is the EE MFA module's requirement result (or undefined
* when no EE MFA module is bundled — a community/fork build). If MFA is
* present and the user has MFA enabled OR needs MFA setup, throw Unauthorized
* telling the caller to use a Bearer access token (Basic cannot complete MFA).
* - no SSO + no MFA gate -> resolve (the Basic login is allowed to proceed).
*
* McpService.enforceBasicLoginGate wires the concrete `validateSsoEnforcement`
* result and the lazily-loaded MFA module result into this, so the gate decision
* itself carries no framework dependencies. Throws UnauthorizedException on
* rejection (surfaced as a clean 401); never logs the password.
*/
export function decideBasicGate(input: {
ssoEnforced: boolean;
mfa?: BasicGateMfaResult;
}): void {
if (input.ssoEnforced) {
throw new UnauthorizedException(
'This workspace has enforced SSO login. Use SSO; MCP HTTP Basic is not allowed.',
);
}
const mfa = input.mfa;
if (mfa && (mfa.userHasMfa || mfa.requiresMfaSetup)) {
throw new UnauthorizedException(
'This account requires multi-factor authentication. MCP HTTP Basic ' +
'cannot complete MFA — log in normally and use a Bearer access token ' +
'instead.',
);
}
}
/** Extract a Bearer token from an Authorization header (case-insensitive). */

View File

@@ -0,0 +1,259 @@
import { UnauthorizedException } from '@nestjs/common';
// ---------------------------------------------------------------------------
// These tests exercise the REAL McpService.enforceBasicLoginGate (the pre-token
// SSO/MFA gate on the /mcp HTTP-Basic path). Unlike the resolveMcpSessionConfig
// tests in mcp.service.spec.ts — which STUB the gate and only assert it runs
// before login()/verifyCredentials — here the gate logic is instantiated for
// real and only its LEAF dependencies are mocked:
// - the workspace object (plain object with/without enforceSso),
// - the user credentials (plain object),
// - the lazily-required EE MFA module (jest.mock with { virtual: true } so we
// can simulate BOTH "bundled" and "not bundled" community-build states),
// - the injected MfaService instance (via a stub moduleRef).
//
// McpService cannot normally be imported under jest because it imports
// AuthService, which drags in the React email-template graph
// (@docmost/transactional/emails/*) that the jest moduleNameMapper does not
// resolve. We therefore mock the heavy collaborator modules (auth.service,
// token.service, the @docmost/db repos and mcp-auth.helpers) at the module
// level so importing mcp.service.ts succeeds. None of those are touched by the
// gate itself, so the gate runs unmodified against the real code path.
// ---------------------------------------------------------------------------
// The EE MFA module specifier the jest.mock below intercepts MUST be
// byte-for-byte the specifier that mcp.service.ts lazily require()s
// ('./../../ee/mfa/services/mfa.service'). jest.mock is hoisted above all
// non-hoisted code, so the path is inlined as a literal in the call below
// rather than referenced through a const (which would not yet be initialised).
// `{ virtual: true }` is required because the EE module does not exist in this
// OSS build (there is no src/ee directory) — without it jest cannot register a
// mock for a path it cannot resolve on disk.
// Mutable handle the virtual mock factory reads, so each test can decide whether
// the EE module is "bundled" (factory returns a MfaService class) or "not
// bundled" (factory throws, mimicking the require() failing on a community
// build). jest.mock is hoisted, so the factory must close over this lazily.
let mfaModuleState: { bundled: boolean; checkMfaRequirements?: jest.Mock } = {
bundled: false,
};
jest.mock(
'./../../ee/mfa/services/mfa.service',
() => {
if (!mfaModuleState.bundled) {
// Simulate a community/fork build with no EE MFA module: the real
// require() throws, which the gate catches as the "no MFA gate" path.
throw new Error('Cannot find module (EE MFA not bundled)');
}
// "Bundled" build: expose a MfaService class token. The actual instance the
// gate calls is resolved through moduleRef.get(MfaModule.MfaService), which
// our stub moduleRef returns regardless of the token identity.
class MfaService {}
return { MfaService };
},
{ virtual: true },
);
// --- Mock the heavy collaborator modules so importing mcp.service succeeds. ---
// The gate never calls into these; they exist only to satisfy the import graph.
jest.mock('../../core/auth/services/auth.service', () => ({
AuthService: class AuthService {},
}));
jest.mock('../../core/auth/services/token.service', () => ({
TokenService: class TokenService {},
}));
jest.mock('@docmost/db/repos/workspace/workspace.repo', () => ({
WorkspaceRepo: class WorkspaceRepo {},
}));
jest.mock('@docmost/db/repos/user/user.repo', () => ({
UserRepo: class UserRepo {},
}));
jest.mock('@docmost/db/repos/session/user-session.repo', () => ({
UserSessionRepo: class UserSessionRepo {},
}));
// mcp-auth.helpers exports runtime values the gate relies on (decideBasicGate,
// mapAuthResultToResponse, etc.). Keep the REAL helpers so the gate exercises
// real logic; only stub FailedLoginLimiter so its constructor runs without a
// real sweep timer. The module is framework-free and loads cleanly under jest
// (mcp.service.spec.ts already imports it directly), so requireActual is safe.
jest.mock('./mcp-auth.helpers', () => {
const actual = jest.requireActual('./mcp-auth.helpers');
return {
...actual,
FailedLoginLimiter: class FailedLoginLimiter {
sweep() {}
},
};
});
// Import AFTER the mocks are registered.
// eslint-disable-next-line @typescript-eslint/no-require-imports
import { McpService } from './mcp.service';
type GateCreds = { email: string; password: string };
// Build an McpService instance with stubbed constructor deps. We never call the
// auth/db collaborators from the gate, so undefined stand-ins are fine for all
// but moduleRef, which the MFA branch reads.
function makeService(opts: {
checkMfaRequirements?: jest.Mock;
}): { service: McpService; gate: (ws: unknown, creds: GateCreds) => Promise<void> } {
// Stub moduleRef.get -> returns an object whose checkMfaRequirements is the
// provided mock. The gate calls moduleRef.get(MfaModule.MfaService).
const moduleRef = {
get: jest.fn().mockReturnValue({
checkMfaRequirements:
opts.checkMfaRequirements ?? jest.fn().mockResolvedValue(undefined),
}),
};
const service = new McpService(
undefined as never, // environmentService
undefined as never, // workspaceRepo
undefined as never, // authService
undefined as never, // tokenService
undefined as never, // userRepo
undefined as never, // userSessionRepo
moduleRef as never, // moduleRef (read by the MFA branch)
);
// Stop the constructor's unref'd sweep timer leaking across tests.
service.onModuleDestroy();
// enforceBasicLoginGate is private; reach it through the instance. Calling the
// REAL method (not a stub) is the whole point of this suite.
const gate = (
service as unknown as {
enforceBasicLoginGate: (ws: unknown, creds: GateCreds) => Promise<void>;
}
).enforceBasicLoginGate.bind(service);
return { service, gate };
}
const CREDS: GateCreds = { email: 'user@example.com', password: 'pw' };
describe('McpService.enforceBasicLoginGate (REAL gate, leaf deps mocked)', () => {
beforeEach(() => {
// Reset to the community-build default (no EE module) before each test.
mfaModuleState = { bundled: false };
jest.clearAllMocks();
});
describe('SSO enforcement (validateSsoEnforcement)', () => {
it('rejects with Unauthorized when the workspace enforces SSO, before any MFA/login', async () => {
const { gate } = makeService({});
const workspace = { id: 'ws-1', enforceSso: true };
await expect(gate(workspace, CREDS)).rejects.toBeInstanceOf(
UnauthorizedException,
);
// The /mcp 401 surfaces an SSO-specific message (not a generic MCP error).
await expect(gate(workspace, CREDS)).rejects.toThrow(/enforced SSO/i);
});
it('does NOT consult the MFA module when SSO is enforced (gate short-circuits)', async () => {
// Even if the EE module WERE bundled, the SSO branch throws first, so the
// moduleRef MFA lookup must never run.
mfaModuleState = {
bundled: true,
checkMfaRequirements: jest.fn(),
};
const { service, gate } = makeService({
checkMfaRequirements: mfaModuleState.checkMfaRequirements,
});
const moduleRefGet = (
service as unknown as { moduleRef: { get: jest.Mock } }
).moduleRef.get;
await expect(
gate({ id: 'ws-1', enforceSso: true }, CREDS),
).rejects.toThrow(/enforced SSO/i);
// The SSO branch fired before the MFA require/lookup.
expect(moduleRefGet).not.toHaveBeenCalled();
expect(mfaModuleState.checkMfaRequirements).not.toHaveBeenCalled();
});
});
describe('community build: EE MFA module NOT bundled', () => {
it('passes (no throw) when SSO is not enforced and the lazy require fails (no MFA gate)', async () => {
// mfaModuleState.bundled === false -> the virtual mock factory throws,
// exactly like require() of a missing EE module on a community build.
const { service, gate } = makeService({});
const moduleRefGet = (
service as unknown as { moduleRef: { get: jest.Mock } }
).moduleRef.get;
await expect(
gate({ id: 'ws-1', enforceSso: false }, CREDS),
).resolves.toBeUndefined();
// The require() failed, so the gate returned before touching moduleRef.
expect(moduleRefGet).not.toHaveBeenCalled();
});
});
describe('EE MFA module bundled', () => {
it('rejects with a "use a Bearer token" signal when the user has MFA enabled', async () => {
const check = jest.fn().mockResolvedValue({
userHasMfa: true,
requiresMfaSetup: false,
});
mfaModuleState = { bundled: true, checkMfaRequirements: check };
const { gate } = makeService({ checkMfaRequirements: check });
const promise = gate({ id: 'ws-1', enforceSso: false }, CREDS);
await expect(promise).rejects.toBeInstanceOf(UnauthorizedException);
await expect(
gate({ id: 'ws-1', enforceSso: false }, CREDS),
).rejects.toThrow(/Bearer access token/i);
// The real requirement check was consulted with the creds + workspace.
expect(check).toHaveBeenCalledWith(
CREDS,
{ id: 'ws-1', enforceSso: false },
undefined,
);
});
it('rejects when the workspace enforces MFA (requiresMfaSetup)', async () => {
// requiresMfaSetup === true models a workspace that enforces MFA for a
// user who has not set it up yet; the Basic path cannot complete it.
const check = jest.fn().mockResolvedValue({
userHasMfa: false,
requiresMfaSetup: true,
});
mfaModuleState = { bundled: true, checkMfaRequirements: check };
const { gate } = makeService({ checkMfaRequirements: check });
await expect(
gate({ id: 'ws-1', enforceSso: false }, CREDS),
).rejects.toThrow(/Bearer access token/i);
});
it('passes when the user has no MFA and the workspace does not enforce it', async () => {
const check = jest.fn().mockResolvedValue({
userHasMfa: false,
requiresMfaSetup: false,
});
mfaModuleState = { bundled: true, checkMfaRequirements: check };
const { gate } = makeService({ checkMfaRequirements: check });
await expect(
gate({ id: 'ws-1', enforceSso: false }, CREDS),
).resolves.toBeUndefined();
// The bundled module's requirement check WAS consulted (proving we took
// the bundled branch, not the community no-op branch).
expect(check).toHaveBeenCalledTimes(1);
});
it('passes when checkMfaRequirements returns a falsy result (no requirement flags)', async () => {
// Defensive: a bundled module that returns undefined must not reject.
const check = jest.fn().mockResolvedValue(undefined);
mfaModuleState = { bundled: true, checkMfaRequirements: check };
const { gate } = makeService({ checkMfaRequirements: check });
await expect(
gate({ id: 'ws-1', enforceSso: false }, CREDS),
).resolves.toBeUndefined();
});
});
});

View File

@@ -9,6 +9,9 @@ import {
sharedTokenMatches,
clientIp,
bindAccessJwtVerifier,
extractBearer,
decideBasicGate,
mapAuthResultToResponse,
McpAuthDeps,
} from './mcp-auth.helpers';
import { JwtType } from '../../core/auth/dto/jwt-payload';
@@ -79,6 +82,26 @@ describe('parseBasicAuth', () => {
});
});
describe('extractBearer', () => {
it('extracts the token from a "Bearer <token>" header', () => {
expect(extractBearer('Bearer abc.def.ghi')).toBe('abc.def.ghi');
});
it('is case-insensitive on the scheme (lowercase + uppercase)', () => {
// The split keeps the token as-is; only the scheme is compared lowercased.
expect(extractBearer('bearer abc')).toBe('abc');
expect(extractBearer('BEARER abc')).toBe('abc');
});
it('returns undefined for a non-Bearer scheme (e.g. Basic)', () => {
expect(extractBearer('Basic abc')).toBeUndefined();
});
it('returns undefined for an undefined header', () => {
expect(extractBearer(undefined)).toBeUndefined();
});
});
describe('isCredentialsFailure', () => {
it('is true for the credentials-mismatch UnauthorizedException', () => {
expect(
@@ -185,6 +208,43 @@ describe('FailedLoginLimiter', () => {
expect(lim.isBlocked(k, 0)).toBe(true);
expect(lim.isBlocked(k, 1000)).toBe(false);
});
describe('sweep (expired-bucket eviction, injectable clock)', () => {
// sweep() drops buckets whose windowStart is older than windowMs so
// never-revisited keys cannot accumulate forever. It takes an injectable
// `now` so the behaviour is deterministic without faking timers.
it('drops a bucket strictly older than windowMs', () => {
const lim = new FailedLoginLimiter(5, 1000);
// Seed a bucket at t=0 (windowStart=0).
lim.recordFailure('stale', 0);
// Sweep well past the window: now - windowStart = 5000 >= 1000 -> dropped.
lim.sweep(5000);
// A dropped bucket means a brand-new bucket is created on next touch, so
// the prior failure count is gone (a single fresh failure is far from 5).
lim.recordFailure('stale', 5001);
expect(lim.isBlocked('stale', 5001)).toBe(false);
});
it('drops a bucket exactly at the windowMs boundary (>= is inclusive)', () => {
const lim = new FailedLoginLimiter(1, 1000);
lim.recordFailure('boundary', 0); // windowStart=0, blocked at threshold 1
expect(lim.isBlocked('boundary', 0)).toBe(true);
// now - windowStart = 1000 == windowMs -> the >= check evicts it.
lim.sweep(1000);
// Re-touch at the same instant: a fresh bucket (count 0) is created, so the
// key is no longer blocked, proving the boundary bucket was swept.
expect(lim.isBlocked('boundary', 1000)).toBe(false);
});
it('retains a fresh bucket still within the window', () => {
const lim = new FailedLoginLimiter(1, 1000);
lim.recordFailure('fresh', 0); // windowStart=0
// now - windowStart = 999 < 1000 -> the bucket survives the sweep.
lim.sweep(999);
// Still blocked because the bucket (and its count) was retained.
expect(lim.isBlocked('fresh', 999)).toBe(true);
});
});
});
describe('verifyBearerAccess (Bearer revocation/disabled checks)', () => {
@@ -264,6 +324,31 @@ describe('verifyBearerAccess (Bearer revocation/disabled checks)', () => {
),
).rejects.toThrow('jwt expired');
});
// Item 3: bind the Bearer token to THIS instance's workspace (mirrors
// JwtStrategy). A token whose workspaceId claim differs from the instance
// workspace must be rejected; matching/absent expectedWorkspaceId is allowed.
it('rejects a token from a DIFFERENT workspace when expectedWorkspaceId is set', async () => {
await expect(
verifyBearerAccess('t', {
...bearerDeps(),
expectedWorkspaceId: 'ws-OTHER',
}),
).rejects.toThrow(UnauthorizedException);
});
it('accepts a token whose workspace matches expectedWorkspaceId', async () => {
const res = await verifyBearerAccess('t', {
...bearerDeps(),
expectedWorkspaceId: 'ws-1',
});
expect(res).toEqual({ sub: 'user-1', email: 'u@e.com' });
});
it('does NOT enforce a workspace when expectedWorkspaceId is undefined (single-workspace no-op)', async () => {
const res = await verifyBearerAccess('t', bearerDeps());
expect(res).toEqual({ sub: 'user-1', email: 'u@e.com' });
});
});
describe('resolveMcpSessionConfig', () => {
@@ -587,23 +672,48 @@ describe('resolveMcpSessionConfig', () => {
});
});
describe('isInitializeRequestBody (session-INIT detection)', () => {
it('true only for a single JSON-RPC object with method === "initialize"', () => {
expect(isInitializeRequestBody({ jsonrpc: '2.0', method: 'initialize' })).toBe(
true,
);
// A full, valid JSON-RPC InitializeRequest as the @modelcontextprotocol/sdk
// `isInitializeRequest` predicate (which isInitializeRequestBody now delegates
// to) requires: jsonrpc + id + method === 'initialize' + params.protocolVersion.
const fullInitializeRequest = {
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {
protocolVersion: '2024-11-05',
capabilities: {},
clientInfo: { name: 'test-client', version: '1.0.0' },
},
};
describe('isInitializeRequestBody (session-INIT detection, matches SDK predicate)', () => {
it('true for a FULL valid InitializeRequest (the SDK predicate signal)', () => {
expect(isInitializeRequestBody(fullInitializeRequest)).toBe(true);
});
it('false for a bare { method: "initialize" } with no id/params (item 1)', () => {
// Item 1: this previously returned true (method-only check) and let an
// authenticated client POST a params-less body with no mcp-session-id, which
// ran the side-effecting login() before http.ts 400'd it. The SDK predicate
// rejects it (no id, no params.protocolVersion), so it no longer mints a
// session / audit row.
expect(isInitializeRequestBody({ method: 'initialize' })).toBe(false);
expect(
isInitializeRequestBody({ jsonrpc: '2.0', method: 'initialize' }),
).toBe(false);
expect(
isInitializeRequestBody({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }),
).toBe(false);
});
it('false for a non-initialize method (e.g. tools/call)', () => {
expect(
isInitializeRequestBody({ jsonrpc: '2.0', method: 'tools/call' }),
isInitializeRequestBody({ ...fullInitializeRequest, method: 'tools/call' }),
).toBe(false);
});
it('false for a batch (array) body, null/undefined, or a non-object', () => {
expect(
isInitializeRequestBody([{ jsonrpc: '2.0', method: 'initialize' }]),
).toBe(false);
expect(isInitializeRequestBody([fullInitializeRequest])).toBe(false);
expect(isInitializeRequestBody(undefined)).toBe(false);
expect(isInitializeRequestBody(null)).toBe(false);
expect(isInitializeRequestBody('initialize')).toBe(false);
@@ -618,8 +728,14 @@ describe('isSessionInit decision (no mcp-session-id AND initialize body)', () =>
const decide = (sessionId: string | undefined, body: unknown): boolean =>
!sessionId && isInitializeRequestBody(body);
it('no header + initialize body -> init', () => {
expect(decide(undefined, { method: 'initialize' })).toBe(true);
it('no header + full initialize body -> init', () => {
expect(decide(undefined, fullInitializeRequest)).toBe(true);
});
it('no header + bare params-less initialize body -> NOT init (item 1)', () => {
// A header-less { method: 'initialize' } with no params is no longer treated
// as an init by the SDK predicate, so it does not mint a session via login().
expect(decide(undefined, { method: 'initialize' })).toBe(false);
});
it('no header + non-initialize body -> NOT init (verifyCredentials path)', () => {
@@ -627,7 +743,7 @@ describe('isSessionInit decision (no mcp-session-id AND initialize body)', () =>
});
it('has session-id -> never init regardless of body', () => {
expect(decide('sess-1', { method: 'initialize' })).toBe(false);
expect(decide('sess-1', fullInitializeRequest)).toBe(false);
});
});
@@ -769,3 +885,138 @@ describe('bindAccessJwtVerifier enforces JwtType.ACCESS (item 3)', () => {
expect(res).toEqual({ sub: 'user-1', email: undefined });
});
});
describe('decideBasicGate (pure SSO/MFA pre-token gate, refactor R1)', () => {
// The pure decision extracted out of McpService.enforceBasicLoginGate. It is
// tested WITHOUT ModuleRef and WITHOUT an on-disk EE MFA module: the SSO verdict
// and the MFA requirement result are passed in as plain values.
it('SSO enforced -> throws Unauthorized ("enforced SSO")', () => {
expect(() => decideBasicGate({ ssoEnforced: true })).toThrow(
UnauthorizedException,
);
expect(() => decideBasicGate({ ssoEnforced: true })).toThrow(/enforced SSO/);
// SSO takes precedence even if MFA flags are also set.
expect(() =>
decideBasicGate({ ssoEnforced: true, mfa: { userHasMfa: true } }),
).toThrow(/enforced SSO/);
});
it('no SSO + no MFA module (mfa undefined) -> resolves (Basic allowed)', () => {
// A community/fork build with no EE MFA module passes mfa: undefined and the
// gate must allow the password login (same as the controller with no MFA).
expect(() => decideBasicGate({ ssoEnforced: false })).not.toThrow();
expect(() =>
decideBasicGate({ ssoEnforced: false, mfa: undefined }),
).not.toThrow();
});
it('MFA present + userHasMfa -> rejects ("use a Bearer access token")', () => {
expect(() =>
decideBasicGate({ ssoEnforced: false, mfa: { userHasMfa: true } }),
).toThrow(/use a Bearer access token/);
expect(() =>
decideBasicGate({ ssoEnforced: false, mfa: { userHasMfa: true } }),
).toThrow(UnauthorizedException);
});
it('MFA present + requiresMfaSetup -> rejects', () => {
expect(() =>
decideBasicGate({ ssoEnforced: false, mfa: { requiresMfaSetup: true } }),
).toThrow(/use a Bearer access token/);
});
it('MFA present but none required (both flags false) -> resolves', () => {
expect(() =>
decideBasicGate({
ssoEnforced: false,
mfa: { userHasMfa: false, requiresMfaSetup: false },
}),
).not.toThrow();
});
});
describe('mapAuthResultToResponse (handle status/body mapping, refactor R2)', () => {
// The pure response decision extracted out of McpService.handle. It maps the
// pre-hijack gauntlet (shared token, enablement, auth error) to either a fixed
// JSON error response or the hijack path — never leaking the password/header.
it('wrong X-MCP-Token -> 401 {error:"Unauthorized"} and NOT the hijack path', () => {
const d = mapAuthResultToResponse({ sharedTokenOk: false, enabled: true });
expect(d).toEqual({
kind: 'respond',
status: 401,
body: { error: 'Unauthorized' },
});
});
it('workspace MCP disabled -> 403', () => {
const d = mapAuthResultToResponse({ sharedTokenOk: true, enabled: false });
expect(d.kind).toBe('respond');
if (d.kind === 'respond') {
expect(d.status).toBe(403);
expect(d.body).toEqual({ error: 'MCP is disabled for this workspace' });
}
});
it('an UnauthorizedException -> 401 with err.message; no password/header leaked', () => {
// Construct an UnauthorizedException whose message is the SPECIFIC auth reason.
const err = new UnauthorizedException('Email or password does not match');
const d = mapAuthResultToResponse({
sharedTokenOk: true,
enabled: true,
error: err,
});
expect(d).toEqual({
kind: 'respond',
status: 401,
body: { error: 'Email or password does not match' },
});
// The surfaced body is ONLY the exception message — never the raw secret.
if (d.kind === 'respond') {
const serialized = JSON.stringify(d.body);
expect(serialized).not.toContain('password=');
expect(serialized).not.toContain('Authorization');
expect(serialized).not.toContain('Basic ');
expect(serialized).not.toContain('Bearer ');
}
});
it('a non-Unauthorized error -> 500 generic (no error detail surfaced)', () => {
const err = new Error('db blew up: connection string secret');
const d = mapAuthResultToResponse({
sharedTokenOk: true,
enabled: true,
error: err,
});
expect(d).toEqual({
kind: 'respond',
status: 500,
body: { error: 'Internal server error' },
});
// The generic body must NOT echo the underlying error message.
if (d.kind === 'respond') {
expect(d.body.error).not.toContain('secret');
}
});
it('happy path (auth resolved, no error) -> hijack', () => {
const d = mapAuthResultToResponse({ sharedTokenOk: true, enabled: true });
expect(d).toEqual({ kind: 'hijack' });
});
it('shared-token failure takes precedence over disabled/error', () => {
// Even with a disabled workspace and an error, a bad shared token is the
// first gate, so the response is the uniform 401 Unauthorized.
const d = mapAuthResultToResponse({
sharedTokenOk: false,
enabled: false,
error: new UnauthorizedException('should not surface'),
});
expect(d).toEqual({
kind: 'respond',
status: 401,
body: { error: 'Unauthorized' },
});
});
});

View File

@@ -25,6 +25,8 @@ import {
sharedTokenMatches,
clientIp,
bindAccessJwtVerifier,
decideBasicGate,
mapAuthResultToResponse,
DocmostMcpConfig,
ResolvedMcpAuth,
} from './mcp-auth.helpers';
@@ -154,6 +156,15 @@ export class McpService implements OnModuleDestroy {
private async verifyMcpBearer(
token: string,
): Promise<{ sub?: string; email?: string }> {
// Resolve THIS instance's workspace so verifyBearerAccess can bind the
// token's `workspaceId` claim to it (mirrors JwtStrategy). The community
// build is single-workspace (findFirst), so this is the default workspace
// and the check is a no-op here; it only rejects a foreign-workspace token
// in a multi-workspace deployment. Undefined (no workspace configured) means
// no check — the credentials path would already have failed with no
// workspace, and an undefined here keeps the helper a no-op rather than
// rejecting every token.
const instanceWorkspace = await this.workspaceRepo.findFirst();
// The revocation/disabled decision logic lives in the framework-free
// verifyBearerAccess helper (unit-testable without the heavy auth graph);
// this method only wires in the concrete TokenService + repos.
@@ -163,6 +174,7 @@ export class McpService implements OnModuleDestroy {
verifyJwt: bindAccessJwtVerifier(this.tokenService) as (
t: string,
) => Promise<JwtPayload>,
expectedWorkspaceId: instanceWorkspace?.id,
findUser: (sub, workspaceId) =>
this.userRepo.findById(sub, workspaceId),
findActiveSession: (sessionId) =>
@@ -231,49 +243,54 @@ export class McpService implements OnModuleDestroy {
workspace: Workspace,
creds: { email: string; password: string },
): Promise<void> {
// 1) SSO enforcement. validateSsoEnforcement throws BadRequestException; we
// re-surface it as Unauthorized so the /mcp 401 path is consistent and a
// token is never issued.
// 1) SSO enforcement. validateSsoEnforcement throws when the workspace
// enforces SSO; we only need the boolean verdict for the pure decision.
let ssoEnforced = false;
try {
validateSsoEnforcement(workspace);
} catch {
throw new UnauthorizedException(
'This workspace has enforced SSO login. Use SSO; MCP HTTP Basic is not allowed.',
);
ssoEnforced = true;
}
// 2) MFA gate — lazy-require the EE module exactly like AuthController.login.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let MfaModule: any;
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
MfaModule = require('./../../ee/mfa/services/mfa.service');
} catch {
// No EE MFA module bundled in this build: same as the controller -> no
// MFA gate. (A community/fork build has no MFA, so Basic is allowed.)
return;
// On a fork WITHOUT the EE module bundled, mfaResult stays undefined and the
// pure gate behaves exactly like the controller (no MFA module -> no MFA
// gate). We only LOAD the module + read the requirement flags here; the
// accept/reject decision lives in the framework-free decideBasicGate so the
// SSO/MFA logic is unit-testable without ModuleRef or the on-disk EE module.
let mfaResult: { userHasMfa?: boolean; requiresMfaSetup?: boolean } | undefined;
// Only consult the MFA module when SSO has not already disqualified the
// request (SSO short-circuits, and skipping the load avoids a needless
// require on the SSO-reject path).
if (!ssoEnforced) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let MfaModule: any;
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
MfaModule = require('./../../ee/mfa/services/mfa.service');
} catch {
// No EE MFA module bundled in this build: same as the controller -> no
// MFA gate. (A community/fork build has no MFA, so Basic is allowed.)
MfaModule = undefined;
}
if (MfaModule) {
const mfaService = this.moduleRef.get(MfaModule.MfaService, {
strict: false,
});
// Same requirement check the controller uses. We pass NO FastifyReply
// (the controller passes `res` only to set a cookie on the no-MFA happy
// path, which we never take here): we only read the requirement flags.
mfaResult = await mfaService.checkMfaRequirements(
creds,
workspace,
undefined,
);
}
}
const mfaService = this.moduleRef.get(MfaModule.MfaService, {
strict: false,
});
// Use the same requirement check the controller uses. We pass NO FastifyReply
// (the controller passes `res` only to set a cookie on the no-MFA happy path,
// which we never take here): we only read the requirement flags. Be tolerant
// of either a (loginInput, workspace) or (loginInput, workspace, res) shape.
const mfaResult = await mfaService.checkMfaRequirements(
creds,
workspace,
undefined,
);
if (mfaResult && (mfaResult.userHasMfa || mfaResult.requiresMfaSetup)) {
throw new UnauthorizedException(
'This account requires multi-factor authentication. MCP HTTP Basic ' +
'cannot complete MFA — log in normally and use a Bearer access token ' +
'instead.',
);
}
// Pure accept/reject decision (throws UnauthorizedException on rejection).
decideBasicGate({ ssoEnforced, mfa: mfaResult });
}
// Lazily create the HTTP handler exactly once. The import is indirected so
@@ -333,52 +350,61 @@ export class McpService implements OnModuleDestroy {
// matching `X-MCP-Token` header. It now lives in its OWN header so it never
// collides with `Authorization`, which carries the per-user credentials.
const sharedToken = process.env.MCP_TOKEN;
if (sharedToken) {
const provided = req.headers['x-mcp-token'];
if (!sharedTokenMatches(sharedToken, provided)) {
res.status(401).send({ error: 'Unauthorized' });
return;
}
}
const sharedTokenOk = sharedToken
? sharedTokenMatches(sharedToken, req.headers['x-mcp-token'])
: true;
if (!(await this.isEnabled())) {
res.status(403).send({ error: 'MCP is disabled for this workspace' });
return;
}
// Short-circuit checks (shared token, enablement) that do not need the auth
// resolution. Compute them up front so the response mapping is a single pure
// decision (mapAuthResultToResponse) that cannot leak the password/header.
const enabled = sharedTokenOk ? await this.isEnabled() : false;
// Resolve + validate the per-session identity BEFORE hijacking the response
// so bad credentials surface as a clean 401 JSON (never a torn response and
// never a generic "MCP error"). The resolved config/identity is stashed on
// the raw request for the package's resolver + identify hook to read back.
let resolved: ResolvedMcpAuth;
try {
resolved = await this.resolveSessionConfig(req);
} catch (err) {
if (err instanceof UnauthorizedException) {
// Warn once if the only thing missing is the service account, to keep
// the original operator hint.
if (
!this.credsConfigured() &&
!req.headers['authorization'] &&
!this.warnedMissingCreds
) {
this.warnedMissingCreds = true;
this.logger.warn(
'MCP is enabled but received a request with no credentials and no ' +
'MCP_DOCMOST_EMAIL/MCP_DOCMOST_PASSWORD service account configured.',
);
let resolved: ResolvedMcpAuth | undefined;
let authError: unknown;
if (sharedTokenOk && enabled) {
try {
resolved = await this.resolveSessionConfig(req);
} catch (err) {
authError = err;
if (err instanceof UnauthorizedException) {
// Warn once if the only thing missing is the service account, to keep
// the original operator hint.
if (
!this.credsConfigured() &&
!req.headers['authorization'] &&
!this.warnedMissingCreds
) {
this.warnedMissingCreds = true;
this.logger.warn(
'MCP is enabled but received a request with no credentials and no ' +
'MCP_DOCMOST_EMAIL/MCP_DOCMOST_PASSWORD service account configured.',
);
}
} else {
this.logger.error('MCP auth resolution failed', err as Error);
}
res.status(401).send({ error: err.message });
return;
}
this.logger.error('MCP auth resolution failed', err as Error);
res.status(500).send({ error: 'Internal server error' });
}
// Pure status/body mapping for the whole pre-hijack gauntlet.
const decision = mapAuthResultToResponse({
sharedTokenOk,
enabled,
error: authError,
});
if (decision.kind === 'respond') {
res.status(decision.status).send(decision.body);
return;
}
// Stash the resolved auth on the raw request so the package's resolver +
// identify hook (wired in getHandler) read it back instead of re-parsing.
(req.raw as unknown as Record<symbol, unknown>)[MCP_RESOLVED] = resolved;
(req.raw as unknown as Record<symbol, unknown>)[MCP_RESOLVED] =
resolved as ResolvedMcpAuth;
// Hand the raw Node req/res to the MCP transport. hijack() tells Fastify
// to stop managing this response so the transport can write to it directly.