fix(core): Expose incoming request headers to MCP trigger tools (#35236)

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
This commit is contained in:
Elias Meire 2026-08-13 14:24:28 +00:00 committed by GitHub
parent c567279606
commit 64ed864315
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 889 additions and 124 deletions

View File

@ -2,11 +2,19 @@ import { WebhookAuthorizationError } from 'n8n-nodes-base/dist/nodes/Webhook/err
import { validateWebhookAuthentication } from 'n8n-nodes-base/dist/nodes/Webhook/utils';
import type {
CredentialCheckResult,
IDataObject,
INodeTypeDescription,
IUser,
IWebhookFunctions,
IWebhookResponseData,
} from 'n8n-workflow';
import { NodeConnectionTypes, Node, nodeNameToToolName, n8nOAuth2Auth } from 'n8n-workflow';
import {
NodeConnectionTypes,
Node,
nodeNameToToolName,
n8nOAuth2Auth,
redactedHeaders,
} from 'n8n-workflow';
import { getConnectedTools } from '@utils/helpers';
@ -26,7 +34,7 @@ export class McpTrigger extends Node {
dark: 'file:../mcp.dark.svg',
},
group: ['trigger'],
version: [1, 1.1, 2],
version: [1, 1.1, 2, 2.1],
description: 'Expose n8n tools as an MCP Server endpoint',
activationMessage:
'You can now connect your MCP Clients to the URL, using SSE or Streamable HTTP transports.',
@ -66,6 +74,7 @@ export class McpTrigger extends Node {
},
],
outputs: [],
sensitiveOutputFields: ['headers.authorization', 'headers.cookie'],
credentials: [
{
// eslint-disable-next-line n8n-nodes-base/node-class-description-credentials-name-unsuffixed
@ -121,6 +130,20 @@ export class McpTrigger extends Node {
description:
'Whether the triggering user must also have permission to execute the workflow in the project it belongs to',
},
{
displayName: 'Include User in Output',
name: 'includeUserInOutput',
type: 'boolean',
default: true,
displayOptions: {
show: {
authentication: ['n8nOAuth2'],
'@version': [{ _cnd: { gte: 2.1 } }],
},
},
description:
"Whether to include the calling user's ID, email and name in the trigger output and in the request the connected tools receive",
},
{
displayName: 'Path',
name: 'path',
@ -170,6 +193,8 @@ export class McpTrigger extends Node {
const req = context.getRequestObject();
const resp = context.getResponseObject() as unknown as CompressionResponse;
let authedUser: IUser | undefined;
if (context.getNodeParameter('authentication') === 'n8nOAuth2') {
if (context.getNode().typeVersion < 2) {
resp.writeHead(401);
@ -181,6 +206,7 @@ export class McpTrigger extends Node {
return { noWebhookResponse: true };
}
await context.establishTriggerIdentity(authResult.token, authResult.resource);
authedUser = authResult.user;
} else {
try {
await validateWebhookAuthentication(context, 'authentication');
@ -195,6 +221,25 @@ export class McpTrigger extends Node {
}
const node = context.getNode();
// n8n's own auth credential must never reach the tools — not here, and not on the
// worker, which rebuilds their input from `toolInput`. The caller's identity is
// surfaced as `user` instead, so tools never need the token to know who called.
const headers = redactedHeaders(req);
const user =
authedUser && context.getNodeParameter('includeUserInOutput', true) !== false
? {
id: authedUser.id,
email: authedUser.email,
firstName: authedUser.firstName,
lastName: authedUser.lastName,
}
: undefined;
const exposesRequest = node.typeVersion >= 2.1;
const toolInput: IDataObject | undefined = exposesRequest
? { body: context.getBodyData(), headers, ...(user && { user }) }
: undefined;
const serverName = node.typeVersion > 1 ? nodeNameToToolName(node) : 'n8n-mcp-server';
const mcpServer = McpServer.instance(context.logger);
@ -204,7 +249,9 @@ export class McpTrigger extends Node {
? req.path.replace(new RegExp(`/${MCP_SSE_SETUP_PATH}$`), `/${MCP_SSE_MESSAGES_PATH}`)
: req.path;
const connectedTools = await getConnectedTools(context, true);
const connectedTools = await getConnectedTools(context, true, undefined, undefined, {
inputData: toolInput,
});
await mcpServer.handleSetupRequest(req, resp, serverName, postUrl, connectedTools);
return { noWebhookResponse: true };
@ -217,7 +264,9 @@ export class McpTrigger extends Node {
context.logger.debug('MCP POST request received for existing session');
if (sessionId) {
const connectedTools = await getConnectedTools(context, true);
const connectedTools = await getConnectedTools(context, true, undefined, undefined, {
inputData: toolInput,
});
// For a tool call, check the triggering user's private-credential status
// before executing. Returns undefined (no gate) unless an OAuth2 identity
@ -234,10 +283,12 @@ export class McpTrigger extends Node {
const workflowData = {
...(toolCallInfo && { mcpToolCall: toolCallInfo }),
...(messageId && { mcpMessageId: messageId }),
...(exposesRequest && { headers, ...(user && { user }) }),
};
return {
noWebhookResponse: true,
workflowData: [[{ json: workflowData }]],
toolInput,
};
}
@ -255,7 +306,9 @@ export class McpTrigger extends Node {
};
}
} else {
const connectedTools = await getConnectedTools(context, true);
const connectedTools = await getConnectedTools(context, true, undefined, undefined, {
inputData: toolInput,
});
await mcpServer.handleStreamableHttpSetup(req, resp, serverName, connectedTools);
}
}

View File

@ -4,9 +4,12 @@ import type {
IWebhookFunctions,
ICredentialDataDecryptedObject,
} from 'n8n-workflow';
import { recordConsumedAuth, REDACTED } from 'n8n-workflow';
import type { Mock, Mocked } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { getConnectedTools } from '@utils/helpers';
import { createMockLogger, createMockRequest, createMockResponse } from './helpers';
import { McpServer } from '../McpServer';
import { McpTrigger } from '../McpTrigger.node';
@ -76,6 +79,9 @@ describe('McpTrigger', () => {
vi.clearAllMocks();
});
const toolInputPassedToSubNodes = () =>
vi.mocked(getConnectedTools).mock.calls.at(-1)?.[4]?.inputData;
describe('description', () => {
it('should have the correct node metadata', () => {
expect(mcpTrigger.description.name).toBe('mcpTrigger');
@ -84,7 +90,7 @@ describe('McpTrigger', () => {
});
it('should support multiple versions', () => {
expect(mcpTrigger.description.version).toEqual([1, 1.1, 2]);
expect(mcpTrigger.description.version).toEqual([1, 1.1, 2, 2.1]);
});
it('should have authentication options', () => {
@ -260,13 +266,14 @@ describe('McpTrigger', () => {
expect(result).toEqual({ noWebhookResponse: true });
});
it('should return workflow data when tool call is detected', async () => {
const req = createMockRequest({ method: 'POST', query: { sessionId: 'test-session' } });
const resp = createMockResponse();
const node = mock<INode>({
typeVersion: 2,
name: 'MCP Server Trigger',
const setupToolCall = (typeVersion: number) => {
const req = createMockRequest({
method: 'POST',
query: { sessionId: 'test-session' },
headers: { 'x-user-id': 'user-1' },
});
const resp = createMockResponse();
const node = mock<INode>({ typeVersion, name: 'MCP Server Trigger' });
mockMcpServer.getSessionId.mockReturnValue('test-session');
mockMcpServer.handlePostMessage.mockResolvedValue({
@ -281,6 +288,13 @@ describe('McpTrigger', () => {
mockContext.getRequestObject.mockReturnValue(req as never);
mockContext.getResponseObject.mockReturnValue(resp as never);
mockContext.getNode.mockReturnValue(node);
mockContext.getBodyData.mockReturnValue({ method: 'tools/call' });
return { req };
};
it('should return workflow data when tool call is detected', async () => {
setupToolCall(2);
const result = await mcpTrigger.webhook(mockContext);
@ -299,6 +313,38 @@ describe('McpTrigger', () => {
});
});
it('should include the request headers from version 2.1 on', async () => {
setupToolCall(2.1);
const result = await mcpTrigger.webhook(mockContext);
expect(result.workflowData?.[0][0].json).toEqual({
mcpToolCall: { toolName: 'test-tool', arguments: { arg1: 'value1' } },
mcpMessageId: 'msg-123',
headers: { 'x-user-id': 'user-1' },
});
});
it('should carry the tool input on the response from version 2.1 on', async () => {
setupToolCall(2.1);
const result = await mcpTrigger.webhook(mockContext);
expect(result.toolInput).toEqual({
body: { method: 'tools/call' },
headers: { 'x-user-id': 'user-1' },
});
});
it('should not produce a tool input on version 2.0', async () => {
setupToolCall(2);
const result = await mcpTrigger.webhook(mockContext);
expect(result.toolInput).toBeUndefined();
expect(toolInputPassedToSubNodes()).toBeUndefined();
});
it('should handle Streamable HTTP setup when no session exists', async () => {
const req = createMockRequest({ method: 'POST' });
const resp = createMockResponse();
@ -392,13 +438,22 @@ describe('McpTrigger', () => {
describe('n8nOAuth2 authentication', () => {
const resourceUrl = 'https://n8n.example.com/mcp/abc';
const prmUrl = 'https://n8n.example.com/.well-known/oauth-protected-resource/mcp/abc';
const OAUTH_USER = { id: 'u1', email: 'u@example.com', firstName: 'U', lastName: 'One' };
function setupContext(opts: { typeVersion: number; headers?: Record<string, string> }) {
function setupContext(opts: {
typeVersion: number;
headers?: Record<string, string>;
includeUserInOutput?: boolean;
}) {
const req = createMockRequest({ path: '/mcp/abc', headers: opts.headers ?? {} });
const resp = createMockResponse();
const node = mock<INode>({ typeVersion: opts.typeVersion, name: 'MCP Server Trigger' });
mockContext.getNodeParameter.mockReturnValue('n8nOAuth2');
mockContext.getNodeParameter.mockImplementation((name, fallback) => {
if (name === 'authentication') return 'n8nOAuth2';
if (name === 'includeUserInOutput') return opts.includeUserInOutput ?? fallback;
return fallback;
});
mockContext.getWebhookResourceUrl.mockReturnValue(resourceUrl);
mockContext.getWebhookName.mockReturnValue('setup');
mockContext.getRequestObject.mockReturnValue(req as never);
@ -407,6 +462,23 @@ describe('McpTrigger', () => {
return { req, resp, node };
}
const setupToolCall = (opts: Parameters<typeof setupContext>[0]) => {
const context = setupContext(opts);
mockContext.getWebhookName.mockReturnValue('default');
mockContext.getBodyData.mockReturnValue({ method: 'tools/call' });
mockMcpServer.getSessionId.mockReturnValue('test-session');
mockMcpServer.handlePostMessage.mockResolvedValue({
wasToolCall: true,
toolCallInfo: { toolName: 'test-tool', arguments: {} },
messageId: 'msg-123',
relaySessionId: undefined,
needsListToolsRelay: false,
});
return context;
};
it('fails closed with 401 on node version below 2', async () => {
const { resp } = setupContext({ typeVersion: 1.1 });
@ -468,16 +540,113 @@ describe('McpTrigger', () => {
it('proceeds to MCP dispatch when the token is valid', async () => {
setupContext({ typeVersion: 2, headers: { authorization: 'Bearer good-token' } });
mockContext.validateN8nOAuth2Token.mockResolvedValue({
valid: true,
user: { id: 'u1', email: 'u@example.com', firstName: 'U', lastName: 'One' },
});
mockContext.validateN8nOAuth2Token.mockResolvedValue({ valid: true, user: OAUTH_USER });
const result = await mcpTrigger.webhook(mockContext);
expect(mockMcpServer.handleSetupRequest).toHaveBeenCalled();
expect(result).toEqual({ noWebhookResponse: true });
});
it('redacts the bearer token in the input the tools see, exposing the caller as `user`', async () => {
const { req } = setupContext({
typeVersion: 2.1,
headers: { authorization: 'Bearer good-token', 'x-user-id': 'user-1' },
});
mockContext.validateN8nOAuth2Token.mockResolvedValue({ valid: true, user: OAUTH_USER });
mockContext.getBodyData.mockReturnValue({ method: 'tools/list' });
await mcpTrigger.webhook(mockContext);
expect(toolInputPassedToSubNodes()).toEqual({
body: { method: 'tools/list' },
headers: { authorization: REDACTED, 'x-user-id': 'user-1' },
user: OAUTH_USER,
});
expect(req.headers.authorization).toBe('Bearer good-token');
});
it('omits the caller when the user is excluded from the output', async () => {
setupContext({
typeVersion: 2.1,
headers: { authorization: 'Bearer good-token' },
includeUserInOutput: false,
});
mockContext.validateN8nOAuth2Token.mockResolvedValue({ valid: true, user: OAUTH_USER });
mockContext.getBodyData.mockReturnValue({ method: 'tools/list' });
await mcpTrigger.webhook(mockContext);
expect(toolInputPassedToSubNodes()).not.toHaveProperty('user');
});
it('exposes no user on version 2.0', async () => {
setupToolCall({ typeVersion: 2, headers: { authorization: 'Bearer good-token' } });
mockContext.validateN8nOAuth2Token.mockResolvedValue({ valid: true, user: OAUTH_USER });
const result = await mcpTrigger.webhook(mockContext);
expect(result.workflowData?.[0][0].json).not.toHaveProperty('user');
expect(result.toolInput).toBeUndefined();
});
it('exposes the caller in the trigger output on a tool call', async () => {
setupToolCall({ typeVersion: 2.1, headers: { authorization: 'Bearer good-token' } });
mockContext.validateN8nOAuth2Token.mockResolvedValue({ valid: true, user: OAUTH_USER });
const result = await mcpTrigger.webhook(mockContext);
expect(result.workflowData?.[0][0].json).toEqual({
mcpToolCall: { toolName: 'test-tool', arguments: {} },
mcpMessageId: 'msg-123',
headers: { authorization: REDACTED },
user: OAUTH_USER,
});
expect(result.toolInput).toMatchObject({ user: OAUTH_USER });
});
});
describe('consumed auth headers', () => {
const setup = (typeVersion: number) => {
const req = createMockRequest({
path: '/mcp/abc',
headers: { 'x-api-key': 'secret-value', 'x-tenant-id': 'acme' },
});
const resp = createMockResponse();
validateWebhookAuthenticationMock.mockImplementation(async () => {
recordConsumedAuth(req, ['x-api-key']);
});
mockContext.getNodeParameter.mockReturnValue('headerAuth');
mockContext.getWebhookName.mockReturnValue('setup');
mockContext.getRequestObject.mockReturnValue(req as never);
mockContext.getResponseObject.mockReturnValue(resp as never);
mockContext.getBodyData.mockReturnValue({ method: 'tools/list' });
mockContext.getNode.mockReturnValue(mock<INode>({ typeVersion, name: 'MCP Server Trigger' }));
return { req };
};
it('redacts the header the credential named, keeping the rest', async () => {
const { req } = setup(2.1);
await mcpTrigger.webhook(mockContext);
expect(toolInputPassedToSubNodes()).toEqual({
body: { method: 'tools/list' },
headers: { 'x-api-key': REDACTED, 'x-tenant-id': 'acme' },
});
expect(req.headers).toEqual({ 'x-api-key': 'secret-value', 'x-tenant-id': 'acme' });
});
it('exposes no headers to the tools on version 2.0', async () => {
setup(2);
await mcpTrigger.webhook(mockContext);
expect(toolInputPassedToSubNodes()).toBeUndefined();
});
});
describe('list tools relay', () => {

View File

@ -55,6 +55,25 @@ describe('Test N8nTool wrapper - DynamicTool fallback', () => {
expect(dynamicTool).toBeInstanceOf(DynamicTool);
});
// `getConnectedTools` stamps `sourceNodeName` before converting, and the MCP trigger needs it
// on the far side to tell a worker which node to run
it('should carry metadata over to the dynamic tool', () => {
const ctx = createMockExecuteFunction<ISupplyDataFunctions>({}, mockNode);
const tool = new N8nTool(ctx, {
name: 'Dummy Tool',
description: 'A dummy tool for testing',
func: vi.fn(),
schema: z.object({ foo: z.string() }),
});
tool.metadata = { sourceNodeName: 'My Tool Node', isFromToolkit: false };
expect(tool.asDynamicTool().metadata).toEqual({
sourceNodeName: 'My Tool Node',
isFromToolkit: false,
});
});
it('should format fallback description correctly', () => {
const func = vi.fn();

View File

@ -105,10 +105,12 @@ export class N8nTool extends DynamicStructuredTool<ZodObjectAny> {
}
};
return new DynamicTool({
const dynamicTool = new DynamicTool({
name,
description: prepareFallbackToolDescription(description, schema),
func: wrappedFunc,
});
dynamicTool.metadata = this.metadata;
return dynamicTool;
}
}

View File

@ -4,6 +4,7 @@ import type { JSONSchema7 } from 'json-schema';
import { StructuredToolkit, type SupplyDataToolResponse } from 'n8n-core';
import type {
ICredentialDataDecryptedObject,
IDataObject,
IExecuteFunctions,
ISupplyDataFunctions,
IWebhookFunctions,
@ -194,11 +195,13 @@ export const getConnectedTools = async (
enforceUniqueNames: boolean,
convertStructuredTool: boolean = true,
escapeCurlyBrackets: boolean = false,
options?: { inputData?: IDataObject },
): Promise<Tool[]> => {
const toolkitConnections = (await ctx.getInputConnectionData(
NodeConnectionTypes.AiTool,
0,
)) as SupplyDataToolResponse[];
// `getRequestObject` narrows to IWebhookFunctions, the only context whose
// getInputConnectionData signature takes the input override.
const toolkitConnections = (await ('getRequestObject' in ctx
? ctx.getInputConnectionData(NodeConnectionTypes.AiTool, 0, options)
: ctx.getInputConnectionData(NodeConnectionTypes.AiTool, 0))) as SupplyDataToolResponse[];
// Get parent nodes to map toolkits to their source nodes.
// getInputConnectionData filters out disabled nodes, so parents must be filtered

View File

@ -497,14 +497,17 @@ describe('enqueueExecution', () => {
);
});
it('should carry the manual-execution identity into job data', async () => {
it.each([
{ encryptedRunnerIdentity: 'encrypted-identity-blob' },
{ mcpToolInput: { method: 'tools/call', headers: { 'x-user-id': 'user-1' } } },
])('should carry %o into job data', async (processData) => {
const activeExecutions = Container.get(ActiveExecutions);
vi.spyOn(activeExecutions, 'attachWorkflowExecution').mockReturnValue();
vi.spyOn(runner, 'processError').mockResolvedValue();
const data = mock<IWorkflowExecutionDataProcess>({
workflowData: { nodes: [], staticData: {} },
executionData: undefined,
encryptedRunnerIdentity: 'encrypted-identity-blob',
...processData,
});
const error = new Error('stop for test purposes');
@ -515,12 +518,7 @@ describe('enqueueExecution', () => {
// @ts-expect-error Private method
await expect(runner.enqueueExecution('1', 'workflow-xyz', data)).rejects.toThrowError(error);
expect(addJob).toHaveBeenCalledWith(
expect.objectContaining({
encryptedRunnerIdentity: 'encrypted-identity-blob',
}),
expect.any(Object),
);
expect(addJob).toHaveBeenCalledWith(expect.objectContaining(processData), expect.any(Object));
});
});

View File

@ -16,6 +16,7 @@ import {
type ISupplyDataFunctions,
type ITaskData,
type IDataObject,
type INodeExecutionData,
type IWorkflowExecuteAdditionalData,
Workflow,
NodeConnectionTypes,
@ -1266,6 +1267,151 @@ describe('JobProcessor', () => {
expect(lastResponse.response).toBe('supply data tool result');
});
describe('MCP request context on the worker', () => {
const toolNode = {
name: 'Calculator',
type: '@n8n/n8n-nodes-langchain.toolCalculator',
typeVersion: 1,
parameters: {},
position: [0, 0] as [number, number],
};
const mcpToolInput = { method: 'tools/call', headers: { 'x-user-id': 'user-1' } };
/** `triggerNames` is in connection order; only `triggerThatRan` gets run data. */
const runToolCall = async ({
triggerNames,
triggerThatRan,
toolInput,
}: {
triggerNames: string[];
triggerThatRan: string;
toolInput?: IDataObject;
}) => {
const workflowData = {
id: 'wf-1',
nodes: [
...triggerNames.map((name) => ({
name,
type: '@n8n/n8n-nodes-langchain.mcpTrigger',
typeVersion: 2,
parameters: {},
position: [0, 0] as [number, number],
})),
toolNode,
],
connections: {
[toolNode.name]: {
[NodeConnectionTypes.AiTool]: [
triggerNames.map((node) => ({ node, type: NodeConnectionTypes.AiTool, index: 0 })),
],
},
},
staticData: {},
};
const executionPersistence = mock<ExecutionPersistence>();
executionPersistence.findSingleExecution.mockResolvedValueOnce(
mock<IExecutionResponse>({
mode: 'trigger',
workflowData,
data: mock<IRunExecutionData>({ executionData: undefined }),
}),
);
executionPersistence.findSingleExecution.mockResolvedValueOnce(
mock<IExecutionResponse>({ status: 'success', workflowData }),
);
// Real (mutable) run-execution-data, not a mock, so the tool node's own run
// lands in `resultData.runData` next to the trigger's.
const run: IRun = {
mode: 'trigger',
status: 'success',
finished: true,
startedAt: new Date(),
stoppedAt: new Date(),
storedAt: 'db',
data: createRunExecutionData({
resultData: { runData: { [triggerThatRan]: [mock<ITaskData>()] } },
}),
};
let toolNodeInput: INodeExecutionData[] | undefined;
const nodeTypes = mock<NodeTypes>();
nodeTypes.getByNameAndVersion.mockReturnValue({
description: {
name: 'toolCalculator',
outputs: [NodeConnectionTypes.AiTool],
properties: [],
},
execute: vi.fn().mockImplementation(function (this: ISupplyDataFunctions) {
toolNodeInput = this.getInputData(0, NodeConnectionTypes.Main);
return [[{ json: { response: 42 } }]];
}),
} as never);
const jobProcessor = new JobProcessor(
logger,
mock(), // executionRepository
executionPersistence,
mock(), // workflowRepository
nodeTypes,
{ hostId: 'worker-host-123' } as unknown as InstanceSettings,
createManualExecutionServiceMock(run),
executionsConfig,
mock(), // eventService
mock(), // webhookResponseRelay
);
const job = mock<Job>();
job.data = {
workflowId: 'wf-1',
executionId: 'exec-mcp-input',
loadStaticData: false,
isMcpExecution: true,
mcpType: 'trigger',
mcpSessionId: 'session-input',
mcpMessageId: 'msg-1',
mcpToolCall: {
toolName: toolNode.name,
arguments: { input: '2 + 2' },
sourceNodeName: toolNode.name,
},
mcpToolInput: toolInput,
};
await jobProcessor.processJob(job);
return { toolNodeInput, toolRun: run.data.resultData.runData[toolNode.name]?.[0] };
};
it.each([
{ toolInput: mcpToolInput, expected: mcpToolInput },
{ toolInput: undefined, expected: {} },
])('should give the tool node $toolInput as input data', async ({ toolInput, expected }) => {
const { toolNodeInput } = await runToolCall({
triggerNames: ['MCP Server Trigger'],
triggerThatRan: 'MCP Server Trigger',
toolInput,
});
expect(toolNodeInput).toEqual([{ json: expected }]);
});
it.each(['MCP Trigger A', 'MCP Trigger B'])(
'should record the tool run against the trigger that ran (%s)',
async (triggerThatRan) => {
const { toolRun } = await runToolCall({
triggerNames: ['MCP Trigger A', 'MCP Trigger B'],
triggerThatRan,
toolInput: mcpToolInput,
});
expect(toolRun?.source?.[0]?.previousNode).toBe(triggerThatRan);
},
);
});
it('should expose the established execution context to the tool node', async () => {
// Regression: in queue mode the tool runs on the worker, where dynamic
// (private) credentials resolve from the node's execution context. The

View File

@ -376,17 +376,18 @@ export class JobProcessor {
toolResult = await withExpressionIsolate(
workflow,
async () =>
await this.invokeTool(
await this.invokeTool({
workflow,
sourceNodeName,
toolArgs,
toolInput: job.data.mcpToolInput,
additionalData,
run.data,
runExecutionData: run.data,
// The execution context (e.g. the OAuth identity for private credentials)
// is established on the main and loaded with the execution here; pass it
// through so the tool node can resolve dynamic credentials on the worker.
execution.data?.executionData?.runtimeData,
),
executionContext: execution.data?.executionData?.runtimeData,
}),
);
// A tool result is not a response, so it has no offload path: this limit
@ -516,18 +517,23 @@ export class JobProcessor {
* For tool wrapper nodes without supplyData (e.g. httpRequestTool), calls
* execute directly — mirroring the fallback in get-input-connection-data.ts.
*/
private async invokeTool(
workflow: Workflow,
sourceNodeName: string,
toolArgs: Record<string, unknown>,
additionalData: ReturnType<typeof WorkflowExecuteAdditionalData.getBase> extends Promise<
infer T
>
? T
: never,
runExecutionData: IRunExecutionData,
executionContext?: IExecutionContext,
): Promise<unknown> {
private async invokeTool({
workflow,
sourceNodeName,
toolArgs,
toolInput,
additionalData,
runExecutionData,
executionContext,
}: {
workflow: Workflow;
sourceNodeName: string;
toolArgs: Record<string, unknown>;
toolInput?: IDataObject;
additionalData: Awaited<ReturnType<typeof WorkflowExecuteAdditionalData.getBase>>;
runExecutionData: IRunExecutionData;
executionContext?: IExecutionContext;
}): Promise<unknown> {
const toolNode = workflow.getNode(sourceNodeName);
if (!toolNode) {
throw new UnexpectedError(`Tool node "${sourceNodeName}" not found in workflow`);
@ -540,14 +546,15 @@ export class JobProcessor {
const validatedToolArgs =
typeof toolArgs === 'object' && toolArgs !== null && !Array.isArray(toolArgs) ? toolArgs : {};
// Create input data for the tool node with the tool arguments
const inputData: INodeExecutionData[][] = [
[
{
json: validatedToolArgs as INodeExecutionData['json'],
},
],
];
// A tool can feed several MCP triggers, and connection order says nothing about which
// one this execution came from — only the trigger that ran has run data. Recording it
// as parent points the tool's run data `source` at it, as in direct mode.
const { runData } = runExecutionData.resultData;
const triggerNames = workflow.getChildNodes(sourceNodeName, NodeConnectionTypes.AiTool, 1);
const triggerName = triggerNames.find((name) => runData[name]) ?? triggerNames[0];
const parentNode = triggerName ? (workflow.getNode(triggerName) ?? undefined) : undefined;
const inputData: INodeExecutionData[][] = [[{ json: toolInput ?? {} }]];
// `executionData` must exist for output recording; init it if the run lacks it.
runExecutionData.executionData ??= {
@ -572,11 +579,6 @@ export class JobProcessor {
const closeFunctions: CloseFunction[] = [];
// Parent = the node the tool feeds (the MCP trigger), so the recorded run
// data's `source` points back at it, as in direct mode.
const [parentNodeName] = workflow.getChildNodes(sourceNodeName, NodeConnectionTypes.AiTool, 1);
const parentNode = parentNodeName ? (workflow.getNode(parentNodeName) ?? undefined) : undefined;
// Create SupplyDataContext for the tool node
const context = new SupplyDataContext(
workflow,

View File

@ -3,6 +3,7 @@ import type Bull from 'bull';
import type {
ExecutionError,
ExecutionStatus,
IDataObject,
IExecuteResponsePromiseData,
IRun,
StructuredChunk,
@ -44,6 +45,8 @@ export type JobData = {
/** The n8n node name that provides this tool. */
sourceNodeName?: string;
};
/** The MCP request as node input, so the worker gives the tool node the same `$json` as direct mode. */
mcpToolInput?: IDataObject;
};
export type JobResult = {

View File

@ -34,6 +34,7 @@ import {
WAIT_NODE_TYPE,
CHAT_TRIGGER_NODE_TYPE,
WEBHOOK_NODE_TYPE,
MCP_TRIGGER_NODE_TYPE,
WorkflowConfigurationError,
NodeOperationError,
MICROSOFT_AGENT365_TRIGGER_NODE_TYPE,
@ -755,58 +756,60 @@ describe('prepareExecutionData', () => {
expect(runExecutionData.manualData).toBeUndefined();
});
describe('MICROSOFT_AGENT365_TRIGGER_NODE_TYPE merge condition', () => {
test('should merge nodeExecutionStack when node type is MICROSOFT_AGENT365_TRIGGER_NODE_TYPE and runExecutionData exists', () => {
const microsoftAgentNode = mock<INode>({
name: 'Microsoft Agent 365',
type: MICROSOFT_AGENT365_TRIGGER_NODE_TYPE,
});
describe('seeded execution stack merge condition', () => {
test.each([
MICROSOFT_AGENT365_TRIGGER_NODE_TYPE,
MCP_TRIGGER_NODE_TYPE,
CHAT_TRIGGER_NODE_TYPE,
])(
'should merge nodeExecutionStack when node type is %s and runExecutionData exists',
(type) => {
const seededTriggerNode = mock<INode>({ name: 'Seeded Trigger', type });
const existingNodeExecutionStack: IExecuteData[] = [
{
node: mock<INode>({ name: 'ExistingNode' }),
data: {
main: [[{ json: { existing: 'data' } }]],
const existingNodeExecutionStack: IExecuteData[] = [
{
node: mock<INode>({ name: 'ExistingNode' }),
data: {
main: [[{ json: { existing: 'data' } }]],
},
source: null,
},
source: null,
},
];
];
const existingRunExecutionData: IRunExecutionData = {
version: 1,
startData: {},
resultData: { runData: {} },
executionData: {
contextData: {},
metadata: {},
nodeExecutionStack: existingNodeExecutionStack,
waitingExecution: {},
waitingExecutionSource: {},
},
} as IRunExecutionData;
const existingRunExecutionData: IRunExecutionData = {
version: 1,
startData: {},
resultData: { runData: {} },
executionData: {
contextData: {},
metadata: {},
nodeExecutionStack: existingNodeExecutionStack,
waitingExecution: {},
waitingExecutionSource: {},
},
} as IRunExecutionData;
const { runExecutionData } = prepareExecutionData(
'trigger',
microsoftAgentNode,
webhookResultData,
existingRunExecutionData,
);
const { runExecutionData } = prepareExecutionData(
'trigger',
seededTriggerNode,
webhookResultData,
existingRunExecutionData,
);
expect(runExecutionData.executionData?.nodeExecutionStack).toHaveLength(1);
expect(runExecutionData.executionData?.nodeExecutionStack[0].node.name).toBe(
'Microsoft Agent 365',
);
expect(runExecutionData.executionData?.nodeExecutionStack[0].node.type).toBe(
MICROSOFT_AGENT365_TRIGGER_NODE_TYPE,
);
expect(runExecutionData.executionData?.nodeExecutionStack[0].data.main[0]).toHaveLength(1);
expect(runExecutionData.executionData?.nodeExecutionStack[0].data.main[0]?.[0]?.json).toEqual(
{
expect(runExecutionData.executionData?.nodeExecutionStack).toHaveLength(1);
expect(runExecutionData.executionData?.nodeExecutionStack[0].node.name).toBe(
'Seeded Trigger',
);
expect(runExecutionData.executionData?.nodeExecutionStack[0].node.type).toBe(type);
expect(runExecutionData.executionData?.nodeExecutionStack[0].data.main[0]).toHaveLength(1);
expect(
runExecutionData.executionData?.nodeExecutionStack[0].data.main[0]?.[0]?.json,
).toEqual({
existing: 'data',
data: 'test',
},
);
});
});
},
);
test('should not merge when node type is MICROSOFT_AGENT365_TRIGGER_NODE_TYPE but runExecutionData is undefined', () => {
const microsoftAgentNode = mock<INode>({

View File

@ -417,7 +417,9 @@ function reconcileSeededExecutionStack(
if (!executionData?.nodeExecutionStack) return;
if (
[MICROSOFT_AGENT365_TRIGGER_NODE_TYPE, CHAT_TRIGGER_NODE_TYPE].includes(workflowStartNode.type)
[MCP_TRIGGER_NODE_TYPE, MICROSOFT_AGENT365_TRIGGER_NODE_TYPE, CHAT_TRIGGER_NODE_TYPE].includes(
workflowStartNode.type,
)
) {
merge(executionData.nodeExecutionStack, nodeExecutionStack);
} else if (shouldEstablishTriggerIdentity(workflowStartNode)) {
@ -892,6 +894,9 @@ export async function executeWebhook(
runData.mcpToolCall = mcpToolCallValue;
}
// The worker has no access to the request, so carry the node input the trigger built
runData.mcpToolInput = webhookResultData.toolInput;
// Handle MCP list tools relay - forward to main with SSE transport via pub/sub
const mcpListToolsRelayValue =
firstItem && 'json' in firstItem ? firstItem.json?.mcpListToolsRelay : null;

View File

@ -537,6 +537,7 @@ export class WorkflowRunner {
mcpSessionId: data.mcpSessionId,
mcpMessageId: data.mcpMessageId,
mcpToolCall: data.mcpToolCall,
mcpToolInput: data.mcpToolInput,
};
if (!this.scalingService) {

View File

@ -12,10 +12,14 @@ import type {
WorkflowExecuteMode,
WorkflowExpression,
} from 'n8n-workflow';
import { NodeConnectionTypes } from 'n8n-workflow';
import { mock } from 'vitest-mock-extended';
import { getInputConnectionData } from '../utils/get-input-connection-data';
import { WebhookContext } from '../webhook-context';
vi.mock('../utils/get-input-connection-data');
describe('WebhookContext', () => {
const testCredentialType = 'testCredential';
const nodeType = mock<INodeType>({
@ -258,6 +262,56 @@ describe('WebhookContext', () => {
});
});
describe('getInputConnectionData', () => {
const inputPassedToSubNodes = () => vi.mocked(getInputConnectionData).mock.calls[0][3];
it('should expose the request body to sub-nodes', async () => {
await webhookContext.getInputConnectionData(NodeConnectionTypes.AiTool, 0);
expect(inputPassedToSubNodes()).toEqual([{ json: { test: 'body' } }]);
});
it('should expose the input data the trigger supplied instead', async () => {
await webhookContext.getInputConnectionData(NodeConnectionTypes.AiTool, 0, {
inputData: { body: { test: 'body' }, headers: { test: 'header' } },
});
expect(inputPassedToSubNodes()).toEqual([
{ json: { body: { test: 'body' }, headers: { test: 'header' } } },
]);
});
it('should fall back to the request body when the trigger supplies no input data', async () => {
await webhookContext.getInputConnectionData(NodeConnectionTypes.AiTool, 0, {});
expect(inputPassedToSubNodes()).toEqual([{ json: { test: 'body' } }]);
});
it('should ignore a legacy numeric third argument', async () => {
await webhookContext.getInputConnectionData(NodeConnectionTypes.AiTool, 0, 1);
expect(inputPassedToSubNodes()).toEqual([{ json: { test: 'body' } }]);
});
it('should not fail when there is no HTTP request', async () => {
const contextAdditionalData = mock<IWorkflowExecuteAdditionalData>({ credentialsHelper });
contextAdditionalData.httpRequest = undefined;
const context = new WebhookContext(
workflow,
node,
contextAdditionalData,
mode,
webhookData,
[],
null,
);
await context.getInputConnectionData(NodeConnectionTypes.AiTool, 0);
expect(inputPassedToSubNodes()).toEqual([{ json: {} }]);
});
});
describe('getNodeParameter', () => {
beforeEach(() => {
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);

View File

@ -29,6 +29,7 @@ import { getInputConnectionData } from './utils/get-input-connection-data';
import { getRequestHelperFunctions } from './utils/request-helper-functions';
import { returnJsonArray } from './utils/return-json-array';
import { getNodeWebhookUrl } from './utils/webhook-helper-functions';
export class WebhookContext extends NodeExecutionContext implements IWebhookFunctions {
readonly helpers: IWebhookFunctions['helpers'];
@ -225,13 +226,16 @@ export class WebhookContext extends NodeExecutionContext implements IWebhookFunc
async getInputConnectionData(
connectionType: AINodeConnectionType,
itemIndex: number,
options?: number | { inputData?: IDataObject },
): Promise<unknown> {
const inputData =
typeof options === 'object' && options !== null ? options.inputData : undefined;
// To be able to use expressions like "$json.sessionId" set the
// body data the webhook received to what is normally used for
// incoming node data.
// incoming node data, unless the trigger supplied its own shape.
const connectionInputData: INodeExecutionData[] = [
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
{ json: this.additionalData.httpRequest?.body || {} },
{ json: inputData ?? (this.additionalData.httpRequest?.body || {}) },
];
const runExecutionData = this.runExecutionData ?? createEmptyRunExecutionData();
const executeData: IExecuteData = {

View File

@ -1,5 +1,7 @@
import jwt from 'jsonwebtoken';
import {
REDACTED,
redactedHeaders,
UnexpectedError,
type IWebhookFunctions,
type INodeExecutionData,
@ -772,6 +774,139 @@ describe('Webhook Utils', () => {
);
expect(result).toEqual(decodedPayload);
});
describe('records the headers it authenticated with', () => {
const setup = (
authentication: string,
headers: Record<string, string | undefined>,
credentials: IDataObject,
node?: INode,
) => {
const request = { headers };
const ctx: Partial<IWebhookFunctions> = {
getNode: vi.fn().mockReturnValue(node ?? ({} as INode)),
getNodeParameter: vi.fn().mockReturnValue(authentication),
getCredentials: vi.fn().mockResolvedValue(credentials),
getRequestObject: vi.fn().mockReturnValue(request),
getHeaderData: vi.fn().mockReturnValue(headers),
};
return { ctx: ctx as IWebhookFunctions, request };
};
it('leaves the request alone', async () => {
const authorization = `Basic ${Buffer.from('admin:password').toString('base64')}`;
const { ctx, request } = setup(
'basicAuth',
{ authorization, 'x-tenant-id': 'acme' },
{ user: 'admin', password: 'password' },
);
await validateWebhookAuthentication(ctx, 'authentication');
expect(request.headers).toEqual({ authorization, 'x-tenant-id': 'acme' });
});
it('records the basic auth header, keeping the rest', async () => {
const { ctx, request } = setup(
'basicAuth',
{
authorization: `Basic ${Buffer.from('admin:password').toString('base64')}`,
'x-tenant-id': 'acme',
},
{ user: 'admin', password: 'password' },
);
await validateWebhookAuthentication(ctx, 'authentication');
expect(redactedHeaders(request)).toEqual({
authorization: REDACTED,
'x-tenant-id': 'acme',
});
});
it('records only the carrier that authenticated', async () => {
const { ctx, request } = setup(
'basicAuth',
{
authorization: `Basic ${Buffer.from('admin:password').toString('base64')}`,
'x-auth-token': 'callers-own-token',
},
{ user: 'admin', password: 'password' },
);
await validateWebhookAuthentication(ctx, 'authentication');
expect(redactedHeaders(request)).toEqual({
authorization: REDACTED,
'x-auth-token': 'callers-own-token',
});
});
it('records the form auth token accepted in place of basic auth', async () => {
const node = { id: 'node-789', webhookId: 'webhook-456' } as INode;
const credentials = { user: 'admin', password: 'password' };
const { ctx, request } = setup(
'basicAuth',
{ 'x-auth-token': generateBasicAuthToken(node, credentials) },
credentials,
node,
);
await validateWebhookAuthentication(ctx, 'authentication');
expect(redactedHeaders(request)).toEqual({ 'x-auth-token': REDACTED });
});
it('records the bearer token header', async () => {
const { ctx, request } = setup(
'bearerAuth',
{ authorization: 'Bearer secret-token', accept: 'application/json' },
{ token: 'secret-token' },
);
await validateWebhookAuthentication(ctx, 'authentication');
expect(redactedHeaders(request)).toEqual({
authorization: REDACTED,
accept: 'application/json',
});
});
it('records the custom header the credential names', async () => {
const { ctx, request } = setup(
'headerAuth',
{ test: 'secret-value', 'x-tenant-id': 'acme' },
{ name: 'test', value: 'secret-value' },
);
await validateWebhookAuthentication(ctx, 'authentication');
expect(redactedHeaders(request)).toEqual({ test: REDACTED, 'x-tenant-id': 'acme' });
});
it('records the JWT header while still returning its payload', async () => {
const decodedPayload = { sub: '1234567890' };
(jwt.verify as Mock).mockReturnValue(decodedPayload);
const { ctx, request } = setup(
'jwtAuth',
{ authorization: 'Bearer some.jwt.token' },
{ keyType: 'passphrase', publicKey: '', secret: 'secret', algorithm: 'HS256' },
);
const result = await validateWebhookAuthentication(ctx, 'authentication');
expect(result).toEqual(decodedPayload);
expect(redactedHeaders(request)).toEqual({ authorization: REDACTED });
});
it('records nothing when authentication is "none"', async () => {
const { ctx, request } = setup('none', { authorization: 'Bearer caller-token' }, {});
await validateWebhookAuthentication(ctx, 'authentication');
expect(redactedHeaders(request)).toEqual({ authorization: 'Bearer caller-token' });
});
});
});
describe('handleFormData', () => {

View File

@ -2,7 +2,7 @@ import { formatPemBlock } from '@n8n/utils/format-pem-block';
import basicAuth from 'basic-auth';
import { rm } from 'fs/promises';
import jwt from 'jsonwebtoken';
import { WorkflowConfigurationError } from 'n8n-workflow';
import { recordConsumedAuth, WorkflowConfigurationError } from 'n8n-workflow';
import type {
IWebhookFunctions,
INodeExecutionData,
@ -294,12 +294,16 @@ export async function validateWebhookAuthentication(
) {
throw new WebhookAuthorizationError(403);
}
recordConsumedAuth(req, ['x-auth-token']);
} else if (
providedAuth.name !== expectedAuth.user ||
providedAuth.pass !== expectedAuth.password
) {
// Provided authentication data is wrong
throw new WebhookAuthorizationError(401, 'Authentication data is wrong!');
} else {
recordConsumedAuth(req, ['authorization']);
}
} else if (authentication === 'bearerAuth') {
let expectedAuth: ICredentialDataDecryptedObject | undefined;
@ -315,6 +319,8 @@ export async function validateWebhookAuthentication(
if (headers.authorization !== `Bearer ${expectedToken}`) {
throw new WebhookAuthorizationError(403);
}
recordConsumedAuth(req, ['authorization']);
} else if (authentication === 'headerAuth') {
// Special header with value is needed to call webhook
let expectedAuth: ICredentialDataDecryptedObject | undefined;
@ -336,6 +342,8 @@ export async function validateWebhookAuthentication(
// Provided authentication data is wrong
throw new WebhookAuthorizationError(403);
}
recordConsumedAuth(req, [headerName]);
} else if (authentication === 'jwtAuth') {
let expectedAuth;
@ -369,9 +377,13 @@ export async function validateWebhookAuthentication(
}
try {
return jwt.verify(token, secretOrPublicKey, {
const payload = jwt.verify(token, secretOrPublicKey, {
algorithms: [expectedAuth.algorithm],
}) as IDataObject;
recordConsumedAuth(req, ['authorization']);
return payload;
} catch (error) {
throw new WebhookAuthorizationError(403, error.message);
}

View File

@ -0,0 +1,33 @@
import type { IncomingHttpHeaders } from 'http';
type RequestWithHeaders = { headers: IncomingHttpHeaders };
export const REDACTED = '**hidden**';
const CONSUMED_AUTH = Symbol.for('n8n.consumedAuth');
function readRecord(req: RequestWithHeaders): string[] {
const record = CONSUMED_AUTH in req ? req[CONSUMED_AUTH] : [];
return Array.isArray(record) ? (record as string[]) : [];
}
/**
* Saves which headers n8n consumed to authenticate this request, so that they can be redacted later
*/
export function recordConsumedAuth(req: RequestWithHeaders, names: string[]): void {
Object.defineProperty(req, CONSUMED_AUTH, {
value: [...readRecord(req), ...names],
enumerable: false,
writable: true,
configurable: true,
});
}
export function redactedHeaders(req: RequestWithHeaders): IncomingHttpHeaders {
const headers = { ...req.headers };
for (const name of readRecord(req)) {
if (name in headers) headers[name] = REDACTED;
}
return headers;
}

View File

@ -41,6 +41,7 @@ export * from './tool-helpers';
export * from './trigger-credential-gate';
export * from './trigger-identity';
export * from './n8n-oauth2-auth';
export * from './auth-redaction';
export * from './node-reference-parser-utils';
export * from './metadata-utils';
export * from './highlighted-data';

View File

@ -1539,7 +1539,8 @@ export interface IWebhookFunctions extends FunctionsBaseWithRequiredKeys<'getMod
getInputConnectionData(
connectionType: AINodeConnectionType,
itemIndex: number,
inputIndex?: number,
/** A `number` is the never-implemented `inputIndex`, still accepted and ignored so existing nodes keep compiling. */
options?: number | { inputData?: IDataObject },
): Promise<unknown>;
getNodeParameter(
parameterName: string,
@ -3133,6 +3134,8 @@ export interface IWebhookResponseData {
workflowData?: INodeExecutionData[][];
webhookResponse?: any;
noWebhookResponse?: boolean;
/** Input the trigger wants connected sub-nodes to receive, also when they run on a worker. */
toolInput?: IDataObject;
}
export type WebhookResponseData = 'allEntries' | 'firstEntryJson' | 'firstEntryBinary' | 'noData';
@ -3567,6 +3570,8 @@ export interface IWorkflowExecutionDataProcess {
arguments: Record<string, unknown>;
sourceNodeName?: string;
};
/** The MCP request as node input, so the worker gives the tool node the same `$json` as direct mode. */
mcpToolInput?: IDataObject;
/** Key to uniquely identify this execution, generated by the trigger. */
deduplicationKey?: string;
/** W3C trace context extracted from inbound webhook headers. */

View File

@ -1,5 +1,6 @@
import { recordConsumedAuth } from './auth-redaction';
import { UnexpectedError } from './errors';
import type { IWebhookFunctions } from './interfaces';
import type { IUser, IWebhookFunctions } from './interfaces';
function trimTrailingSlash(url: string): string {
return url.endsWith('/') ? url.slice(0, -1) : url;
@ -35,7 +36,8 @@ function sendUnauthorizedResponse(
* and the MCP trigger — both expose it as the `n8nOAuth2` authentication mode.
*
* On success returns the validated `token` and `resource` URL, which the caller
* passes to `context.establishTriggerIdentity` to run the workflow as that user.
* passes to `context.establishTriggerIdentity` to run the workflow as that user,
* plus the resolved `user` for triggers that surface the caller in their output.
* When the token is missing or invalid the response is written here (401/403/503,
* advertising the OAuth protected-resource metadata URL in `WWW-Authenticate`) and
* `'handled'` is returned, so the request never reaches workflow execution.
@ -53,6 +55,7 @@ export const n8nOAuth2Auth = async (
status: 'ok';
token: string;
resource: string;
user: IUser;
}
| 'handled'
> => {
@ -94,9 +97,12 @@ export const n8nOAuth2Auth = async (
return 'handled';
}
recordConsumedAuth(req, ['authorization']);
return {
status: 'ok',
token,
resource: resourceUrl,
user: validationResult.user,
};
};

View File

@ -0,0 +1,66 @@
import { recordConsumedAuth, redactedHeaders, REDACTED } from '../src/auth-redaction';
const request = (headers: Record<string, string>) => ({ headers });
describe('recordConsumedAuth / redactedHeaders', () => {
it('should leave the request untouched', () => {
const req = request({ authorization: 'Bearer secret', 'x-tenant-id': 'acme' });
recordConsumedAuth(req, ['authorization']);
redactedHeaders(req);
expect(req.headers).toEqual({ authorization: 'Bearer secret', 'x-tenant-id': 'acme' });
});
it('should redact only the recorded headers', () => {
const req = request({ authorization: 'Bearer secret', 'x-tenant-id': 'acme' });
recordConsumedAuth(req, ['authorization']);
expect(redactedHeaders(req)).toEqual({ authorization: REDACTED, 'x-tenant-id': 'acme' });
});
it('should redact a credential-named header', () => {
const req = request({ test: 'secret-value', 'x-tenant-id': 'acme' });
recordConsumedAuth(req, ['test']);
expect(redactedHeaders(req)).toEqual({ test: REDACTED, 'x-tenant-id': 'acme' });
});
it('should accumulate across several records', () => {
const req = request({ authorization: 'a', 'x-auth-token': 'b', keep: 'c' });
recordConsumedAuth(req, ['authorization']);
recordConsumedAuth(req, ['x-auth-token']);
expect(redactedHeaders(req)).toEqual({
authorization: REDACTED,
'x-auth-token': REDACTED,
keep: 'c',
});
});
it('should ignore a recorded header the request never sent', () => {
const req = request({ 'x-tenant-id': 'acme' });
recordConsumedAuth(req, ['authorization']);
expect(redactedHeaders(req)).toEqual({ 'x-tenant-id': 'acme' });
});
it('should return the headers unchanged when nothing was recorded', () => {
const req = request({ authorization: 'Bearer caller-token' });
expect(redactedHeaders(req)).toEqual({ authorization: 'Bearer caller-token' });
});
it('should not be enumerable on the request', () => {
const req = request({ authorization: 'Bearer secret' });
recordConsumedAuth(req, ['authorization']);
expect(Object.keys(req)).toEqual(['headers']);
expect({ ...req }).toEqual({ headers: { authorization: 'Bearer secret' } });
});
});

View File

@ -1,13 +1,16 @@
import type { Response } from 'express';
import { mock } from 'vitest-mock-extended';
import { REDACTED, redactedHeaders } from '../src/auth-redaction';
import type { IWebhookFunctions, N8nOAuth2ValidationResult } from '../src/interfaces';
import { n8nOAuth2Auth } from '../src/n8n-oauth2-auth';
const WEBHOOK_URL = 'https://n8n.example.com/webhook/protected-path';
const USER = { id: 'u1', email: 'u@example.com', firstName: 'U', lastName: 'One' };
const buildContext = (opts: {
authorization?: string;
otherHeaders?: Record<string, string>;
validation?: N8nOAuth2ValidationResult;
webhookUrl?: string | undefined;
}) => {
@ -22,27 +25,34 @@ const buildContext = (opts: {
'webhookUrl' in opts ? opts.webhookUrl : WEBHOOK_URL,
);
context.getResponseObject.mockReturnValue(response);
context.getRequestObject.mockReturnValue({
headers: opts.authorization ? { authorization: opts.authorization } : {},
} as never);
context.validateN8nOAuth2Token.mockResolvedValue(
opts.validation ?? { valid: true, user: { id: 'u1' } as never },
);
const request = {
headers: {
...(opts.authorization ? { authorization: opts.authorization } : {}),
...opts.otherHeaders,
} as Record<string, string>,
};
context.getRequestObject.mockReturnValue(request as never);
context.validateN8nOAuth2Token.mockResolvedValue(opts.validation ?? { valid: true, user: USER });
return { context, response, validateN8nOAuth2Token: context.validateN8nOAuth2Token };
return { context, response, request, validateN8nOAuth2Token: context.validateN8nOAuth2Token };
};
describe('n8nOAuth2Auth', () => {
it('returns the token and resource for a valid bearer token', async () => {
it('returns the token, resource and resolved user for a valid bearer token', async () => {
const { context, validateN8nOAuth2Token } = buildContext({
authorization: 'Bearer good-token',
validation: { valid: true, user: { id: 'u1' } as never },
validation: { valid: true, user: USER },
});
const result = await n8nOAuth2Auth(context, { realm: 'n8n Webhook' });
expect(validateN8nOAuth2Token).toHaveBeenCalledWith('good-token', WEBHOOK_URL);
expect(result).toEqual({ status: 'ok', token: 'good-token', resource: WEBHOOK_URL });
expect(result).toEqual({
status: 'ok',
token: 'good-token',
resource: WEBHOOK_URL,
user: USER,
});
});
it('encodes the served method into the resource and the protected-resource metadata URL', async () => {
@ -55,7 +65,12 @@ describe('n8nOAuth2Auth', () => {
const expectedResource = `${WEBHOOK_URL}?method=POST`;
expect(validateN8nOAuth2Token).toHaveBeenCalledWith('good-token', expectedResource);
expect(result).toEqual({ status: 'ok', token: 'good-token', resource: expectedResource });
expect(result).toEqual({
status: 'ok',
token: 'good-token',
resource: expectedResource,
user: USER,
});
});
it('advertises the method-qualified metadata URL in WWW-Authenticate', async () => {
@ -113,6 +128,36 @@ describe('n8nOAuth2Auth', () => {
expect(response.send).toHaveBeenCalledWith('OAuth token validation is not available');
});
it('records the authorization header as consumed once the token is validated', async () => {
const { context, request } = buildContext({
authorization: 'Bearer good-token',
otherHeaders: { 'x-tenant-id': 'acme' },
});
await n8nOAuth2Auth(context, { realm: 'n8n Webhook' });
expect(request.headers).toEqual({
authorization: 'Bearer good-token',
'x-tenant-id': 'acme',
});
expect(redactedHeaders(request)).toEqual({
authorization: REDACTED,
'x-tenant-id': 'acme',
});
});
it('records nothing when validation fails', async () => {
const { context, request } = buildContext({
authorization: 'Bearer bad-token',
validation: { valid: false, reason: 'invalid_token' },
});
await n8nOAuth2Auth(context, { realm: 'n8n Webhook' });
expect(redactedHeaders(request)).toEqual({ authorization: 'Bearer bad-token' });
});
it('throws when the webhook URL is unavailable', async () => {
const { context } = buildContext({ webhookUrl: undefined });