feat(core): Resolve MCP trigger workflows as OAuth protected resources (no-changelog) (#32235)

This commit is contained in:
Andreas Fitzek
2026-06-15 11:48:25 +02:00
committed by GitHub
parent 5a28683c78
commit 9b199a651d
12 changed files with 1034 additions and 23 deletions
@@ -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,
@@ -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();
});
});
@@ -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() {
@@ -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);
}
@@ -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;
}
}
@@ -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()];
}
+24 -4
View File
@@ -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.