/** * 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('../security/crypto-utils'); const { log } = require('../utils/logging'); // 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 log.info('auth', '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 } ); log.info('auth', 'Generated JWT', { user: payload.sub, expiresIn }); return token; } catch (error) { log.error('auth', error, { operation: 'jwtGenerate' }); 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') { log.info('auth', 'JWT token expired'); } else if (error.name === 'JsonWebTokenError') { log.info('auth', 'JWT token invalid', { error: error.message }); } else { log.error('auth', error, { operation: 'jwtVerify' }); } 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); log.info('auth', 'Generated API key', { name, keyId }); return { key: apiKey, id: keyId, name, scopes, createdAt: metadata.createdAt }; } catch (error) { log.error('auth', error, { operation: 'apiKeyGenerate' }); 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) { log.info('auth', '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))) { log.info('auth', 'API key hash mismatch', { keyId }); return null; } // Get metadata const metadata = await this.getKeyMetadata(keyId); if (!metadata) { log.info('auth', 'API key metadata not found', { keyId }); return null; } // Update last used timestamp (non-blocking) this.updateLastUsed(keyId, metadata).catch(err => log.error('auth', err, { keyId, operation: 'updateLastUsed' }) ); log.info('auth', 'API key verified', { name: metadata.name, keyId }); return { keyId, scopes: metadata.scopes || [], name: metadata.name }; } catch (error) { log.error('auth', error, { operation: 'apiKeyVerify' }); 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); log.info('auth', 'Revoked API key', { keyId }); return true; } catch (error) { log.error('auth', error, { keyId, operation: 'revoke' }); 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) { log.error('auth', error, { operation: 'listApiKeys' }); 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) { log.error('auth', error, { keyId, operation: 'getMetadata' }); 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) { log.error('auth', error, { keyId, operation: 'updateLastUsed' }); } } /** * Clear metadata cache (useful for testing or cache invalidation) */ clearCache() { this.keyMetadataCache.clear(); log.info('auth', 'Cache cleared'); } } // Export singleton instance module.exports = new AuthManager();