mirror of
https://github.com/n8n-io/n8n.git
synced 2026-06-19 07:36:52 +00:00
feat(core): Resolve MCP trigger workflows as OAuth protected resources (no-changelog) (#32235)
This commit is contained in:
@@ -38,7 +38,7 @@ describe('OAuthServerService', () => {
|
||||
authorizationCodeService = mockInstance(OAuthAuthorizationCodeService);
|
||||
userConsentRepository = mockInstance(UserConsentRepository);
|
||||
|
||||
const resourceRegistry = new ProtectedResourceRegistry();
|
||||
const resourceRegistry = new ProtectedResourceRegistry(mock<Logger>());
|
||||
resourceRegistry.register({
|
||||
id: 'instance-mcp',
|
||||
getResourceUrl: () => TEST_RESOURCE_URL,
|
||||
@@ -862,7 +862,7 @@ describe('OAuthServerService', () => {
|
||||
|
||||
describe('resource indicator validation across multiple resources', () => {
|
||||
it('should accept any registered resource and reject unregistered ones', async () => {
|
||||
const multiRegistry = new ProtectedResourceRegistry();
|
||||
const multiRegistry = new ProtectedResourceRegistry(mock<Logger>());
|
||||
multiRegistry.register({
|
||||
id: 'instance-mcp',
|
||||
getResourceUrl: () => TEST_RESOURCE_URL,
|
||||
|
||||
@@ -28,7 +28,7 @@ const TEST_BASE_URL = 'https://n8n.example.com';
|
||||
const TEST_RESOURCE_URL = `${TEST_BASE_URL}/mcp-server/http`;
|
||||
const LEGACY_AUDIENCE = 'mcp-server-api';
|
||||
|
||||
const registry = new ProtectedResourceRegistry();
|
||||
const registry = new ProtectedResourceRegistry(mock<Logger>());
|
||||
registry.register({
|
||||
id: 'instance-mcp',
|
||||
getResourceUrl: () => TEST_RESOURCE_URL,
|
||||
@@ -499,7 +499,7 @@ describe('OAuthTokenService', () => {
|
||||
let multiResourceService: OAuthTokenService;
|
||||
|
||||
beforeAll(() => {
|
||||
const multiResourceRegistry = new ProtectedResourceRegistry();
|
||||
const multiResourceRegistry = new ProtectedResourceRegistry(mock<Logger>());
|
||||
multiResourceRegistry.register({
|
||||
id: 'instance-mcp',
|
||||
getResourceUrl: () => RESOURCE_A_URL,
|
||||
|
||||
+280
@@ -0,0 +1,280 @@
|
||||
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 { randomUUID } from 'node:crypto';
|
||||
|
||||
import { createOwner } from '@test-integration/db/users';
|
||||
import { setupTestServer } from '@test-integration/utils';
|
||||
|
||||
import { MCP_TRIGGER_NODE_TYPE } from '@/constants';
|
||||
import { OAuthTokenService } from '@/modules/oauth-server/oauth-token.service';
|
||||
import { CacheService } from '@/services/cache/cache.service';
|
||||
import { UrlService } from '@/services/url.service';
|
||||
import { TestWebhookRegistrationsService } from '@/webhooks/test-webhook-registrations.service';
|
||||
|
||||
const testServer = setupTestServer({ modules: ['oauth-server', 'mcp'], endpointGroups: ['mcp'] });
|
||||
|
||||
let owner: User;
|
||||
let mcpEndpoint: string;
|
||||
let mcpTestEndpoint: string;
|
||||
let registrations: TestWebhookRegistrationsService;
|
||||
|
||||
const webhookBaseUrl = () => Container.get(UrlService).getWebhookBaseUrl().replace(/\/$/, '');
|
||||
|
||||
const testResourceUrlFor = (webhookPath: string) =>
|
||||
`${webhookBaseUrl()}/${mcpTestEndpoint}/${webhookPath}`;
|
||||
|
||||
const prmPathFor = (webhookPath: string) =>
|
||||
`/.well-known/oauth-protected-resource/${mcpTestEndpoint}/${webhookPath}`;
|
||||
|
||||
const mcpTriggerNode = ({
|
||||
name = 'MCP Server Trigger',
|
||||
authentication = 'n8nOAuth2',
|
||||
disabled = false,
|
||||
}: {
|
||||
name?: string;
|
||||
authentication?: string;
|
||||
disabled?: boolean;
|
||||
} = {}): INode => ({
|
||||
id: randomUUID(),
|
||||
name,
|
||||
type: MCP_TRIGGER_NODE_TYPE,
|
||||
typeVersion: 2,
|
||||
position: [0, 0],
|
||||
disabled,
|
||||
parameters: { path: 'unused', authentication },
|
||||
});
|
||||
|
||||
/**
|
||||
* Mirrors what `TestWebhooks.needsWebhook` registers when the user tests an
|
||||
* MCP trigger in the editor. The workflow does not need to exist in the DB:
|
||||
* the registration is self-contained.
|
||||
*/
|
||||
const registerTestWebhook = async (
|
||||
webhookPath: string,
|
||||
node: INode,
|
||||
{
|
||||
workflowId = randomUUID(),
|
||||
workflowName = 'My test workflow',
|
||||
}: { workflowId?: string; workflowName?: string } = {},
|
||||
) => {
|
||||
await registrations.register({
|
||||
version: 1,
|
||||
workflowEntity: {
|
||||
id: workflowId,
|
||||
name: workflowName,
|
||||
active: false,
|
||||
nodes: [node],
|
||||
connections: {},
|
||||
} as IWorkflowBase,
|
||||
webhook: {
|
||||
httpMethod: 'POST',
|
||||
path: webhookPath,
|
||||
node: node.name,
|
||||
workflowId,
|
||||
} as IWebhookData,
|
||||
});
|
||||
return { workflowId, workflowName };
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
owner = await createOwner();
|
||||
const { endpoints } = Container.get(GlobalConfig);
|
||||
mcpEndpoint = endpoints.mcp;
|
||||
mcpTestEndpoint = endpoints.mcpTest;
|
||||
registrations = Container.get(TestWebhookRegistrationsService);
|
||||
});
|
||||
|
||||
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 MCP triggers', () => {
|
||||
test('should serve the metadata document while a test registration exists', async () => {
|
||||
const webhookPath = randomUUID();
|
||||
await registerTestWebhook(webhookPath, mcpTriggerNode());
|
||||
|
||||
const response = await testServer.restlessAgent.get(prmPathFor(webhookPath));
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
// exact match: `scopes_supported` must be absent (the resource advertises no scopes)
|
||||
expect(response.body).toEqual({
|
||||
resource: testResourceUrlFor(webhookPath),
|
||||
bearer_methods_supported: ['header'],
|
||||
authorization_servers: [expect.any(String)],
|
||||
});
|
||||
});
|
||||
|
||||
test('should resolve from the registration alone, without the workflow in the DB', async () => {
|
||||
const webhookPath = randomUUID();
|
||||
const { workflowName } = await registerTestWebhook(webhookPath, mcpTriggerNode(), {
|
||||
workflowName: 'Unsaved workflow',
|
||||
});
|
||||
|
||||
const response = await testServer.restlessAgent.get(prmPathFor(webhookPath));
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(workflowName).toBe('Unsaved workflow');
|
||||
});
|
||||
|
||||
test('should not resolve an unknown test path', async () => {
|
||||
const response = await testServer.restlessAgent.get(prmPathFor(randomUUID()));
|
||||
|
||||
expect(response.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
test('should not resolve when the registration node name does not match', async () => {
|
||||
const webhookPath = randomUUID();
|
||||
// the webhook points at a node name absent from the registered workflow
|
||||
await registrations.register({
|
||||
version: 1,
|
||||
workflowEntity: {
|
||||
id: randomUUID(),
|
||||
name: 'My test workflow',
|
||||
active: false,
|
||||
nodes: [mcpTriggerNode()],
|
||||
connections: {},
|
||||
} as IWorkflowBase,
|
||||
webhook: {
|
||||
httpMethod: 'POST',
|
||||
path: webhookPath,
|
||||
node: 'Ghost node',
|
||||
workflowId: randomUUID(),
|
||||
} as IWebhookData,
|
||||
});
|
||||
|
||||
const response = await testServer.restlessAgent.get(prmPathFor(webhookPath));
|
||||
|
||||
expect(response.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
test.each([
|
||||
['authentication is none', mcpTriggerNode({ authentication: 'none' })],
|
||||
['authentication is bearerAuth', mcpTriggerNode({ authentication: 'bearerAuth' })],
|
||||
['the node is disabled', mcpTriggerNode({ disabled: true })],
|
||||
])('should not resolve when %s', async (_, node) => {
|
||||
const webhookPath = randomUUID();
|
||||
await registerTestWebhook(webhookPath, node);
|
||||
|
||||
const response = await testServer.restlessAgent.get(prmPathFor(webhookPath));
|
||||
|
||||
expect(response.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
test('should stop resolving as soon as the registration is removed', async () => {
|
||||
const webhookPath = randomUUID();
|
||||
await registerTestWebhook(webhookPath, mcpTriggerNode());
|
||||
|
||||
expect((await testServer.restlessAgent.get(prmPathFor(webhookPath))).statusCode).toBe(200);
|
||||
|
||||
await registrations.deregister(registrations.toKey({ httpMethod: 'POST', path: webhookPath }));
|
||||
|
||||
expect((await testServer.restlessAgent.get(prmPathFor(webhookPath))).statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('test vs production resources', () => {
|
||||
const registerOAuthClient = async () => {
|
||||
const response = await testServer.restlessAgent.post('/mcp-oauth/register').send({
|
||||
client_name: 'test-resolver-tests',
|
||||
redirect_uris: ['https://example.com/callback'],
|
||||
grant_types: ['authorization_code'],
|
||||
token_endpoint_auth_method: 'none',
|
||||
});
|
||||
expect(response.statusCode).toBe(201);
|
||||
return response.body.client_id as string;
|
||||
};
|
||||
|
||||
test('should serve the same trigger path as two distinct resources', async () => {
|
||||
const webhookPath = randomUUID();
|
||||
const node = mcpTriggerNode();
|
||||
|
||||
// production: active workflow with published version + webhook row
|
||||
const workflow = await createWorkflowWithHistory({ active: true, nodes: [node] }, owner);
|
||||
await setActiveVersion(workflow.id, workflow.versionId);
|
||||
await Container.get(WebhookRepository).insert({
|
||||
workflowId: workflow.id,
|
||||
webhookPath,
|
||||
method: 'POST',
|
||||
node: node.name,
|
||||
});
|
||||
|
||||
// test: editor registration for the same path
|
||||
await registerTestWebhook(webhookPath, node, { workflowId: workflow.id });
|
||||
|
||||
const production = await testServer.restlessAgent.get(
|
||||
`/.well-known/oauth-protected-resource/${mcpEndpoint}/${webhookPath}`,
|
||||
);
|
||||
const test = await testServer.restlessAgent.get(prmPathFor(webhookPath));
|
||||
|
||||
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 webhookPath = randomUUID();
|
||||
const node = mcpTriggerNode();
|
||||
|
||||
const workflow = await createWorkflowWithHistory({ active: true, nodes: [node] }, owner);
|
||||
await setActiveVersion(workflow.id, workflow.versionId);
|
||||
await Container.get(WebhookRepository).insert({
|
||||
workflowId: workflow.id,
|
||||
webhookPath,
|
||||
method: 'POST',
|
||||
node: node.name,
|
||||
});
|
||||
await registerTestWebhook(webhookPath, node, { workflowId: workflow.id });
|
||||
|
||||
const clientId = await registerOAuthClient();
|
||||
const tokenService = Container.get(OAuthTokenService);
|
||||
const productionResourceUrl = `${webhookBaseUrl()}/${mcpEndpoint}/${webhookPath}`;
|
||||
const testResourceUrl = testResourceUrlFor(webhookPath);
|
||||
|
||||
const testToken = tokenService.generateTokenPair(owner.id, clientId, testResourceUrl);
|
||||
await tokenService.saveTokenPair(
|
||||
testToken.accessToken,
|
||||
testToken.refreshToken,
|
||||
clientId,
|
||||
owner.id,
|
||||
);
|
||||
const productionToken = tokenService.generateTokenPair(
|
||||
owner.id,
|
||||
clientId,
|
||||
productionResourceUrl,
|
||||
);
|
||||
await tokenService.saveTokenPair(
|
||||
productionToken.accessToken,
|
||||
productionToken.refreshToken,
|
||||
clientId,
|
||||
owner.id,
|
||||
);
|
||||
|
||||
await expect(
|
||||
tokenService.verifyAccessToken(testToken.accessToken, testResourceUrl),
|
||||
).resolves.toMatchObject({ clientId });
|
||||
await expect(
|
||||
tokenService.verifyAccessToken(productionToken.accessToken, productionResourceUrl),
|
||||
).resolves.toMatchObject({ clientId });
|
||||
|
||||
await expect(
|
||||
tokenService.verifyAccessToken(testToken.accessToken, productionResourceUrl),
|
||||
).rejects.toThrow();
|
||||
await expect(
|
||||
tokenService.verifyAccessToken(productionToken.accessToken, testResourceUrl),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
+340
@@ -0,0 +1,340 @@
|
||||
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 { randomUUID } from 'node:crypto';
|
||||
|
||||
import { createOwner } from '@test-integration/db/users';
|
||||
import { setupTestServer } from '@test-integration/utils';
|
||||
|
||||
import { MCP_TRIGGER_NODE_TYPE } from '@/constants';
|
||||
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';
|
||||
|
||||
const testServer = setupTestServer({ modules: ['oauth-server', 'mcp'], endpointGroups: ['mcp'] });
|
||||
|
||||
let owner: User;
|
||||
let mcpEndpoint: string;
|
||||
|
||||
const webhookBaseUrl = () => Container.get(UrlService).getWebhookBaseUrl().replace(/\/$/, '');
|
||||
|
||||
const resourceUrlFor = (webhookPath: string) => `${webhookBaseUrl()}/${mcpEndpoint}/${webhookPath}`;
|
||||
|
||||
const prmPathFor = (webhookPath: string) =>
|
||||
`/.well-known/oauth-protected-resource/${mcpEndpoint}/${webhookPath}`;
|
||||
|
||||
const mcpTriggerNode = ({
|
||||
name = 'MCP Server Trigger',
|
||||
authentication = 'n8nOAuth2',
|
||||
disabled = false,
|
||||
}: {
|
||||
name?: string;
|
||||
authentication?: string;
|
||||
disabled?: boolean;
|
||||
} = {}): INode => ({
|
||||
id: randomUUID(),
|
||||
name,
|
||||
type: MCP_TRIGGER_NODE_TYPE,
|
||||
typeVersion: 2,
|
||||
position: [0, 0],
|
||||
disabled,
|
||||
parameters: { path: 'unused', authentication },
|
||||
});
|
||||
|
||||
/** Mirrors what `ActiveWorkflowManager.addWebhooks` persists on activation. */
|
||||
const insertWebhookRow = async (workflowId: string, webhookPath: string, node: string) => {
|
||||
await Container.get(WebhookRepository).insert({ workflowId, webhookPath, method: 'POST', node });
|
||||
};
|
||||
|
||||
/** Active workflow whose published version contains the given trigger node. */
|
||||
const createPublishedMcpWorkflow = async (webhookPath: string, node: INode) => {
|
||||
const workflow = await createWorkflowWithHistory({ active: true, nodes: [node] }, owner);
|
||||
await setActiveVersion(workflow.id, workflow.versionId);
|
||||
await insertWebhookRow(workflow.id, webhookPath, 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 registerOAuthClient = async () => {
|
||||
const response = await testServer.restlessAgent.post('/mcp-oauth/register').send({
|
||||
client_name: 'resolver-tests',
|
||||
redirect_uris: ['https://example.com/callback'],
|
||||
grant_types: ['authorization_code'],
|
||||
token_endpoint_auth_method: 'none',
|
||||
});
|
||||
expect(response.statusCode).toBe(201);
|
||||
return response.body.client_id as string;
|
||||
};
|
||||
|
||||
const authorizeQueryFor = (clientId: string, resource: string) => ({
|
||||
response_type: 'code',
|
||||
client_id: clientId,
|
||||
redirect_uri: 'https://example.com/callback',
|
||||
// PKCE values are only verified at the token exchange, any well-formed challenge works here
|
||||
code_challenge: 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM',
|
||||
code_challenge_method: 'S256',
|
||||
state: 'state',
|
||||
resource,
|
||||
});
|
||||
|
||||
const decodeJwtPayload = (token: string): Record<string, unknown> =>
|
||||
JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString()) as Record<string, unknown>;
|
||||
|
||||
beforeAll(async () => {
|
||||
owner = await createOwner();
|
||||
mcpEndpoint = Container.get(GlobalConfig).endpoints.mcp;
|
||||
});
|
||||
|
||||
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 workflow MCP triggers', () => {
|
||||
test('should serve the metadata document for an active n8nOAuth2 trigger', async () => {
|
||||
const webhookPath = randomUUID();
|
||||
await createPublishedMcpWorkflow(webhookPath, mcpTriggerNode());
|
||||
|
||||
const response = await testServer.restlessAgent.get(prmPathFor(webhookPath));
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
// exact match: `scopes_supported` must be absent (the resource advertises no scopes)
|
||||
expect(response.body).toEqual({
|
||||
resource: resourceUrlFor(webhookPath),
|
||||
bearer_methods_supported: ['header'],
|
||||
authorization_servers: [expect.any(String)],
|
||||
});
|
||||
});
|
||||
|
||||
test('should tolerate a trailing slash', async () => {
|
||||
const webhookPath = randomUUID();
|
||||
await createPublishedMcpWorkflow(webhookPath, mcpTriggerNode());
|
||||
|
||||
const response = await testServer.restlessAgent.get(`${prmPathFor(webhookPath)}/`);
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.body.resource).toBe(resourceUrlFor(webhookPath));
|
||||
});
|
||||
|
||||
test('should expose the workflow name for the consent screen', async () => {
|
||||
const webhookPath = randomUUID();
|
||||
const workflow = await createPublishedMcpWorkflow(webhookPath, mcpTriggerNode());
|
||||
|
||||
const resource = await Container.get(ProtectedResourceRegistry).getByResourcePath(
|
||||
`/${mcpEndpoint}/${webhookPath}`,
|
||||
);
|
||||
|
||||
expect(resource?.displayName).toBe(workflow.name);
|
||||
});
|
||||
|
||||
test('should not resolve an unknown path', async () => {
|
||||
const response = await testServer.restlessAgent.get(prmPathFor(randomUUID()));
|
||||
|
||||
expect(response.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
test('should not resolve a non-mcp path even if the webhook exists', async () => {
|
||||
const webhookPath = randomUUID();
|
||||
await createPublishedMcpWorkflow(webhookPath, mcpTriggerNode());
|
||||
|
||||
const response = await testServer.restlessAgent.get(
|
||||
`/.well-known/oauth-protected-resource/webhook/${webhookPath}`,
|
||||
);
|
||||
|
||||
expect(response.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
test.each([
|
||||
['authentication is none', mcpTriggerNode({ authentication: 'none' })],
|
||||
['authentication is bearerAuth', mcpTriggerNode({ authentication: 'bearerAuth' })],
|
||||
['authentication is an expression', mcpTriggerNode({ authentication: '={{ $json.auth }}' })],
|
||||
['the node is disabled', mcpTriggerNode({ disabled: true })],
|
||||
])('should not resolve when %s', async (_, node) => {
|
||||
const webhookPath = randomUUID();
|
||||
await createPublishedMcpWorkflow(webhookPath, node);
|
||||
|
||||
const response = await testServer.restlessAgent.get(prmPathFor(webhookPath));
|
||||
|
||||
expect(response.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
test('should not resolve a workflow without a published version', async () => {
|
||||
const node = mcpTriggerNode();
|
||||
const webhookPath = randomUUID();
|
||||
const workflow = await createWorkflowWithHistory({ active: false, nodes: [node] }, owner);
|
||||
await insertWebhookRow(workflow.id, webhookPath, node.name);
|
||||
|
||||
const response = await testServer.restlessAgent.get(prmPathFor(webhookPath));
|
||||
|
||||
expect(response.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
test('should not resolve when the webhook node is missing from the active version', async () => {
|
||||
const node = mcpTriggerNode();
|
||||
const webhookPath = randomUUID();
|
||||
const workflow = await createWorkflowWithHistory({ active: true, nodes: [node] }, owner);
|
||||
await setActiveVersion(workflow.id, workflow.versionId);
|
||||
// the webhook row points at a node name that the published version does not contain
|
||||
await insertWebhookRow(workflow.id, webhookPath, 'Ghost node');
|
||||
|
||||
const response = await testServer.restlessAgent.get(prmPathFor(webhookPath));
|
||||
|
||||
expect(response.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
test('should not resolve a dynamic webhook path', async () => {
|
||||
const node = mcpTriggerNode();
|
||||
const workflow = await createWorkflowWithHistory({ active: true, nodes: [node] }, owner);
|
||||
await setActiveVersion(workflow.id, workflow.versionId);
|
||||
const webhookId = randomUUID();
|
||||
await Container.get(WebhookRepository).insert({
|
||||
workflowId: workflow.id,
|
||||
webhookPath: ':param',
|
||||
method: 'POST',
|
||||
node: node.name,
|
||||
webhookId,
|
||||
pathLength: 1,
|
||||
});
|
||||
|
||||
const response = await testServer.restlessAgent.get(prmPathFor(`${webhookId}/anything`));
|
||||
|
||||
expect(response.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
test('should follow the published version, not the draft', async () => {
|
||||
// published n8nOAuth2, draft switched to none -> resource stays
|
||||
const protectedPath = randomUUID();
|
||||
const protectedWorkflow = await createPublishedMcpWorkflow(protectedPath, mcpTriggerNode());
|
||||
await updateDraftNodes(protectedWorkflow.id, [mcpTriggerNode({ authentication: 'none' })]);
|
||||
|
||||
const stillProtected = await testServer.restlessAgent.get(prmPathFor(protectedPath));
|
||||
expect(stillProtected.statusCode).toBe(200);
|
||||
// pin which resource resolved, not merely that something did
|
||||
expect(stillProtected.body.resource).toBe(resourceUrlFor(protectedPath));
|
||||
|
||||
// published none, draft switched to n8nOAuth2 -> no resource
|
||||
const unprotectedPath = randomUUID();
|
||||
const unprotectedWorkflow = await createPublishedMcpWorkflow(
|
||||
unprotectedPath,
|
||||
mcpTriggerNode({ authentication: 'none' }),
|
||||
);
|
||||
await updateDraftNodes(unprotectedWorkflow.id, [mcpTriggerNode()]);
|
||||
|
||||
const stillUnprotected = await testServer.restlessAgent.get(prmPathFor(unprotectedPath));
|
||||
expect(stillUnprotected.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
test('should stop resolving once the webhook is deregistered', async () => {
|
||||
const webhookPath = randomUUID();
|
||||
await createPublishedMcpWorkflow(webhookPath, mcpTriggerNode());
|
||||
|
||||
expect((await testServer.restlessAgent.get(prmPathFor(webhookPath))).statusCode).toBe(200);
|
||||
|
||||
// what `clearWebhooks` does on deactivation
|
||||
await Container.get(WebhookRepository).delete({ webhookPath });
|
||||
await Container.get(CacheService).reset();
|
||||
|
||||
expect((await testServer.restlessAgent.get(prmPathFor(webhookPath))).statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resource indicator validation at authorize', () => {
|
||||
test('should accept the trigger URL as resource indicator', async () => {
|
||||
const webhookPath = randomUUID();
|
||||
await createPublishedMcpWorkflow(webhookPath, mcpTriggerNode());
|
||||
const clientId = await registerOAuthClient();
|
||||
|
||||
const response = await testServer.restlessAgent
|
||||
.get('/mcp-oauth/authorize')
|
||||
.query(authorizeQueryFor(clientId, resourceUrlFor(webhookPath)));
|
||||
|
||||
expect(response.statusCode).toBe(302);
|
||||
expect(response.headers.location).toContain('/oauth/consent');
|
||||
});
|
||||
|
||||
test.each([
|
||||
['an unknown trigger path', () => resourceUrlFor(randomUUID())],
|
||||
['a foreign origin', (webhookPath: string) => `https://evil.example/mcp/${webhookPath}`],
|
||||
[
|
||||
'a suffix-spoofed host',
|
||||
(webhookPath: string) => {
|
||||
const base = new URL(webhookBaseUrl());
|
||||
const port = base.port ? `:${base.port}` : '';
|
||||
return `${base.protocol}//${base.hostname}.evil.example${port}/${mcpEndpoint}/${webhookPath}`;
|
||||
},
|
||||
],
|
||||
])('should reject %s with invalid_target', async (_, makeResource) => {
|
||||
const webhookPath = randomUUID();
|
||||
await createPublishedMcpWorkflow(webhookPath, mcpTriggerNode());
|
||||
const clientId = await registerOAuthClient();
|
||||
|
||||
const response = await testServer.restlessAgent
|
||||
.get('/mcp-oauth/authorize')
|
||||
.query(authorizeQueryFor(clientId, makeResource(webhookPath)));
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.body.error).toBe('invalid_target');
|
||||
});
|
||||
});
|
||||
|
||||
describe('token audience', () => {
|
||||
test('should mint tokens whose audience is the trigger URL and reject cross-resource use', async () => {
|
||||
const pathA = randomUUID();
|
||||
const pathB = randomUUID();
|
||||
await createPublishedMcpWorkflow(pathA, mcpTriggerNode());
|
||||
await createPublishedMcpWorkflow(pathB, mcpTriggerNode());
|
||||
const clientId = await registerOAuthClient();
|
||||
const tokenService = Container.get(OAuthTokenService);
|
||||
|
||||
const { accessToken, refreshToken } = tokenService.generateTokenPair(
|
||||
owner.id,
|
||||
clientId,
|
||||
resourceUrlFor(pathA),
|
||||
);
|
||||
await tokenService.saveTokenPair(accessToken, refreshToken, clientId, owner.id);
|
||||
|
||||
expect(decodeJwtPayload(accessToken).aud).toBe(resourceUrlFor(pathA));
|
||||
|
||||
await expect(
|
||||
tokenService.verifyAccessToken(accessToken, resourceUrlFor(pathA)),
|
||||
).resolves.toMatchObject({ clientId });
|
||||
|
||||
// a token minted for workflow A must fail workflow B's audience gate
|
||||
await expect(
|
||||
tokenService.verifyAccessToken(accessToken, resourceUrlFor(pathB)),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
test('should reject an instance MCP token at a workflow resource', async () => {
|
||||
const webhookPath = randomUUID();
|
||||
await createPublishedMcpWorkflow(webhookPath, mcpTriggerNode());
|
||||
const clientId = await registerOAuthClient();
|
||||
const tokenService = Container.get(OAuthTokenService);
|
||||
|
||||
// no resource indicator -> falls back to the default resource (instance MCP)
|
||||
const { accessToken, refreshToken } = tokenService.generateTokenPair(owner.id, clientId);
|
||||
await tokenService.saveTokenPair(accessToken, refreshToken, clientId, owner.id);
|
||||
|
||||
expect(decodeJwtPayload(accessToken).aud).not.toBe(resourceUrlFor(webhookPath));
|
||||
await expect(
|
||||
tokenService.verifyAccessToken(accessToken, resourceUrlFor(webhookPath)),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ProtectedResourceRegistry } from '@/services/protected-resource.registry';
|
||||
import type { ModuleInterface } from '@n8n/decorators';
|
||||
import { BackendModule } from '@n8n/decorators';
|
||||
import { Container } from '@n8n/di';
|
||||
@@ -25,6 +26,20 @@ export class OAuthServerModule implements ModuleInterface {
|
||||
);
|
||||
const { OAuthTokenService } = await import('./oauth-token.service');
|
||||
Container.get(OAuthTokenVerifierProxy).registerProvider(Container.get(OAuthTokenService));
|
||||
|
||||
const { WorkflowMcpTriggerResourceResolver } = await import(
|
||||
'./protected-resource-resolvers/workflow-mcp-trigger-resource.resolver'
|
||||
);
|
||||
Container.get(ProtectedResourceRegistry).registerResolver(
|
||||
Container.get(WorkflowMcpTriggerResourceResolver),
|
||||
);
|
||||
|
||||
const { WorkflowMcpTestTriggerResourceResolver } = await import(
|
||||
'./protected-resource-resolvers/workflow-mcp-test-trigger-resource.resolver'
|
||||
);
|
||||
Container.get(ProtectedResourceRegistry).registerResolver(
|
||||
Container.get(WorkflowMcpTestTriggerResourceResolver),
|
||||
);
|
||||
}
|
||||
|
||||
async entities() {
|
||||
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
import { resourceUrlToWebhookPath, trimSlashes, trimTrailingSlash } from '../utils';
|
||||
|
||||
describe('resourceUrlToWebhookPath', () => {
|
||||
test('should return the path for a URL under a root-mounted base URL', () => {
|
||||
expect(resourceUrlToWebhookPath('https://host.example/mcp/abc', 'https://host.example/')).toBe(
|
||||
'/mcp/abc',
|
||||
);
|
||||
// base URL without a trailing slash resolves the same way
|
||||
expect(resourceUrlToWebhookPath('https://host.example/mcp/abc', 'https://host.example')).toBe(
|
||||
'/mcp/abc',
|
||||
);
|
||||
});
|
||||
|
||||
test('should strip the base URL path prefix for a sub-path deployment', () => {
|
||||
expect(
|
||||
resourceUrlToWebhookPath('https://host.example/n8n/mcp/abc', 'https://host.example/n8n/'),
|
||||
).toBe('/mcp/abc');
|
||||
});
|
||||
|
||||
test('should reject a URL that omits the base URL path prefix', () => {
|
||||
// without the `/n8n` prefix the URL is not actually served by this instance,
|
||||
// so it must not resolve to the prefixed resource
|
||||
expect(
|
||||
resourceUrlToWebhookPath('https://host.example/mcp/abc', 'https://host.example/n8n/'),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
test('should reject a foreign origin', () => {
|
||||
expect(
|
||||
resourceUrlToWebhookPath('https://evil.example/mcp/abc', 'https://host.example/'),
|
||||
).toBeUndefined();
|
||||
// origin includes the port
|
||||
expect(
|
||||
resourceUrlToWebhookPath('https://host.example:9000/mcp/abc', 'https://host.example/'),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
test('should return undefined for a malformed resource URL', () => {
|
||||
expect(resourceUrlToWebhookPath('not-a-url', 'https://host.example/')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('trimSlashes / trimTrailingSlash', () => {
|
||||
test('trimTrailingSlash removes a single trailing slash', () => {
|
||||
expect(trimTrailingSlash('https://host.example/')).toBe('https://host.example');
|
||||
expect(trimTrailingSlash('https://host.example')).toBe('https://host.example');
|
||||
});
|
||||
|
||||
test('trimSlashes removes a leading and a trailing slash', () => {
|
||||
expect(trimSlashes('/abc/')).toBe('abc');
|
||||
expect(trimSlashes('abc')).toBe('abc');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Scopes advertised for per-workflow MCP trigger resources. Empty on purpose:
|
||||
* the gate enforces no scopes and tokens carry none, and an empty list makes
|
||||
* the protected-resource metadata omit `scopes_supported`.
|
||||
*/
|
||||
export const WORKFLOW_MCP_TRIGGER_SCOPES: string[] = [];
|
||||
|
||||
export function trimTrailingSlash(path: string): string {
|
||||
if (path.endsWith('/')) {
|
||||
path = path.slice(0, -1);
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
export function trimSlashes(path: string): string {
|
||||
if (path.startsWith('/')) {
|
||||
path = path.slice(1);
|
||||
}
|
||||
if (path.endsWith('/')) {
|
||||
path = path.slice(0, -1);
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map an RFC 8707 resource URL to the instance-relative path it is served at, or
|
||||
* `undefined` if the URL is not under this instance's webhook base URL.
|
||||
*
|
||||
* The base URL may carry a path prefix (e.g. `WEBHOOK_URL=https://host/n8n/` or a
|
||||
* non-root `N8N_PATH`), so the prefix is stripped before the path is returned.
|
||||
* This keeps `resolveByPath` — which matches against `/{endpoint}/…` — working the
|
||||
* same for sub-path deployments as for root deployments, and matches the path the
|
||||
* unauthenticated well-known route already receives (relative to the mount point).
|
||||
*/
|
||||
export function resourceUrlToWebhookPath(
|
||||
resourceUrl: string,
|
||||
webhookBaseUrl: string,
|
||||
): string | undefined {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(resourceUrl);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const base = new URL(webhookBaseUrl);
|
||||
if (url.origin !== base.origin) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const basePath = trimTrailingSlash(base.pathname);
|
||||
if (basePath && !url.pathname.startsWith(`${basePath}/`)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return url.pathname.slice(basePath.length);
|
||||
}
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
import { MCP_TRIGGER_NODE_TYPE } from '@/constants';
|
||||
import type { ProtectedResourceResolver } from '@/services/protected-resource.registry';
|
||||
import { UrlService } from '@/services/url.service';
|
||||
import { TestWebhookRegistrationsService } from '@/webhooks/test-webhook-registrations.service';
|
||||
import { Logger } from '@n8n/backend-common';
|
||||
import { GlobalConfig } from '@n8n/config';
|
||||
import { Service } from '@n8n/di';
|
||||
|
||||
import {
|
||||
WORKFLOW_MCP_TRIGGER_SCOPES,
|
||||
resourceUrlToWebhookPath,
|
||||
trimSlashes,
|
||||
trimTrailingSlash,
|
||||
} from './utils';
|
||||
|
||||
@Service()
|
||||
export class WorkflowMcpTestTriggerResourceResolver implements ProtectedResourceResolver {
|
||||
constructor(
|
||||
private readonly config: GlobalConfig,
|
||||
private readonly registrations: TestWebhookRegistrationsService,
|
||||
private readonly urlService: UrlService,
|
||||
private readonly logger: Logger,
|
||||
) {}
|
||||
|
||||
readonly id = 'workflow-mcp-test-trigger';
|
||||
readonly scopes = WORKFLOW_MCP_TRIGGER_SCOPES;
|
||||
|
||||
async resolveByUrl(resourceUrl: string) {
|
||||
const pathname = resourceUrlToWebhookPath(resourceUrl, this.urlService.getWebhookBaseUrl());
|
||||
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) {
|
||||
if (!pathname.startsWith(`/${this.config.endpoints.mcpTest}/`)) {
|
||||
// we can quickly rule out non-MCP-test paths without doing any URL parsing, so check that first
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const path = trimSlashes(pathname.slice(this.config.endpoints.mcpTest.length + 1));
|
||||
|
||||
this.logger.debug(`Resolving workflow MCP test trigger resource for path: ${path}`);
|
||||
|
||||
// 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. Only the static key shape is looked up: dynamic registrations
|
||||
// are keyed differently and are not protectable resources.
|
||||
const registration = await this.registrations.get(
|
||||
this.registrations.toKey({ httpMethod: 'POST', 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;
|
||||
}
|
||||
|
||||
if (
|
||||
node.type === MCP_TRIGGER_NODE_TYPE &&
|
||||
!node.disabled &&
|
||||
node.parameters.authentication === 'n8nOAuth2'
|
||||
) {
|
||||
const resourceUrl = `${trimTrailingSlash(this.urlService.getWebhookBaseUrl())}/${this.config.endpoints.mcpTest}/${path}`;
|
||||
return {
|
||||
id: 'workflow-mcp-test:' + workflowEntity.id,
|
||||
getResourceUrl: () => resourceUrl,
|
||||
getAudiences: () => [resourceUrl],
|
||||
scopes: WORKFLOW_MCP_TRIGGER_SCOPES,
|
||||
displayName: workflowEntity.name,
|
||||
};
|
||||
}
|
||||
|
||||
this.logger.debug(
|
||||
`Node with name ${webhook.node} in test registration for workflow with ID: ${workflowEntity.id} is not an enabled MCP trigger with n8nOAuth2 authentication`,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
import { MCP_TRIGGER_NODE_TYPE } from '@/constants';
|
||||
import type { ProtectedResourceResolver } from '@/services/protected-resource.registry';
|
||||
import { UrlService } from '@/services/url.service';
|
||||
import { WebhookService } from '@/webhooks/webhook.service';
|
||||
import { Logger } from '@n8n/backend-common';
|
||||
import { GlobalConfig } from '@n8n/config';
|
||||
import { WorkflowRepository } from '@n8n/db';
|
||||
import { Service } from '@n8n/di';
|
||||
|
||||
import {
|
||||
WORKFLOW_MCP_TRIGGER_SCOPES,
|
||||
resourceUrlToWebhookPath,
|
||||
trimSlashes,
|
||||
trimTrailingSlash,
|
||||
} from './utils';
|
||||
|
||||
@Service()
|
||||
export class WorkflowMcpTriggerResourceResolver implements ProtectedResourceResolver {
|
||||
constructor(
|
||||
private readonly config: GlobalConfig,
|
||||
private readonly webhookService: WebhookService,
|
||||
private readonly workflowRepository: WorkflowRepository,
|
||||
private readonly urlService: UrlService,
|
||||
private readonly logger: Logger,
|
||||
) {}
|
||||
|
||||
readonly id = 'workflow-mcp-trigger';
|
||||
readonly scopes = WORKFLOW_MCP_TRIGGER_SCOPES;
|
||||
|
||||
async resolveByUrl(resourceUrl: string) {
|
||||
const pathname = resourceUrlToWebhookPath(resourceUrl, this.urlService.getWebhookBaseUrl());
|
||||
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) {
|
||||
if (!pathname.startsWith(`/${this.config.endpoints.mcp}/`)) {
|
||||
// we can quickly rule out non-MCP paths without doing any URL parsing, so check that first
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const path = trimSlashes(pathname.slice(this.config.endpoints.mcp.length + 1));
|
||||
|
||||
this.logger.debug(`Resolving workflow MCP trigger resource for path: ${path}`);
|
||||
|
||||
// Static-only lookup: this resolver never owns dynamic webhooks, so we avoid
|
||||
// the extra dynamic-webhook DB probe that `findWebhook` does on a static miss
|
||||
// — this path is reachable unauthenticated via the well-known route.
|
||||
const webhook = await this.webhookService.findStaticWebhook('POST', path);
|
||||
|
||||
if (!webhook) {
|
||||
this.logger.debug(`No webhook found for path: ${path}`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (webhook.isDynamic) {
|
||||
// A request path that literally matches a dynamic webhook's template
|
||||
// (e.g. `:param`) can still be returned by the static lookup; reject it.
|
||||
this.logger.debug(
|
||||
`Webhook for path ${path} is dynamic, skipping protected resource resolution`,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const { workflowId, node: nodeName } = webhook;
|
||||
|
||||
const workflow = await this.workflowRepository.findOne({
|
||||
where: { id: workflowId },
|
||||
relations: {
|
||||
activeVersion: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!workflow?.activeVersion) {
|
||||
this.logger.debug(`No active version found for workflow with ID: ${workflowId}`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const node = workflow.activeVersion.nodes.find((n) => n.name === nodeName);
|
||||
|
||||
if (!node) {
|
||||
this.logger.debug(
|
||||
`No node found with name ${nodeName} in active version of workflow with ID: ${workflowId}`,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (
|
||||
node.type === MCP_TRIGGER_NODE_TYPE &&
|
||||
!node.disabled &&
|
||||
node.parameters.authentication === 'n8nOAuth2'
|
||||
) {
|
||||
const resourceUrl = `${trimTrailingSlash(this.urlService.getWebhookBaseUrl())}/${this.config.endpoints.mcp}/${path}`;
|
||||
return {
|
||||
id: 'workflow-mcp:' + workflow.id,
|
||||
getResourceUrl: () => resourceUrl,
|
||||
getAudiences: () => [resourceUrl],
|
||||
scopes: WORKFLOW_MCP_TRIGGER_SCOPES,
|
||||
displayName: workflow.name,
|
||||
};
|
||||
}
|
||||
|
||||
this.logger.debug(
|
||||
`Node with name ${nodeName} in active version of workflow with ID: ${workflowId} is not an enabled MCP trigger with n8nOAuth2 authentication`,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { ProtectedResource } from '../protected-resource.registry';
|
||||
import type { Logger } from '@n8n/backend-common';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
|
||||
import type { ProtectedResource, ProtectedResourceResolver } from '../protected-resource.registry';
|
||||
import { ProtectedResourceRegistry } from '../protected-resource.registry';
|
||||
|
||||
const resourceA: ProtectedResource = {
|
||||
@@ -20,7 +23,7 @@ describe('ProtectedResourceRegistry', () => {
|
||||
let registry: ProtectedResourceRegistry;
|
||||
|
||||
beforeEach(() => {
|
||||
registry = new ProtectedResourceRegistry();
|
||||
registry = new ProtectedResourceRegistry(mock<Logger>());
|
||||
registry.register(resourceA);
|
||||
registry.register(resourceB);
|
||||
});
|
||||
@@ -58,7 +61,7 @@ describe('ProtectedResourceRegistry', () => {
|
||||
});
|
||||
|
||||
it('should return undefined as default when no resource is marked default', () => {
|
||||
const emptyDefault = new ProtectedResourceRegistry();
|
||||
const emptyDefault = new ProtectedResourceRegistry(mock<Logger>());
|
||||
emptyDefault.register(resourceB);
|
||||
expect(emptyDefault.getDefaultResource()).toBeUndefined();
|
||||
});
|
||||
@@ -96,4 +99,23 @@ describe('ProtectedResourceRegistry', () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolver failures', () => {
|
||||
it('should treat a throwing resolver as a non-match and log a warning', async () => {
|
||||
const logger = mock<Logger>();
|
||||
const failingRegistry = new ProtectedResourceRegistry(logger);
|
||||
const resolver = mock<ProtectedResourceResolver>({ id: 'boom', scopes: [] });
|
||||
resolver.resolveByUrl.mockRejectedValue(new Error('backing store unavailable'));
|
||||
resolver.resolveByPath.mockRejectedValue(new Error('backing store unavailable'));
|
||||
failingRegistry.registerResolver(resolver);
|
||||
|
||||
expect(await failingRegistry.getByResourceUrl('https://n8n.example.com/x')).toBeUndefined();
|
||||
expect(await failingRegistry.getByResourcePath('/x')).toBeUndefined();
|
||||
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
'Protected resource resolver "boom" failed to resolve',
|
||||
{ error: 'backing store unavailable' },
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Logger } from '@n8n/backend-common';
|
||||
import { Service } from '@n8n/di';
|
||||
import { ensureError } from 'n8n-workflow';
|
||||
|
||||
/**
|
||||
* Descriptor for an OAuth 2.1 protected resource served by this instance.
|
||||
@@ -12,6 +14,9 @@ export interface ProtectedResource {
|
||||
/** Stable identifier, e.g. `'instance-mcp'`. */
|
||||
id: string;
|
||||
|
||||
/** Human readable name, for consent screen */
|
||||
displayName?: string;
|
||||
|
||||
/**
|
||||
* Canonical RFC 8707 resource URL used as the JWT `aud` claim and advertised
|
||||
* as the resource indicator (e.g. `https://instance.example/mcp-server/http`).
|
||||
@@ -42,7 +47,8 @@ export interface ProtectedResource {
|
||||
* static map. Registered via {@link ProtectedResourceRegistry.registerResolver},
|
||||
* resolvers are consulted only after the static map misses — letting a resource
|
||||
* be resolved lazily (e.g. from the database) instead of being materialized up
|
||||
* front.
|
||||
* front. A resolver that throws is treated as a non-match (the registry logs and
|
||||
* continues), so a backing-store outage fails closed rather than surfacing a 500.
|
||||
*/
|
||||
export interface ProtectedResourceResolver {
|
||||
/** Stable identifier for the resolver, e.g. `'workflow-trigger'`. */
|
||||
@@ -68,13 +74,6 @@ export interface ProtectedResourceResolver {
|
||||
* pre-normalized (trailing slash trimmed) by the registry.
|
||||
*/
|
||||
resolveByPath(pathname: string): Promise<ProtectedResource | undefined>;
|
||||
|
||||
/**
|
||||
* Whether this resolver currently owns at least one enabled resource. Drives
|
||||
* the shared OAuth server's availability guard via
|
||||
* {@link ProtectedResourceRegistry.isAnyResourceEnabled}.
|
||||
*/
|
||||
hasAnyEnabledResource(): Promise<boolean>;
|
||||
}
|
||||
|
||||
const trimTrailingSlash = (url: string): string => url.replace(/\/$/, '');
|
||||
@@ -90,6 +89,8 @@ export class ProtectedResourceRegistry {
|
||||
private readonly resources = new Map<string, ProtectedResource>();
|
||||
private readonly resolvers = new Set<ProtectedResourceResolver>();
|
||||
|
||||
constructor(private readonly logger: Logger) {}
|
||||
|
||||
register(resource: ProtectedResource): void {
|
||||
this.resources.set(resource.id, resource);
|
||||
}
|
||||
@@ -109,8 +110,12 @@ export class ProtectedResourceRegistry {
|
||||
if (trimTrailingSlash(resource.getResourceUrl()) === normalized) return resource;
|
||||
}
|
||||
for (const resolver of this.resolvers) {
|
||||
const resource = await resolver.resolveByUrl(normalized);
|
||||
if (resource) return resource;
|
||||
try {
|
||||
const resource = await resolver.resolveByUrl(normalized);
|
||||
if (resource) return resource;
|
||||
} catch (error) {
|
||||
this.logResolverFailure(resolver, error);
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -129,12 +134,29 @@ export class ProtectedResourceRegistry {
|
||||
}
|
||||
|
||||
for (const resolver of this.resolvers) {
|
||||
const resource = await resolver.resolveByPath(normalized);
|
||||
if (resource) return resource;
|
||||
try {
|
||||
const resource = await resolver.resolveByPath(normalized);
|
||||
if (resource) return resource;
|
||||
} catch (error) {
|
||||
this.logResolverFailure(resolver, error);
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* A resolver that throws (e.g. its backing database or cache is unavailable) is
|
||||
* treated as a non-match rather than propagating the error. Resolution is a
|
||||
* lookup, so a failure is indistinguishable to the caller from "no such
|
||||
* resource" — failing closed yields a 404 / `invalid_target` on the
|
||||
* (unauthenticated) discovery and authorize paths instead of a 500.
|
||||
*/
|
||||
private logResolverFailure(resolver: ProtectedResourceResolver, error: unknown): void {
|
||||
this.logger.warn(`Protected resource resolver "${resolver.id}" failed to resolve`, {
|
||||
error: ensureError(error).message,
|
||||
});
|
||||
}
|
||||
|
||||
getAll(): ProtectedResource[] {
|
||||
return [...this.resources.values()];
|
||||
}
|
||||
|
||||
@@ -50,6 +50,18 @@ export class WebhookService {
|
||||
}
|
||||
|
||||
private async findCached(method: Method, path: string) {
|
||||
const staticWebhook = await this.findCachedStaticWebhook(method, path);
|
||||
|
||||
if (staticWebhook) return staticWebhook;
|
||||
|
||||
return await this.findDynamicWebhook(path, method);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cached lookup for a webhook with zero dynamic path segments. Returns the
|
||||
* cached entity if present, otherwise queries the database and caches a hit.
|
||||
*/
|
||||
private async findCachedStaticWebhook(method: Method, path: string) {
|
||||
const cacheKey = `webhook:${method}-${path}`;
|
||||
|
||||
let cachedStaticWebhook;
|
||||
@@ -64,7 +76,7 @@ export class WebhookService {
|
||||
|
||||
if (cachedStaticWebhook) return this.webhookRepository.create(cachedStaticWebhook);
|
||||
|
||||
const dbStaticWebhook = await this.findStaticWebhook(method, path);
|
||||
const dbStaticWebhook = await this.findStaticWebhookInDb(method, path);
|
||||
|
||||
if (dbStaticWebhook) {
|
||||
void this.cacheService.set(cacheKey, dbStaticWebhook).catch((error) => {
|
||||
@@ -72,19 +84,27 @@ export class WebhookService {
|
||||
error: ensureError(error).message,
|
||||
});
|
||||
});
|
||||
return dbStaticWebhook;
|
||||
}
|
||||
|
||||
return await this.findDynamicWebhook(path, method);
|
||||
return dbStaticWebhook;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a matching webhook with zero dynamic path segments, e.g. `<uuid>` or `user/profile`.
|
||||
*/
|
||||
private async findStaticWebhook(method: Method, path: string) {
|
||||
private async findStaticWebhookInDb(method: Method, path: string) {
|
||||
return await this.webhookRepository.findOneBy({ webhookPath: path, method });
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a static webhook (no dynamic path segments) by method and path, using the
|
||||
* cache. Unlike {@link findWebhook}, this never falls back to a dynamic-webhook DB
|
||||
* probe, so it is cheaper for callers that only handle static paths.
|
||||
*/
|
||||
async findStaticWebhook(method: Method, path: string) {
|
||||
return await this.findCachedStaticWebhook(method, path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a matching webhook with one or more dynamic path segments, e.g. `<uuid>/user/:id/posts`.
|
||||
* It is mandatory for dynamic webhooks to have `<uuid>/` at the base.
|
||||
|
||||
Reference in New Issue
Block a user