chore: Add groundwork for OAuth2 protected Chats (no-changelog) (#37045)

This commit is contained in:
James Martin
2026-08-26 08:06:56 +00:00
committed by GitHub
parent 78130a1a65
commit b8eacf445f
13 changed files with 1049 additions and 16 deletions
@@ -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 }}',
},
@@ -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);
});
});
@@ -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));
@@ -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 }),
};
}
}
@@ -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 };
}
}
@@ -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);
+2 -1
View File
@@ -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(
+2
View File
@@ -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';