feat(core): Chat trigger publish validation & workflow:execute access control (#37231)

This commit is contained in:
MHMD
2026-08-27 15:45:04 +00:00
committed by GitHub
parent bcd240f589
commit 1f062f9ec9
12 changed files with 511 additions and 46 deletions
@@ -438,6 +438,21 @@ export class ChatTrigger extends Node {
"Default to 'none'. n8n exposes inbound trigger URLs publicly by design. Only select an authentication method when the user explicitly asks to authenticate inbound traffic.",
},
},
{
displayName: 'Require Workflow Execute Permission',
name: 'requireExecuteAccess',
type: 'boolean',
default: false,
displayOptions: {
show: {
authentication: ['n8nUserAuth'],
mode: ['hostedChat'],
public: [true],
},
},
description:
'Whether the triggering user must also have permission to execute the workflow in the project it belongs to',
},
{
displayName: 'Initial Message(s)',
name: 'initialMessages',
@@ -170,6 +170,22 @@ describe('ChatTrigger Node', () => {
});
});
describe('requireExecuteAccess property', () => {
it('exposes the toggle, off by default and scoped to n8nUserAuth hosted chat', () => {
const requireExecuteParam = chatTrigger.description.properties.find(
(property) => property.name === 'requireExecuteAccess',
);
expect(requireExecuteParam).toMatchObject({
type: 'boolean',
default: false,
displayOptions: {
show: { authentication: ['n8nUserAuth'], mode: ['hostedChat'], public: [true] },
},
});
});
});
describe('webhook method', () => {
it('returns 404 for public chat when instance policy disables public chat', async () => {
chatTriggerConfig.disablePublicChat = true;
@@ -1,4 +1,9 @@
import { createWorkflowWithHistory, setActiveVersion, testDb } from '@n8n/backend-test-utils';
import {
createWorkflowWithHistory,
setActiveVersion,
shareWorkflowWithUsers,
testDb,
} from '@n8n/backend-test-utils';
import { GlobalConfig } from '@n8n/config';
import type { User } from '@n8n/db';
import { WebhookRepository, WorkflowRepository } from '@n8n/db';
@@ -10,10 +15,13 @@ import { randomUUID } from 'node:crypto';
import { createMember, createOwner } from '@test-integration/db/users';
import { setupTestServer } from '@test-integration/utils';
import { OAuthTokenService } from '@/modules/oauth-server/oauth-token.service';
import { CacheService } from '@/services/cache/cache.service';
import { ProtectedResourceRegistry } from '@/services/protected-resource.registry';
import { UrlService } from '@/services/url.service';
import { OAuthClientRepository } from '../database/repositories/oauth-client.repository';
const testServer = setupTestServer({ modules: ['oauth-server', 'mcp'], endpointGroups: ['mcp'] });
let owner: User;
@@ -34,6 +42,7 @@ const chatTriggerNode = ({
mode = 'hostedChat',
authentication = 'n8nUserAuth',
disabled = false,
requireExecuteAccess,
}: {
name?: string;
// `null` drops the key entirely, so the "parameter stripped at its default" shape the
@@ -42,6 +51,7 @@ const chatTriggerNode = ({
mode?: string | null;
authentication?: string | null;
disabled?: boolean;
requireExecuteAccess?: boolean;
} = {}): INode => ({
id: randomUUID(),
name,
@@ -54,6 +64,7 @@ const chatTriggerNode = ({
...(isPublic === null ? {} : { public: isPublic }),
...(mode === null ? {} : { mode }),
...(authentication === null ? {} : { authentication }),
...(requireExecuteAccess === undefined ? {} : { requireExecuteAccess }),
},
});
@@ -293,14 +304,116 @@ describe('protected resource metadata for chat triggers', () => {
});
});
describe('authorize gate', () => {
test('authorizes any authenticated user — there is no execute gate yet', async () => {
describe('authorize gate (workflow:execute)', () => {
test('authorizes the owner but denies a visitor without execute access', async () => {
const path = chatPath();
await createPublishedChatWorkflow(path, chatTriggerNode());
await createPublishedChatWorkflow(path, chatTriggerNode({ requireExecuteAccess: true }));
const resource = await resolveResource(path);
await expect(resource?.authorize(owner)).resolves.toBe(true);
await expect(resource?.authorize(member)).resolves.toBe(false);
});
test('authorizes a visitor granted execute via a project role', async () => {
const path = chatPath();
const workflow = await createPublishedChatWorkflow(
path,
chatTriggerNode({ requireExecuteAccess: true }),
);
await shareWorkflowWithUsers(workflow, [member]);
const resource = await resolveResource(path);
await expect(resource?.authorize(member)).resolves.toBe(true);
});
test('authorizes any authenticated visitor when require-execute is turned off', async () => {
const path = chatPath();
await createPublishedChatWorkflow(path, chatTriggerNode({ requireExecuteAccess: false }));
const resource = await resolveResource(path);
await expect(resource?.authorize(member)).resolves.toBe(true);
});
test('authorizes any authenticated visitor when the parameter is absent', async () => {
// `requireExecuteAccess` is opt-in, so an unset parameter means any authenticated
// visitor may chat — a trigger whose node never set it stays open.
const node = chatTriggerNode();
expect(node.parameters.requireExecuteAccess).toBeUndefined();
const path = chatPath();
await createPublishedChatWorkflow(path, node);
const resource = await resolveResource(path);
await expect(resource?.authorize(member)).resolves.toBe(true);
});
});
/**
* The same check the chat POST handler and dynamic-credential resolution go through:
* holding a token for the chat resource is not enough — the visitor must still have
* `workflow:execute` on the workflow behind it.
*/
describe('runtime gate: verifyOAuthAccessToken enforces workflow:execute', () => {
const mintAccessToken = async (userId: string, resourceUrl: string) => {
const tokenService = Container.get(OAuthTokenService);
// A registered client is needed only to satisfy the token rows' FK.
const clientId = `client-${randomUUID()}`;
await Container.get(OAuthClientRepository).save({
id: clientId,
name: 'Chat resolver tests',
redirectUris: ['https://example.com/callback'],
grantTypes: ['authorization_code'],
tokenEndpointAuthMethod: 'none',
});
const pair = tokenService.generateTokenPair(userId, clientId, resourceUrl, []);
await tokenService.saveTokenPair(pair.accessToken, pair.refreshToken, clientId, userId, []);
return pair.accessToken;
};
test('refuses a visitor without execute access on the workflow', async () => {
const path = chatPath();
await createPublishedChatWorkflow(path, chatTriggerNode({ requireExecuteAccess: true }));
const token = await mintAccessToken(member.id, resourceUrlFor(path));
const result = await Container.get(OAuthTokenService).verifyOAuthAccessToken(
token,
resourceUrlFor(path),
);
expect(result.user).toBeNull();
expect(result.context?.reason).toBe('insufficient_scope');
});
test('allows a visitor granted execute via a project role', async () => {
const path = chatPath();
const workflow = await createPublishedChatWorkflow(
path,
chatTriggerNode({ requireExecuteAccess: true }),
);
await shareWorkflowWithUsers(workflow, [member]);
const token = await mintAccessToken(member.id, resourceUrlFor(path));
const result = await Container.get(OAuthTokenService).verifyOAuthAccessToken(
token,
resourceUrlFor(path),
);
expect(result.user?.id).toBe(member.id);
});
test('allows the same visitor once require-execute is turned off', async () => {
const path = chatPath();
await createPublishedChatWorkflow(path, chatTriggerNode({ requireExecuteAccess: false }));
const token = await mintAccessToken(member.id, resourceUrlFor(path));
const result = await Container.get(OAuthTokenService).verifyOAuthAccessToken(
token,
resourceUrlFor(path),
);
expect(result.user?.id).toBe(member.id);
});
});
@@ -1,4 +1,9 @@
import { createWorkflowWithHistory, setActiveVersion, testDb } from '@n8n/backend-test-utils';
import {
createWorkflowWithHistory,
setActiveVersion,
shareWorkflowWithUsers,
testDb,
} from '@n8n/backend-test-utils';
import { GlobalConfig } from '@n8n/config';
import type { User } from '@n8n/db';
import { WebhookRepository } from '@n8n/db';
@@ -43,6 +48,7 @@ const chatTriggerNode = ({
mode = 'hostedChat',
authentication = 'n8nUserAuth',
disabled = false,
requireExecuteAccess,
}: {
name?: string;
// `null` drops the key entirely, so the "parameter stripped at its default" shape the
@@ -51,6 +57,7 @@ const chatTriggerNode = ({
mode?: string | null;
authentication?: string | null;
disabled?: boolean;
requireExecuteAccess?: boolean;
} = {}): INode => ({
id: randomUUID(),
name,
@@ -63,6 +70,7 @@ const chatTriggerNode = ({
...(isPublic === null ? {} : { public: isPublic }),
...(mode === null ? {} : { mode }),
...(authentication === null ? {} : { authentication }),
...(requireExecuteAccess === undefined ? {} : { requireExecuteAccess }),
},
});
@@ -246,18 +254,84 @@ describe('protected resource metadata for test chat triggers', () => {
});
});
describe('authorize gate', () => {
test('authorizes any authenticated user — there is no execute gate yet', async () => {
describe('authorize gate (workflow:execute)', () => {
const registerWithWorkflow = async (node: INode) => {
const path = chatPath();
const node = chatTriggerNode();
const workflow = await createWorkflowWithHistory({ active: false, nodes: [node] }, owner);
await registerTestWebhook(path, node, { workflowId: workflow.id });
return { path, workflow };
};
test('authorizes the owner but denies a visitor without execute access', async () => {
const { path } = await registerWithWorkflow(chatTriggerNode({ requireExecuteAccess: true }));
const resource = await resolveResource(path);
await expect(resource?.authorize(owner)).resolves.toBe(true);
await expect(resource?.authorize(member)).resolves.toBe(false);
});
test('authorizes a visitor granted execute via a project role', async () => {
const { path, workflow } = await registerWithWorkflow(
chatTriggerNode({ requireExecuteAccess: true }),
);
await shareWorkflowWithUsers(workflow, [member]);
const resource = await resolveResource(path);
await expect(resource?.authorize(member)).resolves.toBe(true);
});
test('authorizes any authenticated visitor when require-execute is turned off', async () => {
const { path } = await registerWithWorkflow(chatTriggerNode({ requireExecuteAccess: false }));
const resource = await resolveResource(path);
await expect(resource?.authorize(member)).resolves.toBe(true);
});
test('authorizes any authenticated visitor when the parameter is absent', async () => {
const node = chatTriggerNode();
expect(node.parameters.requireExecuteAccess).toBeUndefined();
const { path } = await registerWithWorkflow(node);
const resource = await resolveResource(path);
await expect(resource?.authorize(member)).resolves.toBe(true);
});
});
describe('runtime gate: verifyOAuthAccessToken enforces workflow:execute', () => {
const mintAccessToken = async (userId: string, resourceUrl: string) => {
const tokenService = Container.get(OAuthTokenService);
const clientId = `client-${randomUUID()}`;
await Container.get(OAuthClientRepository).save({
id: clientId,
name: 'Chat test resolver tests',
redirectUris: ['https://example.com/callback'],
grantTypes: ['authorization_code'],
tokenEndpointAuthMethod: 'none',
});
const pair = tokenService.generateTokenPair(userId, clientId, resourceUrl, []);
await tokenService.saveTokenPair(pair.accessToken, pair.refreshToken, clientId, userId, []);
return pair.accessToken;
};
test('refuses a visitor without execute access on the workflow', async () => {
const path = chatPath();
const node = chatTriggerNode({ requireExecuteAccess: true });
const workflow = await createWorkflowWithHistory({ active: false, nodes: [node] }, owner);
await registerTestWebhook(path, node, { workflowId: workflow.id });
const token = await mintAccessToken(member.id, testResourceUrlFor(path));
const result = await Container.get(OAuthTokenService).verifyOAuthAccessToken(
token,
testResourceUrlFor(path),
);
expect(result.user).toBeNull();
expect(result.context?.reason).toBe('insufficient_scope');
});
});
describe('test vs production chat resources', () => {
@@ -99,6 +99,10 @@ export abstract class ChatTriggerResourceResolverBase implements ProtectedResour
// and the single `redirect_uri` — it has to equal the page the visitor loads.
const resourceUrl = `${trimTrailingSlash(this.baseUrl)}/${endpoint}/${path}`;
const audiences = [resourceUrl];
// Opt-in, unlike the MCP/webhook resolvers' `!== false`: defaulting off preserves the
// existing any-authenticated-visitor behaviour, so turning the chat OAuth2 flag on does
// not change who may chat with an already-published workflow.
const requireExecute = node.parameters.requireExecuteAccess === true;
return {
// Path included, like the webhook resolver's id: one workflow can hold several chat
// triggers, each its own resource.
@@ -109,9 +113,11 @@ export abstract class ChatTriggerResourceResolverBase implements ProtectedResour
getAllowedRedirectUris: async () => [resourceUrl],
scopes: CHAT_TRIGGER_SCOPES,
displayName: workflowName,
// No `executeAccessWorkflowId`: today any authenticated visitor may chat, and IAM-1263
// owns the opt-in toggle. `uiHints` is IAM-1266's.
...triggerResourceGate(this.workflowFinderService, { audiences }),
// `uiHints` is IAM-1266's.
...triggerResourceGate(this.workflowFinderService, {
audiences,
executeAccessWorkflowId: requireExecute ? workflowId : undefined,
}),
};
}
}
@@ -888,7 +888,7 @@ describe('WorkflowValidationService', () => {
expect(result.error).toContain('end-user credentials');
expect(result.error).toContain('"My OAuth2"');
expect(result.error).toContain(
'only supported with manual and sub-workflow triggers, chat triggers available in n8n Chat Hub, and MCP, form, or webhook triggers with n8n user authentication',
'only supported with manual and sub-workflow triggers, chat triggers available in n8n Chat Hub or using n8n user authentication in hosted chat mode, and MCP, form, or webhook triggers with n8n user authentication',
);
});
@@ -1239,7 +1239,7 @@ describe('WorkflowValidationService', () => {
expect(result.isValid).toBe(false);
expect(result.error).toBe(
'Cannot publish workflow: end-user credentials ("My OAuth2") are only supported with manual and sub-workflow triggers, chat triggers available in n8n Chat Hub, and MCP, form, or webhook triggers with n8n user authentication. To use another trigger, switch the credential to Fixed.',
'Cannot publish workflow: end-user credentials ("My OAuth2") are only supported with manual and sub-workflow triggers, chat triggers available in n8n Chat Hub or using n8n user authentication in hosted chat mode, and MCP, form, or webhook triggers with n8n user authentication. To use another trigger, switch the credential to Fixed.',
);
});
@@ -1265,7 +1265,7 @@ describe('WorkflowValidationService', () => {
expect(result.isValid).toBe(false);
expect(result.error).toContain(
'only supported with manual and sub-workflow triggers, chat triggers available in n8n Chat Hub, and MCP, form, or webhook triggers with n8n user authentication',
'only supported with manual and sub-workflow triggers, chat triggers available in n8n Chat Hub or using n8n user authentication in hosted chat mode, and MCP, form, or webhook triggers with n8n user authentication',
);
});
});
@@ -1273,11 +1273,9 @@ describe('WorkflowValidationService', () => {
describe('chat trigger', () => {
const CHAT_TRIGGER = '@n8n/n8n-nodes-langchain.chatTrigger';
const validateWithChatTrigger = async (authentication: string) => {
const validateWithChatTrigger = async (parameters: Record<string, unknown>) => {
const nodes: INode[] = [
createNode('When chat message received', CHAT_TRIGGER, {
parameters: { authentication },
}),
createNode('When chat message received', CHAT_TRIGGER, { parameters }),
createNode('HTTP', 'n8n-nodes-base.httpRequest', {
credentials: { oAuth2Api: { id: 'cred-1' } },
}),
@@ -1296,22 +1294,75 @@ describe('WorkflowValidationService', () => {
return await service.validateDynamicCredentials(nodes, mockNodeTypes);
};
// A chat trigger establishes no identity at runtime through `n8nUserAuth`, so
// the flag being on must not let publish accept a configuration that would
// only fail later, mid-execution.
it.each(['n8nUserAuth', 'none', 'basicAuth'])(
// A chat trigger establishes no identity at runtime through `none`/`basicAuth`, so
// the flag being on must not let publish accept a configuration that would only
// fail later, mid-execution.
it.each(['none', 'basicAuth'])(
'should reject authentication %s even when chat OAuth2 is enabled',
async (authentication) => {
withChatOAuth2(true);
const result = await validateWithChatTrigger(authentication);
const result = await validateWithChatTrigger({ authentication });
expect(result.isValid).toBe(false);
expect(result.error).toBe(
'Cannot publish workflow: end-user credentials ("My OAuth2") are only supported with manual and sub-workflow triggers, chat triggers available in n8n Chat Hub, and MCP, form, or webhook triggers with n8n user authentication. To use another trigger, switch the credential to Fixed.',
'Cannot publish workflow: end-user credentials ("My OAuth2") are only supported with manual and sub-workflow triggers, chat triggers available in n8n Chat Hub or using n8n user authentication in hosted chat mode, and MCP, form, or webhook triggers with n8n user authentication. To use another trigger, switch the credential to Fixed.',
);
},
);
it.each([{}, { mode: 'hostedChat' }])(
'should return valid for public n8nUserAuth in hosted-chat mode when chat OAuth2 is enabled (%o)',
async (modeParams) => {
withChatOAuth2(true);
const result = await validateWithChatTrigger({
public: true,
authentication: 'n8nUserAuth',
...modeParams,
});
expect(result.isValid).toBe(true);
},
);
// With the flag off (the default), hosted-chat `n8nUserAuth` falls back to a cookie
// check that never binds the visitor's identity — publish must not accept an
// end-user credential it can't actually resolve at runtime.
it('should reject public n8nUserAuth in hosted-chat mode when chat OAuth2 is disabled', async () => {
const result = await validateWithChatTrigger({
public: true,
authentication: 'n8nUserAuth',
});
expect(result.isValid).toBe(false);
expect(result.error).toBe(
'Cannot publish workflow: end-user credentials ("My OAuth2") are only supported with manual and sub-workflow triggers, chat triggers available in n8n Chat Hub or using n8n user authentication in hosted chat mode, and MCP, form, or webhook triggers with n8n user authentication. To use another trigger, switch the credential to Fixed.',
);
});
// A non-public trigger 404s on every production request and skips auth entirely
// in test mode, so it never reaches the code that establishes identity.
it('should reject n8nUserAuth in hosted-chat mode when not public', async () => {
const result = await validateWithChatTrigger({ authentication: 'n8nUserAuth' });
expect(result.isValid).toBe(false);
});
// Embedded/webhook-mode chat has no hosted page to run the OAuth2 handshake on, so
// `n8nUserAuth` establishes no identity there despite being selected.
it('should reject authentication n8nUserAuth in webhook mode', async () => {
const result = await validateWithChatTrigger({
public: true,
authentication: 'n8nUserAuth',
mode: 'webhook',
});
expect(result.isValid).toBe(false);
expect(result.error).toBe(
'Cannot publish workflow: end-user credentials ("My OAuth2") are only supported with manual and sub-workflow triggers, chat triggers available in n8n Chat Hub or using n8n user authentication in hosted chat mode, and MCP, form, or webhook triggers with n8n user authentication. To use another trigger, switch the credential to Fixed.',
);
});
});
it('should state the identity-extractor requirement for a custom resolver', async () => {
@@ -1355,7 +1406,7 @@ describe('WorkflowValidationService', () => {
const result = await service.validateDynamicCredentials(nodes, mockNodeTypes);
expect(result.error).toBe(
'Cannot publish workflow: end-user credentials ("My OAuth2") are only supported with manual and sub-workflow triggers, chat triggers available in n8n Chat Hub, and MCP, form, or webhook triggers with n8n user authentication. To use another trigger, switch the credential to Fixed.',
'Cannot publish workflow: end-user credentials ("My OAuth2") are only supported with manual and sub-workflow triggers, chat triggers available in n8n Chat Hub or using n8n user authentication in hosted chat mode, and MCP, form, or webhook triggers with n8n user authentication. To use another trigger, switch the credential to Fixed.',
);
});
@@ -23,6 +23,7 @@ import type {
} from 'n8n-workflow';
import { STARTING_NODES } from '@/constants';
import { isChatOAuth2Enabled } from '@/constants/oauth2-triggers';
import { CredentialTypes } from '@/credential-types';
import { DynamicCredentialsProxy } from '@/credentials/dynamic-credentials-proxy';
import type { NodeTypes } from '@/node-types';
@@ -417,13 +418,14 @@ export class WorkflowValidationService {
/**
* Describes which trigger configurations the system resolver currently accepts,
* for the publish-error copy. Chat only qualifies when available in Chat Hub — a
* `n8nUserAuth` chat trigger establishes no identity at runtime, so it's not
* listed here regardless of the chat OAuth2 flag; MCP only with n8n user auth
* (OAuth2). Mirrors `classifyTriggerIdentity`.
* for the publish-error copy. Chat qualifies when available in Chat Hub, or with
* `n8nUserAuth` in hosted-chat mode specifically — embedded/webhook-mode chat has
* no page to run the OAuth2 handshake on, so it establishes no identity regardless
* of the chat OAuth2 flag; MCP only with n8n user auth (OAuth2). Mirrors
* `classifyTriggerIdentity`.
*/
private getN8nUserAuthTriggersList(): string {
return 'manual and sub-workflow triggers, chat triggers available in n8n Chat Hub, and MCP, form, or webhook triggers with n8n user authentication';
return 'manual and sub-workflow triggers, chat triggers available in n8n Chat Hub or using n8n user authentication in hosted chat mode, and MCP, form, or webhook triggers with n8n user authentication';
}
/** Collects the ids of all credentials referenced by enabled nodes. */
@@ -480,6 +482,7 @@ export class WorkflowValidationService {
const { providesExternalIdentity, providesN8nIdentity } = classifyTriggerIdentity(
node.type,
node.parameters,
{ isChatOAuth2Enabled: isChatOAuth2Enabled() },
);
allTriggersProvideExternalIdentity &&= providesExternalIdentity;
allTriggersProvideN8nIdentity &&= providesN8nIdentity;
@@ -4415,7 +4415,7 @@
"nodeIssues.credentials.notIdentified": "Credentials with name {name} exist for {type}.",
"nodeIssues.credentials.notIdentified.hint": "Credentials are not clearly identified. Please select the correct credentials.",
"nodeIssues.credentials.privateNotConnected": "'{name}' end-user credential is not connected for you. Connect yours to execute this step manually.",
"nodeIssues.credentials.privateRequiresIdentityTriggerWithFormAndWebhook": "End-user credentials aren't supported by this workflow's trigger. Supported triggers: Manual, Sub-workflow, Chat available in n8n Chat Hub, and MCP, Form, or Webhook with n8n user authentication. To use another trigger, switch this credential to Fixed.",
"nodeIssues.credentials.privateRequiresIdentityTriggerWithFormAndWebhook": "End-user credentials aren't supported by this workflow's trigger. Supported triggers: Manual, Sub-workflow, Chat available in n8n Chat Hub or using n8n user authentication in hosted chat mode, and MCP, Form, or Webhook with n8n user authentication. To use another trigger, switch this credential to Fixed.",
"nodeIssues.credentials.privateRequiresIdentityExtractor": "End-user credentials with this resolver need a trigger that extracts an identity. Configure an identity extractor on the trigger, or switch this credential to Fixed.",
"nodeIssues.input.missing": "No node connected to required input \"{inputName}\"",
"ndv.trigger.moreInfo": "More info",
@@ -1041,7 +1041,7 @@ describe('useNodeHelpers()', () => {
const result = getNodeCredentialIssues(buildNotionNode(), notionNodeType);
expect(result?.credentials?.[NOTION_API]).toEqual([
"End-user credentials aren't supported by this workflow's trigger. Supported triggers: Manual, Sub-workflow, Chat available in n8n Chat Hub, and MCP, Form, or Webhook with n8n user authentication. To use another trigger, switch this credential to Fixed.",
"End-user credentials aren't supported by this workflow's trigger. Supported triggers: Manual, Sub-workflow, Chat available in n8n Chat Hub or using n8n user authentication in hosted chat mode, and MCP, Form, or Webhook with n8n user authentication. To use another trigger, switch this credential to Fixed.",
]);
});
@@ -1070,7 +1070,7 @@ describe('useNodeHelpers()', () => {
const result = getNodeCredentialIssues(buildNotionNode(), notionNodeType);
expect(result?.credentials?.[NOTION_API]).toEqual([
"End-user credentials aren't supported by this workflow's trigger. Supported triggers: Manual, Sub-workflow, Chat available in n8n Chat Hub, and MCP, Form, or Webhook with n8n user authentication. To use another trigger, switch this credential to Fixed.",
"End-user credentials aren't supported by this workflow's trigger. Supported triggers: Manual, Sub-workflow, Chat available in n8n Chat Hub or using n8n user authentication in hosted chat mode, and MCP, Form, or Webhook with n8n user authentication. To use another trigger, switch this credential to Fixed.",
]);
});
@@ -1084,7 +1084,7 @@ describe('useNodeHelpers()', () => {
const result = getNodeCredentialIssues(buildNotionNode(), notionNodeType);
expect(result?.credentials?.[NOTION_API]).toEqual([
"End-user credentials aren't supported by this workflow's trigger. Supported triggers: Manual, Sub-workflow, Chat available in n8n Chat Hub, and MCP, Form, or Webhook with n8n user authentication. To use another trigger, switch this credential to Fixed.",
"End-user credentials aren't supported by this workflow's trigger. Supported triggers: Manual, Sub-workflow, Chat available in n8n Chat Hub or using n8n user authentication in hosted chat mode, and MCP, Form, or Webhook with n8n user authentication. To use another trigger, switch this credential to Fixed.",
]);
});
@@ -1139,19 +1139,33 @@ describe('useNodeHelpers()', () => {
const result = getNodeCredentialIssues(buildNotionNode(), notionNodeType);
expect(result?.credentials?.[NOTION_API]).toEqual([
"End-user credentials aren't supported by this workflow's trigger. Supported triggers: Manual, Sub-workflow, Chat available in n8n Chat Hub, and MCP, Form, or Webhook with n8n user authentication. To use another trigger, switch this credential to Fixed.",
"End-user credentials aren't supported by this workflow's trigger. Supported triggers: Manual, Sub-workflow, Chat available in n8n Chat Hub or using n8n user authentication in hosted chat mode, and MCP, Form, or Webhook with n8n user authentication. To use another trigger, switch this credential to Fixed.",
]);
});
});
describe('chat trigger n8nUserAuth', () => {
const buildChatUserAuthTrigger = (authentication: string) =>
buildTriggerNode(CHAT_TRIGGER, { parameters: { authentication } });
// `isPublic` has no default: an omitted arg leaves the `public` key out of
// `parameters` entirely (the "stripped at its default" shape a saved node can
// carry), which a JS default parameter can't express since it also fires for an
// explicitly-passed `undefined`.
const buildChatUserAuthTrigger = (
authentication: string,
mode?: string,
isPublic?: boolean,
) =>
buildTriggerNode(CHAT_TRIGGER, {
parameters: {
...(isPublic === undefined ? {} : { public: isPublic }),
authentication,
mode,
},
});
// A chat trigger establishes no identity at runtime through `n8nUserAuth`, so
// the flag being on must not clear this warning — that would tell the
// A chat trigger establishes no identity at runtime through `none`/`basicAuth`,
// so the flag being on must not clear this warning — that would tell the
// builder a fix works when it doesn't.
it.each(['n8nUserAuth', 'none', 'basicAuth'])(
it.each(['none', 'basicAuth'])(
'warns for authentication %s even when chat OAuth2 is enabled',
(authentication) => {
setChatOAuth2(true);
@@ -1162,10 +1176,78 @@ describe('useNodeHelpers()', () => {
const result = getNodeCredentialIssues(buildNotionNode(), notionNodeType);
expect(result?.credentials?.[NOTION_API]).toEqual([
"End-user credentials aren't supported by this workflow's trigger. Supported triggers: Manual, Sub-workflow, Chat available in n8n Chat Hub, and MCP, Form, or Webhook with n8n user authentication. To use another trigger, switch this credential to Fixed.",
"End-user credentials aren't supported by this workflow's trigger. Supported triggers: Manual, Sub-workflow, Chat available in n8n Chat Hub or using n8n user authentication in hosted chat mode, and MCP, Form, or Webhook with n8n user authentication. To use another trigger, switch this credential to Fixed.",
]);
},
);
it.each([undefined, 'hostedChat'])(
'does not warn for public n8nUserAuth in hosted-chat mode when chat OAuth2 is enabled (mode: %s)',
(mode) => {
setChatOAuth2(true);
mockConnectedPrivateCred(true);
mockDocumentStore.workflowTriggerNodes = [
buildChatUserAuthTrigger('n8nUserAuth', mode, true),
];
const { getNodeCredentialIssues } = useNodeHelpers();
const result = getNodeCredentialIssues(buildNotionNode(), notionNodeType);
expect(result).toBeNull();
},
);
// With the flag off (the default), hosted-chat `n8nUserAuth` falls back to a
// cookie check that never binds the visitor's identity for credential
// resolution — the warning must still show.
it('warns for public n8nUserAuth in hosted-chat mode when chat OAuth2 is disabled', () => {
mockConnectedPrivateCred(true);
mockDocumentStore.workflowTriggerNodes = [
buildChatUserAuthTrigger('n8nUserAuth', 'hostedChat', true),
];
const { getNodeCredentialIssues } = useNodeHelpers();
const result = getNodeCredentialIssues(buildNotionNode(), notionNodeType);
expect(result?.credentials?.[NOTION_API]).toEqual([
"End-user credentials aren't supported by this workflow's trigger. Supported triggers: Manual, Sub-workflow, Chat available in n8n Chat Hub or using n8n user authentication in hosted chat mode, and MCP, Form, or Webhook with n8n user authentication. To use another trigger, switch this credential to Fixed.",
]);
});
// A non-public trigger 404s on every production request and skips auth
// entirely in test mode, so it never reaches the code that establishes identity.
it.each([undefined, false])(
'warns for n8nUserAuth in hosted-chat mode when not public (public: %s)',
(isPublic) => {
mockConnectedPrivateCred(true);
mockDocumentStore.workflowTriggerNodes = [
buildChatUserAuthTrigger('n8nUserAuth', 'hostedChat', isPublic),
];
const { getNodeCredentialIssues } = useNodeHelpers();
const result = getNodeCredentialIssues(buildNotionNode(), notionNodeType);
expect(result?.credentials?.[NOTION_API]).toEqual([
"End-user credentials aren't supported by this workflow's trigger. Supported triggers: Manual, Sub-workflow, Chat available in n8n Chat Hub or using n8n user authentication in hosted chat mode, and MCP, Form, or Webhook with n8n user authentication. To use another trigger, switch this credential to Fixed.",
]);
},
);
// Embedded/webhook-mode chat has no hosted page to run the OAuth2 handshake
// on, so `n8nUserAuth` establishes no identity there despite being selected.
it('warns for n8nUserAuth in webhook mode', () => {
mockConnectedPrivateCred(true);
mockDocumentStore.workflowTriggerNodes = [
buildChatUserAuthTrigger('n8nUserAuth', 'webhook', true),
];
const { getNodeCredentialIssues } = useNodeHelpers();
const result = getNodeCredentialIssues(buildNotionNode(), notionNodeType);
expect(result?.credentials?.[NOTION_API]).toEqual([
"End-user credentials aren't supported by this workflow's trigger. Supported triggers: Manual, Sub-workflow, Chat available in n8n Chat Hub or using n8n user authentication in hosted chat mode, and MCP, Form, or Webhook with n8n user authentication. To use another trigger, switch this credential to Fixed.",
]);
});
});
it('does not warn when a private credential is used under a Chat Trigger with availableInChat', () => {
@@ -1385,7 +1467,7 @@ describe('useNodeHelpers()', () => {
const result = getNodeCredentialIssues(buildGenericAuthNode(), httpRequestWithSslAuth);
expect(result?.credentials?.[OAUTH2_API]).toEqual([
"End-user credentials aren't supported by this workflow's trigger. Supported triggers: Manual, Sub-workflow, Chat available in n8n Chat Hub, and MCP, Form, or Webhook with n8n user authentication. To use another trigger, switch this credential to Fixed.",
"End-user credentials aren't supported by this workflow's trigger. Supported triggers: Manual, Sub-workflow, Chat available in n8n Chat Hub or using n8n user authentication in hosted chat mode, and MCP, Form, or Webhook with n8n user authentication. To use another trigger, switch this credential to Fixed.",
]);
});
@@ -1417,7 +1499,7 @@ describe('useNodeHelpers()', () => {
const result = getNodeCredentialIssues(buildPredefinedAuthNode(), httpRequestWithSslAuth);
expect(result?.credentials?.[OAUTH2_API]).toEqual([
"End-user credentials aren't supported by this workflow's trigger. Supported triggers: Manual, Sub-workflow, Chat available in n8n Chat Hub, and MCP, Form, or Webhook with n8n user authentication. To use another trigger, switch this credential to Fixed.",
"End-user credentials aren't supported by this workflow's trigger. Supported triggers: Manual, Sub-workflow, Chat available in n8n Chat Hub or using n8n user authentication in hosted chat mode, and MCP, Form, or Webhook with n8n user authentication. To use another trigger, switch this credential to Fixed.",
]);
});
@@ -48,6 +48,7 @@ import { EnableNodeToggleCommand } from '@/app/models/history';
import { useTelemetry } from '@n8n/composables/useTelemetry';
import { hasPermission } from '@/app/utils/rbac/permissions';
import { useCanvasStore } from '@/app/stores/canvas.store';
import { useEnvFeatureFlag } from '@/features/shared/envFeatureFlag/useEnvFeatureFlag';
import { useSettingsStore } from '@n8n/stores/settings.store';
import { injectWorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
import { injectWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store';
@@ -70,6 +71,7 @@ export function useNodeHelpers() {
const settingsStore = useSettingsStore();
const i18n = useI18n();
const canvasStore = useCanvasStore();
const { check: envFeatureFlag } = useEnvFeatureFlag();
const workflowDocumentStore = injectWorkflowDocumentStore();
const workflowExecutionStateStore = injectWorkflowExecutionStateStore();
const { isEnabled: isPrivateCredentialsEnabled } = usePrivateCredentials();
@@ -439,6 +441,7 @@ export function useNodeHelpers() {
const { providesN8nIdentity, providesExternalIdentity } = classifyTriggerIdentity(
trigger.type,
trigger.parameters,
{ isChatOAuth2Enabled: envFeatureFlag.value('CHAT_TRIGGER_OAUTH2') },
);
return isSystemResolver ? !providesN8nIdentity : !providesExternalIdentity;
});
+31
View File
@@ -37,6 +37,29 @@ function hasContextEstablishmentHook(parameters: INodeParameters | undefined): b
);
}
/**
* Whether a Chat Trigger's `n8nUserAuth` establishes the visitor's n8n identity: only in
* hosted-chat mode (embedded/webhook mode has no page to run the OAuth2 handshake on), only
* when public (a non-public trigger never reaches the auth code at all), and only when the
* chat-trigger OAuth2 pipeline is enabled — with it off, `n8nUserAuth` falls back to a plain
* cookie check that authenticates the request but never binds the visitor's identity for
* credential resolution (`GenericFunctions.ts`'s `validateAuth`). Absent `mode` counts as
* `hostedChat`, its default.
*/
function isHostedChatUserAuthTrigger(
nodeType: string,
parameters: INodeParameters | undefined,
isChatOAuth2Enabled: boolean,
) {
return (
isChatOAuth2Enabled &&
nodeType === CHAT_TRIGGER_NODE_TYPE &&
parameters?.public === true &&
parameters?.authentication === 'n8nUserAuth' &&
(parameters?.mode ?? 'hostedChat') === 'hostedChat'
);
}
/**
* Classifies a single trigger node by the identity it can establish at runtime.
*
@@ -45,10 +68,17 @@ function hasContextEstablishmentHook(parameters: INodeParameters | undefined): b
* sync with how the engine establishes identity (`execution-context.ts`,
* manual/parent inheritance) and the resolvers' identifiers (e.g. `N8NIdentifier`):
* when a new trigger or identity source is added there, reflect it here too.
*
* `isChatOAuth2Enabled` mirrors the `N8N_ENV_FEAT_CHAT_TRIGGER_OAUTH2` opt-in flag: it
* gates the Chat Trigger's hosted-chat `n8nUserAuth` branch, since that configuration
* only establishes identity through the flagged pipeline. Defaults to `false` so a
* caller that forgets to pass it gets the safe (no-identity) answer rather than a false
* positive.
*/
export function classifyTriggerIdentity(
nodeType: string,
parameters: INodeParameters | undefined,
{ isChatOAuth2Enabled = false }: { isChatOAuth2Enabled?: boolean } = {},
): TriggerIdentityCapabilities {
// Sub-workflows inherit identity from the parent; Chat Hub and MCP-over-n8nOAuth2
// inject it. All provide both identity families.
@@ -68,6 +98,7 @@ export function classifyTriggerIdentity(
if (
isSubWorkflowTrigger ||
isChatHubTrigger ||
isHostedChatUserAuthTrigger(nodeType, parameters, isChatOAuth2Enabled) ||
isMcpTrigger ||
isFormTrigger ||
isOAuth2Webhook
@@ -51,9 +51,8 @@ describe('classifyTriggerIdentity', () => {
},
);
// A chat trigger establishes no identity at runtime through `n8nUserAuth`,
// regardless of the chat OAuth2 flag (which classification ignores).
it.each(['n8nUserAuth', 'none', 'basicAuth'])(
// A chat trigger establishes no identity at runtime through `none`/`basicAuth`.
it.each(['none', 'basicAuth'])(
'provides no identity for authentication %s',
(authentication) => {
expect(classifyTriggerIdentity(CHAT_TRIGGER_NODE_TYPE, { authentication })).toEqual({
@@ -62,6 +61,78 @@ describe('classifyTriggerIdentity', () => {
});
},
);
// Only the hosted-chat page runs the OAuth2 handshake that establishes the
// visitor's identity — `mode` absent or explicit defaults to hosted. Requires the
// chat-trigger OAuth2 flag: with it off, `n8nUserAuth` falls back to a plain cookie
// check that never binds the visitor's identity for credential resolution.
it.each([{}, { mode: 'hostedChat' }])(
'provides both identities for public n8nUserAuth in hosted-chat mode when chat OAuth2 is enabled (%o)',
(modeParams) => {
expect(
classifyTriggerIdentity(
CHAT_TRIGGER_NODE_TYPE,
{
public: true,
authentication: 'n8nUserAuth',
...modeParams,
},
{ isChatOAuth2Enabled: true },
),
).toEqual({ providesN8nIdentity: true, providesExternalIdentity: true });
},
);
// With the flag off (the default), the cookie fallback authenticates the request but
// never binds the visitor's identity — publish must not advertise identity it can't
// actually establish.
it.each([{}, { isChatOAuth2Enabled: false }])(
'provides no identity for public n8nUserAuth in hosted-chat mode when chat OAuth2 is disabled (%o)',
(options) => {
expect(
classifyTriggerIdentity(
CHAT_TRIGGER_NODE_TYPE,
{ public: true, authentication: 'n8nUserAuth' },
options,
),
).toEqual({ providesN8nIdentity: false, providesExternalIdentity: false });
},
);
// Embedded/webhook-mode chat has no hosted page to run the OAuth2 handshake on, so
// `n8nUserAuth` establishes no identity there despite being selected (IAM-1262/IAM-1272),
// regardless of the chat OAuth2 flag.
it.each([{}, { isChatOAuth2Enabled: true }])(
'provides no identity for n8nUserAuth in webhook mode (%o)',
(options) => {
expect(
classifyTriggerIdentity(
CHAT_TRIGGER_NODE_TYPE,
{
public: true,
authentication: 'n8nUserAuth',
mode: 'webhook',
},
options,
),
).toEqual({ providesN8nIdentity: false, providesExternalIdentity: false });
},
);
// A non-public trigger 404s on every production request and skips auth entirely in
// test mode (`ChatTrigger.node.ts`'s `webhook()`), so it never reaches the code that
// establishes identity — regardless of authentication/mode.
it.each([{}, { public: false }])(
'provides no identity for n8nUserAuth in hosted-chat mode when not public (%o)',
(publicParams) => {
expect(
classifyTriggerIdentity(CHAT_TRIGGER_NODE_TYPE, {
authentication: 'n8nUserAuth',
...publicParams,
}),
).toEqual({ providesN8nIdentity: false, providesExternalIdentity: false });
},
);
});
describe('MCP Trigger', () => {