UNPKG

n8n

Version:

n8n Workflow Automation Tool

464 lines 19.1 kB
"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.TrustedKeyService = void 0; const node_crypto_1 = require("node:crypto"); const backend_common_1 = require("@n8n/backend-common"); const constants_1 = require("@n8n/constants"); const db_1 = require("@n8n/db"); const decorators_1 = require("@n8n/decorators"); const di_1 = require("@n8n/di"); const typeorm_1 = require("@n8n/typeorm"); const n8n_core_1 = require("n8n-core"); const n8n_workflow_1 = require("n8n-workflow"); const zod_1 = require("zod"); const trusted_key_source_entity_1 = require("../database/entities/trusted-key-source.entity"); const trusted_key_entity_1 = require("../database/entities/trusted-key.entity"); const trusted_key_source_repository_1 = require("../database/repositories/trusted-key-source.repository"); const trusted_key_repository_1 = require("../database/repositories/trusted-key.repository"); const token_exchange_config_1 = require("../token-exchange.config"); const token_exchange_schemas_1 = require("../token-exchange.schemas"); const jwks_resolver_1 = require("./jwks-resolver"); const ALGORITHM_FAMILY = { RS256: 'RSA', RS384: 'RSA', RS512: 'RSA', PS256: 'RSA', PS384: 'RSA', PS512: 'RSA', ES256: 'EC', ES384: 'EC', ES512: 'EC', EdDSA: 'EdDSA', }; const STATIC_SOURCE_ID = 'static'; const REFRESH_POLL_INTERVAL_MS = 30 * constants_1.Time.seconds.toMilliseconds; let TrustedKeyService = class TrustedKeyService { constructor(logger, config, trustedKeySourceRepository, trustedKeyRepository, instanceSettings, dbLockService, jwksResolverService) { this.config = config; this.trustedKeySourceRepository = trustedKeySourceRepository; this.trustedKeyRepository = trustedKeyRepository; this.instanceSettings = instanceSettings; this.dbLockService = dbLockService; this.jwksResolverService = jwksResolverService; this.isShuttingDown = false; this.cryptoCache = new Map(); this.logger = logger.scoped('token-exchange'); } async initialize() { const sources = this.parseConfigSources(); await this.syncSourcesToDb(sources); await this.refreshAllSources(); if (this.instanceSettings.isLeader) { this.startRefresh(); } else { this.logger.debug('Follower instance — skipping periodic refresh loop'); } } async onLeaderTakeover() { await this.refreshAllSources(); this.startRefresh(); } startRefresh() { if (this.isShuttingDown || this.refreshInterval) return; this.refreshInterval = setInterval(async () => await this.refreshDueSources(), REFRESH_POLL_INTERVAL_MS); this.logger.debug('Trusted key refresh poller started'); } stopRefresh() { clearInterval(this.refreshInterval); this.refreshInterval = undefined; } shutdown() { this.isShuttingDown = true; this.stopRefresh(); } async getByKidAndIss(kid, issuer) { const entities = await this.trustedKeyRepository.findAllByKid(kid); if (entities.length === 0) return undefined; for (const entity of entities) { let data; try { const parsed = token_exchange_schemas_1.TrustedKeyDataSchema.safeParse(JSON.parse(entity.data)); if (!parsed.success) { this.logger.warn('Skipping corrupted trusted key entity', { kid, sourceId: entity.sourceId, error: parsed.error.message, }); continue; } data = parsed.data; } catch { this.logger.warn('Skipping corrupted trusted key entity', { kid, sourceId: entity.sourceId, error: 'invalid JSON', }); continue; } if (data.issuer !== issuer) continue; const cryptoKey = this.resolveCryptoKey(`${entity.sourceId}:${kid}`, data.keyMaterial); if (!cryptoKey) continue; return { kid, algorithms: data.algorithms, key: cryptoKey, issuer: data.issuer, expectedAudience: data.expectedAudience, allowedRoles: data.allowedRoles, }; } return undefined; } async refreshSource(sourceId) { const source = await this.trustedKeySourceRepository.findOneBy({ id: sourceId }); if (!source) { throw new n8n_workflow_1.UnexpectedError(`Trusted key source not found: ${sourceId}`); } await this.refreshSourceInternal(source); } async listAll() { return await this.trustedKeyRepository.find(); } async listSources() { return await this.trustedKeySourceRepository.find(); } parseConfigSources() { const raw = this.config.trustedKeys; if (!raw) { this.logger.info('No trusted keys configured'); return []; } let parsed; try { parsed = JSON.parse(raw); } catch (error) { this.logger.error('Failed to parse trusted keys JSON', { error }); throw new n8n_workflow_1.UnexpectedError('Failed to parse trusted keys JSON'); } const result = zod_1.z.array(token_exchange_schemas_1.TrustedKeySourceSchema).safeParse(parsed); if (!result.success) { this.logger.error('Trusted keys JSON has invalid format', { error: result.error }); throw new n8n_workflow_1.UnexpectedError('Trusted keys JSON has invalid format'); } return result.data; } generateSourceId(source) { if (source.type === 'static') return STATIC_SOURCE_ID; return (0, node_crypto_1.createHash)('sha256').update(source.url).digest('hex').slice(0, 36); } async syncSourcesToDb(sources) { await this.dbLockService.withLock(1002, async (tx) => { this.logger.debug('Syncing sources to the database', { sources }); const staticSources = sources.filter((s) => s.type === 'static'); const jwksSources = sources.filter((s) => s.type === 'jwks'); const expectedSourceIds = new Set(); if (staticSources.length > 0) { const sourceId = STATIC_SOURCE_ID; expectedSourceIds.add(sourceId); await tx.save(trusted_key_source_entity_1.TrustedKeySourceEntity, { id: sourceId, type: 'static', config: JSON.stringify(staticSources), status: 'pending', }); } for (const jwks of jwksSources) { const sourceId = this.generateSourceId(jwks); expectedSourceIds.add(sourceId); await tx.save(trusted_key_source_entity_1.TrustedKeySourceEntity, { id: sourceId, type: 'jwks', config: JSON.stringify(jwks), status: 'pending', }); } if (expectedSourceIds.size > 0) { await tx.delete(trusted_key_source_entity_1.TrustedKeySourceEntity, { id: (0, typeorm_1.Not)((0, typeorm_1.In)([...expectedSourceIds])), }); } else { await tx.delete(trusted_key_source_entity_1.TrustedKeySourceEntity, {}); } }); } async refreshAllSources() { try { const sources = await this.trustedKeySourceRepository.find(); for (const source of sources) { await this.refreshSourceInternal(source); } } catch (error) { this.logger.error('Failed to run trusted key refresh cycle', { error }); } } async refreshDueSources() { try { this.logger.debug('Refreshing due sources'); const sources = await this.trustedKeySourceRepository.find(); const now = Date.now(); for (const source of sources) { const intervalMs = this.getRefreshIntervalMs(source); const lastRefresh = source.lastRefreshedAt?.getTime() ?? 0; if (now - lastRefresh >= intervalMs) { await this.refreshSourceInternal(source); } } } catch (error) { this.logger.error('Failed to run trusted key refresh cycle', { error }); } } getRefreshIntervalMs(source) { if (source.type === 'jwks') { try { const config = (0, n8n_workflow_1.jsonParse)(source.config); if (typeof config.cacheTtlSeconds === 'number' && config.cacheTtlSeconds > 0) { return config.cacheTtlSeconds * constants_1.Time.seconds.toMilliseconds; } } catch (e) { this.logger.warn('Failed to parse source configuration for jwks source', { id: source.id, error: e, }); } } return this.config.keyRefreshIntervalSeconds * constants_1.Time.seconds.toMilliseconds; } async refreshSourceInternal(source) { try { await this.dbLockService.withLock(1002, async (tx) => { const freshSource = await tx.findOneBy(trusted_key_source_entity_1.TrustedKeySourceEntity, { id: source.id }); if (!freshSource) return; await this.refreshSourceWithinTransaction(freshSource, tx); }); } catch (error) { const message = error instanceof Error ? error.message : String(error); this.logger.error('Failed to refresh trusted key source', { sourceId: source.id, error: message, }); await this.trustedKeySourceRepository.update(source.id, { status: 'error', lastError: message, lastRefreshedAt: new Date(), }); } } async refreshSourceWithinTransaction(source, tx) { const result = await this.resolveKeysForSource(source); if (!result) { await tx.update(trusted_key_source_entity_1.TrustedKeySourceEntity, source.id, { status: 'healthy', lastRefreshedAt: new Date(), }); return; } const keys = result.keys; const cacheTtlSeconds = result.cacheTtlSeconds; await tx.delete(trusted_key_entity_1.TrustedKeyEntity, { sourceId: source.id }); for (const key of keys) { await tx.save(trusted_key_entity_1.TrustedKeyEntity, { sourceId: source.id, kid: key.kid, data: JSON.stringify(key.data), createdAt: new Date(), }); } const updatePayload = { status: 'healthy', lastError: null, lastRefreshedAt: new Date(), }; if (cacheTtlSeconds !== undefined) { const config = (0, n8n_workflow_1.jsonParse)(source.config); config.cacheTtlSeconds = cacheTtlSeconds; updatePayload.config = JSON.stringify(config); } await tx.update(trusted_key_source_entity_1.TrustedKeySourceEntity, source.id, updatePayload); } async resolveKeysForSource(source) { switch (source.type) { case 'static': return this.resolveKeysForStaticSource(source); case 'jwks': return await this.resolveKeysForJwksSource(source); default: this.logger.warn('Unknown key source type, skipping', { sourceId: source.id, type: source.type, }); return undefined; } } async resolveKeysForJwksSource(source) { let jwksConfig; try { jwksConfig = (0, n8n_workflow_1.jsonParse)(source.config); } catch { throw new n8n_workflow_1.UnexpectedError('Invalid JWKS source config: malformed JSON'); } const result = await this.jwksResolverService.resolveKeys(jwksConfig); if (result.skipped.length > 0) { this.logger.debug(`JWKS "${jwksConfig.url}": skipped ${result.skipped.length} key(s)`, { skipped: result.skipped, }); } return { keys: result.keys.map((key) => ({ kid: key.kid, data: { algorithms: key.algorithms, keyMaterial: key.keyMaterial, issuer: key.issuer, expectedAudience: key.expectedAudience, allowedRoles: key.allowedRoles, expiresAt: new Date(Date.now() + result.ttlSeconds * 1000).toISOString(), }, })), cacheTtlSeconds: result.ttlSeconds, }; } resolveKeysForStaticSource(source) { let rawConfig; try { rawConfig = JSON.parse(source.config); } catch { throw new n8n_workflow_1.UnexpectedError('Invalid static source config: malformed JSON'); } const configResult = zod_1.z.array(token_exchange_schemas_1.TrustedKeySourceSchema).safeParse(rawConfig); if (!configResult.success) { throw new n8n_workflow_1.UnexpectedError(`Invalid static source config: ${configResult.error.message}`); } const staticConfigs = configResult.data.filter((s) => s.type === 'static'); return { keys: this.resolveStaticKeys(staticConfigs), }; } resolveStaticKeys(configs) { const result = []; const seenKids = new Set(); for (const config of configs) { const { kid, algorithms, key: pemString, issuer, expectedAudience, allowedRoles } = config; if (seenKids.has(kid)) { throw new n8n_workflow_1.UnexpectedError(`Trusted key "${kid}": duplicate kid`); } seenKids.add(kid); this.validateKeyMaterial(kid, algorithms, pemString); result.push({ kid, data: { algorithms, keyMaterial: pemString, issuer, expectedAudience, allowedRoles, }, }); } return result; } validateKeyMaterial(kid, algorithms, pemString) { const families = new Set(); for (const alg of algorithms) { const family = ALGORITHM_FAMILY[alg]; if (!family) { throw new n8n_workflow_1.UnexpectedError(`Trusted key "${kid}": unknown algorithm "${alg}"`); } families.add(family); } if (families.size > 1) { throw new n8n_workflow_1.UnexpectedError(`Trusted key "${kid}": algorithms must belong to the same family, got ${[...families].join(', ')}`); } const family = [...families][0]; let keyObject; try { keyObject = (0, node_crypto_1.createPublicKey)(pemString); } catch (error) { const message = error instanceof Error ? error.message : 'unknown error'; throw new n8n_workflow_1.UnexpectedError(`Trusted key "${kid}": failed to parse public key — ${message}`); } const keyType = keyObject.asymmetricKeyType; const expectedTypes = { RSA: ['rsa'], EC: ['ec'], EdDSA: ['ed25519', 'ed448'], }; if (!expectedTypes[family].includes(keyType ?? '')) { throw new n8n_workflow_1.UnexpectedError(`Trusted key "${kid}": key type "${keyType}" does not match algorithm family "${family}"`); } } resolveCryptoKey(cacheKey, keyMaterial) { const hash = (0, node_crypto_1.createHash)('sha256').update(keyMaterial).digest('hex'); const cached = this.cryptoCache.get(cacheKey); if (cached && cached.keyMaterialHash === hash) { return cached.cryptoKey; } try { const cryptoKey = (0, node_crypto_1.createPublicKey)(keyMaterial); this.cryptoCache.set(cacheKey, { keyMaterialHash: hash, cryptoKey }); return cryptoKey; } catch (error) { this.logger.warn('Failed to parse key material from DB', { cacheKey, error: error instanceof Error ? error.message : String(error), }); return undefined; } } }; exports.TrustedKeyService = TrustedKeyService; __decorate([ (0, decorators_1.OnLeaderTakeover)(), __metadata("design:type", Function), __metadata("design:paramtypes", []), __metadata("design:returntype", Promise) ], TrustedKeyService.prototype, "onLeaderTakeover", null); __decorate([ (0, decorators_1.OnLeaderStepdown)(), __metadata("design:type", Function), __metadata("design:paramtypes", []), __metadata("design:returntype", void 0) ], TrustedKeyService.prototype, "stopRefresh", null); __decorate([ (0, decorators_1.OnShutdown)(), __metadata("design:type", Function), __metadata("design:paramtypes", []), __metadata("design:returntype", void 0) ], TrustedKeyService.prototype, "shutdown", null); exports.TrustedKeyService = TrustedKeyService = __decorate([ (0, di_1.Service)(), __metadata("design:paramtypes", [backend_common_1.Logger, token_exchange_config_1.TokenExchangeConfig, trusted_key_source_repository_1.TrustedKeySourceRepository, trusted_key_repository_1.TrustedKeyRepository, n8n_core_1.InstanceSettings, db_1.DbLockService, jwks_resolver_1.JwksResolverService]) ], TrustedKeyService); //# sourceMappingURL=trusted-key.service.js.map