import crypto from 'crypto'; import { query, queryOne } from '../../config/database.js'; import { ValidationError, NotFoundError, UnauthorizedError } from '../../shared/types/index.js'; import { logger } from '../../shared/utils/logger.js'; // ============================================================================ // TYPES // ============================================================================ export interface ApiKey { id: string; user_id: string; tenant_id: string; name: string; key_index: string; key_hash: string; scope: string | null; allowed_ips: string[] | null; expiration_date: Date | null; last_used_at: Date | null; is_active: boolean; created_at: Date; updated_at: Date; } export interface CreateApiKeyDto { user_id: string; tenant_id: string; name: string; scope?: string; allowed_ips?: string[]; expiration_days?: number; } export interface UpdateApiKeyDto { name?: string; scope?: string; allowed_ips?: string[]; expiration_date?: Date | null; is_active?: boolean; } export interface ApiKeyWithPlainKey { apiKey: Omit; plainKey: string; } export interface ApiKeyValidationResult { valid: boolean; apiKey?: ApiKey; user?: { id: string; tenant_id: string; email: string; roles: string[]; }; error?: string; } export interface ApiKeyFilters { user_id?: string; tenant_id?: string; is_active?: boolean; scope?: string; } // ============================================================================ // CONSTANTS // ============================================================================ const API_KEY_PREFIX = 'mgn_'; const KEY_LENGTH = 32; // 32 bytes = 256 bits const HASH_ITERATIONS = 100000; const HASH_KEYLEN = 64; const HASH_DIGEST = 'sha512'; // ============================================================================ // SERVICE // ============================================================================ class ApiKeysService { /** * Generate a cryptographically secure API key */ private generatePlainKey(): string { const randomBytes = crypto.randomBytes(KEY_LENGTH); const key = randomBytes.toString('base64url'); return `${API_KEY_PREFIX}${key}`; } /** * Extract the key index (first 16 chars after prefix) for lookup */ private getKeyIndex(plainKey: string): string { const keyWithoutPrefix = plainKey.replace(API_KEY_PREFIX, ''); return keyWithoutPrefix.substring(0, 16); } /** * Hash the API key using PBKDF2 */ private async hashKey(plainKey: string): Promise { const salt = crypto.randomBytes(16).toString('hex'); return new Promise((resolve, reject) => { crypto.pbkdf2( plainKey, salt, HASH_ITERATIONS, HASH_KEYLEN, HASH_DIGEST, (err, derivedKey) => { if (err) reject(err); resolve(`${salt}:${derivedKey.toString('hex')}`); } ); }); } /** * Verify a plain key against a stored hash */ private async verifyKey(plainKey: string, storedHash: string): Promise { const [salt, hash] = storedHash.split(':'); return new Promise((resolve, reject) => { crypto.pbkdf2( plainKey, salt, HASH_ITERATIONS, HASH_KEYLEN, HASH_DIGEST, (err, derivedKey) => { if (err) reject(err); resolve(derivedKey.toString('hex') === hash); } ); }); } /** * Create a new API key * Returns the plain key only once - it cannot be retrieved later */ async create(dto: CreateApiKeyDto): Promise { // Validate user exists const user = await queryOne<{ id: string }>( 'SELECT id FROM auth.users WHERE id = $1 AND tenant_id = $2', [dto.user_id, dto.tenant_id] ); if (!user) { throw new ValidationError('Usuario no encontrado'); } // Check for duplicate name const existing = await queryOne<{ id: string }>( 'SELECT id FROM auth.api_keys WHERE user_id = $1 AND name = $2', [dto.user_id, dto.name] ); if (existing) { throw new ValidationError('Ya existe una API key con ese nombre'); } // Generate key const plainKey = this.generatePlainKey(); const keyIndex = this.getKeyIndex(plainKey); const keyHash = await this.hashKey(plainKey); // Calculate expiration date let expirationDate: Date | null = null; if (dto.expiration_days) { expirationDate = new Date(); expirationDate.setDate(expirationDate.getDate() + dto.expiration_days); } // Insert API key const apiKey = await queryOne( `INSERT INTO auth.api_keys ( user_id, tenant_id, name, key_index, key_hash, scope, allowed_ips, expiration_date, is_active ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, true) RETURNING id, user_id, tenant_id, name, key_index, scope, allowed_ips, expiration_date, is_active, created_at, updated_at`, [ dto.user_id, dto.tenant_id, dto.name, keyIndex, keyHash, dto.scope || null, dto.allowed_ips || null, expirationDate, ] ); if (!apiKey) { throw new Error('Error al crear API key'); } logger.info('API key created', { apiKeyId: apiKey.id, userId: dto.user_id, name: dto.name }); return { apiKey, plainKey, // Only returned once! }; } /** * Find all API keys for a user/tenant */ async findAll(filters: ApiKeyFilters): Promise[]> { const conditions: string[] = []; const params: any[] = []; let paramIndex = 1; if (filters.user_id) { conditions.push(`user_id = $${paramIndex++}`); params.push(filters.user_id); } if (filters.tenant_id) { conditions.push(`tenant_id = $${paramIndex++}`); params.push(filters.tenant_id); } if (filters.is_active !== undefined) { conditions.push(`is_active = $${paramIndex++}`); params.push(filters.is_active); } if (filters.scope) { conditions.push(`scope = $${paramIndex++}`); params.push(filters.scope); } const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; const apiKeys = await query( `SELECT id, user_id, tenant_id, name, key_index, scope, allowed_ips, expiration_date, last_used_at, is_active, created_at, updated_at FROM auth.api_keys ${whereClause} ORDER BY created_at DESC`, params ); return apiKeys; } /** * Find a specific API key by ID */ async findById(id: string, tenantId: string): Promise | null> { const apiKey = await queryOne( `SELECT id, user_id, tenant_id, name, key_index, scope, allowed_ips, expiration_date, last_used_at, is_active, created_at, updated_at FROM auth.api_keys WHERE id = $1 AND tenant_id = $2`, [id, tenantId] ); return apiKey; } /** * Update an API key */ async update(id: string, tenantId: string, dto: UpdateApiKeyDto): Promise> { const existing = await this.findById(id, tenantId); if (!existing) { throw new NotFoundError('API key no encontrada'); } const updates: string[] = ['updated_at = NOW()']; const params: any[] = []; let paramIndex = 1; if (dto.name !== undefined) { updates.push(`name = $${paramIndex++}`); params.push(dto.name); } if (dto.scope !== undefined) { updates.push(`scope = $${paramIndex++}`); params.push(dto.scope); } if (dto.allowed_ips !== undefined) { updates.push(`allowed_ips = $${paramIndex++}`); params.push(dto.allowed_ips); } if (dto.expiration_date !== undefined) { updates.push(`expiration_date = $${paramIndex++}`); params.push(dto.expiration_date); } if (dto.is_active !== undefined) { updates.push(`is_active = $${paramIndex++}`); params.push(dto.is_active); } params.push(id); params.push(tenantId); const updated = await queryOne( `UPDATE auth.api_keys SET ${updates.join(', ')} WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex} RETURNING id, user_id, tenant_id, name, key_index, scope, allowed_ips, expiration_date, last_used_at, is_active, created_at, updated_at`, params ); if (!updated) { throw new Error('Error al actualizar API key'); } logger.info('API key updated', { apiKeyId: id }); return updated; } /** * Revoke (soft delete) an API key */ async revoke(id: string, tenantId: string): Promise { const result = await query( `UPDATE auth.api_keys SET is_active = false, updated_at = NOW() WHERE id = $1 AND tenant_id = $2`, [id, tenantId] ); if (!result) { throw new NotFoundError('API key no encontrada'); } logger.info('API key revoked', { apiKeyId: id }); } /** * Delete an API key permanently */ async delete(id: string, tenantId: string): Promise { const result = await query( 'DELETE FROM auth.api_keys WHERE id = $1 AND tenant_id = $2', [id, tenantId] ); logger.info('API key deleted', { apiKeyId: id }); } /** * Validate an API key and return the associated user info * This is the main method used by the authentication middleware */ async validate(plainKey: string, clientIp?: string): Promise { // Check prefix if (!plainKey.startsWith(API_KEY_PREFIX)) { return { valid: false, error: 'Formato de API key inválido' }; } // Extract key index for lookup const keyIndex = this.getKeyIndex(plainKey); // Find API key by index const apiKey = await queryOne( `SELECT * FROM auth.api_keys WHERE key_index = $1 AND is_active = true`, [keyIndex] ); if (!apiKey) { return { valid: false, error: 'API key no encontrada o inactiva' }; } // Verify hash const isValid = await this.verifyKey(plainKey, apiKey.key_hash); if (!isValid) { return { valid: false, error: 'API key inválida' }; } // Check expiration if (apiKey.expiration_date && new Date(apiKey.expiration_date) < new Date()) { return { valid: false, error: 'API key expirada' }; } // Check IP whitelist if (apiKey.allowed_ips && apiKey.allowed_ips.length > 0 && clientIp) { if (!apiKey.allowed_ips.includes(clientIp)) { logger.warn('API key IP not allowed', { apiKeyId: apiKey.id, clientIp, allowedIps: apiKey.allowed_ips }); return { valid: false, error: 'IP no autorizada' }; } } // Get user info with roles const user = await queryOne<{ id: string; tenant_id: string; email: string; role_codes: string[]; }>( `SELECT u.id, u.tenant_id, u.email, array_agg(r.code) as role_codes FROM auth.users u LEFT JOIN auth.user_roles ur ON u.id = ur.user_id LEFT JOIN auth.roles r ON ur.role_id = r.id WHERE u.id = $1 AND u.status = 'active' GROUP BY u.id`, [apiKey.user_id] ); if (!user) { return { valid: false, error: 'Usuario asociado no encontrado o inactivo' }; } // Update last used timestamp (async, don't wait) query( 'UPDATE auth.api_keys SET last_used_at = NOW() WHERE id = $1', [apiKey.id] ).catch(err => logger.error('Error updating last_used_at', { error: err })); return { valid: true, apiKey, user: { id: user.id, tenant_id: user.tenant_id, email: user.email, roles: user.role_codes?.filter(Boolean) || [], }, }; } /** * Regenerate an API key (creates new key, invalidates old) */ async regenerate(id: string, tenantId: string): Promise { const existing = await queryOne( 'SELECT * FROM auth.api_keys WHERE id = $1 AND tenant_id = $2', [id, tenantId] ); if (!existing) { throw new NotFoundError('API key no encontrada'); } // Generate new key const plainKey = this.generatePlainKey(); const keyIndex = this.getKeyIndex(plainKey); const keyHash = await this.hashKey(plainKey); // Update with new key const updated = await queryOne( `UPDATE auth.api_keys SET key_index = $1, key_hash = $2, updated_at = NOW() WHERE id = $3 AND tenant_id = $4 RETURNING id, user_id, tenant_id, name, key_index, scope, allowed_ips, expiration_date, is_active, created_at, updated_at`, [keyIndex, keyHash, id, tenantId] ); if (!updated) { throw new Error('Error al regenerar API key'); } logger.info('API key regenerated', { apiKeyId: id }); return { apiKey: updated, plainKey, }; } } export const apiKeysService = new ApiKeysService();