/** * Authentication Manager for DashCaddy * Handles JWT tokens and API key generation/validation * Provides defense-in-depth alongside Caddy forward_auth */ const jwt = require('jsonwebtoken'); const crypto = require('crypto'); const credentialManager = require('./credential-manager'); const cryptoUtils = require('./crypto-utils'); // JWT signing secret - derived from encryption key for consistency const JWT_SECRET = cryptoUtils.loadOrCreateKey(); // Namespace for API keys in credential manager const API_KEY_NAMESPACE = 'auth.apikey'; const API_KEY_METADATA_NAMESPACE = 'auth.metadata'; class AuthManager { constructor() { this.keyMetadataCache = new Map(); // Cache for API key metadata console.log('[AuthManager] Initialized'); } /** * Generate JWT token * @param {Object} payload - Token payload (must include sub: userId) * @param {string} expiresIn - Expiration time (default: '24h') * @returns {Promise} JWT token */ async generateJWT(payload, expiresIn = '24h') { try { if (!payload.sub) { throw new Error('JWT payload must include "sub" (subject/userId)'); } const token = jwt.sign( { ...payload, iat: Math.floor(Date.now() / 1000), scope: payload.scope || ['read', 'write'] }, JWT_SECRET, { expiresIn } ); console.log(`[AuthManager] Generated JWT for user: ${payload.sub}, expires in: ${expiresIn}`); return token; } catch (error) { console.error('[AuthManager] JWT generation failed:', error.message); throw error; } } /** * Verify JWT token * @param {string} token - JWT token to verify * @returns {Promise} Decoded payload or null if invalid */ async verifyJWT(token) { try { const decoded = jwt.verify(token, JWT_SECRET); return { userId: decoded.sub, scope: decoded.scope || [], iat: decoded.iat, exp: decoded.exp }; } catch (error) { if (error.name === 'TokenExpiredError') { console.log('[AuthManager] JWT token expired'); } else if (error.name === 'JsonWebTokenError') { console.log('[AuthManager] JWT token invalid:', error.message); } else { console.error('[AuthManager] JWT verification failed:', error.message); } return null; } } /** * Generate API key * @param {string} name - Human-readable name for the key * @param {Array} scopes - Permission scopes (default: ['read', 'write']) * @returns {Promise} { key, id, name, scopes, createdAt } */ async generateAPIKey(name, scopes = ['read', 'write']) { try { if (!name || typeof name !== 'string') { throw new Error('API key name is required'); } // Generate secure random key (32 bytes = 64 hex chars) const keyId = crypto.randomBytes(16).toString('hex'); const keySecret = crypto.randomBytes(32).toString('hex'); const apiKey = `dk_${keyId}_${keySecret}`; // dk = DashCaddy Key // Store key hash (not the key itself) in credential manager const keyHash = crypto.createHash('sha256').update(apiKey).digest('hex'); const credentialKey = `${API_KEY_NAMESPACE}.${keyId}`; await credentialManager.store(credentialKey, keyHash); // Store metadata separately (non-sensitive) const metadata = { id: keyId, name, scopes, createdAt: new Date().toISOString(), lastUsed: null }; const metadataKey = `${API_KEY_METADATA_NAMESPACE}.${keyId}`; await credentialManager.store(metadataKey, JSON.stringify(metadata)); // Cache metadata this.keyMetadataCache.set(keyId, metadata); console.log(`[AuthManager] Generated API key: ${name} (${keyId})`); return { key: apiKey, id: keyId, name, scopes, createdAt: metadata.createdAt }; } catch (error) { console.error('[AuthManager] API key generation failed:', error.message); throw error; } } /** * Verify API key * @param {string} key - API key to verify * @returns {Promise} { keyId, scopes, name } or null if invalid */ async verifyAPIKey(key) { try { // Parse key format: dk__ if (!key || !key.startsWith('dk_')) { return null; } const parts = key.split('_'); if (parts.length !== 3) { return null; } const keyId = parts[1]; const credentialKey = `${API_KEY_NAMESPACE}.${keyId}`; // Retrieve stored hash const storedHash = await credentialManager.retrieve(credentialKey); if (!storedHash) { console.log(`[AuthManager] API key not found: ${keyId}`); return null; } // Verify key matches stored hash const providedHash = crypto.createHash('sha256').update(key).digest('hex'); if (!crypto.timingSafeEqual(Buffer.from(storedHash), Buffer.from(providedHash))) { console.log(`[AuthManager] API key hash mismatch: ${keyId}`); return null; } // Get metadata const metadata = await this.getKeyMetadata(keyId); if (!metadata) { console.log(`[AuthManager] API key metadata not found: ${keyId}`); return null; } // Update last used timestamp (non-blocking) this.updateLastUsed(keyId, metadata).catch(err => console.error(`[AuthManager] Failed to update lastUsed for ${keyId}:`, err.message) ); console.log(`[AuthManager] API key verified: ${metadata.name} (${keyId})`); return { keyId, scopes: metadata.scopes || [], name: metadata.name }; } catch (error) { console.error('[AuthManager] API key verification failed:', error.message); return null; } } /** * Revoke API key * @param {string} keyId - Key ID to revoke * @returns {Promise} Success status */ async revokeAPIKey(keyId) { try { const credentialKey = `${API_KEY_NAMESPACE}.${keyId}`; const metadataKey = `${API_KEY_METADATA_NAMESPACE}.${keyId}`; await credentialManager.delete(credentialKey); await credentialManager.delete(metadataKey); this.keyMetadataCache.delete(keyId); console.log(`[AuthManager] Revoked API key: ${keyId}`); return true; } catch (error) { console.error(`[AuthManager] Failed to revoke API key ${keyId}:`, error.message); return false; } } /** * List all API keys (returns metadata, not actual keys) * @returns {Promise>} Array of API key metadata */ async listAPIKeys() { try { const allKeys = await credentialManager.list(); const metadataKeys = allKeys.filter(k => k.startsWith(API_KEY_METADATA_NAMESPACE)); const keys = []; for (const metaKey of metadataKeys) { const keyId = metaKey.replace(`${API_KEY_METADATA_NAMESPACE}.`, ''); const metadata = await this.getKeyMetadata(keyId); if (metadata) { keys.push(metadata); } } return keys; } catch (error) { console.error('[AuthManager] Failed to list API keys:', error.message); return []; } } /** * Get metadata for a specific API key * @param {string} keyId - Key ID * @returns {Promise} Metadata or null */ async getKeyMetadata(keyId) { try { // Check cache first if (this.keyMetadataCache.has(keyId)) { return this.keyMetadataCache.get(keyId); } const metadataKey = `${API_KEY_METADATA_NAMESPACE}.${keyId}`; const metadataJson = await credentialManager.retrieve(metadataKey); if (!metadataJson) { return null; } const metadata = JSON.parse(metadataJson); this.keyMetadataCache.set(keyId, metadata); return metadata; } catch (error) { console.error(`[AuthManager] Failed to get metadata for ${keyId}:`, error.message); return null; } } /** * Update last used timestamp for API key * @param {string} keyId - Key ID * @param {Object} metadata - Current metadata * @returns {Promise} */ async updateLastUsed(keyId, metadata) { try { const updatedMetadata = { ...metadata, lastUsed: new Date().toISOString() }; const metadataKey = `${API_KEY_METADATA_NAMESPACE}.${keyId}`; await credentialManager.store(metadataKey, JSON.stringify(updatedMetadata)); this.keyMetadataCache.set(keyId, updatedMetadata); } catch (error) { console.error(`[AuthManager] Failed to update lastUsed for ${keyId}:`, error.message); } } /** * Clear metadata cache (useful for testing or cache invalidation) */ clearCache() { this.keyMetadataCache.clear(); console.log('[AuthManager] Cache cleared'); } } // Export singleton instance module.exports = new AuthManager();