UNPKG

@eventcatalog/license

Version:

License verification for EventCatalog

333 lines 13.7 kB
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