Files
dashcaddy/dashcaddy-api/src/managers/auth-manager.js
T
Hermes 84f63a3261 [grade=A] P1-5, P1-6: replace 40 console.* calls in credential-manager.js + auth-manager.js
credential-manager.js: 20 console calls → log.info/warn/error tagged 'cred'.
auth-manager.js: 20 console calls → log.info/error tagged 'auth'.
Mixed-content strings extracted into meta payload (key, keyId, operation, etc).

1539/1539 Jest tests pass. ESLint: 4 pre-existing warnings unchanged.
2026-08-10 20:15:15 -07:00

304 lines
8.6 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('../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<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 }
);
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<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') {
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<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);
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<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) {
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<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);
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<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) {
log.error('auth', error, { operation: 'listApiKeys' });
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) {
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<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) {
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();