mirror of
https://github.com/n8n-io/n8n.git
synced 2026-08-29 01:39:24 +08:00
chore: Add groundwork for OAuth2 protected Chats (no-changelog) (#37045)
This commit is contained in:
@@ -10,6 +10,7 @@ import {
|
||||
assertParamIsString,
|
||||
getHighlightedInputKey,
|
||||
HIGHLIGHTED_SESSION_KEY,
|
||||
CHAT_TRIGGER_PATH_SUFFIX,
|
||||
} from 'n8n-workflow';
|
||||
import type {
|
||||
IDataObject,
|
||||
@@ -40,8 +41,6 @@ import {
|
||||
import { createPage, createShellPage } from './templates';
|
||||
import { assertValidLoadPreviousSessionOption } from './types';
|
||||
|
||||
const CHAT_TRIGGER_PATH_IDENTIFIER = 'chat';
|
||||
|
||||
const isPublicChatTriggerDisabled = () => Container.get(ChatTriggerConfig).disablePublicChat;
|
||||
const allowFileUploadsOption: INodeProperties = {
|
||||
displayName: 'Allow File Uploads',
|
||||
@@ -327,7 +326,7 @@ export class ChatTrigger extends Node {
|
||||
name: 'setup',
|
||||
httpMethod: 'GET',
|
||||
responseMode: 'onReceived',
|
||||
path: CHAT_TRIGGER_PATH_IDENTIFIER,
|
||||
path: CHAT_TRIGGER_PATH_SUFFIX,
|
||||
ndvHideUrl: true,
|
||||
},
|
||||
{
|
||||
@@ -335,7 +334,7 @@ export class ChatTrigger extends Node {
|
||||
httpMethod: 'POST',
|
||||
responseMode:
|
||||
'={{$parameter.options?.["responseMode"] ?? ($parameter.availableInChat ? "streaming" : "lastNode") }}',
|
||||
path: CHAT_TRIGGER_PATH_IDENTIFIER,
|
||||
path: CHAT_TRIGGER_PATH_SUFFIX,
|
||||
ndvHideMethod: true,
|
||||
ndvHideUrl: isPublicChatTriggerDisabled() ? true : '={{ !$parameter.public }}',
|
||||
},
|
||||
|
||||
+306
@@ -0,0 +1,306 @@
|
||||
import { createWorkflowWithHistory, setActiveVersion, testDb } from '@n8n/backend-test-utils';
|
||||
import { GlobalConfig } from '@n8n/config';
|
||||
import type { User } from '@n8n/db';
|
||||
import { WebhookRepository, WorkflowRepository } from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
import type { INode } from 'n8n-workflow';
|
||||
import { CHAT_TRIGGER_NODE_TYPE, CHAT_TRIGGER_PATH_SUFFIX, WEBHOOK_NODE_TYPE } from 'n8n-workflow';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { createMember, createOwner } from '@test-integration/db/users';
|
||||
import { setupTestServer } from '@test-integration/utils';
|
||||
|
||||
import { CacheService } from '@/services/cache/cache.service';
|
||||
import { ProtectedResourceRegistry } from '@/services/protected-resource.registry';
|
||||
import { UrlService } from '@/services/url.service';
|
||||
|
||||
const testServer = setupTestServer({ modules: ['oauth-server', 'mcp'], endpointGroups: ['mcp'] });
|
||||
|
||||
let owner: User;
|
||||
let member: User;
|
||||
let webhookEndpoint: string;
|
||||
|
||||
/** The path a chat trigger registers under: `{webhookId}/chat`. */
|
||||
const chatPath = () => `${randomUUID()}/${CHAT_TRIGGER_PATH_SUFFIX}`;
|
||||
|
||||
const webhookBaseUrl = () => Container.get(UrlService).getWebhookBaseUrl().replace(/\/$/, '');
|
||||
const resourceUrlFor = (path: string) => `${webhookBaseUrl()}/${webhookEndpoint}/${path}`;
|
||||
const prmPathFor = (path: string) =>
|
||||
`/.well-known/oauth-protected-resource/${webhookEndpoint}/${path}`;
|
||||
|
||||
const chatTriggerNode = ({
|
||||
name = 'When chat message received',
|
||||
public: isPublic = true,
|
||||
mode = 'hostedChat',
|
||||
authentication = 'n8nUserAuth',
|
||||
disabled = false,
|
||||
}: {
|
||||
name?: string;
|
||||
// `null` drops the key entirely, so the "parameter stripped at its default" shape the
|
||||
// editor actually saves is exercised too; `undefined` keeps the default value.
|
||||
public?: boolean | null;
|
||||
mode?: string | null;
|
||||
authentication?: string | null;
|
||||
disabled?: boolean;
|
||||
} = {}): INode => ({
|
||||
id: randomUUID(),
|
||||
name,
|
||||
type: CHAT_TRIGGER_NODE_TYPE,
|
||||
typeVersion: 1.3,
|
||||
position: [0, 0],
|
||||
disabled,
|
||||
webhookId: randomUUID(),
|
||||
parameters: {
|
||||
...(isPublic === null ? {} : { public: isPublic }),
|
||||
...(mode === null ? {} : { mode }),
|
||||
...(authentication === null ? {} : { authentication }),
|
||||
},
|
||||
});
|
||||
|
||||
/** Mirrors the two rows `ActiveWorkflowManager.addWebhooks` persists for a chat trigger. */
|
||||
const insertWebhookRows = async (workflowId: string, path: string, node: string) => {
|
||||
await Container.get(WebhookRepository).insert([
|
||||
{ workflowId, webhookPath: path, method: 'GET', node },
|
||||
{ workflowId, webhookPath: path, method: 'POST', node },
|
||||
]);
|
||||
};
|
||||
|
||||
/** Active workflow whose published version contains the given trigger node. */
|
||||
const createPublishedChatWorkflow = async (path: string, node: INode, ownedBy = owner) => {
|
||||
const workflow = await createWorkflowWithHistory({ active: true, nodes: [node] }, ownedBy);
|
||||
await setActiveVersion(workflow.id, workflow.versionId);
|
||||
await insertWebhookRows(workflow.id, path, node.name);
|
||||
return workflow;
|
||||
};
|
||||
|
||||
/** Overwrite the draft nodes without touching the published (active) version. */
|
||||
const updateDraftNodes = async (workflowId: string, nodes: INode[]) => {
|
||||
await Container.get(WorkflowRepository).update(workflowId, { nodes, versionId: randomUUID() });
|
||||
};
|
||||
|
||||
const resolveResource = async (path: string) =>
|
||||
await Container.get(ProtectedResourceRegistry).getByResourcePath(`/${webhookEndpoint}/${path}`);
|
||||
|
||||
beforeAll(async () => {
|
||||
process.env.N8N_ENV_FEAT_CHAT_TRIGGER_OAUTH2 = 'true'; // gates the chat-trigger resolver
|
||||
owner = await createOwner();
|
||||
member = await createMember();
|
||||
webhookEndpoint = Container.get(GlobalConfig).endpoints.webhook;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
delete process.env.N8N_ENV_FEAT_CHAT_TRIGGER_OAUTH2;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await Container.get(CacheService).reset(); // WebhookService caches static webhook lookups
|
||||
await testDb.truncate([
|
||||
'AccessToken',
|
||||
'RefreshToken',
|
||||
'AuthorizationCode',
|
||||
'OAuthClient',
|
||||
'WebhookEntity',
|
||||
'SharedWorkflow',
|
||||
'WorkflowEntity',
|
||||
'WorkflowHistory',
|
||||
]);
|
||||
});
|
||||
|
||||
describe('protected resource metadata for chat triggers', () => {
|
||||
test('should serve the metadata document for an active n8nUserAuth hosted chat trigger', async () => {
|
||||
const path = chatPath();
|
||||
await createPublishedChatWorkflow(path, chatTriggerNode());
|
||||
|
||||
const response = await testServer.restlessAgent.get(prmPathFor(path));
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
// exact match: `scopes_supported` must be absent (the resource advertises no scopes)
|
||||
expect(response.body).toEqual({
|
||||
resource: resourceUrlFor(path),
|
||||
bearer_methods_supported: ['header'],
|
||||
authorization_servers: [expect.any(String)],
|
||||
});
|
||||
});
|
||||
|
||||
test('should resolve as a first-party resource whose only redirect URI is the chat page URL', async () => {
|
||||
const path = chatPath();
|
||||
await createPublishedChatWorkflow(path, chatTriggerNode());
|
||||
|
||||
const resource = await resolveResource(path);
|
||||
|
||||
expect(resource?.isFirstParty).toBe(true);
|
||||
expect(resource?.getResourceUrl()).toBe(resourceUrlFor(path));
|
||||
await expect(resource?.getAllowedRedirectUris?.()).resolves.toEqual([resourceUrlFor(path)]);
|
||||
});
|
||||
|
||||
test('should expose the workflow name for the consent screen', async () => {
|
||||
const path = chatPath();
|
||||
const workflow = await createPublishedChatWorkflow(path, chatTriggerNode());
|
||||
|
||||
const resource = await resolveResource(path);
|
||||
|
||||
expect(resource?.displayName).toBe(workflow.name);
|
||||
});
|
||||
|
||||
test('should resolve when mode is absent, defaulting to hostedChat', async () => {
|
||||
// The editor strips a parameter left at its default, so a saved hosted chat
|
||||
// trigger may carry no `mode` key at all.
|
||||
const node = chatTriggerNode({ mode: null });
|
||||
expect(node.parameters.mode).toBeUndefined();
|
||||
const path = chatPath();
|
||||
await createPublishedChatWorkflow(path, node);
|
||||
|
||||
const response = await testServer.restlessAgent.get(prmPathFor(path));
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.body.resource).toBe(resourceUrlFor(path));
|
||||
});
|
||||
|
||||
test('should resolve when mode is explicitly hostedChat', async () => {
|
||||
const path = chatPath();
|
||||
await createPublishedChatWorkflow(path, chatTriggerNode({ mode: 'hostedChat' }));
|
||||
|
||||
const response = await testServer.restlessAgent.get(prmPathFor(path));
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
test('should not resolve an unknown path', async () => {
|
||||
const response = await testServer.restlessAgent.get(prmPathFor(chatPath()));
|
||||
|
||||
expect(response.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
test.each([
|
||||
['the chat is not public', chatTriggerNode({ public: false })],
|
||||
['the public parameter is absent', chatTriggerNode({ public: null })],
|
||||
['the chat is embedded rather than hosted', chatTriggerNode({ mode: 'webhook' })],
|
||||
['authentication is none', chatTriggerNode({ authentication: 'none' })],
|
||||
['authentication is basicAuth', chatTriggerNode({ authentication: 'basicAuth' })],
|
||||
['authentication is an expression', chatTriggerNode({ authentication: '={{ $json.auth }}' })],
|
||||
['the authentication parameter is absent', chatTriggerNode({ authentication: null })],
|
||||
['the node is disabled', chatTriggerNode({ disabled: true })],
|
||||
])('should not resolve when %s', async (_, node) => {
|
||||
const path = chatPath();
|
||||
await createPublishedChatWorkflow(path, node);
|
||||
|
||||
const response = await testServer.restlessAgent.get(prmPathFor(path));
|
||||
|
||||
expect(response.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
test('should not resolve when the feature flag is disabled', async () => {
|
||||
const path = chatPath();
|
||||
await createPublishedChatWorkflow(path, chatTriggerNode());
|
||||
|
||||
delete process.env.N8N_ENV_FEAT_CHAT_TRIGGER_OAUTH2;
|
||||
try {
|
||||
// Also proves the generic webhook resolver does not pick a chat path up itself.
|
||||
const response = await testServer.restlessAgent.get(prmPathFor(path));
|
||||
expect(response.statusCode).toBe(404);
|
||||
} finally {
|
||||
process.env.N8N_ENV_FEAT_CHAT_TRIGGER_OAUTH2 = 'true';
|
||||
}
|
||||
});
|
||||
|
||||
test('should not resolve when public chat is disabled instance-wide', async () => {
|
||||
const path = chatPath();
|
||||
await createPublishedChatWorkflow(path, chatTriggerNode());
|
||||
const config = Container.get(GlobalConfig);
|
||||
|
||||
config.chatTrigger.disablePublicChat = true;
|
||||
try {
|
||||
// The node serves a 404 for the page, so advertising a resource for it is wrong.
|
||||
const response = await testServer.restlessAgent.get(prmPathFor(path));
|
||||
expect(response.statusCode).toBe(404);
|
||||
} finally {
|
||||
config.chatTrigger.disablePublicChat = false;
|
||||
}
|
||||
});
|
||||
|
||||
test('should not resolve a workflow without a published version', async () => {
|
||||
const node = chatTriggerNode();
|
||||
const path = chatPath();
|
||||
const workflow = await createWorkflowWithHistory({ active: false, nodes: [node] }, owner);
|
||||
await insertWebhookRows(workflow.id, path, node.name);
|
||||
|
||||
const response = await testServer.restlessAgent.get(prmPathFor(path));
|
||||
|
||||
expect(response.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
test('should not resolve when the webhook node is missing from the active version', async () => {
|
||||
const node = chatTriggerNode();
|
||||
const path = chatPath();
|
||||
const workflow = await createWorkflowWithHistory({ active: true, nodes: [node] }, owner);
|
||||
await setActiveVersion(workflow.id, workflow.versionId);
|
||||
await insertWebhookRows(workflow.id, path, 'Ghost node');
|
||||
|
||||
const response = await testServer.restlessAgent.get(prmPathFor(path));
|
||||
|
||||
expect(response.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
test('should stop resolving once the webhook is deregistered', async () => {
|
||||
const path = chatPath();
|
||||
await createPublishedChatWorkflow(path, chatTriggerNode());
|
||||
|
||||
expect((await testServer.restlessAgent.get(prmPathFor(path))).statusCode).toBe(200);
|
||||
|
||||
await Container.get(WebhookRepository).delete({ webhookPath: path });
|
||||
await Container.get(CacheService).reset();
|
||||
|
||||
expect((await testServer.restlessAgent.get(prmPathFor(path))).statusCode).toBe(404);
|
||||
});
|
||||
|
||||
test('should follow the published version, not the draft', async () => {
|
||||
// published n8nUserAuth, draft switched to none -> resource stays
|
||||
const protectedPath = chatPath();
|
||||
const protectedWorkflow = await createPublishedChatWorkflow(protectedPath, chatTriggerNode());
|
||||
await updateDraftNodes(protectedWorkflow.id, [chatTriggerNode({ authentication: 'none' })]);
|
||||
|
||||
const stillProtected = await testServer.restlessAgent.get(prmPathFor(protectedPath));
|
||||
expect(stillProtected.statusCode).toBe(200);
|
||||
expect(stillProtected.body.resource).toBe(resourceUrlFor(protectedPath));
|
||||
|
||||
// published none, draft switched to n8nUserAuth -> no resource
|
||||
const unprotectedPath = chatPath();
|
||||
const unprotectedWorkflow = await createPublishedChatWorkflow(
|
||||
unprotectedPath,
|
||||
chatTriggerNode({ authentication: 'none' }),
|
||||
);
|
||||
await updateDraftNodes(unprotectedWorkflow.id, [chatTriggerNode()]);
|
||||
|
||||
const stillUnprotected = await testServer.restlessAgent.get(prmPathFor(unprotectedPath));
|
||||
expect(stillUnprotected.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
test('should not resolve a generic Webhook node whose path happens to end in /chat', async () => {
|
||||
const path = chatPath();
|
||||
const node: INode = {
|
||||
id: randomUUID(),
|
||||
name: 'Webhook',
|
||||
type: WEBHOOK_NODE_TYPE,
|
||||
typeVersion: 2,
|
||||
position: [0, 0],
|
||||
parameters: { path, httpMethod: 'GET', authentication: 'none' },
|
||||
};
|
||||
await createPublishedChatWorkflow(path, node);
|
||||
|
||||
const response = await testServer.restlessAgent.get(prmPathFor(path));
|
||||
|
||||
expect(response.statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('authorize gate', () => {
|
||||
test('authorizes any authenticated user — there is no execute gate yet', async () => {
|
||||
const path = chatPath();
|
||||
await createPublishedChatWorkflow(path, chatTriggerNode());
|
||||
|
||||
const resource = await resolveResource(path);
|
||||
|
||||
await expect(resource?.authorize(owner)).resolves.toBe(true);
|
||||
await expect(resource?.authorize(member)).resolves.toBe(true);
|
||||
});
|
||||
});
|
||||
+333
@@ -0,0 +1,333 @@
|
||||
import { createWorkflowWithHistory, setActiveVersion, testDb } from '@n8n/backend-test-utils';
|
||||
import { GlobalConfig } from '@n8n/config';
|
||||
import type { User } from '@n8n/db';
|
||||
import { WebhookRepository } from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
import type { INode, IWebhookData, IWorkflowBase } from 'n8n-workflow';
|
||||
import { CHAT_TRIGGER_NODE_TYPE, CHAT_TRIGGER_PATH_SUFFIX } from 'n8n-workflow';
|
||||
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 { TestWebhookRegistrationsService } from '@/webhooks/test-webhook-registrations.service';
|
||||
|
||||
import { OAuthClientRepository } from '../database/repositories/oauth-client.repository';
|
||||
|
||||
const testServer = setupTestServer({ modules: ['oauth-server', 'mcp'], endpointGroups: ['mcp'] });
|
||||
|
||||
let owner: User;
|
||||
let member: User;
|
||||
let webhookEndpoint: string;
|
||||
let webhookTestEndpoint: string;
|
||||
let registrations: TestWebhookRegistrationsService;
|
||||
|
||||
/** The path a chat trigger registers under: `{webhookId}/chat`. */
|
||||
const chatPath = () => `${randomUUID()}/${CHAT_TRIGGER_PATH_SUFFIX}`;
|
||||
|
||||
const webhookBaseUrl = () => Container.get(UrlService).getWebhookBaseUrl().replace(/\/$/, '');
|
||||
const testWebhookBaseUrl = () =>
|
||||
Container.get(UrlService).getTestWebhookBaseUrl().replace(/\/$/, '');
|
||||
const testResourceUrlFor = (path: string) =>
|
||||
`${testWebhookBaseUrl()}/${webhookTestEndpoint}/${path}`;
|
||||
const prmPathFor = (path: string) =>
|
||||
`/.well-known/oauth-protected-resource/${webhookTestEndpoint}/${path}`;
|
||||
|
||||
const chatTriggerNode = ({
|
||||
name = 'When chat message received',
|
||||
public: isPublic = true,
|
||||
mode = 'hostedChat',
|
||||
authentication = 'n8nUserAuth',
|
||||
disabled = false,
|
||||
}: {
|
||||
name?: string;
|
||||
// `null` drops the key entirely, so the "parameter stripped at its default" shape the
|
||||
// editor actually saves is exercised too; `undefined` keeps the default value.
|
||||
public?: boolean | null;
|
||||
mode?: string | null;
|
||||
authentication?: string | null;
|
||||
disabled?: boolean;
|
||||
} = {}): INode => ({
|
||||
id: randomUUID(),
|
||||
name,
|
||||
type: CHAT_TRIGGER_NODE_TYPE,
|
||||
typeVersion: 1.3,
|
||||
position: [0, 0],
|
||||
disabled,
|
||||
webhookId: randomUUID(),
|
||||
parameters: {
|
||||
...(isPublic === null ? {} : { public: isPublic }),
|
||||
...(mode === null ? {} : { mode }),
|
||||
...(authentication === null ? {} : { authentication }),
|
||||
},
|
||||
});
|
||||
|
||||
/** Mirrors what `TestWebhooks.needsWebhook` registers when the user tests a chat trigger. */
|
||||
const registerTestWebhook = async (
|
||||
path: string,
|
||||
node: INode,
|
||||
{
|
||||
workflowId = randomUUID(),
|
||||
workflowName = 'My test workflow',
|
||||
nodeName = node.name,
|
||||
}: { workflowId?: string; workflowName?: string; nodeName?: string } = {},
|
||||
) => {
|
||||
await registrations.register({
|
||||
version: 1,
|
||||
workflowEntity: {
|
||||
id: workflowId,
|
||||
name: workflowName,
|
||||
active: false,
|
||||
nodes: [node],
|
||||
connections: {},
|
||||
} as IWorkflowBase,
|
||||
webhook: {
|
||||
httpMethod: 'GET',
|
||||
path,
|
||||
node: nodeName,
|
||||
workflowId,
|
||||
} as IWebhookData,
|
||||
});
|
||||
return { workflowId, workflowName };
|
||||
};
|
||||
|
||||
const resolveResource = async (path: string) =>
|
||||
await Container.get(ProtectedResourceRegistry).getByResourcePath(
|
||||
`/${webhookTestEndpoint}/${path}`,
|
||||
);
|
||||
|
||||
beforeAll(async () => {
|
||||
process.env.N8N_ENV_FEAT_CHAT_TRIGGER_OAUTH2 = 'true'; // gates the chat-trigger resolver
|
||||
owner = await createOwner();
|
||||
member = await createMember();
|
||||
const { endpoints } = Container.get(GlobalConfig);
|
||||
webhookEndpoint = endpoints.webhook;
|
||||
webhookTestEndpoint = endpoints.webhookTest;
|
||||
registrations = Container.get(TestWebhookRegistrationsService);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
delete process.env.N8N_ENV_FEAT_CHAT_TRIGGER_OAUTH2;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await Container.get(CacheService).reset(); // test webhook registrations live in the cache
|
||||
await testDb.truncate([
|
||||
'AccessToken',
|
||||
'RefreshToken',
|
||||
'AuthorizationCode',
|
||||
'OAuthClient',
|
||||
'WebhookEntity',
|
||||
'SharedWorkflow',
|
||||
'WorkflowEntity',
|
||||
'WorkflowHistory',
|
||||
]);
|
||||
});
|
||||
|
||||
describe('protected resource metadata for test chat triggers', () => {
|
||||
test('should serve the metadata document while a test registration exists', async () => {
|
||||
const path = chatPath();
|
||||
await registerTestWebhook(path, chatTriggerNode());
|
||||
|
||||
const response = await testServer.restlessAgent.get(prmPathFor(path));
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.body).toEqual({
|
||||
resource: testResourceUrlFor(path),
|
||||
bearer_methods_supported: ['header'],
|
||||
authorization_servers: [expect.any(String)],
|
||||
});
|
||||
});
|
||||
|
||||
test('should resolve as a first-party resource whose only redirect URI is the chat page URL', async () => {
|
||||
const path = chatPath();
|
||||
await registerTestWebhook(path, chatTriggerNode());
|
||||
|
||||
const resource = await resolveResource(path);
|
||||
|
||||
expect(resource?.isFirstParty).toBe(true);
|
||||
expect(resource?.getResourceUrl()).toBe(testResourceUrlFor(path));
|
||||
await expect(resource?.getAllowedRedirectUris?.()).resolves.toEqual([testResourceUrlFor(path)]);
|
||||
});
|
||||
|
||||
test('should resolve from the registration alone, without the workflow in the DB', async () => {
|
||||
const path = chatPath();
|
||||
await registerTestWebhook(path, chatTriggerNode(), { workflowName: 'Unsaved workflow' });
|
||||
|
||||
const resource = await resolveResource(path);
|
||||
|
||||
expect(resource?.displayName).toBe('Unsaved workflow');
|
||||
});
|
||||
|
||||
test('should resolve when mode is absent, defaulting to hostedChat', async () => {
|
||||
const node = chatTriggerNode({ mode: null });
|
||||
expect(node.parameters.mode).toBeUndefined();
|
||||
const path = chatPath();
|
||||
await registerTestWebhook(path, node);
|
||||
|
||||
const response = await testServer.restlessAgent.get(prmPathFor(path));
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
test('should not resolve an unknown test path', async () => {
|
||||
const response = await testServer.restlessAgent.get(prmPathFor(chatPath()));
|
||||
|
||||
expect(response.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
test('should not resolve when the registration node name does not match', async () => {
|
||||
const path = chatPath();
|
||||
await registerTestWebhook(path, chatTriggerNode(), { nodeName: 'Ghost node' });
|
||||
|
||||
const response = await testServer.restlessAgent.get(prmPathFor(path));
|
||||
|
||||
expect(response.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
test.each([
|
||||
['the chat is not public', chatTriggerNode({ public: false })],
|
||||
['the public parameter is absent', chatTriggerNode({ public: null })],
|
||||
['the chat is embedded rather than hosted', chatTriggerNode({ mode: 'webhook' })],
|
||||
['authentication is none', chatTriggerNode({ authentication: 'none' })],
|
||||
['authentication is basicAuth', chatTriggerNode({ authentication: 'basicAuth' })],
|
||||
['authentication is an expression', chatTriggerNode({ authentication: '={{ $json.auth }}' })],
|
||||
['the authentication parameter is absent', chatTriggerNode({ authentication: null })],
|
||||
['the node is disabled', chatTriggerNode({ disabled: true })],
|
||||
])('should not resolve when %s', async (_, node) => {
|
||||
const path = chatPath();
|
||||
await registerTestWebhook(path, node);
|
||||
|
||||
const response = await testServer.restlessAgent.get(prmPathFor(path));
|
||||
|
||||
expect(response.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
test('should not resolve when the feature flag is disabled', async () => {
|
||||
const path = chatPath();
|
||||
await registerTestWebhook(path, chatTriggerNode());
|
||||
|
||||
delete process.env.N8N_ENV_FEAT_CHAT_TRIGGER_OAUTH2;
|
||||
try {
|
||||
const response = await testServer.restlessAgent.get(prmPathFor(path));
|
||||
expect(response.statusCode).toBe(404);
|
||||
} finally {
|
||||
process.env.N8N_ENV_FEAT_CHAT_TRIGGER_OAUTH2 = 'true';
|
||||
}
|
||||
});
|
||||
|
||||
test('should not resolve when public chat is disabled instance-wide', async () => {
|
||||
const path = chatPath();
|
||||
await registerTestWebhook(path, chatTriggerNode());
|
||||
const config = Container.get(GlobalConfig);
|
||||
|
||||
config.chatTrigger.disablePublicChat = true;
|
||||
try {
|
||||
const response = await testServer.restlessAgent.get(prmPathFor(path));
|
||||
expect(response.statusCode).toBe(404);
|
||||
} finally {
|
||||
config.chatTrigger.disablePublicChat = false;
|
||||
}
|
||||
});
|
||||
|
||||
test('should stop resolving as soon as the registration is removed', async () => {
|
||||
const path = chatPath();
|
||||
await registerTestWebhook(path, chatTriggerNode());
|
||||
|
||||
expect((await testServer.restlessAgent.get(prmPathFor(path))).statusCode).toBe(200);
|
||||
|
||||
await registrations.deregister(registrations.toKey({ httpMethod: 'GET', path }));
|
||||
|
||||
expect((await testServer.restlessAgent.get(prmPathFor(path))).statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('authorize gate', () => {
|
||||
test('authorizes any authenticated user — there is no execute gate yet', async () => {
|
||||
const path = chatPath();
|
||||
const node = chatTriggerNode();
|
||||
const workflow = await createWorkflowWithHistory({ active: false, nodes: [node] }, owner);
|
||||
await registerTestWebhook(path, node, { workflowId: workflow.id });
|
||||
|
||||
const resource = await resolveResource(path);
|
||||
|
||||
await expect(resource?.authorize(owner)).resolves.toBe(true);
|
||||
await expect(resource?.authorize(member)).resolves.toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('test vs production chat resources', () => {
|
||||
/** The same chat trigger, live both as a published webhook and as a test registration. */
|
||||
const createBothRegistrations = async () => {
|
||||
const path = chatPath();
|
||||
const node = chatTriggerNode();
|
||||
|
||||
const workflow = await createWorkflowWithHistory({ active: true, nodes: [node] }, owner);
|
||||
await setActiveVersion(workflow.id, workflow.versionId);
|
||||
await Container.get(WebhookRepository).insert([
|
||||
{ workflowId: workflow.id, webhookPath: path, method: 'GET', node: node.name },
|
||||
{ workflowId: workflow.id, webhookPath: path, method: 'POST', node: node.name },
|
||||
]);
|
||||
await registerTestWebhook(path, node, { workflowId: workflow.id });
|
||||
|
||||
return path;
|
||||
};
|
||||
|
||||
test('should serve the same trigger path as two distinct resources', async () => {
|
||||
const path = await createBothRegistrations();
|
||||
|
||||
const production = await testServer.restlessAgent.get(
|
||||
`/.well-known/oauth-protected-resource/${webhookEndpoint}/${path}`,
|
||||
);
|
||||
const test = await testServer.restlessAgent.get(prmPathFor(path));
|
||||
|
||||
expect(production.statusCode).toBe(200);
|
||||
expect(test.statusCode).toBe(200);
|
||||
expect(production.body.resource).not.toBe(test.body.resource);
|
||||
});
|
||||
|
||||
test('should reject a test-resource token at the production resource and vice versa', async () => {
|
||||
const path = await createBothRegistrations();
|
||||
|
||||
const tokenService = Container.get(OAuthTokenService);
|
||||
const productionResourceUrl = `${webhookBaseUrl()}/${webhookEndpoint}/${path}`;
|
||||
const testResourceUrl = testResourceUrlFor(path);
|
||||
|
||||
// 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 mint = async (resourceUrl: string) => {
|
||||
const pair = tokenService.generateTokenPair(owner.id, clientId, resourceUrl, []);
|
||||
await tokenService.saveTokenPair(pair.accessToken, pair.refreshToken, clientId, owner.id, []);
|
||||
return pair.accessToken;
|
||||
};
|
||||
|
||||
const testToken = await mint(testResourceUrl);
|
||||
const productionToken = await mint(productionResourceUrl);
|
||||
|
||||
await expect(tokenService.verifyAccessToken(testToken, testResourceUrl)).resolves.toMatchObject(
|
||||
{ clientId },
|
||||
);
|
||||
await expect(
|
||||
tokenService.verifyAccessToken(productionToken, productionResourceUrl),
|
||||
).resolves.toMatchObject({ clientId });
|
||||
|
||||
await expect(
|
||||
tokenService.verifyAccessToken(testToken, productionResourceUrl),
|
||||
).rejects.toThrow();
|
||||
await expect(
|
||||
tokenService.verifyAccessToken(productionToken, testResourceUrl),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,12 @@ import type { User } from '@n8n/db';
|
||||
import { WebhookRepository } from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
import type { INode } from 'n8n-workflow';
|
||||
import { FORM_TRIGGER_NODE_TYPE, UserError } from 'n8n-workflow';
|
||||
import {
|
||||
CHAT_TRIGGER_NODE_TYPE,
|
||||
CHAT_TRIGGER_PATH_SUFFIX,
|
||||
FORM_TRIGGER_NODE_TYPE,
|
||||
UserError,
|
||||
} from 'n8n-workflow';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { CacheService } from '@/services/cache/cache.service';
|
||||
@@ -24,6 +29,7 @@ setupTestServer({ modules: ['oauth-server', 'mcp'], endpointGroups: ['mcp'] });
|
||||
let owner: User;
|
||||
let member: User;
|
||||
let formEndpoint: string;
|
||||
let webhookEndpoint: string;
|
||||
|
||||
let flow: OAuth2FlowService;
|
||||
let codes: OAuthAuthorizationCodeService;
|
||||
@@ -63,6 +69,29 @@ const createProtectedFormWorkflow = async (ownedBy = owner) => {
|
||||
return resourceUrlFor(webhookPath);
|
||||
};
|
||||
|
||||
const chatTriggerNode = (): INode => ({
|
||||
id: randomUUID(),
|
||||
name: 'When chat message received',
|
||||
type: CHAT_TRIGGER_NODE_TYPE,
|
||||
typeVersion: 1.3,
|
||||
position: [0, 0],
|
||||
webhookId: randomUUID(),
|
||||
parameters: { public: true, mode: 'hostedChat', authentication: 'n8nUserAuth' },
|
||||
});
|
||||
|
||||
/** Active chat workflow + the two production webhook rows; returns the chat page URL. */
|
||||
const createProtectedChatWorkflow = async (ownedBy = owner) => {
|
||||
const node = chatTriggerNode();
|
||||
const path = `${randomUUID()}/${CHAT_TRIGGER_PATH_SUFFIX}`;
|
||||
const workflow = await createWorkflowWithHistory({ active: true, nodes: [node] }, ownedBy);
|
||||
await setActiveVersion(workflow.id, workflow.versionId);
|
||||
await Container.get(WebhookRepository).insert([
|
||||
{ workflowId: workflow.id, webhookPath: path, method: 'GET', node: node.name },
|
||||
{ workflowId: workflow.id, webhookPath: path, method: 'POST', node: node.name },
|
||||
]);
|
||||
return `${webhookBaseUrl()}/${webhookEndpoint}/${path}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Drive the browser legs the backend never performs itself in a test: pull the
|
||||
* PKCE challenge + state out of the authorize URL, materialize the virtual client
|
||||
@@ -92,15 +121,22 @@ const authorizeAndMintCode = async (
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
process.env.N8N_ENV_FEAT_CHAT_TRIGGER_OAUTH2 = 'true'; // gates the chat-trigger resolver
|
||||
owner = await createOwner();
|
||||
member = await createMember();
|
||||
formEndpoint = Container.get(GlobalConfig).endpoints.form;
|
||||
const { endpoints } = Container.get(GlobalConfig);
|
||||
formEndpoint = endpoints.form;
|
||||
webhookEndpoint = endpoints.webhook;
|
||||
flow = Container.get(OAuth2FlowService);
|
||||
codes = Container.get(OAuthAuthorizationCodeService);
|
||||
oauthServer = Container.get(OAuthServerService);
|
||||
tokenService = Container.get(OAuthTokenService);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
delete process.env.N8N_ENV_FEAT_CHAT_TRIGGER_OAUTH2;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await Container.get(CacheService).reset();
|
||||
await testDb.truncate([
|
||||
@@ -238,3 +274,49 @@ describe('complete', () => {
|
||||
expect(result).toEqual({ valid: false, reason: 'invalid_grant' });
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Direct cover for "a credential-connect OAuth request initiated from a chat session is
|
||||
* accepted as legitimate": otherwise it only holds transitively through `N8NIdentifier`.
|
||||
*/
|
||||
describe('chat trigger resources', () => {
|
||||
test('begins a flow against a chat resource and completes it into a chat-scoped token', async () => {
|
||||
const chatResourceUrl = await createProtectedChatWorkflow();
|
||||
|
||||
const url = new URL(await flow.begin(chatResourceUrl));
|
||||
expect(Object.fromEntries(url.searchParams)).toMatchObject({
|
||||
client_id: chatResourceUrl,
|
||||
redirect_uri: chatResourceUrl,
|
||||
resource: chatResourceUrl,
|
||||
});
|
||||
|
||||
const { code, state } = await authorizeAndMintCode(chatResourceUrl, member.id);
|
||||
const result = await flow.complete(code, state);
|
||||
|
||||
// No execute gate on chat yet, so any authenticated visitor completes the flow.
|
||||
expect(result).toMatchObject({ valid: true, user: { id: member.id } });
|
||||
if (result.valid) {
|
||||
expect(decodeJwtPayload(result.token).sub).toBe(member.id);
|
||||
expect(decodeJwtPayload(result.token).aud).toBe(chatResourceUrl);
|
||||
}
|
||||
});
|
||||
|
||||
test('produces a token that another trigger resource rejects', async () => {
|
||||
const chatResourceUrl = await createProtectedChatWorkflow();
|
||||
const otherChatResourceUrl = await createProtectedChatWorkflow();
|
||||
const formResourceUrl = await createProtectedFormWorkflow();
|
||||
const { code, state } = await authorizeAndMintCode(chatResourceUrl, owner.id);
|
||||
|
||||
const result = await flow.complete(code, state);
|
||||
expect(result.valid).toBe(true);
|
||||
|
||||
if (result.valid) {
|
||||
await expect(
|
||||
tokenService.verifyOAuthAccessToken(result.token, otherChatResourceUrl),
|
||||
).resolves.toMatchObject({ user: null });
|
||||
await expect(
|
||||
tokenService.verifyOAuthAccessToken(result.token, formResourceUrl),
|
||||
).resolves.toMatchObject({ user: null });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -159,6 +159,8 @@ describe('OAuthServerService', () => {
|
||||
|
||||
describe('getClient — virtual first-party client', () => {
|
||||
const FIRST_PARTY_URL = 'https://n8n.example.com/form/abc';
|
||||
const CHAT_FIRST_PARTY_URL =
|
||||
'https://n8n.example.com/webhook/f0a1b2c3-d4e5-4678-9abc-def012345678/chat';
|
||||
const NON_FIRST_PARTY_URL = 'https://n8n.example.com/mcp-server/http';
|
||||
let firstPartyService: OAuthServerService;
|
||||
|
||||
@@ -174,6 +176,18 @@ describe('OAuthServerService', () => {
|
||||
scopes: [],
|
||||
authorize: async () => true,
|
||||
});
|
||||
// A chat trigger's resource: served under the generic webhook base URL rather
|
||||
// than a dedicated endpoint, so it covers the client-id guard's prefix check.
|
||||
registry.register({
|
||||
id: 'chat-abc',
|
||||
isFirstParty: true,
|
||||
displayName: 'My Chat',
|
||||
getResourceUrl: () => CHAT_FIRST_PARTY_URL,
|
||||
getAudiences: () => [CHAT_FIRST_PARTY_URL],
|
||||
getAllowedRedirectUris: async () => [CHAT_FIRST_PARTY_URL],
|
||||
scopes: [],
|
||||
authorize: async () => true,
|
||||
});
|
||||
// A resource that exists but is not first-party (mirror of an MCP resource).
|
||||
registry.register({
|
||||
id: 'mcp-x',
|
||||
@@ -229,6 +243,31 @@ describe('OAuthServerService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('lazily upserts and returns a virtual client for a chat trigger resource URL', async () => {
|
||||
oauthClientRepository.findOneBy.mockResolvedValue(null);
|
||||
|
||||
const result = await firstPartyService.clientsStore.getClient(CHAT_FIRST_PARTY_URL);
|
||||
|
||||
expect(oauthClientRepository.upsert).toHaveBeenCalledWith(
|
||||
{
|
||||
id: CHAT_FIRST_PARTY_URL,
|
||||
name: 'My Chat',
|
||||
redirectUris: [CHAT_FIRST_PARTY_URL],
|
||||
grantTypes: ['authorization_code'],
|
||||
tokenEndpointAuthMethod: 'none',
|
||||
clientSecret: null,
|
||||
clientSecretExpiresAt: null,
|
||||
isFirstParty: true,
|
||||
},
|
||||
['id'],
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
client_id: CHAT_FIRST_PARTY_URL,
|
||||
client_name: 'My Chat',
|
||||
redirect_uris: [CHAT_FIRST_PARTY_URL],
|
||||
});
|
||||
});
|
||||
|
||||
it('returns undefined and does not upsert when the resolved resource is not first-party', async () => {
|
||||
oauthClientRepository.findOneBy.mockResolvedValue(null);
|
||||
|
||||
|
||||
@@ -204,19 +204,19 @@ export class OAuthServerService implements OAuthServerProvider {
|
||||
|
||||
/**
|
||||
* On-demand per-trigger virtual client for a first-party protected resource
|
||||
* (form trigger). Public + PKCE, single redirect_uri = the trigger URL (which
|
||||
* equals the client_id and the resource URL). The row is persisted lazily only
|
||||
* to satisfy the FKs from auth codes / tokens; it is never a DCR client and is
|
||||
* excluded from the registered-client cap.
|
||||
* (form or chat trigger). Public + PKCE, single redirect_uri = the trigger URL
|
||||
* (which equals the client_id and the resource URL). The row is persisted lazily
|
||||
* only to satisfy the FKs from auth codes / tokens; it is never a DCR client and
|
||||
* is excluded from the registered-client cap.
|
||||
*/
|
||||
private async resolveVirtualClient(
|
||||
clientId: string,
|
||||
): Promise<OAuthClientInformationFull | undefined> {
|
||||
// First-party resources are form triggers served under the (test) webhook base
|
||||
// URL, so a client_id that isn't can never resolve to one. Skip the resolver
|
||||
// First-party resources are form and chat triggers served under the (test) webhook
|
||||
// base URL, so a client_id that isn't can never resolve to one. Skip the resolver
|
||||
// sweep + lazy upsert for anything else, so the unauthenticated /authorize path
|
||||
// can't be used to fan out DB lookups on arbitrary client_ids.
|
||||
if (!this.isFormTriggerClientId(clientId)) {
|
||||
if (!this.isTriggerResourceClientId(clientId)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -251,8 +251,8 @@ export class OAuthServerService implements OAuthServerProvider {
|
||||
};
|
||||
}
|
||||
|
||||
/** Whether a client_id could be a form-trigger resource URL (served under a webhook base URL). */
|
||||
private isFormTriggerClientId(clientId: string): boolean {
|
||||
/** Whether a client_id could be a trigger resource URL (served under a webhook base URL). */
|
||||
private isTriggerResourceClientId(clientId: string): boolean {
|
||||
return [this.urlService.getWebhookBaseUrl(), this.urlService.getTestWebhookBaseUrl()]
|
||||
.map((base) => (base.endsWith('/') ? base : `${base}/`))
|
||||
.some((base) => clientId.startsWith(base));
|
||||
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
import type { Logger } from '@n8n/backend-common';
|
||||
import type { GlobalConfig } from '@n8n/config';
|
||||
import type { INode } from 'n8n-workflow';
|
||||
import { CHAT_TRIGGER_PATH_SUFFIX } from 'n8n-workflow';
|
||||
|
||||
import { isChatOAuth2Enabled } from '@/constants/oauth2-triggers';
|
||||
import type {
|
||||
ProtectedResource,
|
||||
ProtectedResourceResolver,
|
||||
} from '@/services/protected-resource.registry';
|
||||
import type { WorkflowFinderService } from '@/workflows/workflow-finder.service';
|
||||
|
||||
import { triggerResourceGate } from '../resource-gate';
|
||||
import {
|
||||
CHAT_TRIGGER_SCOPES,
|
||||
isOAuthProtectedChatTrigger,
|
||||
resourceUrlToWebhookPath,
|
||||
trimSlashes,
|
||||
trimTrailingSlash,
|
||||
} from './utils';
|
||||
|
||||
/** The trigger a chat path resolves to, however the subclass found it. */
|
||||
export interface ChatTriggerLookupResult {
|
||||
node: INode;
|
||||
workflowId: string;
|
||||
workflowName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything a chat trigger's protected-resource resolution does bar the lookup: the
|
||||
* feature gate, the path guards, the node gate, and the descriptor. The production and
|
||||
* test resolvers differ only in which endpoint and base URL they serve, and in how they
|
||||
* find the trigger — one reads the published workflow behind a registered webhook row,
|
||||
* the other the workflow the editor is currently testing.
|
||||
*
|
||||
* Shared as a base class so the two resources can't drift, for the same reason
|
||||
* {@link isOAuthProtectedChatTrigger} is shared: a difference between the production and
|
||||
* test gates would be a security difference.
|
||||
*/
|
||||
export abstract class ChatTriggerResourceResolverBase implements ProtectedResourceResolver {
|
||||
abstract readonly id: string;
|
||||
|
||||
readonly scopes = CHAT_TRIGGER_SCOPES;
|
||||
|
||||
// Supplied by each subclass as constructor parameter properties, so DI stays on the
|
||||
// concrete class and this base needs no constructor of its own.
|
||||
protected abstract readonly config: GlobalConfig;
|
||||
protected abstract readonly logger: Logger;
|
||||
protected abstract readonly workflowFinderService: WorkflowFinderService;
|
||||
|
||||
/** `endpoints.webhook` or `endpoints.webhookTest`. */
|
||||
protected abstract get endpoint(): string;
|
||||
|
||||
/** The matching (test) webhook base URL. */
|
||||
protected abstract get baseUrl(): string;
|
||||
|
||||
/** Find the chat trigger serving `path`, or `undefined` if there is none. */
|
||||
protected abstract findChatTrigger(path: string): Promise<ChatTriggerLookupResult | undefined>;
|
||||
|
||||
async resolveByUrl(resourceUrl: string) {
|
||||
const pathname = resourceUrlToWebhookPath(resourceUrl, this.baseUrl);
|
||||
if (pathname === undefined) {
|
||||
this.logger.debug(`Resource URL is not under the webhook base URL: ${resourceUrl}`);
|
||||
return undefined;
|
||||
}
|
||||
return await this.resolveByPath(pathname);
|
||||
}
|
||||
|
||||
async resolveByPath(pathname: string): Promise<ProtectedResource | undefined> {
|
||||
if (!isChatOAuth2Enabled()) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const { endpoint } = this;
|
||||
if (!pathname.startsWith(`/${endpoint}/`)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const path = trimSlashes(pathname.slice(endpoint.length + 1));
|
||||
|
||||
// Chat shares the generic webhook prefix with every other webhook on the instance, so
|
||||
// rule the rest out on the path alone before paying for any cache, DB or registry lookup.
|
||||
if (!path.endsWith(`/${CHAT_TRIGGER_PATH_SUFFIX}`)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const found = await this.findChatTrigger(path);
|
||||
if (!found) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const { node, workflowId, workflowName } = found;
|
||||
if (!isOAuthProtectedChatTrigger(node, this.config.chatTrigger.disablePublicChat)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// The bare page URL, with no `?method=` selector: the path is derived from the node's own
|
||||
// `webhookId`, so no other node can register it, and the URL doubles as the `client_id`
|
||||
// and the single `redirect_uri` — it has to equal the page the visitor loads.
|
||||
const resourceUrl = `${trimTrailingSlash(this.baseUrl)}/${endpoint}/${path}`;
|
||||
const audiences = [resourceUrl];
|
||||
return {
|
||||
// Path included, like the webhook resolver's id: one workflow can hold several chat
|
||||
// triggers, each its own resource.
|
||||
id: `workflow-chat:${workflowId}:${path}`,
|
||||
isFirstParty: true,
|
||||
getResourceUrl: () => resourceUrl,
|
||||
getAudiences: () => audiences,
|
||||
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 }),
|
||||
};
|
||||
}
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
import { Logger } from '@n8n/backend-common';
|
||||
import { GlobalConfig } from '@n8n/config';
|
||||
import { WorkflowRepository } from '@n8n/db';
|
||||
import { Service } from '@n8n/di';
|
||||
|
||||
import { UrlService } from '@/services/url.service';
|
||||
import { WebhookService } from '@/webhooks/webhook.service';
|
||||
import { WorkflowFinderService } from '@/workflows/workflow-finder.service';
|
||||
|
||||
import { ChatTriggerResourceResolverBase } from './chat-trigger-resource.base';
|
||||
|
||||
@Service()
|
||||
export class ChatTriggerResourceResolver extends ChatTriggerResourceResolverBase {
|
||||
constructor(
|
||||
protected readonly config: GlobalConfig,
|
||||
private readonly webhookService: WebhookService,
|
||||
private readonly workflowRepository: WorkflowRepository,
|
||||
private readonly urlService: UrlService,
|
||||
protected readonly logger: Logger,
|
||||
protected readonly workflowFinderService: WorkflowFinderService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
readonly id = 'chat-trigger';
|
||||
|
||||
protected get endpoint() {
|
||||
return this.config.endpoints.webhook;
|
||||
}
|
||||
|
||||
protected get baseUrl() {
|
||||
return this.urlService.getWebhookBaseUrl();
|
||||
}
|
||||
|
||||
/**
|
||||
* The `setup` GET is the page the visitor loads and the only redirect target, so it is the
|
||||
* leg the resource names. Static-only (no dynamic probe): this path is reachable
|
||||
* unauthenticated. The node comes from the published version, never the draft.
|
||||
*/
|
||||
protected async findChatTrigger(path: string) {
|
||||
const webhook = await this.webhookService.findStaticWebhook('GET', path);
|
||||
if (!webhook || webhook.isDynamic) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const workflow = await this.workflowRepository.findOne({
|
||||
where: { id: webhook.workflowId },
|
||||
relations: { activeVersion: true },
|
||||
});
|
||||
if (!workflow?.activeVersion) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const node = workflow.activeVersion.nodes.find((n) => n.name === webhook.node);
|
||||
if (!node) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return { node, workflowId: workflow.id, workflowName: workflow.name };
|
||||
}
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
import { Logger } from '@n8n/backend-common';
|
||||
import { GlobalConfig } from '@n8n/config';
|
||||
import { Service } from '@n8n/di';
|
||||
|
||||
import { UrlService } from '@/services/url.service';
|
||||
import { TestWebhookRegistrationsService } from '@/webhooks/test-webhook-registrations.service';
|
||||
import { WorkflowFinderService } from '@/workflows/workflow-finder.service';
|
||||
|
||||
import { ChatTriggerResourceResolverBase } from './chat-trigger-resource.base';
|
||||
|
||||
@Service()
|
||||
export class ChatTriggerTestResourceResolver extends ChatTriggerResourceResolverBase {
|
||||
constructor(
|
||||
protected readonly config: GlobalConfig,
|
||||
private readonly registrations: TestWebhookRegistrationsService,
|
||||
private readonly urlService: UrlService,
|
||||
protected readonly logger: Logger,
|
||||
protected readonly workflowFinderService: WorkflowFinderService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
readonly id = 'chat-trigger-test';
|
||||
|
||||
protected get endpoint() {
|
||||
return this.config.endpoints.webhookTest;
|
||||
}
|
||||
|
||||
protected get baseUrl() {
|
||||
return this.urlService.getTestWebhookBaseUrl();
|
||||
}
|
||||
|
||||
/**
|
||||
* The registration holds the workflow exactly as the editor is testing it (including
|
||||
* unsaved changes), so it is the source of truth here — not the DB draft. The `setup` GET
|
||||
* is the page the visitor loads and the only redirect target, so it is the leg the
|
||||
* resource names.
|
||||
*/
|
||||
protected async findChatTrigger(path: string) {
|
||||
const registration = await this.registrations.get(
|
||||
this.registrations.toKey({ httpMethod: 'GET', path }),
|
||||
);
|
||||
if (!registration) {
|
||||
this.logger.debug(`No test webhook registration found for path: ${path}`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const { workflowEntity, webhook } = registration;
|
||||
|
||||
const node = workflowEntity.nodes.find((n) => n.name === webhook.node);
|
||||
if (!node) {
|
||||
this.logger.debug(
|
||||
`No node found with name ${webhook.node} in test registration for workflow with ID: ${workflowEntity.id}`,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return { node, workflowId: workflowEntity.id, workflowName: workflowEntity.name };
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Container } from '@n8n/di';
|
||||
import { ProtectedResourceRegistry } from '@/services/protected-resource.registry';
|
||||
import { ChatTriggerResourceResolver } from './chat-trigger-resource.resolver';
|
||||
import { ChatTriggerTestResourceResolver } from './chat-trigger-test-resource.resolver';
|
||||
import { FormTriggerTestResourceResolver } from './form-trigger-test-resource.resolver';
|
||||
import { FormTriggerResourceResolver } from './form-trigger-resource.resolver';
|
||||
import { WorkflowMcpTestTriggerResourceResolver } from './workflow-mcp-test-trigger-resource.resolver';
|
||||
@@ -20,6 +22,12 @@ export function registerProtectedResourceResolvers() {
|
||||
Container.get(ProtectedResourceRegistry).registerResolver(
|
||||
Container.get(FormTriggerTestResourceResolver),
|
||||
);
|
||||
Container.get(ProtectedResourceRegistry).registerResolver(
|
||||
Container.get(ChatTriggerResourceResolver),
|
||||
);
|
||||
Container.get(ProtectedResourceRegistry).registerResolver(
|
||||
Container.get(ChatTriggerTestResourceResolver),
|
||||
);
|
||||
Container.get(ProtectedResourceRegistry).registerResolver(
|
||||
Container.get(WorkflowWebhookTriggerResourceResolver),
|
||||
);
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { ConsentUiHints } from '@n8n/api-types';
|
||||
import type { INode } from 'n8n-workflow';
|
||||
import { CHAT_TRIGGER_NODE_TYPE } from 'n8n-workflow';
|
||||
|
||||
/**
|
||||
* Scopes advertised for per-workflow MCP trigger resources. Empty on purpose:
|
||||
@@ -19,6 +21,29 @@ export const FORM_TRIGGER_CONSENT_HINTS: ConsentUiHints = {
|
||||
/** Scopes advertised for per-workflow Webhook trigger resources. */
|
||||
export const WEBHOOK_TRIGGER_SCOPES: string[] = [];
|
||||
|
||||
/** Scopes advertised for per-workflow Chat trigger resources. Empty, like the other triggers. */
|
||||
export const CHAT_TRIGGER_SCOPES: string[] = [];
|
||||
|
||||
/**
|
||||
* A chat trigger is an OAuth protected resource only in the shape the hosted page can actually
|
||||
* serve: enabled, published publicly, on the n8n-hosted page rather than the embedded widget, and
|
||||
* on `n8nUserAuth`. `mode` defaults to `hostedChat` and is stripped from a saved node when left at
|
||||
* its default, so an absent value counts as hosted. Shared by both chat resolvers so the
|
||||
* production and test gates can't drift.
|
||||
*/
|
||||
export function isOAuthProtectedChatTrigger(node: INode, disablePublicChat: boolean): boolean {
|
||||
const mode = node.parameters.mode ?? 'hostedChat';
|
||||
|
||||
return (
|
||||
node.type === CHAT_TRIGGER_NODE_TYPE &&
|
||||
!node.disabled &&
|
||||
!disablePublicChat &&
|
||||
node.parameters.public === true &&
|
||||
mode === 'hostedChat' &&
|
||||
node.parameters.authentication === 'n8nUserAuth'
|
||||
);
|
||||
}
|
||||
|
||||
export function trimTrailingSlash(path: string): string {
|
||||
if (path.endsWith('/')) {
|
||||
path = path.slice(0, -1);
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { Response } from 'express';
|
||||
import {
|
||||
Workflow,
|
||||
CHAT_TRIGGER_NODE_TYPE,
|
||||
CHAT_TRIGGER_PATH_SUFFIX,
|
||||
WEBHOOK_NODE_TYPE,
|
||||
nodeParametersAreStatic,
|
||||
webhookDescriptionIsNativelyResolvable,
|
||||
@@ -63,7 +64,7 @@ export class LiveWebhooks implements IWebhookManager {
|
||||
});
|
||||
|
||||
const isChatWebhookNode = (type: string, webhookId?: string) =>
|
||||
type === CHAT_TRIGGER_NODE_TYPE && `${webhookId}/chat` === path;
|
||||
type === CHAT_TRIGGER_NODE_TYPE && `${webhookId}/${CHAT_TRIGGER_PATH_SUFFIX}` === path;
|
||||
|
||||
const nodes = workflowData?.activeVersion?.nodes;
|
||||
const webhookNode = nodes?.find(
|
||||
|
||||
@@ -108,6 +108,8 @@ export const WORKFLOW_TOOL_LANGCHAIN_NODE_TYPE = '@n8n/n8n-nodes-langchain.toolW
|
||||
export const RETRIEVER_WORKFLOW_LANGCHAIN_NODE_TYPE = '@n8n/n8n-nodes-langchain.retrieverWorkflow';
|
||||
export const HTTP_REQUEST_TOOL_LANGCHAIN_NODE_TYPE = '@n8n/n8n-nodes-langchain.toolHttpRequest';
|
||||
export const CHAT_TRIGGER_NODE_TYPE = '@n8n/n8n-nodes-langchain.chatTrigger';
|
||||
/** Trailing segment of the path a Chat trigger registers its webhooks under: `{webhookId}/chat`. */
|
||||
export const CHAT_TRIGGER_PATH_SUFFIX = 'chat';
|
||||
export const CHAT_NODE_TYPE = '@n8n/n8n-nodes-langchain.chat';
|
||||
export const CHAT_TOOL_NODE_TYPE = '@n8n/n8n-nodes-langchain.chatTool';
|
||||
export const MEMORY_MANAGER_NODE_TYPE = '@n8n/n8n-nodes-langchain.memoryManager';
|
||||
|
||||
Reference in New Issue
Block a user