From b8eacf445ff186ca1d6f44cc8d07544bb6c6c91f Mon Sep 17 00:00:00 2001 From: James Martin <393605+ymhr@users.noreply.github.com> Date: Wed, 26 Aug 2026 08:06:56 +0000 Subject: [PATCH] chore: Add groundwork for OAuth2 protected Chats (no-changelog) (#37045) --- .../trigger/ChatTrigger/ChatTrigger.node.ts | 7 +- ...chat-trigger-resource.resolver.api.test.ts | 306 ++++++++++++++++ ...trigger-test-resource.resolver.api.test.ts | 333 ++++++++++++++++++ .../__tests__/oauth-flow.service.api.test.ts | 86 ++++- .../__tests__/oauth-server.service.test.ts | 39 ++ .../oauth-server/oauth-server.service.ts | 18 +- .../chat-trigger-resource.base.ts | 117 ++++++ .../chat-trigger-resource.resolver.ts | 61 ++++ .../chat-trigger-test-resource.resolver.ts | 60 ++++ .../protected-resource-resolvers/index.ts | 8 + .../protected-resource-resolvers/utils.ts | 25 ++ packages/cli/src/webhooks/live-webhooks.ts | 3 +- packages/workflow/src/constants.ts | 2 + 13 files changed, 1049 insertions(+), 16 deletions(-) create mode 100644 packages/cli/src/modules/oauth-server/__tests__/chat-trigger-resource.resolver.api.test.ts create mode 100644 packages/cli/src/modules/oauth-server/__tests__/chat-trigger-test-resource.resolver.api.test.ts create mode 100644 packages/cli/src/modules/oauth-server/protected-resource-resolvers/chat-trigger-resource.base.ts create mode 100644 packages/cli/src/modules/oauth-server/protected-resource-resolvers/chat-trigger-resource.resolver.ts create mode 100644 packages/cli/src/modules/oauth-server/protected-resource-resolvers/chat-trigger-test-resource.resolver.ts diff --git a/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/ChatTrigger.node.ts b/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/ChatTrigger.node.ts index 091c042874b..d87e237f327 100644 --- a/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/ChatTrigger.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/ChatTrigger.node.ts @@ -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 }}', }, diff --git a/packages/cli/src/modules/oauth-server/__tests__/chat-trigger-resource.resolver.api.test.ts b/packages/cli/src/modules/oauth-server/__tests__/chat-trigger-resource.resolver.api.test.ts new file mode 100644 index 00000000000..f101a4835fc --- /dev/null +++ b/packages/cli/src/modules/oauth-server/__tests__/chat-trigger-resource.resolver.api.test.ts @@ -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); + }); +}); diff --git a/packages/cli/src/modules/oauth-server/__tests__/chat-trigger-test-resource.resolver.api.test.ts b/packages/cli/src/modules/oauth-server/__tests__/chat-trigger-test-resource.resolver.api.test.ts new file mode 100644 index 00000000000..bce6be0aa92 --- /dev/null +++ b/packages/cli/src/modules/oauth-server/__tests__/chat-trigger-test-resource.resolver.api.test.ts @@ -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(); + }); +}); diff --git a/packages/cli/src/modules/oauth-server/__tests__/oauth-flow.service.api.test.ts b/packages/cli/src/modules/oauth-server/__tests__/oauth-flow.service.api.test.ts index 6ad9242739f..bdfc920dcb0 100644 --- a/packages/cli/src/modules/oauth-server/__tests__/oauth-flow.service.api.test.ts +++ b/packages/cli/src/modules/oauth-server/__tests__/oauth-flow.service.api.test.ts @@ -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 }); + } + }); +}); diff --git a/packages/cli/src/modules/oauth-server/__tests__/oauth-server.service.test.ts b/packages/cli/src/modules/oauth-server/__tests__/oauth-server.service.test.ts index d9f4b4008a8..b0138dd2c31 100644 --- a/packages/cli/src/modules/oauth-server/__tests__/oauth-server.service.test.ts +++ b/packages/cli/src/modules/oauth-server/__tests__/oauth-server.service.test.ts @@ -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); diff --git a/packages/cli/src/modules/oauth-server/oauth-server.service.ts b/packages/cli/src/modules/oauth-server/oauth-server.service.ts index c49ffbe2a97..52c598a28ee 100644 --- a/packages/cli/src/modules/oauth-server/oauth-server.service.ts +++ b/packages/cli/src/modules/oauth-server/oauth-server.service.ts @@ -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 { - // 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)); diff --git a/packages/cli/src/modules/oauth-server/protected-resource-resolvers/chat-trigger-resource.base.ts b/packages/cli/src/modules/oauth-server/protected-resource-resolvers/chat-trigger-resource.base.ts new file mode 100644 index 00000000000..dc40584c38d --- /dev/null +++ b/packages/cli/src/modules/oauth-server/protected-resource-resolvers/chat-trigger-resource.base.ts @@ -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; + + 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 { + 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 }), + }; + } +} diff --git a/packages/cli/src/modules/oauth-server/protected-resource-resolvers/chat-trigger-resource.resolver.ts b/packages/cli/src/modules/oauth-server/protected-resource-resolvers/chat-trigger-resource.resolver.ts new file mode 100644 index 00000000000..870c36a676b --- /dev/null +++ b/packages/cli/src/modules/oauth-server/protected-resource-resolvers/chat-trigger-resource.resolver.ts @@ -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 }; + } +} diff --git a/packages/cli/src/modules/oauth-server/protected-resource-resolvers/chat-trigger-test-resource.resolver.ts b/packages/cli/src/modules/oauth-server/protected-resource-resolvers/chat-trigger-test-resource.resolver.ts new file mode 100644 index 00000000000..afe8e75926b --- /dev/null +++ b/packages/cli/src/modules/oauth-server/protected-resource-resolvers/chat-trigger-test-resource.resolver.ts @@ -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 }; + } +} diff --git a/packages/cli/src/modules/oauth-server/protected-resource-resolvers/index.ts b/packages/cli/src/modules/oauth-server/protected-resource-resolvers/index.ts index 47db67dd656..a3a7067e0cc 100644 --- a/packages/cli/src/modules/oauth-server/protected-resource-resolvers/index.ts +++ b/packages/cli/src/modules/oauth-server/protected-resource-resolvers/index.ts @@ -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), ); diff --git a/packages/cli/src/modules/oauth-server/protected-resource-resolvers/utils.ts b/packages/cli/src/modules/oauth-server/protected-resource-resolvers/utils.ts index b26c7eaabf4..617dc7beece 100644 --- a/packages/cli/src/modules/oauth-server/protected-resource-resolvers/utils.ts +++ b/packages/cli/src/modules/oauth-server/protected-resource-resolvers/utils.ts @@ -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); diff --git a/packages/cli/src/webhooks/live-webhooks.ts b/packages/cli/src/webhooks/live-webhooks.ts index 3191b1ef1cf..3b10bbe40ec 100644 --- a/packages/cli/src/webhooks/live-webhooks.ts +++ b/packages/cli/src/webhooks/live-webhooks.ts @@ -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( diff --git a/packages/workflow/src/constants.ts b/packages/workflow/src/constants.ts index aa7074c54e3..f793d244d1c 100644 --- a/packages/workflow/src/constants.ts +++ b/packages/workflow/src/constants.ts @@ -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';