@eventcatalog/license
Version:
License verification for EventCatalog
333 lines • 13.7 kB
JavaScript
import { readFileSync, existsSync } from 'fs';
import { jwtVerify, importSPKI } from 'jose';
import { loadPublicPem } from './key.js';
import path from 'path';
import chalk from 'chalk';
import boxen from 'boxen';
import https from 'https';
import http from 'http';
import { URL } from 'url';
import { HttpsProxyAgent } from 'https-proxy-agent';
// Module-level cache for verified entitlements
let cachedEntitlements = null;
/**
* Simple HTTP request using Node.js built-ins only
*/
function makeHttpRequest(url, options) {
return new Promise((resolve, reject) => {
const urlObj = new URL(url);
const isHttps = urlObj.protocol === 'https:';
const client = isHttps ? https : http;
const requestOptions = {
hostname: urlObj.hostname,
port: urlObj.port || (isHttps ? 443 : 80),
path: urlObj.pathname + urlObj.search,
method: options.method || 'GET',
headers: options.headers || {},
};
// Add proxy support using standard proxy environment variables
const proxyUrl = process.env.PROXY_SERVER_URI || process.env.HTTPS_PROXY || process.env.HTTP_PROXY;
if (proxyUrl) {
// Use HttpsProxyAgent for proper proxy tunneling support
requestOptions.agent = new HttpsProxyAgent(proxyUrl);
}
const req = client.request(requestOptions, (res) => {
let data = '';
res.on('data', (chunk) => (data += chunk));
res.on('end', () => {
resolve({
status: res.statusCode,
json: () => Promise.resolve(JSON.parse(data)),
});
});
});
req.on('error', reject);
if (options.body) {
req.write(options.body);
}
req.end();
});
}
/**
* Verifies a JWT license file and returns entitlements
* Results are cached after first successful verification
*
* @param opts Verification options
* @returns Promise resolving to verified entitlements
* @throws Error if verification fails
*/
export async function verifyOfflineLicense(opts = {}) {
// Return cached result if available
if (cachedEntitlements) {
return cachedEntitlements;
}
const { licensePath, audience = 'EventCatalog', issuer = 'EventCatalog Ltd', clockTolerance = '12h', currentVersion, fingerprintProvider, } = opts;
try {
// 1. Locate license file
const licenseFilePath = licensePath || findLicenseFile();
if (!licenseFilePath) {
const licenseError = new Error('License file not found. Check EC_LICENSE environment variable or place license.jwt in current directory or /etc/eventcatalog/');
licenseError.code = 'LICENSE_FILE_NOT_FOUND';
throw licenseError;
}
// 2. Read license token
const token = readFileSync(licenseFilePath, 'utf8').trim();
if (!token) {
const licenseError = new Error('License file is empty');
licenseError.code = 'LICENSE_FILE_EMPTY';
throw licenseError;
}
// 3. Load and import public key
const pemContent = await loadPublicPem();
const publicKey = await importSPKI(pemContent, 'EdDSA');
// 4. Verify JWT
const { payload } = await jwtVerify(token, publicKey, {
issuer,
audience,
clockTolerance,
});
const entitlements = payload;
// 5. Additional validation checks
await performAdditionalChecks(entitlements, { currentVersion, fingerprintProvider });
// 6. Cache and return
cachedEntitlements = entitlements;
return entitlements;
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
const errorName = error && typeof error === 'object' && 'name' in error ? error.name : '';
const errorCode = error && typeof error === 'object' && 'code' in error ? error.code : '';
// Create structured errors with codes for consuming applications
if (errorName === 'JWTExpired' || errorMessage.includes('JWTExpired') || errorCode === 'JWTExpired') {
const licenseError = new Error('License has expired');
licenseError.code = 'LICENSE_EXPIRED';
console.error(chalk.red(`You have an expired license. Please renew your license to continue using EventCatalog.`));
throw licenseError;
}
if (errorName === 'JWTNotYetValid' || errorMessage.includes('JWTNotYetValid') || errorCode === 'JWTNotYetValid') {
const licenseError = new Error('License is not yet valid');
licenseError.code = 'LICENSE_NOT_YET_VALID';
console.error(chalk.red(`License is not yet valid`));
throw licenseError;
}
if (errorName === 'JWTClaimValidationFailed' ||
errorMessage.includes('JWTClaimValidationFailed') ||
errorCode === 'JWTClaimValidationFailed') {
const licenseError = new Error('License validation failed - invalid issuer or audience');
licenseError.code = 'LICENSE_VALIDATION_FAILED';
console.error(chalk.red(`License validation failed - invalid issuer or audience`));
throw licenseError;
}
if (errorName === 'JWSSignatureVerificationFailed' ||
errorMessage.includes('JWSSignatureVerificationFailed') ||
errorCode === 'JWSSignatureVerificationFailed') {
const licenseError = new Error('License signature verification failed - invalid or tampered license');
licenseError.code = 'LICENSE_SIGNATURE_INVALID';
console.error(chalk.red(`License signature verification failed - invalid or tampered license`));
throw licenseError;
}
throw error;
}
}
/**
* Verifies a license key online using the EventCatalog API
* @param key - The license key to verify
* @returns The license data
* @throws Error if verification fails
*/
export async function verifyOnlineLicense(key, plugin) {
// Validate license key format
if (!isValidLicenseKeyFormat(key)) {
const licenseError = new Error('Invalid license key format. Expected format: XXXX-XXXX-XXXX-XXXX-XXXX-XXXX');
licenseError.code = 'INVALID_LICENSE_KEY_FORMAT';
throw licenseError;
}
const PLANS = {
'@eventcatalog/eventcatalog-starter': 'EventCatalog Starter',
'@eventcatalog/eventcatalog-scale': 'EventCatalog Scale',
'@eventcatalog/eventcatalog-enterprise': 'EventCatalog Enterprise',
'@eventcatalog/generator-amazon-apigateway': 'Amazon API Gateway',
'@eventcatalog/generator-asyncapi': 'AsyncAPI',
'@eventcatalog/generator-openapi': 'OpenAPI',
'@eventcatalog/backstage-plugin-eventcatalog': 'Backstage',
'@eventcatalog/generator-aws-glue': 'AWS Glue',
'@eventcatalog/generator-confluent-schema-registry': 'Confluent Schema Registry',
'@eventcatalog/generator-github': 'GitHub',
'@eventcatalog/generator-federation': 'Federation',
};
const requestOptions = {
method: 'POST',
headers: {
Authorization: `Bearer ${key}`,
'Content-Type': 'application/json',
},
};
let response;
try {
response = await makeHttpRequest('https://api.eventcatalog.cloud/functions/v1/license', requestOptions);
}
catch (err) {
console.log(chalk.redBright('Network Connection Error: Unable to establish a connection to license server. Check network or proxy settings.'));
console.log(chalk.red(`Error details: ${err?.message || err}`));
const proxyUrl = process.env.PROXY_SERVER_URI || process.env.HTTPS_PROXY || process.env.HTTP_PROXY;
if (proxyUrl) {
console.log(chalk.yellow('\n💡 Proxy is configured but connection failed.'));
console.log(chalk.yellow(` Proxy: ${proxyUrl}`));
}
else {
console.log(chalk.yellow('\n💡 For proxy support, set PROXY_SERVER_URI, HTTPS_PROXY, or HTTP_PROXY environment variable'));
}
process.exit(1);
}
if (response.status !== 200) {
console.log(chalk.bgRed(`\nInvalid license key`));
console.log(`\nTried to verify your ${PLANS[plugin]} license, but your license key is not valid. Please check your license key or purchase a license at https://eventcatalog.cloud/\n`);
return false;
}
if (response.status === 200) {
const data = (await response.json());
if (plugin !== data.plugin) {
console.log(chalk.bgRed(`\nInvalid license key for this plugin`));
console.log(`\nInvalid license key for ${PLANS[plugin]} license, please check your license key or purchase a license at https://eventcatalog.cloud/\n`);
return false;
}
let message = `${PLANS[plugin]} license is enabled for EventCatalog`;
if (data.is_trial) {
message += `\nYou are using a trial license for ${PLANS[plugin]}. Please upgrade to a paid version to continue using EventCatalog.`;
}
if (PLANS[plugin]) {
console.log(boxen(message, {
padding: 1,
margin: 1,
borderColor: 'green',
title: PLANS[plugin],
titleAlignment: 'center',
}));
}
}
return true;
}
/**
* Checks if a specific plugin/feature is enabled in the current license
*
* @param name Plugin or feature name to check
* @returns true if the feature is enabled, false otherwise
*/
export function isFeatureEnabled(name) {
if (!cachedEntitlements?.plugins) {
return false;
}
const isEnabled = cachedEntitlements.plugins.some((plugin) => plugin === name);
let message = `${name} is enabled for EventCatalog`;
if (isEnabled) {
console.log(boxen(message, {
padding: 1,
margin: 1,
borderColor: 'green',
title: name,
titleAlignment: 'center',
}));
}
return isEnabled;
}
/**
* Returns the currently cached entitlements, if any
*
* @returns Cached entitlements or null if not verified yet
*/
export function getEntitlements() {
return cachedEntitlements;
}
/**
* Clears the cached entitlements (useful for testing)
*/
export function clearCache() {
cachedEntitlements = null;
}
/**
* Utility to normalize plugin names for comparison
* Handles both string and object plugin specifications
*
* @param plugins Array of plugin specifications
* @returns Array of normalized plugin names
*/
export function normalizePluginNames(plugins) {
return plugins.map((plugin) => (typeof plugin === 'string' ? plugin : plugin.name));
}
/**
* Checks if an offline license key is available
* @returns true if a license file is found, false otherwise
*/
export function hasOfflineLicenseKey() {
return findLicenseFile() !== null;
}
/**
* Finds license file using discovery order
*/
function findLicenseFile() {
const candidates = [
process.env.EC_LICENSE,
path.join(process.env.PROJECT_DIR || process.cwd(), 'license.jwt'),
'./license.jwt',
'/etc/eventcatalog/license.jwt',
].filter(Boolean);
for (const candidate of candidates) {
if (existsSync(candidate)) {
return candidate;
}
}
return null;
}
/**
* Performs additional validation checks beyond basic JWT verification
*/
async function performAdditionalChecks(entitlements, options) {
const { currentVersion, fingerprintProvider } = options;
// Machine fingerprint check
if (entitlements.fingerprint && fingerprintProvider) {
const actualFingerprint = fingerprintProvider();
if (actualFingerprint && actualFingerprint !== entitlements.fingerprint) {
const licenseError = new Error('License fingerprint mismatch - license is bound to a different machine');
licenseError.code = 'LICENSE_FINGERPRINT_MISMATCH';
throw licenseError;
}
}
// Optional: Version range check (commented out - requires semver dependency)
/*
if (entitlements.ecVersionRange && currentVersion) {
// TODO: Add semver dependency and implement version range checking
// import semver from 'semver';
// if (!semver.satisfies(currentVersion, entitlements.ecVersionRange)) {
// throw new Error(`EventCatalog version ${currentVersion} does not satisfy license requirement: ${entitlements.ecVersionRange}`);
// }
}
*/
}
/**
* Validates license key format
* Expected format: XXXX-XXXX-XXXX-XXXX-XXXX-XXXX (24 alphanumeric characters with dashes)
* @param key - The license key to validate
* @returns true if valid format, false otherwise
*/
function isValidLicenseKeyFormat(key) {
if (typeof key !== 'string') {
return false;
}
// Remove any whitespace
const trimmedKey = key.trim();
// Check basic format: 6 groups of 4 characters separated by dashes
const licenseKeyPattern = /^[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$/;
if (!licenseKeyPattern.test(trimmedKey)) {
return false;
}
// Additional validation: ensure it's exactly 29 characters (24 alphanumeric + 5 dashes)
if (trimmedKey.length !== 29) {
return false;
}
// Ensure no consecutive dashes or invalid characters
if (trimmedKey.includes('--') || /[^A-Z0-9-]/.test(trimmedKey)) {
return false;
}
return true;
}
//# sourceMappingURL=verify.js.map