n8n
Version:
n8n Workflow Automation Tool
190 lines • 7.39 kB
JavaScript
;
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.JwksResolverService = void 0;
const node_crypto_1 = require("node:crypto");
const backend_common_1 = require("@n8n/backend-common");
const di_1 = require("@n8n/di");
const n8n_workflow_1 = require("n8n-workflow");
const zod_1 = require("zod");
const token_exchange_schemas_1 = require("../token-exchange.schemas");
const FETCH_TIMEOUT_MS = 10_000;
const DEFAULT_TTL_SECONDS = 3600;
const MIN_TTL_SECONDS = 60;
const MAX_TTL_SECONDS = 86_400;
const supportedAlgorithmSet = new Set(token_exchange_schemas_1.JwtAlgorithmSchema.options);
const SigningJwkSchema = zod_1.z
.object({
kid: zod_1.z.string().min(1),
kty: zod_1.z.enum(['RSA', 'EC', 'OKP']),
alg: zod_1.z.string().optional(),
use: zod_1.z
.string()
.optional()
.refine((use) => use === undefined || use === 'sig', {
message: 'Only signing keys (use: "sig" or absent) are accepted',
}),
crv: zod_1.z.string().optional(),
})
.passthrough();
const JwkSetSchema = zod_1.z.object({
keys: zod_1.z.array(zod_1.z.unknown()).min(1),
});
const CURVE_TO_ALGORITHM = {
'P-256': 'ES256',
'P-384': 'ES384',
'P-521': 'ES512',
Ed25519: 'EdDSA',
};
function inferAlgorithm(jwk) {
if (jwk.alg) {
return supportedAlgorithmSet.has(jwk.alg) ? jwk.alg : undefined;
}
switch (jwk.kty) {
case 'RSA':
return 'RS256';
case 'EC':
case 'OKP':
return jwk.crv ? CURVE_TO_ALGORITHM[jwk.crv] : undefined;
default:
return undefined;
}
}
function parseMaxAge(cacheControl) {
if (!cacheControl)
return undefined;
const match = /max-age=(\d+)/.exec(cacheControl);
if (!match)
return undefined;
const value = parseInt(match[1], 10);
return Number.isNaN(value) ? undefined : value;
}
let JwksResolverService = class JwksResolverService {
constructor(logger) {
this.logger = logger.scoped('token-exchange');
}
async resolveKeys(source, options) {
const fetcher = options?.fetcher ?? globalThis.fetch;
const defaultTtl = options?.defaultTtlSeconds ?? DEFAULT_TTL_SECONDS;
const { url } = source;
this.logger.debug(`Fetching JWKS from "${url}"`);
const { rawKeys, cacheControl } = await this.fetchJwkSet(url, fetcher);
const ttlSeconds = this.computeTtl(cacheControl, source, defaultTtl);
const keys = [];
const skipped = [];
for (const rawJwk of rawKeys) {
const result = this.parseJwk(rawJwk, source);
if ('resolved' in result) {
keys.push(result.resolved);
}
else {
skipped.push(result.skipped);
}
}
if (keys.length === 0) {
const reasons = skipped.map((s) => `${s.kid ?? 'unknown'}: ${s.reason}`).join('; ');
throw new n8n_workflow_1.OperationalError(`JWKS response has no usable signing keys for "${url}" (${reasons})`);
}
this.logger.debug(`Resolved ${keys.length} key(s) from "${url}"`, {
resolved: keys.length,
skipped: skipped.length,
ttlSeconds,
});
return { keys, ttlSeconds, skipped };
}
async fetchJwkSet(url, fetcher) {
let response;
try {
response = await fetcher(url, {
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
headers: { Accept: 'application/json' },
redirect: 'error',
});
}
catch (error) {
const message = error instanceof Error ? error.message : 'unknown error';
throw new n8n_workflow_1.OperationalError(`JWKS fetch failed for "${url}": ${message}`);
}
if (!response.ok) {
throw new n8n_workflow_1.OperationalError(`JWKS fetch failed for "${url}": HTTP ${response.status}`);
}
let body;
try {
body = await response.json();
}
catch {
throw new n8n_workflow_1.OperationalError(`JWKS response is not valid JSON for "${url}"`);
}
const jwkSetResult = JwkSetSchema.safeParse(body);
if (!jwkSetResult.success) {
throw new n8n_workflow_1.OperationalError(`JWKS response has no keys for "${url}"`);
}
return {
rawKeys: jwkSetResult.data.keys,
cacheControl: response.headers.get('cache-control'),
};
}
parseJwk(rawJwk, source) {
if (rawJwk === null || typeof rawJwk !== 'object') {
return { skipped: { kid: undefined, reason: 'not an object' } };
}
const jwkResult = SigningJwkSchema.safeParse(rawJwk);
if (!jwkResult.success) {
const raw = rawJwk;
return {
skipped: {
kid: typeof raw.kid === 'string' ? raw.kid : undefined,
reason: 'failed schema validation',
},
};
}
const jwk = jwkResult.data;
const algorithm = inferAlgorithm(jwk);
if (!algorithm) {
return {
skipped: {
kid: jwk.kid,
reason: `unsupported algorithm or key type (kty=${jwk.kty}, alg=${jwk.alg ?? 'none'}, crv=${jwk.crv ?? 'none'})`,
},
};
}
let keyObject;
try {
keyObject = (0, node_crypto_1.createPublicKey)({ format: 'jwk', key: jwk });
}
catch {
return {
skipped: { kid: jwk.kid, reason: 'failed to create public key from JWK material' },
};
}
return {
resolved: {
kid: jwk.kid,
algorithms: [algorithm],
keyMaterial: keyObject.export({ type: 'spki', format: 'pem' }),
issuer: source.issuer,
expectedAudience: source.expectedAudience,
allowedRoles: source.allowedRoles,
},
};
}
computeTtl(cacheControl, source, defaultTtl) {
const maxAge = parseMaxAge(cacheControl);
const rawTtl = maxAge ?? source.cacheTtlSeconds ?? defaultTtl;
return Math.max(MIN_TTL_SECONDS, Math.min(rawTtl, MAX_TTL_SECONDS));
}
};
exports.JwksResolverService = JwksResolverService;
exports.JwksResolverService = JwksResolverService = __decorate([
(0, di_1.Service)(),
__metadata("design:paramtypes", [backend_common_1.Logger])
], JwksResolverService);
//# sourceMappingURL=jwks-resolver.js.map