n8n
Version:
n8n Workflow Automation Tool
464 lines • 19.1 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.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