mirror of
https://github.com/n8n-io/n8n.git
synced 2026-06-01 01:07:04 +02:00
feat(core): Add more details to MCP errors and telemetry (no-changelog) (#22118)
This commit is contained in:
parent
6bec15a114
commit
648f1749e9
|
|
@ -213,7 +213,7 @@ describe('McpOAuthTokenService', () => {
|
|||
const invalidToken = 'invalid.jwt.token';
|
||||
|
||||
await expect(service.verifyAccessToken(invalidToken)).rejects.toThrow(
|
||||
'Invalid access token: JWT verification failed',
|
||||
'JWT Verification Failed',
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -225,7 +225,7 @@ describe('McpOAuthTokenService', () => {
|
|||
});
|
||||
|
||||
await expect(service.verifyAccessToken(wrongAudienceToken)).rejects.toThrow(
|
||||
'Invalid access token: JWT verification failed',
|
||||
'JWT Verification Failed',
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -237,7 +237,7 @@ describe('McpOAuthTokenService', () => {
|
|||
accessTokenRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.verifyAccessToken(accessToken)).rejects.toThrow(
|
||||
'Invalid access token: not found in database',
|
||||
'Access Token Not Found in Database',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -261,7 +261,7 @@ describe('McpOAuthTokenService', () => {
|
|||
|
||||
const result = await service.verifyOAuthAccessToken(accessToken);
|
||||
|
||||
expect(result).toEqual(user);
|
||||
expect(result).toEqual({ user });
|
||||
expect(userRepository.findOne).toHaveBeenCalledWith({
|
||||
where: { id: userId },
|
||||
relations: ['role'],
|
||||
|
|
@ -273,7 +273,7 @@ describe('McpOAuthTokenService', () => {
|
|||
|
||||
const result = await service.verifyOAuthAccessToken(invalidToken);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(result).toMatchObject({ user: null });
|
||||
});
|
||||
|
||||
it('should return null when user not found', async () => {
|
||||
|
|
@ -292,7 +292,7 @@ describe('McpOAuthTokenService', () => {
|
|||
|
||||
const result = await service.verifyOAuthAccessToken(accessToken);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(result).toMatchObject({ user: null });
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -58,11 +58,11 @@ describe('McpServerMiddlewareService', () => {
|
|||
meta: { isOAuth: true },
|
||||
});
|
||||
|
||||
oauthTokenService.verifyOAuthAccessToken.mockResolvedValue(user);
|
||||
oauthTokenService.verifyOAuthAccessToken.mockResolvedValue({ user });
|
||||
|
||||
const result = await service.getUserForToken(oauthToken);
|
||||
|
||||
expect(result).toEqual(user);
|
||||
expect(result).toEqual({ user });
|
||||
expect(oauthTokenService.verifyOAuthAccessToken).toHaveBeenCalledWith(oauthToken);
|
||||
expect(mcpServerApiKeyService.verifyApiKey).not.toHaveBeenCalled();
|
||||
});
|
||||
|
|
@ -74,11 +74,11 @@ describe('McpServerMiddlewareService', () => {
|
|||
aud: 'mcp-server-api',
|
||||
});
|
||||
|
||||
mcpServerApiKeyService.verifyApiKey.mockResolvedValue(user);
|
||||
mcpServerApiKeyService.verifyApiKey.mockResolvedValue({ user });
|
||||
|
||||
const result = await service.getUserForToken(apiKeyToken);
|
||||
|
||||
expect(result).toEqual(user);
|
||||
expect(result).toEqual({ user });
|
||||
expect(mcpServerApiKeyService.verifyApiKey).toHaveBeenCalledWith(apiKeyToken);
|
||||
expect(oauthTokenService.verifyOAuthAccessToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
|
@ -91,11 +91,11 @@ describe('McpServerMiddlewareService', () => {
|
|||
meta: { isOAuth: false },
|
||||
});
|
||||
|
||||
mcpServerApiKeyService.verifyApiKey.mockResolvedValue(user);
|
||||
mcpServerApiKeyService.verifyApiKey.mockResolvedValue({ user });
|
||||
|
||||
const result = await service.getUserForToken(apiKeyToken);
|
||||
|
||||
expect(result).toEqual(user);
|
||||
expect(result).toEqual({ user });
|
||||
expect(mcpServerApiKeyService.verifyApiKey).toHaveBeenCalledWith(apiKeyToken);
|
||||
expect(oauthTokenService.verifyOAuthAccessToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
|
@ -103,10 +103,10 @@ describe('McpServerMiddlewareService', () => {
|
|||
it('should return null for invalid JWT format', async () => {
|
||||
const invalidToken = 'not-a-jwt-token';
|
||||
|
||||
mcpServerApiKeyService.verifyApiKey.mockResolvedValue(null);
|
||||
mcpServerApiKeyService.verifyApiKey.mockResolvedValue({ user: null });
|
||||
const result = await service.getUserForToken(invalidToken);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(result).toMatchObject({ user: null });
|
||||
expect(oauthTokenService.verifyOAuthAccessToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
|
@ -117,11 +117,11 @@ describe('McpServerMiddlewareService', () => {
|
|||
meta: { isOAuth: true },
|
||||
});
|
||||
|
||||
oauthTokenService.verifyOAuthAccessToken.mockResolvedValue(null);
|
||||
oauthTokenService.verifyOAuthAccessToken.mockResolvedValue({ user: null });
|
||||
|
||||
const result = await service.getUserForToken(oauthToken);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(result).toMatchObject({ user: null });
|
||||
});
|
||||
|
||||
it('should return null when API key verification fails', async () => {
|
||||
|
|
@ -130,11 +130,11 @@ describe('McpServerMiddlewareService', () => {
|
|||
aud: 'mcp-server-api',
|
||||
});
|
||||
|
||||
mcpServerApiKeyService.verifyApiKey.mockResolvedValue(null);
|
||||
mcpServerApiKeyService.verifyApiKey.mockResolvedValue({ user: null });
|
||||
|
||||
const result = await service.getUserForToken(apiKeyToken);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(result).toMatchObject({ user: null });
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -153,17 +153,22 @@ describe('McpServerMiddlewareService', () => {
|
|||
|
||||
expect(res.header).toHaveBeenCalledWith('WWW-Authenticate', 'Bearer realm="n8n MCP Server"');
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.send).toHaveBeenCalledWith({ message: 'Unauthorized' });
|
||||
expect(res.send).toHaveBeenCalledWith({
|
||||
message: 'Unauthorized: Authorization header not sent',
|
||||
});
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(telemetry.track).toHaveBeenCalledWith('User connected to MCP server', {
|
||||
mcp_connection_status: 'error',
|
||||
error: 'Unauthorized',
|
||||
client_name: undefined,
|
||||
client_version: undefined,
|
||||
auth_type: 'unknown',
|
||||
error_details: 'Authorization header not sent',
|
||||
reason: 'missing_authorization_header',
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw error when authorization header does not start with Bearer', async () => {
|
||||
it('should return 401 with WWW-Authenticate header when authorization header does not start with Bearer', async () => {
|
||||
const req = mockReqWith('Basic sometoken');
|
||||
const res = mockDeep<Response>();
|
||||
res.status.mockReturnThis();
|
||||
|
|
@ -171,13 +176,26 @@ describe('McpServerMiddlewareService', () => {
|
|||
|
||||
const middleware = service.getAuthMiddleware();
|
||||
|
||||
await expect(middleware(req, res, next)).rejects.toThrow(
|
||||
'Invalid authorization header format',
|
||||
);
|
||||
await middleware(req, res, next);
|
||||
|
||||
expect(res.header).toHaveBeenCalledWith('WWW-Authenticate', 'Bearer realm="n8n MCP Server"');
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.send).toHaveBeenCalledWith({
|
||||
message: 'Unauthorized: Invalid authorization header format - Missing Bearer prefix',
|
||||
});
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(telemetry.track).toHaveBeenCalledWith('User connected to MCP server', {
|
||||
mcp_connection_status: 'error',
|
||||
error: 'Unauthorized',
|
||||
client_name: undefined,
|
||||
client_version: undefined,
|
||||
auth_type: 'unknown',
|
||||
error_details: 'Invalid authorization header format - Missing Bearer prefix',
|
||||
reason: 'invalid_bearer_format',
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw error when Bearer token is malformed', async () => {
|
||||
it('should return 401 with WWW-Authenticate header when Bearer token is malformed', async () => {
|
||||
const req = mockReqWith('Bearer');
|
||||
const res = mockDeep<Response>();
|
||||
res.status.mockReturnThis();
|
||||
|
|
@ -185,10 +203,23 @@ describe('McpServerMiddlewareService', () => {
|
|||
|
||||
const middleware = service.getAuthMiddleware();
|
||||
|
||||
await expect(middleware(req, res, next)).rejects.toThrow(
|
||||
'Invalid authorization header format',
|
||||
);
|
||||
await middleware(req, res, next);
|
||||
|
||||
expect(res.header).toHaveBeenCalledWith('WWW-Authenticate', 'Bearer realm="n8n MCP Server"');
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.send).toHaveBeenCalledWith({
|
||||
message: 'Unauthorized: Invalid authorization header format - Malformed Bearer token',
|
||||
});
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(telemetry.track).toHaveBeenCalledWith('User connected to MCP server', {
|
||||
mcp_connection_status: 'error',
|
||||
error: 'Unauthorized',
|
||||
client_name: undefined,
|
||||
client_version: undefined,
|
||||
auth_type: 'unknown',
|
||||
error_details: 'Invalid authorization header format - Malformed Bearer token',
|
||||
reason: 'invalid_bearer_format',
|
||||
});
|
||||
});
|
||||
|
||||
it('should authenticate with valid OAuth token and call next', async () => {
|
||||
|
|
@ -203,7 +234,7 @@ describe('McpServerMiddlewareService', () => {
|
|||
const res = mockDeep<Response>();
|
||||
const next = jest.fn() as NextFunction;
|
||||
|
||||
oauthTokenService.verifyOAuthAccessToken.mockResolvedValue(user);
|
||||
oauthTokenService.verifyOAuthAccessToken.mockResolvedValue({ user });
|
||||
|
||||
const middleware = service.getAuthMiddleware();
|
||||
|
||||
|
|
@ -225,7 +256,7 @@ describe('McpServerMiddlewareService', () => {
|
|||
const res = mockDeep<Response>();
|
||||
const next = jest.fn() as NextFunction;
|
||||
|
||||
mcpServerApiKeyService.verifyApiKey.mockResolvedValue(user);
|
||||
mcpServerApiKeyService.verifyApiKey.mockResolvedValue({ user });
|
||||
|
||||
const middleware = service.getAuthMiddleware();
|
||||
|
||||
|
|
@ -250,7 +281,7 @@ describe('McpServerMiddlewareService', () => {
|
|||
res.header.mockReturnThis();
|
||||
const next = jest.fn() as NextFunction;
|
||||
|
||||
oauthTokenService.verifyOAuthAccessToken.mockResolvedValue(null);
|
||||
oauthTokenService.verifyOAuthAccessToken.mockResolvedValue({ user: null });
|
||||
|
||||
const middleware = service.getAuthMiddleware();
|
||||
|
||||
|
|
@ -282,11 +313,18 @@ describe('McpServerMiddlewareService', () => {
|
|||
await middleware(req, res, next);
|
||||
|
||||
expect(res.header).toHaveBeenCalledWith('WWW-Authenticate', 'Bearer realm="n8n MCP Server"');
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.send).toHaveBeenCalledWith({
|
||||
message: 'Unauthorized: Authorization header not sent',
|
||||
});
|
||||
expect(telemetry.track).toHaveBeenCalledWith('User connected to MCP server', {
|
||||
mcp_connection_status: 'error',
|
||||
error: 'Unauthorized',
|
||||
client_name: 'test-client',
|
||||
client_version: '1.0.0',
|
||||
auth_type: 'unknown',
|
||||
error_details: 'Authorization header not sent',
|
||||
reason: 'missing_authorization_header',
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -298,7 +336,7 @@ describe('McpServerMiddlewareService', () => {
|
|||
res.header.mockReturnThis();
|
||||
const next = jest.fn() as NextFunction;
|
||||
|
||||
mcpServerApiKeyService.verifyApiKey.mockResolvedValue(null);
|
||||
mcpServerApiKeyService.verifyApiKey.mockResolvedValue({ user: null });
|
||||
|
||||
const middleware = service.getAuthMiddleware();
|
||||
|
||||
|
|
|
|||
|
|
@ -2,11 +2,12 @@ import { ApiKey, ApiKeyRepository, User, UserRepository } from '@n8n/db';
|
|||
import { Service } from '@n8n/di';
|
||||
import { EntityManager } from '@n8n/typeorm';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { ApiKeyAudience } from 'n8n-workflow';
|
||||
|
||||
import { JwtService } from '@/services/jwt.service';
|
||||
import { ApiKeyAudience, ensureError } from 'n8n-workflow';
|
||||
|
||||
import { AccessTokenRepository } from './database/repositories/oauth-access-token.repository';
|
||||
import { UserWithContext } from './mcp.types';
|
||||
|
||||
import { JwtService } from '@/services/jwt.service';
|
||||
|
||||
const API_KEY_AUDIENCE: ApiKeyAudience = 'mcp-server-api';
|
||||
const API_KEY_ISSUER = 'n8n';
|
||||
|
|
@ -77,16 +78,34 @@ export class McpServerApiKeyService {
|
|||
});
|
||||
}
|
||||
|
||||
async verifyApiKey(apiKey: string): Promise<User | null> {
|
||||
async verifyApiKey(apiKey: string): Promise<UserWithContext> {
|
||||
try {
|
||||
this.jwtService.verify(apiKey, {
|
||||
issuer: API_KEY_ISSUER,
|
||||
audience: API_KEY_AUDIENCE,
|
||||
});
|
||||
|
||||
return await this.getUserForApiKey(apiKey);
|
||||
const user = await this.getUserForApiKey(apiKey);
|
||||
if (!user) {
|
||||
return {
|
||||
user: null,
|
||||
context: {
|
||||
reason: 'user_not_found',
|
||||
auth_type: 'api_key',
|
||||
},
|
||||
};
|
||||
}
|
||||
return { user };
|
||||
} catch (error) {
|
||||
return null;
|
||||
const errorForSure = ensureError(error);
|
||||
return {
|
||||
user: null,
|
||||
context: {
|
||||
reason: errorForSure.name === 'JsonWebTokenError' ? 'invalid_token' : 'unknown_error',
|
||||
auth_type: 'api_key',
|
||||
error_details: errorForSure.message,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,17 +2,20 @@ import { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types';
|
|||
import { OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth';
|
||||
import { Logger } from '@n8n/backend-common';
|
||||
import { Time } from '@n8n/constants';
|
||||
import { User, UserRepository, withTransaction } from '@n8n/db';
|
||||
import { UserRepository, withTransaction } from '@n8n/db';
|
||||
import { Service } from '@n8n/di';
|
||||
import { MoreThanOrEqual } from '@n8n/typeorm';
|
||||
import { ensureError } from 'n8n-workflow';
|
||||
import { randomBytes, randomUUID } from 'node:crypto';
|
||||
|
||||
import { JwtService } from '@/services/jwt.service';
|
||||
|
||||
import { AccessToken } from './database/entities/oauth-access-token.entity';
|
||||
import { RefreshToken } from './database/entities/oauth-refresh-token.entity';
|
||||
import { AccessTokenRepository } from './database/repositories/oauth-access-token.repository';
|
||||
import { RefreshTokenRepository } from './database/repositories/oauth-refresh-token.repository';
|
||||
import { AccessTokenNotFoundError, JWTVerificationError } from './mcp.errors';
|
||||
import { UserWithContext } from './mcp.types';
|
||||
|
||||
import { JwtService } from '@/services/jwt.service';
|
||||
|
||||
/**
|
||||
* Manages OAuth 2.1 token lifecycle for MCP server
|
||||
|
|
@ -142,7 +145,7 @@ export class McpOAuthTokenService {
|
|||
try {
|
||||
decoded = this.jwtService.verify(token, { audience: this.MCP_AUDIENCE });
|
||||
} catch (error) {
|
||||
throw new Error('Invalid access token: JWT verification failed');
|
||||
throw new JWTVerificationError();
|
||||
}
|
||||
|
||||
const accessTokenRecord = await this.accessTokenRepository.findOne({
|
||||
|
|
@ -150,7 +153,7 @@ export class McpOAuthTokenService {
|
|||
});
|
||||
|
||||
if (!accessTokenRecord) {
|
||||
throw new Error('Invalid access token: not found in database');
|
||||
throw new AccessTokenNotFoundError();
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
@ -163,13 +166,13 @@ export class McpOAuthTokenService {
|
|||
};
|
||||
}
|
||||
|
||||
async verifyOAuthAccessToken(token: string): Promise<User | null> {
|
||||
async verifyOAuthAccessToken(token: string): Promise<UserWithContext> {
|
||||
try {
|
||||
const authInfo = await this.verifyAccessToken(token);
|
||||
|
||||
const userId = authInfo.extra?.userId as string;
|
||||
if (!userId) {
|
||||
return null;
|
||||
return { user: null, context: { reason: 'user_id_not_in_auth_info', auth_type: 'oauth' } };
|
||||
}
|
||||
|
||||
const user = await this.userRepository.findOne({
|
||||
|
|
@ -177,9 +180,27 @@ export class McpOAuthTokenService {
|
|||
relations: ['role'],
|
||||
});
|
||||
|
||||
return user;
|
||||
if (!user) {
|
||||
return { user: null, context: { reason: 'user_not_found', auth_type: 'oauth' } };
|
||||
}
|
||||
|
||||
return { user };
|
||||
} catch (error) {
|
||||
return null;
|
||||
const errorForSure = ensureError(error);
|
||||
const reason =
|
||||
errorForSure instanceof JWTVerificationError
|
||||
? 'invalid_token'
|
||||
: errorForSure instanceof AccessTokenNotFoundError
|
||||
? 'token_not_found_in_db'
|
||||
: 'unknown_error';
|
||||
return {
|
||||
user: null,
|
||||
context: {
|
||||
reason,
|
||||
auth_type: 'oauth',
|
||||
error_details: errorForSure.message,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,16 +1,18 @@
|
|||
import { AuthenticatedRequest, User } from '@n8n/db';
|
||||
import { AuthenticatedRequest } from '@n8n/db';
|
||||
import { Service } from '@n8n/di';
|
||||
import { NextFunction, Response, Request } from 'express';
|
||||
|
||||
import { AuthError } from '@/errors/response-errors/auth.error';
|
||||
import { JwtService } from '@/services/jwt.service';
|
||||
import { Telemetry } from '@/telemetry';
|
||||
import { ensureError } from 'n8n-workflow';
|
||||
|
||||
import { McpServerApiKeyService } from './mcp-api-key.service';
|
||||
import { McpOAuthTokenService } from './mcp-oauth-token.service';
|
||||
import { USER_CONNECTED_TO_MCP_EVENT, UNAUTHORIZED_ERROR_MESSAGE } from './mcp.constants';
|
||||
import type { TelemetryAuthContext, UserWithContext } from './mcp.types';
|
||||
import { getClientInfo } from './mcp.utils';
|
||||
|
||||
import { AuthError } from '@/errors/response-errors/auth.error';
|
||||
import { JwtService } from '@/services/jwt.service';
|
||||
import { Telemetry } from '@/telemetry';
|
||||
|
||||
/**
|
||||
* MCP Server Middleware Service
|
||||
* Centralizes authentication for MCP server endpoints
|
||||
|
|
@ -29,12 +31,19 @@ export class McpServerMiddlewareService {
|
|||
* Get user for a given token (API key or OAuth access token)
|
||||
* Uses JWT metadata to determine token type and route to correct validation
|
||||
*/
|
||||
async getUserForToken(token: string): Promise<User | null> {
|
||||
async getUserForToken(token: string): Promise<UserWithContext> {
|
||||
let decoded: { meta?: { isOAuth?: boolean } };
|
||||
try {
|
||||
decoded = this.jwtService.decode<{ meta?: { isOAuth?: boolean } }>(token);
|
||||
} catch (error) {
|
||||
return null;
|
||||
return {
|
||||
user: null,
|
||||
context: {
|
||||
reason: 'jwt_decode_failed',
|
||||
auth_type: 'unknown',
|
||||
error_details: ensureError(error).message,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (decoded?.meta?.isOAuth === true) {
|
||||
|
|
@ -53,21 +62,32 @@ export class McpServerMiddlewareService {
|
|||
const authorizationHeader = req.header('authorization');
|
||||
|
||||
if (!authorizationHeader) {
|
||||
this.responseWithUnauthorized(res, req);
|
||||
this.responseWithUnauthorized(res, req, {
|
||||
reason: 'missing_authorization_header',
|
||||
auth_type: 'unknown',
|
||||
error_details: 'Authorization header not sent',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const token = this.extractBearerToken(authorizationHeader);
|
||||
|
||||
if (!token) {
|
||||
this.responseWithUnauthorized(res, req);
|
||||
let token: string;
|
||||
try {
|
||||
token = this.extractBearerToken(authorizationHeader);
|
||||
} catch (er) {
|
||||
const error = ensureError(er);
|
||||
this.responseWithUnauthorized(res, req, {
|
||||
reason: 'invalid_bearer_format',
|
||||
auth_type: 'unknown',
|
||||
error_details: error.message,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const user = await this.getUserForToken(token);
|
||||
const result = await this.getUserForToken(token);
|
||||
const user = result.user;
|
||||
|
||||
if (!user) {
|
||||
this.responseWithUnauthorized(res, req);
|
||||
this.responseWithUnauthorized(res, req, result.context);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -77,9 +97,9 @@ export class McpServerMiddlewareService {
|
|||
};
|
||||
}
|
||||
|
||||
private extractBearerToken(headerValue: string): string | null {
|
||||
private extractBearerToken(headerValue: string): string {
|
||||
if (!headerValue.startsWith('Bearer')) {
|
||||
throw new AuthError('Invalid authorization header format');
|
||||
throw new AuthError('Invalid authorization header format - Missing Bearer prefix');
|
||||
}
|
||||
|
||||
const tokenMatch = headerValue.match(/^Bearer\s+(.+)$/i);
|
||||
|
|
@ -87,23 +107,27 @@ export class McpServerMiddlewareService {
|
|||
return tokenMatch[1];
|
||||
}
|
||||
|
||||
throw new AuthError('Invalid authorization header format');
|
||||
throw new AuthError('Invalid authorization header format - Malformed Bearer token');
|
||||
}
|
||||
|
||||
private responseWithUnauthorized(res: Response, req: Request) {
|
||||
this.trackUnauthorizedEvent(req);
|
||||
private responseWithUnauthorized(res: Response, req: Request, context?: TelemetryAuthContext) {
|
||||
this.trackUnauthorizedEvent(req, context);
|
||||
// RFC 6750 Section 3: Include WWW-Authenticate header for 401 responses
|
||||
res.header('WWW-Authenticate', 'Bearer realm="n8n MCP Server"');
|
||||
res.status(401).send({ message: UNAUTHORIZED_ERROR_MESSAGE });
|
||||
res.status(401).send({
|
||||
message: `${UNAUTHORIZED_ERROR_MESSAGE}${context?.error_details ? ': ' + context.error_details : ''}`,
|
||||
});
|
||||
}
|
||||
|
||||
private trackUnauthorizedEvent(req: Request) {
|
||||
private trackUnauthorizedEvent(req: Request, context?: TelemetryAuthContext) {
|
||||
const clientInfo = getClientInfo(req);
|
||||
this.telemetry.track(USER_CONNECTED_TO_MCP_EVENT, {
|
||||
const payload = {
|
||||
mcp_connection_status: 'error',
|
||||
error: UNAUTHORIZED_ERROR_MESSAGE,
|
||||
client_name: clientInfo?.name,
|
||||
client_version: clientInfo?.version,
|
||||
});
|
||||
...context,
|
||||
};
|
||||
this.telemetry.track(USER_CONNECTED_TO_MCP_EVENT, payload);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { Time } from '@n8n/constants';
|
||||
import { UserError } from 'n8n-workflow';
|
||||
|
||||
import { AuthError } from '@/errors/response-errors/auth.error';
|
||||
|
||||
/**
|
||||
* Error thrown when MCP workflow execution times out
|
||||
*/
|
||||
|
|
@ -17,3 +19,17 @@ export class McpExecutionTimeoutError extends UserError {
|
|||
this.timeoutMs = timeoutMs;
|
||||
}
|
||||
}
|
||||
|
||||
export class JWTVerificationError extends AuthError {
|
||||
constructor() {
|
||||
super('JWT Verification Failed');
|
||||
this.name = 'JWTVerificationError';
|
||||
}
|
||||
}
|
||||
|
||||
export class AccessTokenNotFoundError extends AuthError {
|
||||
constructor() {
|
||||
super('Access Token Not Found in Database');
|
||||
this.name = 'AccessTokenNotFoundError';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { type ToolCallback } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import type { User } from '@n8n/db';
|
||||
import type { INode } from 'n8n-workflow';
|
||||
import type z from 'zod';
|
||||
|
||||
|
|
@ -92,3 +93,26 @@ type SupportedTriggerNodeTypes = keyof typeof SUPPORTED_MCP_TRIGGERS;
|
|||
export type MCPTriggersMap = {
|
||||
[K in SupportedTriggerNodeTypes]: INode[];
|
||||
};
|
||||
|
||||
export type AuthFailureReason =
|
||||
| 'missing_authorization_header'
|
||||
| 'invalid_bearer_format'
|
||||
| 'jwt_decode_failed'
|
||||
| 'invalid_token'
|
||||
| 'token_not_found_in_db'
|
||||
| 'user_not_found'
|
||||
| 'user_id_not_in_auth_info'
|
||||
| 'unknown_error';
|
||||
|
||||
export type Mcpauth_type = 'oauth' | 'api_key' | 'unknown';
|
||||
|
||||
export type TelemetryAuthContext = {
|
||||
reason: AuthFailureReason;
|
||||
auth_type: Mcpauth_type;
|
||||
error_details?: string;
|
||||
};
|
||||
|
||||
export type UserWithContext = {
|
||||
user: User | null;
|
||||
context?: TelemetryAuthContext;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import type { OAuthClientResponseDto } from '@n8n/api-types';
|
|||
import OAuthConnectionInstructions from '@/features/ai/mcpAccess/components/connectionInstructions/OAuthConnectionInstructions.vue';
|
||||
import AccessTokenConnectionInstructions from '@/features/ai/mcpAccess/components/connectionInstructions/AccessTokenConnectionInstructions.vue';
|
||||
import { MCP_DOCS_PAGE_URL } from '@/features/ai/mcpAccess/mcp.constants';
|
||||
import { useTelemetry } from '@/app/composables/useTelemetry';
|
||||
|
||||
const MCP_ENDPOINT = 'mcp-server/http';
|
||||
|
||||
|
|
@ -30,6 +31,7 @@ const emit = defineEmits<{
|
|||
}>();
|
||||
|
||||
const i18n = useI18n();
|
||||
const telemetry = useTelemetry();
|
||||
|
||||
const selectedTab = ref<ConnectionTabType>('oauth');
|
||||
|
||||
|
|
@ -47,6 +49,23 @@ const tabs = ref<Array<TabOptions<ConnectionTabType>>>([
|
|||
const onTabSelected = (tab: ConnectionTabType) => {
|
||||
selectedTab.value = tab;
|
||||
};
|
||||
|
||||
const onClientRevoked = (client: OAuthClientResponseDto) => {
|
||||
emit('revokeClient', client);
|
||||
telemetry.track('User revoked access for MCP OAuth client', {
|
||||
client_name: client.name,
|
||||
});
|
||||
};
|
||||
|
||||
const trackCopyEvent = (payload: {
|
||||
item: 'server-url' | 'access-token' | 'mcp-json';
|
||||
source: 'oauth-tab' | 'token-tab';
|
||||
}) => {
|
||||
telemetry.track('User copied MCP connection parameter', {
|
||||
parameter: payload.item,
|
||||
source: payload.source,
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -61,8 +80,9 @@ const onTabSelected = (tab: ConnectionTabType) => {
|
|||
:server-url="`${props.baseUrl}${MCP_ENDPOINT}`"
|
||||
:clients="props.oAuthClients"
|
||||
:clients-loading="props.loadingOAuthClients"
|
||||
@revoke-client="emit('revokeClient', $event)"
|
||||
@revoke-client="onClientRevoked"
|
||||
@refresh="emit('refreshClientList')"
|
||||
@url-copied="trackCopyEvent({ item: 'server-url', source: 'oauth-tab' })"
|
||||
/>
|
||||
<AccessTokenConnectionInstructions
|
||||
v-show="selectedTab === 'token'"
|
||||
|
|
@ -70,6 +90,9 @@ const onTabSelected = (tab: ConnectionTabType) => {
|
|||
:api-key="props.apiKey"
|
||||
:loading-api-key="props.loadingApiKey"
|
||||
@rotate-key="emit('rotateKey')"
|
||||
@connection-string-copied="trackCopyEvent({ item: 'mcp-json', source: 'token-tab' })"
|
||||
@access-token-copied="trackCopyEvent({ item: 'access-token', source: 'token-tab' })"
|
||||
@url-copied="trackCopyEvent({ item: 'server-url', source: 'token-tab' })"
|
||||
/>
|
||||
<N8nText size="small" data-test-id="mcp-connection-instructions-docs-text">
|
||||
{{ i18n.baseText('settings.mcp.instructions.docs.part1') }}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,9 @@ const props = defineProps<Props>();
|
|||
|
||||
const emit = defineEmits<{
|
||||
rotateKey: [];
|
||||
urlCopied: [url: string];
|
||||
accessTokenCopied: [];
|
||||
connectionStringCopied: [];
|
||||
}>();
|
||||
|
||||
const { copy, copied, isSupported } = useClipboard();
|
||||
|
|
@ -65,6 +68,19 @@ const apiKeyText = computed(() => {
|
|||
}
|
||||
return isKeyRedacted.value ? '<YOUR_ACCESS_TOKEN_HERE>' : props.apiKey.apiKey;
|
||||
});
|
||||
|
||||
const handleConnectionStringCopy = async () => {
|
||||
await copy(connectionString.value);
|
||||
emit('connectionStringCopied');
|
||||
};
|
||||
|
||||
const handleUrlCopy = (url: string) => {
|
||||
emit('urlCopied', url);
|
||||
};
|
||||
|
||||
const handleAccessTokenCopy = () => {
|
||||
emit('accessTokenCopied');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -83,7 +99,7 @@ const apiKeyText = computed(() => {
|
|||
<span :class="$style.label">
|
||||
{{ i18n.baseText('settings.mcp.instructions.serverUrl') }}:
|
||||
</span>
|
||||
<ConnectionParameter :value="props.serverUrl" />
|
||||
<ConnectionParameter :value="props.serverUrl" @copy="handleUrlCopy" />
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
|
|
@ -101,6 +117,7 @@ const apiKeyText = computed(() => {
|
|||
:value="props.apiKey.apiKey"
|
||||
:max-width="400"
|
||||
:allow-copy="!isKeyRedacted"
|
||||
@copy="handleAccessTokenCopy"
|
||||
>
|
||||
<template #customActions>
|
||||
<N8nTooltip :content="i18n.baseText('settings.mcp.instructions.rotateKey.tooltip')">
|
||||
|
|
@ -140,7 +157,7 @@ const apiKeyText = computed(() => {
|
|||
:icon="copied ? 'clipboard-check' : 'clipboard'"
|
||||
:square="true"
|
||||
:class="$style['copy-json-button']"
|
||||
@click="copy(connectionString)"
|
||||
@click="handleConnectionStringCopy"
|
||||
/>
|
||||
</N8nTooltip>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -16,6 +16,15 @@ const props = withDefaults(defineProps<Props>(), {
|
|||
allowCopy: true,
|
||||
maxWidth: undefined,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
copy: [value: string];
|
||||
}>();
|
||||
|
||||
const handleCopy = async (value: string) => {
|
||||
await copy(value);
|
||||
emit('copy', value);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -37,7 +46,7 @@ const props = withDefaults(defineProps<Props>(), {
|
|||
:icon="copied ? 'clipboard-check' : 'clipboard'"
|
||||
:square="true"
|
||||
:class="$style['copy-button']"
|
||||
@click="copy(props.value)"
|
||||
@click="handleCopy(props.value)"
|
||||
/>
|
||||
</N8nTooltip>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ const props = defineProps<Props>();
|
|||
const emit = defineEmits<{
|
||||
revokeClient: [client: OAuthClientResponseDto];
|
||||
refresh: [];
|
||||
urlCopied: [url: string];
|
||||
}>();
|
||||
|
||||
const i18n = useI18n();
|
||||
|
|
@ -26,6 +27,10 @@ const onRefreshOAuthClients = () => {
|
|||
const onRevokeClientAccess = (client: OAuthClientResponseDto) => {
|
||||
emit('revokeClient', client);
|
||||
};
|
||||
|
||||
const onUrlCopied = (url: string) => {
|
||||
emit('urlCopied', url);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -43,7 +48,7 @@ const onRevokeClientAccess = (client: OAuthClientResponseDto) => {
|
|||
<span :class="$style.label">
|
||||
{{ i18n.baseText('settings.mcp.instructions.serverUrl') }}:
|
||||
</span>
|
||||
<ConnectionParameter :value="props.serverUrl" />
|
||||
<ConnectionParameter :value="props.serverUrl" @copy="onUrlCopied" />
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user