Files
dashcaddy/dashcaddy-api/auth-manager.js

308 lines
9.0 KiB
JavaScript

/**
* 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');
const { safeLog } = require('./logger-utils');
// JWT signing secret - derived from encryption key for consistency
// SECURITY: Loaded from secure storage, never logged
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<string>} 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 },
);
// SECURITY: Log event only, never log the actual token
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<Object|null>} 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') {
// SECURITY: Never log the actual token
console.log('[AuthManager] JWT token invalid');
} 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<string>} scopes - Permission scopes (default: ['read', 'write'])
* @returns {Promise<Object>} { 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);
// SECURITY: Log event only, never log the actual API key
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<Object|null>} { keyId, scopes, name } or null if invalid
*/
async verifyAPIKey(key) {
try {
// Parse key format: dk_<keyId>_<secret>
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<boolean>} 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<Object>>} 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<Object|null>} 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<void>}
*/
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();