mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
feat(core): Track disabled MCP authorization attempts
This commit is contained in:
parent
0e8fbb0d4e
commit
4bd75d6217
|
|
@ -30,6 +30,18 @@ export const MCP_TELEMETRY = defineTelemetryEvents({
|
|||
state: z.boolean().describe('Resulting state of MCP access, not the prior one'),
|
||||
}),
|
||||
},
|
||||
MCP_CLIENT_ATTEMPTED_AUTHORIZATION_WHILE_ACCESS_DISABLED: {
|
||||
name: 'MCP client attempted authorization while access disabled',
|
||||
description:
|
||||
'An MCP client started OAuth authorization while instance-level MCP access was disabled.',
|
||||
properties: z.object({
|
||||
client_brand: clientBrand,
|
||||
client_type: clientType,
|
||||
resource_provided: z
|
||||
.boolean()
|
||||
.describe('Whether the authorization request included an RFC 8707 resource indicator'),
|
||||
}),
|
||||
},
|
||||
USER_CLICKED_CONNECT_CLIENT: {
|
||||
name: 'User clicked connect MCP client',
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -248,6 +248,25 @@ describe('TelemetryEventRelay', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('MCP events', () => {
|
||||
it('should track a disabled MCP OAuth authorization attempt', () => {
|
||||
eventService.emit('mcp-oauth-authorization-rejected', {
|
||||
clientBrand: 'claude',
|
||||
clientType: 'cli',
|
||||
resourceProvided: true,
|
||||
});
|
||||
|
||||
expect(telemetry.track).toHaveBeenCalledWith(
|
||||
TELEMETRY_EVENT.MCP.MCP_CLIENT_ATTEMPTED_AUTHORIZATION_WHILE_ACCESS_DISABLED,
|
||||
{
|
||||
client_brand: 'claude',
|
||||
client_type: 'cli',
|
||||
resource_provided: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('project events', () => {
|
||||
it('should track on `team-project-updated` event', () => {
|
||||
const event: RelayEventMap['team-project-updated'] = {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,10 @@
|
|||
import type { AuthenticationMethod, ProjectRelation, RedactionFloor } from '@n8n/api-types';
|
||||
import type {
|
||||
AuthenticationMethod,
|
||||
McpClientBrandName,
|
||||
McpClientType,
|
||||
ProjectRelation,
|
||||
RedactionFloor,
|
||||
} from '@n8n/api-types';
|
||||
import type { AuthProviderType, User, IWorkflowDb } from '@n8n/db';
|
||||
import type {
|
||||
CancellationReason,
|
||||
|
|
@ -1237,6 +1243,12 @@ export type RelayEventMap = {
|
|||
clientName?: string;
|
||||
};
|
||||
|
||||
'mcp-oauth-authorization-rejected': {
|
||||
clientBrand: McpClientBrandName | null;
|
||||
clientType: McpClientType | null;
|
||||
resourceProvided: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* `authType` reports how the call authenticated, so an absent `clientId` is
|
||||
* explicit rather than inferred. `api_key` covers every non-OAuth bearer
|
||||
|
|
|
|||
|
|
@ -221,6 +221,7 @@ export class TelemetryEventRelay extends EventRelay {
|
|||
this.instanceAiMcpRegistryConnectionCreated(event),
|
||||
'instance-ai-mcp-registry-connection-deleted': (event) =>
|
||||
this.instanceAiMcpRegistryConnectionDeleted(event),
|
||||
'mcp-oauth-authorization-rejected': (event) => this.mcpOauthAuthorizationRejected(event),
|
||||
'hitl-response-actioned': (event) => this.hitlResponseActioned(event),
|
||||
'runner-disconnected': (event) => this.runnerDisconnected(event),
|
||||
});
|
||||
|
|
@ -266,6 +267,25 @@ export class TelemetryEventRelay extends EventRelay {
|
|||
|
||||
// #endregion
|
||||
|
||||
// #region MCP
|
||||
|
||||
private mcpOauthAuthorizationRejected({
|
||||
clientBrand,
|
||||
clientType,
|
||||
resourceProvided,
|
||||
}: RelayEventMap['mcp-oauth-authorization-rejected']) {
|
||||
this.telemetry.track(
|
||||
TELEMETRY_EVENT.MCP.MCP_CLIENT_ATTEMPTED_AUTHORIZATION_WHILE_ACCESS_DISABLED,
|
||||
{
|
||||
client_brand: clientBrand,
|
||||
client_type: clientType,
|
||||
resource_provided: resourceProvided,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// #endregion
|
||||
|
||||
// #endregion
|
||||
|
||||
// #region Team
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import { OAuthTokenService } from '../oauth-token.service';
|
|||
|
||||
const SUPPORTED_SCOPES = ['tool:listWorkflows', 'tool:getWorkflowDetails'];
|
||||
const TEST_RESOURCE_URL = 'https://n8n.example.com/mcp-server/http';
|
||||
const NON_MCP_RESOURCE_URL = 'https://n8n.example.com/form/test';
|
||||
|
||||
let logger: Mocked<Logger>;
|
||||
let oauthSessionService: Mocked<OAuthSessionService>;
|
||||
|
|
@ -77,6 +78,14 @@ describe('OAuthServerService', () => {
|
|||
isAvailable: isResourceAvailable,
|
||||
authorize: async () => true,
|
||||
});
|
||||
resourceRegistry.register({
|
||||
id: 'form-test',
|
||||
getResourceUrl: () => NON_MCP_RESOURCE_URL,
|
||||
getAudiences: () => [NON_MCP_RESOURCE_URL],
|
||||
scopes: [],
|
||||
isAvailable: async () => false,
|
||||
authorize: async () => true,
|
||||
});
|
||||
|
||||
service = new OAuthServerService(
|
||||
logger,
|
||||
|
|
@ -580,12 +589,16 @@ describe('OAuthServerService', () => {
|
|||
resource: 'https://n8n.example.com/mcp-server/http',
|
||||
});
|
||||
expect(res.redirect).toHaveBeenCalledWith('/oauth/consent');
|
||||
expect(eventService.emit).not.toHaveBeenCalledWith(
|
||||
'mcp-oauth-authorization-rejected',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject with invalid_target when the named resource is unavailable', async () => {
|
||||
const client = {
|
||||
client_id: 'client-123',
|
||||
client_name: 'Test Client',
|
||||
client_name: 'Claude Code',
|
||||
redirect_uris: ['https://example.com/callback'],
|
||||
grant_types: ['authorization_code'],
|
||||
token_endpoint_auth_method: 'none',
|
||||
|
|
@ -616,6 +629,11 @@ describe('OAuthServerService', () => {
|
|||
});
|
||||
expect(oauthSessionService.createSession).not.toHaveBeenCalled();
|
||||
expect(res.redirect).not.toHaveBeenCalled();
|
||||
expect(eventService.emit).toHaveBeenCalledWith('mcp-oauth-authorization-rejected', {
|
||||
clientBrand: 'claude',
|
||||
clientType: 'cli',
|
||||
resourceProvided: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject with invalid_target when no resource is named and the default resource is unavailable', async () => {
|
||||
|
|
@ -650,6 +668,41 @@ describe('OAuthServerService', () => {
|
|||
error_description: 'Resource is not available for authorization',
|
||||
});
|
||||
expect(oauthSessionService.createSession).not.toHaveBeenCalled();
|
||||
expect(eventService.emit).toHaveBeenCalledWith('mcp-oauth-authorization-rejected', {
|
||||
clientBrand: null,
|
||||
clientType: null,
|
||||
resourceProvided: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should not emit MCP telemetry when a non-MCP resource is unavailable', async () => {
|
||||
const client = {
|
||||
client_id: 'client-123',
|
||||
client_name: 'Test Client',
|
||||
redirect_uris: ['https://example.com/callback'],
|
||||
grant_types: ['authorization_code'],
|
||||
token_endpoint_auth_method: 'none',
|
||||
response_types: ['code'],
|
||||
};
|
||||
const res = mock<Response>();
|
||||
res.status.mockReturnThis();
|
||||
res.json.mockReturnThis();
|
||||
|
||||
await service.authorize(
|
||||
client,
|
||||
{
|
||||
redirectUri: 'https://example.com/callback',
|
||||
codeChallenge: 'challenge-123',
|
||||
resource: new URL(NON_MCP_RESOURCE_URL),
|
||||
},
|
||||
res,
|
||||
);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(eventService.emit).not.toHaveBeenCalledWith(
|
||||
'mcp-oauth-authorization-rejected',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
describe('reusing a prior consent (auto-approval)', () => {
|
||||
|
|
|
|||
|
|
@ -14,7 +14,11 @@ import type {
|
|||
OAuthTokenRevocationRequest,
|
||||
} from '@modelcontextprotocol/sdk/shared/auth.js';
|
||||
import type { McpClientConnectedPeriod, McpClientTypeFilter } from '@n8n/api-types';
|
||||
import { getMcpClientType, MCP_CLIENT_TYPE_FILTER_BUCKETS } from '@n8n/api-types';
|
||||
import {
|
||||
getMcpClientBrand,
|
||||
getMcpClientType,
|
||||
MCP_CLIENT_TYPE_FILTER_BUCKETS,
|
||||
} from '@n8n/api-types';
|
||||
import { Logger } from '@n8n/backend-common';
|
||||
import { GlobalConfig } from '@n8n/config';
|
||||
import { INSTANCE_MCP_RESOURCE_ID } from '@n8n/constants';
|
||||
|
|
@ -420,6 +424,14 @@ export class OAuthServerService implements OAuthServerProvider {
|
|||
clientId: client.client_id,
|
||||
resource: targetResource.getResourceUrl(),
|
||||
});
|
||||
if (targetResource.id === INSTANCE_MCP_RESOURCE_ID) {
|
||||
const clientName = client.client_name ?? '';
|
||||
this.eventService.emit('mcp-oauth-authorization-rejected', {
|
||||
clientBrand: getMcpClientBrand(clientName),
|
||||
clientType: getMcpClientType(clientName),
|
||||
resourceProvided: params.resource !== undefined,
|
||||
});
|
||||
}
|
||||
res.status(400).json({
|
||||
error: 'invalid_target',
|
||||
error_description: 'Resource is not available for authorization',
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user