@ufdevsllc/auth-me
Version:
Comprehensive licensing, security monitoring, and data mirroring package with hardcoded vendor-controlled database connection
557 lines (483 loc) • 18.4 kB
JavaScript
/**
* OfflineManager - Handles offline mode functionality and graceful degradation
* Provides cached license validation and fallback mechanisms for network failures
*/
const fs = require('fs').promises;
const path = require('path');
const crypto = require('crypto');
const SecureGuardError = require('./SecureGuardError');
/**
* @typedef {Object} CachedLicense
* @property {string} key - License key
* @property {string} customerId - Customer identifier
* @property {string} planType - Plan type
* @property {Date} expirationDate - License expiration date
* @property {string} environmentFingerprint - Environment binding fingerprint
* @property {Object} usageLimits - Usage limits
* @property {string} status - License status
* @property {Date} cachedAt - When license was cached
* @property {Date} lastValidation - Last successful validation
* @property {string} checksum - Data integrity checksum
*/
/**
* @typedef {Object} OfflineConfig
* @property {number} cacheExpirationHours - Hours before cache expires
* @property {number} gracePeriodHours - Grace period for expired cache
* @property {boolean} allowOfflineMode - Whether offline mode is enabled
* @property {string} cacheDirectory - Directory for cache files
* @property {boolean} strictMode - Whether to enforce strict validation
*/
class OfflineManager {
constructor() {
/** @type {OfflineConfig} */
this.config = {
cacheExpirationHours: 24,
gracePeriodHours: 72,
allowOfflineMode: true,
cacheDirectory: path.join(process.cwd(), '.secure-guard-cache'),
strictMode: false
};
/** @type {Map<string, CachedLicense>} */
this.licenseCache = new Map();
/** @type {boolean} */
this.isOfflineMode = false;
/** @type {Date|null} */
this.lastOnlineTime = null;
/** @type {boolean} */
this.verboseLogging = false;
/** @type {string|null} */
this.cacheFilePath = null;
}
/**
* Initialize the offline manager
* @param {OfflineConfig} [config] - Configuration options
* @param {boolean} [verboseLogging] - Enable verbose logging
* @returns {Promise<void>}
*/
async initialize(config = {}, verboseLogging = false) {
this.config = { ...this.config, ...config };
this.verboseLogging = verboseLogging;
this.cacheFilePath = path.join(this.config.cacheDirectory, 'license-cache.json');
// Ensure cache directory exists
await this._ensureCacheDirectory();
// Load existing cache
await this.loadCache();
if (this.verboseLogging) {
console.log('[OfflineManager] Initialized with config:', this.config);
console.log(`[OfflineManager] Cache loaded with ${this.licenseCache.size} entries`);
}
}
/**
* Cache a valid license for offline use
* @param {Object} license - License data to cache
* @param {string} licenseKey - License key
* @returns {Promise<void>}
*/
async cacheLicense(license, licenseKey) {
if (!license || !licenseKey) {
throw new Error('License data and key are required');
}
const now = new Date();
const cachedLicense = {
key: licenseKey,
customerId: license.customerId,
planType: license.planType,
expirationDate: new Date(license.expirationDate),
environmentFingerprint: license.environmentFingerprint,
usageLimits: { ...license.usageLimits },
status: license.status,
cachedAt: now,
lastValidation: new Date(license.lastValidation || now),
checksum: this._calculateChecksum(license, licenseKey)
};
this.licenseCache.set(licenseKey, cachedLicense);
this.lastOnlineTime = now;
// Persist cache to disk
await this.saveCache();
if (this.verboseLogging) {
console.log(`[OfflineManager] Cached license: ${licenseKey.substring(0, 8)}...`);
}
}
/**
* Validate license using cached data when offline
* @param {string} licenseKey - License key to validate
* @param {string} [fingerprint] - Environment fingerprint
* @returns {Promise<Object>} Validation result
*/
async validateCachedLicense(licenseKey, fingerprint = null) {
if (!licenseKey) {
return {
isValid: false,
license: null,
reason: 'License key is required',
code: 'INVALID_FORMAT',
isOfflineValidation: true
};
}
const cachedLicense = this.licenseCache.get(licenseKey);
if (!cachedLicense) {
return {
isValid: false,
license: null,
reason: 'License not found in cache',
code: 'NOT_CACHED',
isOfflineValidation: true
};
}
// Verify cache integrity
const expectedChecksum = this._calculateChecksum(cachedLicense, licenseKey);
if (cachedLicense.checksum !== expectedChecksum) {
if (this.verboseLogging) {
console.warn('[OfflineManager] Cache integrity check failed, removing corrupted entry');
}
this.licenseCache.delete(licenseKey);
await this.saveCache();
return {
isValid: false,
license: null,
reason: 'Cached license data corrupted',
code: 'CACHE_CORRUPTED',
isOfflineValidation: true
};
}
// Check cache expiration
const cacheAge = Date.now() - cachedLicense.cachedAt.getTime();
const cacheExpirationMs = this.config.cacheExpirationHours * 60 * 60 * 1000;
const gracePeriodMs = this.config.gracePeriodHours * 60 * 60 * 1000;
if (cacheAge > cacheExpirationMs + gracePeriodMs) {
return {
isValid: false,
license: cachedLicense,
reason: 'Cached license expired beyond grace period',
code: 'CACHE_EXPIRED',
isOfflineValidation: true
};
}
// Check license expiration
const now = new Date();
if (cachedLicense.expirationDate < now) {
return {
isValid: false,
license: cachedLicense,
reason: 'License has expired',
code: 'EXPIRED',
isOfflineValidation: true
};
}
// Check environment binding if provided
if (fingerprint && cachedLicense.environmentFingerprint) {
if (cachedLicense.environmentFingerprint !== fingerprint) {
return {
isValid: false,
license: cachedLicense,
reason: 'Environment fingerprint mismatch',
code: 'ENVIRONMENT_MISMATCH',
isOfflineValidation: true
};
}
}
// Check if cache is stale but within grace period
const isStale = cacheAge > cacheExpirationMs;
const warningMessage = isStale ?
'License validated from stale cache (network unavailable)' :
'License validated from cache';
return {
isValid: true,
license: cachedLicense,
reason: warningMessage,
code: isStale ? 'CACHE_STALE' : 'CACHE_VALID',
isOfflineValidation: true,
cacheAge: cacheAge,
isStale: isStale
};
}
/**
* Enter offline mode
* @param {string} [reason] - Reason for entering offline mode
* @returns {void}
*/
enterOfflineMode(reason = 'Network unavailable') {
if (!this.isOfflineMode) {
this.isOfflineMode = true;
if (this.verboseLogging) {
console.log(`[OfflineManager] Entering offline mode: ${reason}`);
}
}
}
/**
* Exit offline mode
* @returns {void}
*/
exitOfflineMode() {
if (this.isOfflineMode) {
this.isOfflineMode = false;
this.lastOnlineTime = new Date();
if (this.verboseLogging) {
console.log('[OfflineManager] Exiting offline mode - network restored');
}
}
}
/**
* Check if currently in offline mode
* @returns {boolean} True if in offline mode
*/
isInOfflineMode() {
return this.isOfflineMode;
}
/**
* Get offline mode status and statistics
* @returns {Object} Offline mode status
*/
getOfflineStatus() {
const now = new Date();
const offlineDuration = this.isOfflineMode && this.lastOnlineTime ?
now.getTime() - this.lastOnlineTime.getTime() : 0;
return {
isOfflineMode: this.isOfflineMode,
lastOnlineTime: this.lastOnlineTime,
offlineDurationMs: offlineDuration,
cachedLicenses: this.licenseCache.size,
cacheDirectory: this.config.cacheDirectory,
allowOfflineMode: this.config.allowOfflineMode,
config: { ...this.config }
};
}
/**
* Clean expired cache entries
* @returns {Promise<number>} Number of entries removed
*/
async cleanExpiredCache() {
const now = new Date();
const gracePeriodMs = this.config.gracePeriodHours * 60 * 60 * 1000;
const cacheExpirationMs = this.config.cacheExpirationHours * 60 * 60 * 1000;
const totalExpirationMs = cacheExpirationMs + gracePeriodMs;
let removedCount = 0;
for (const [licenseKey, cachedLicense] of this.licenseCache.entries()) {
const cacheAge = now.getTime() - cachedLicense.cachedAt.getTime();
if (cacheAge > totalExpirationMs) {
this.licenseCache.delete(licenseKey);
removedCount++;
if (this.verboseLogging) {
console.log(`[OfflineManager] Removed expired cache entry: ${licenseKey.substring(0, 8)}...`);
}
}
}
if (removedCount > 0) {
await this.saveCache();
}
return removedCount;
}
/**
* Save cache to disk
* @returns {Promise<void>}
*/
async saveCache() {
if (!this.cacheFilePath) {
throw new Error('Cache file path not initialized');
}
try {
const cacheData = {
version: '1.0',
timestamp: new Date().toISOString(),
licenses: Array.from(this.licenseCache.entries()).map(([key, license]) => ({
key,
...license,
cachedAt: license.cachedAt.toISOString(),
lastValidation: license.lastValidation.toISOString(),
expirationDate: license.expirationDate.toISOString()
}))
};
const encryptedData = this._encryptCacheData(JSON.stringify(cacheData));
await fs.writeFile(this.cacheFilePath, encryptedData, 'utf8');
if (this.verboseLogging) {
console.log(`[OfflineManager] Cache saved to ${this.cacheFilePath}`);
}
} catch (error) {
if (this.verboseLogging) {
console.error(`[OfflineManager] Failed to save cache: ${error.message}`);
}
throw new Error(`Failed to save license cache: ${error.message}`);
}
}
/**
* Load cache from disk
* @returns {Promise<void>}
*/
async loadCache() {
if (!this.cacheFilePath) {
return;
}
try {
const encryptedData = await fs.readFile(this.cacheFilePath, 'utf8');
const decryptedData = this._decryptCacheData(encryptedData);
const cacheData = JSON.parse(decryptedData);
this.licenseCache.clear();
if (cacheData.licenses && Array.isArray(cacheData.licenses)) {
for (const licenseData of cacheData.licenses) {
const cachedLicense = {
...licenseData,
cachedAt: new Date(licenseData.cachedAt),
lastValidation: new Date(licenseData.lastValidation),
expirationDate: new Date(licenseData.expirationDate)
};
this.licenseCache.set(licenseData.key, cachedLicense);
}
}
if (this.verboseLogging) {
console.log(`[OfflineManager] Cache loaded from ${this.cacheFilePath}`);
}
} catch (error) {
if (error.code !== 'ENOENT') {
if (this.verboseLogging) {
console.warn(`[OfflineManager] Failed to load cache: ${error.message}`);
}
}
// Initialize empty cache if file doesn't exist or is corrupted
this.licenseCache.clear();
}
}
/**
* Clear all cached data
* @returns {Promise<void>}
*/
async clearCache() {
this.licenseCache.clear();
if (this.cacheFilePath) {
try {
await fs.unlink(this.cacheFilePath);
if (this.verboseLogging) {
console.log('[OfflineManager] Cache file deleted');
}
} catch (error) {
if (error.code !== 'ENOENT') {
if (this.verboseLogging) {
console.warn(`[OfflineManager] Failed to delete cache file: ${error.message}`);
}
}
}
}
if (this.verboseLogging) {
console.log('[OfflineManager] Cache cleared');
}
}
/**
* Get cached license information
* @param {string} licenseKey - License key
* @returns {CachedLicense|null} Cached license or null
*/
getCachedLicense(licenseKey) {
return this.licenseCache.get(licenseKey) || null;
}
/**
* Check if license is cached
* @param {string} licenseKey - License key
* @returns {boolean} True if cached
*/
isLicenseCached(licenseKey) {
return this.licenseCache.has(licenseKey);
}
/**
* Get cache statistics
* @returns {Object} Cache statistics
*/
getCacheStats() {
const now = new Date();
const stats = {
totalEntries: this.licenseCache.size,
validEntries: 0,
expiredEntries: 0,
staleEntries: 0,
corruptedEntries: 0
};
const cacheExpirationMs = this.config.cacheExpirationHours * 60 * 60 * 1000;
const gracePeriodMs = this.config.gracePeriodHours * 60 * 60 * 1000;
for (const [licenseKey, cachedLicense] of this.licenseCache.entries()) {
const cacheAge = now.getTime() - cachedLicense.cachedAt.getTime();
// Check integrity
const expectedChecksum = this._calculateChecksum(cachedLicense, licenseKey);
if (cachedLicense.checksum !== expectedChecksum) {
stats.corruptedEntries++;
continue;
}
// Check expiration
if (cacheAge > cacheExpirationMs + gracePeriodMs) {
stats.expiredEntries++;
} else if (cacheAge > cacheExpirationMs) {
stats.staleEntries++;
} else {
stats.validEntries++;
}
}
return stats;
}
/**
* Ensure cache directory exists
* @private
* @returns {Promise<void>}
*/
async _ensureCacheDirectory() {
try {
await fs.mkdir(this.config.cacheDirectory, { recursive: true });
} catch (error) {
if (error.code !== 'EEXIST') {
throw new Error(`Failed to create cache directory: ${error.message}`);
}
}
}
/**
* Calculate checksum for cache integrity
* @private
* @param {Object} license - License data
* @param {string} licenseKey - License key
* @returns {string} Checksum
*/
_calculateChecksum(license, licenseKey) {
const data = JSON.stringify({
key: licenseKey,
customerId: license.customerId,
planType: license.planType,
expirationDate: license.expirationDate,
environmentFingerprint: license.environmentFingerprint,
usageLimits: license.usageLimits,
status: license.status
});
return crypto.createHash('sha256').update(data).digest('hex');
}
/**
* Encrypt cache data for storage
* @private
* @param {string} data - Data to encrypt
* @returns {string} Encrypted data
*/
_encryptCacheData(data) {
// Simple encryption for cache data (not cryptographically secure)
// In production, use proper encryption
const key = crypto.createHash('sha256').update('secure-guard-cache-key').digest();
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipher('aes-256-cbc', key);
let encrypted = cipher.update(data, 'utf8', 'hex');
encrypted += cipher.final('hex');
return iv.toString('hex') + ':' + encrypted;
}
/**
* Decrypt cache data from storage
* @private
* @param {string} encryptedData - Encrypted data
* @returns {string} Decrypted data
*/
_decryptCacheData(encryptedData) {
try {
const key = crypto.createHash('sha256').update('secure-guard-cache-key').digest();
const parts = encryptedData.split(':');
const iv = Buffer.from(parts[0], 'hex');
const encrypted = parts[1];
const decipher = crypto.createDecipher('aes-256-cbc', key);
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
} catch (error) {
throw new Error('Failed to decrypt cache data');
}
}
}
module.exports = OfflineManager;