n8n
Version:
n8n Workflow Automation Tool
405 lines • 19.6 kB
JavaScript
"use strict";
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __metadata = (this && this.__metadata) || function (k, v) {
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.OAuthServerService = void 0;
const errors_js_1 = require("@modelcontextprotocol/sdk/server/auth/errors.js");
const api_types_1 = require("@n8n/api-types");
const backend_common_1 = require("@n8n/backend-common");
const config_1 = require("@n8n/config");
const di_1 = require("@n8n/di");
const permissions_1 = require("@n8n/permissions");
const protected_resource_registry_1 = require("../../services/protected-resource.registry");
const oauth_client_entity_1 = require("./database/entities/oauth-client.entity");
const oauth_client_repository_1 = require("./database/repositories/oauth-client.repository");
const oauth_user_consent_repository_1 = require("./database/repositories/oauth-user-consent.repository");
const oauth_authorization_code_service_1 = require("./oauth-authorization-code.service");
const oauth_session_service_1 = require("./oauth-session.service");
const oauth_token_service_1 = require("./oauth-token.service");
const oauth_errors_1 = require("./oauth.errors");
const forbidden_error_1 = require("../../errors/response-errors/forbidden.error");
const email_1 = require("../../user-management/email");
const MAX_REDIRECT_URIS = 10;
function matchesTypeFilter(name, type) {
const clientType = (0, api_types_1.getMcpClientType)(name);
return clientType !== null && api_types_1.MCP_CLIENT_TYPE_FILTER_BUCKETS[type].includes(clientType);
}
function sortOwners(owners) {
return [...owners].sort((a, b) => {
const nameA = [a.firstName, a.lastName].filter(Boolean).join(' ') || a.email;
const nameB = [b.firstName, b.lastName].filter(Boolean).join(' ') || b.email;
return nameA.localeCompare(nameB);
});
}
const MAX_REDIRECT_URI_LENGTH = 2048;
let OAuthServerService = class OAuthServerService {
constructor(logger, globalConfig, oauthSessionService, oauthClientRepository, tokenService, authorizationCodeService, userConsentRepository, resourceRegistry, mailer) {
this.logger = logger;
this.globalConfig = globalConfig;
this.oauthSessionService = oauthSessionService;
this.oauthClientRepository = oauthClientRepository;
this.tokenService = tokenService;
this.authorizationCodeService = authorizationCodeService;
this.userConsentRepository = userConsentRepository;
this.resourceRegistry = resourceRegistry;
this.mailer = mailer;
}
get clientsStore() {
return {
getClient: async (clientId) => {
const client = await this.oauthClientRepository.findOneBy({ id: clientId });
if (!client) {
return undefined;
}
const supportedScopes = this.resourceRegistry.getAllScopes();
return {
client_id: client.id,
client_name: client.name,
redirect_uris: client.redirectUris,
grant_types: client.grantTypes,
token_endpoint_auth_method: client.tokenEndpointAuthMethod,
...(client.clientSecret && { client_secret: client.clientSecret }),
...(client.clientSecretExpiresAt && {
client_secret_expires_at: client.clientSecretExpiresAt,
}),
response_types: ['code'],
...(supportedScopes.length > 0 && { scope: supportedScopes.join(' ') }),
logo_uri: undefined,
tos_uri: undefined,
};
},
registerClient: async (client) => {
this.validateClientRegistration(client);
await this.oauthClientRepository.insert({
id: client.client_id,
name: client.client_name,
redirectUris: client.redirect_uris,
grantTypes: client.grant_types,
clientSecret: client.client_secret ?? null,
clientSecretExpiresAt: client.client_secret_expires_at ?? null,
tokenEndpointAuthMethod: client.token_endpoint_auth_method ?? 'none',
});
await this.enforceClientLimit(client.client_id);
return client;
},
};
}
async isClientLimitReached() {
const clientCount = await this.oauthClientRepository.count();
return clientCount >= this.globalConfig.endpoints.mcpMaxRegisteredClients;
}
async getInstanceClientStats() {
const count = await this.oauthClientRepository.count();
const limit = this.globalConfig.endpoints.mcpMaxRegisteredClients;
return { count, limit, atCapacity: count >= limit };
}
async enforceClientLimit(clientId) {
const clientCount = await this.oauthClientRepository.count();
const limit = this.globalConfig.endpoints.mcpMaxRegisteredClients;
if (clientCount > limit) {
await this.oauthClientRepository.delete({ id: clientId });
this.logger.warn('OAuth client registration rejected: instance limit reached (post-insert rollback)', { limit, clientCount });
throw new oauth_errors_1.OAuthClientLimitReachedError(limit);
}
}
validateClientRegistration(client) {
if (!client.client_name) {
throw new Error('client_name is required');
}
if (!client.grant_types || client.grant_types.length === 0) {
throw new Error('grant_types is required');
}
if (!client.redirect_uris || client.redirect_uris.length === 0) {
throw new Error('redirect_uris is required');
}
if (client.redirect_uris.length > MAX_REDIRECT_URIS) {
throw new Error(`redirect_uris exceeds maximum count of ${MAX_REDIRECT_URIS}`);
}
for (const uri of client.redirect_uris) {
if (uri.length > MAX_REDIRECT_URI_LENGTH) {
throw new Error(`redirect_uri exceeds maximum length of ${MAX_REDIRECT_URI_LENGTH} characters`);
}
}
}
isRedirectUriAllowed(allowedUris, redirectUri) {
if (allowedUris.includes(redirectUri)) {
return true;
}
let requested;
try {
requested = new URL(redirectUri);
}
catch {
return false;
}
if (!this.isLoopbackHost(requested.hostname)) {
return false;
}
return allowedUris.some((allowed) => {
let candidate;
try {
candidate = new URL(allowed);
}
catch {
return false;
}
return (this.isLoopbackHost(candidate.hostname) &&
candidate.protocol === requested.protocol &&
candidate.hostname === requested.hostname &&
candidate.pathname === requested.pathname);
});
}
isLoopbackHost(hostname) {
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]';
}
async authorize(client, params, res) {
this.logger.debug('Starting OAuth authorization', { clientId: client.client_id });
try {
const resource = await this.resolveAndValidateResourceIndicator(params.resource?.toString());
const targetResource = resource
? await this.resourceRegistry.getByResourceUrl(resource)
: this.resourceRegistry.getDefaultResource();
const allowedUris = (await targetResource?.getAllowedRedirectUris?.()) ?? [];
if (allowedUris.length > 0 && !this.isRedirectUriAllowed(allowedUris, params.redirectUri)) {
this.logger.warn('MCP OAuth authorization rejected: requested redirect URI is not in the configured allowlist', {
clientId: client.client_id,
attemptedUri: params.redirectUri,
});
res.status(400).json({
error: 'invalid_request',
error_description: 'Redirect URI not in allowed list',
});
return;
}
const supportedScopes = targetResource?.scopes ?? [];
const requestedScopes = params.scopes?.filter((scope) => supportedScopes.includes(scope));
this.oauthSessionService.createSession(res, {
clientId: client.client_id,
redirectUri: params.redirectUri,
codeChallenge: params.codeChallenge,
state: params.state ?? null,
resource,
...(requestedScopes && requestedScopes.length > 0 && { requestedScopes }),
});
res.redirect('/oauth/consent');
}
catch (error) {
if (error instanceof InvalidResourceIndicatorError) {
this.logger.warn('Rejecting OAuth authorization request with invalid resource', {
clientId: client.client_id,
resource: error.resource,
expectedResource: error.expectedResource,
});
this.oauthSessionService.clearSession(res);
res.status(400).json({
error: 'invalid_target',
error_description: 'Invalid resource indicator',
});
return;
}
this.logger.error('Error in authorize method', { error, clientId: client.client_id });
this.oauthSessionService.clearSession(res);
res.status(500).json({ error: 'server_error', error_description: 'Internal server error' });
}
}
async challengeForAuthorizationCode(client, authorizationCode) {
return await this.authorizationCodeService.getCodeChallenge(authorizationCode, client.client_id);
}
async exchangeAuthorizationCode(client, authorizationCode, _codeVerifier, redirectUri, resource) {
const authRecord = await this.authorizationCodeService.findAuthorizationCode(authorizationCode, client.client_id, redirectUri);
if (!authRecord) {
throw new errors_js_1.InvalidGrantError('Invalid authorization code');
}
const resourceStr = resource?.toString();
const tokenResource = await this.resolveAndValidateResourceIndicator(resourceStr);
let finalResource;
const codeResource = authRecord.resource ?? undefined;
if (tokenResource && codeResource) {
if (tokenResource !== codeResource) {
throw new InvalidResourceIndicatorError(tokenResource, codeResource);
}
finalResource = tokenResource;
}
else {
finalResource = tokenResource ?? codeResource;
}
await this.authorizationCodeService.markAuthorizationCodeAsUsed(authorizationCode);
const grantedScopes = authRecord.scope;
const { accessToken, refreshToken } = this.tokenService.generateTokenPair(authRecord.userId, client.client_id, finalResource, grantedScopes);
await this.tokenService.saveTokenPair(accessToken, refreshToken, client.client_id, authRecord.userId, grantedScopes);
return {
access_token: accessToken,
token_type: 'Bearer',
expires_in: this.tokenService.getAccessTokenExpirySeconds(),
refresh_token: refreshToken,
scope: grantedScopes.join(' '),
};
}
async exchangeRefreshToken(client, refreshToken, _scopes, resource) {
const resourceStr = resource?.toString();
return await this.tokenService.validateAndRotateRefreshToken(refreshToken, client.client_id, await this.resolveAndValidateResourceIndicator(resourceStr));
}
async verifyAccessToken(token) {
return await this.tokenService.verifyAccessToken(token);
}
async resolveAndValidateResourceIndicator(resource) {
if (resource === undefined) {
return undefined;
}
const normalizedResource = resource.replace(/\/$/, '');
const match = await this.resourceRegistry.getByResourceUrl(normalizedResource);
if (!match) {
const knownResources = this.resourceRegistry
.getAll()
.map((registered) => registered.getResourceUrl())
.join(', ');
throw new InvalidResourceIndicatorError(resource, knownResources);
}
return normalizedResource;
}
async revokeToken(client, request) {
const { token, token_type_hint } = request;
if (!token_type_hint || token_type_hint === 'access_token') {
const revoked = await this.tokenService.revokeAccessToken(token, client.client_id);
if (revoked) {
return;
}
}
if (!token_type_hint || token_type_hint === 'refresh_token') {
const revoked = await this.tokenService.revokeRefreshToken(token, client.client_id);
if (revoked) {
return;
}
}
this.logger.debug('Token revocation requested for unknown token', {
clientId: client.client_id,
});
}
async getAllClients(user, options = {}) {
const canSeeAll = (0, permissions_1.hasGlobalScope)(user, 'mcp:manage');
const listAll = options.ownership === 'all';
if (listAll && !canSeeAll) {
throw new forbidden_error_1.ForbiddenError('You are not allowed to list connected clients of other users');
}
let clientIds;
if (options.type) {
const registered = await this.oauthClientRepository.find({
select: { id: true, name: true },
});
clientIds = registered
.filter((client) => matchesTypeFilter(client.name, options.type))
.map((client) => client.id);
}
const { rows: consents, total } = clientIds?.length === 0
? { rows: [], total: 0 }
: await this.userConsentRepository.findConnectedClients({
userId: listAll ? undefined : user.id,
withOwner: listAll,
name: options.name,
ownerId: listAll ? options.ownerId : undefined,
clientIds,
connected: options.connected,
now: Date.now(),
skip: options.skip,
take: options.take,
});
const clients = consents.map((consent) => {
const { clientSecret, clientSecretExpiresAt, ...sanitizedClient } = consent.client;
return {
...sanitizedClient,
grantedAt: Number(consent.grantedAt),
scopes: consent.scope,
...(listAll
? {
owner: {
id: consent.user.id,
firstName: consent.user.firstName ?? null,
lastName: consent.user.lastName ?? null,
email: consent.user.email,
},
}
: {}),
};
});
const count = total;
const [consentOwners, mineCount, allCount] = await Promise.all([
listAll ? this.userConsentRepository.findConsentOwners() : undefined,
this.userConsentRepository.countBy({ userId: user.id }),
canSeeAll ? this.userConsentRepository.count() : undefined,
]);
const owners = consentOwners ? sortOwners(consentOwners) : undefined;
const totals = { mine: mineCount };
if (allCount !== undefined) {
totals.all = allCount;
}
return { clients, count, totals, owners };
}
getInstanceScopeTools() {
return this.resourceRegistry.getDefaultResource()?.getScopeTools?.();
}
async deleteClient(clientId, userId, revoker) {
const client = await this.oauthClientRepository.findOne({
where: { id: clientId },
});
if (!client) {
throw new Error(`OAuth client with ID ${clientId} not found`);
}
const consent = await this.userConsentRepository.findOne({
where: { clientId, userId },
relations: ['user'],
});
if (!consent) {
throw new Error(`OAuth client with ID ${clientId} not found`);
}
this.logger.info('Revoking OAuth client access for user', { clientId, userId });
await Promise.all([
this.tokenService.revokeAllTokensForGrant(clientId, userId),
this.authorizationCodeService.deleteForGrant(clientId, userId),
this.userConsentRepository.delete({ clientId, userId }),
]);
const consentsTable = this.userConsentRepository.metadata.tableName;
const result = await this.oauthClientRepository
.createQueryBuilder()
.delete()
.from(oauth_client_entity_1.OAuthClient)
.where(`id = :clientId AND NOT EXISTS (SELECT 1 FROM ${consentsTable} WHERE "clientId" = :clientId)`, { clientId })
.execute();
if (result.affected && result.affected > 0) {
this.logger.info('OAuth client deleted after last consent was revoked', {
clientId,
clientName: client.name,
});
}
if (revoker && revoker.id !== userId) {
this.mailer
.notifyMcpClientRevoked({ clientName: client.name, owner: consent.user, revoker })
.catch((e) => {
this.logger.error('Failed to send MCP client revocation email', {
clientId,
ownerId: userId,
error: e instanceof Error ? e.message : String(e),
});
});
}
}
};
exports.OAuthServerService = OAuthServerService;
exports.OAuthServerService = OAuthServerService = __decorate([
(0, di_1.Service)(),
__metadata("design:paramtypes", [backend_common_1.Logger, config_1.GlobalConfig, oauth_session_service_1.OAuthSessionService, oauth_client_repository_1.OAuthClientRepository, oauth_token_service_1.OAuthTokenService, oauth_authorization_code_service_1.OAuthAuthorizationCodeService, oauth_user_consent_repository_1.UserConsentRepository, protected_resource_registry_1.ProtectedResourceRegistry, email_1.UserManagementMailer])
], OAuthServerService);
class InvalidResourceIndicatorError extends errors_js_1.InvalidTargetError {
constructor(resource, expectedResource) {
super('Invalid resource indicator');
this.resource = resource;
this.expectedResource = expectedResource;
}
}
//# sourceMappingURL=oauth-server.service.js.map