Skip to content

feat(security): implementar autenticación por API Key en core #49

@zkCaleb-dev

Description

@zkCaleb-dev

Descripción

Actualmente los endpoints de apps/core son accesibles sin ningún tipo de autenticación. Se necesita proteger todos los endpoints del módulo core para que únicamente las aplicaciones backoffice e investors puedan consumirlos. Cualquier request que no provenga de una de estas dos aplicaciones debe ser rechazado con 401 Unauthorized.


Contexto

Arquitectura del sistema

El sistema está compuesto por tres aplicaciones:

  • core: API central que expone endpoints de deploy de contratos Soroban y operaciones contra la base de datos (campaigns, vaults, etc).
  • backoffice: Aplicación interna de administración. Consume core para gestionar campañas y desplegar contratos.
  • investors: Aplicación orientada a inversores. Consume core para leer y escribir datos del flujo de inversión.

Ningún cliente externo debe tener acceso directo a core. La comunicación es estrictamente service-to-service.

Por qué API Keys y no JWT

JWT es el mecanismo correcto cuando hay usuarios humanos autenticándose. En este caso los consumidores son servicios, no usuarios, por lo que JWT añade complejidad innecesaria (emisión de tokens, expiración, refresh, etc).

Una API Key por servicio es la solución estándar para este escenario: simple, sin infraestructura adicional, y suficientemente segura si las keys se generan y almacenan correctamente.

Generación de las API Keys

Cada key debe generarse con crypto.randomBytes(32) de Node.js, que produce 256 bits de entropía aleatoria. Nunca deben ser strings inventados manualmente.

# Key para backoffice
node -e "console.log('BACKOFFICE_API_KEY=' + require('crypto').randomBytes(32).toString('hex'))"

# Key para investors
node -e "console.log('INVESTORS_API_KEY=' + require('crypto').randomBytes(32).toString('hex'))"

Los valores generados se distribuyen así:

  • core .env: almacena ambas keys para poder validar de qué servicio proviene cada request.
  • backoffice .env: almacena únicamente su propia key.
  • investors .env: almacena únicamente su propia key.

Cada aplicación solo conoce su propia key. Nunca se comparten entre sí.

Flujo de autenticación

Cada request de backoffice o investors hacia core debe incluir el header x-api-key con su key correspondiente. El ApiKeyGuard en core intercepta todos los requests, extrae el header, y verifica si la key existe en el conjunto de keys válidas. Si no existe o el header está ausente, el request es rechazado antes de llegar a cualquier controller.

backoffice  ──[x-api-key: BACKOFFICE_KEY]──►  core (ApiKeyGuard valida)  ──►  Controller
investors   ──[x-api-key: INVESTORS_KEY]───►  core (ApiKeyGuard valida)  ──►  Controller
tercero     ──[x-api-key: key-inválida]────►  core (ApiKeyGuard rechaza) ──►  401 Unauthorized

Tareas

En apps/core

  • Generar las dos API Keys usando el comando indicado en la sección de contexto y agregarlas al .env del servidor. Agregar las variables al .env.example sin valor:
# .env.example
BACKOFFICE_API_KEY=
INVESTORS_API_KEY=
  • Crear apps/core/src/common/guards/api-key.guard.ts que implemente CanActivate. El guard debe:
    • Leer el header x-api-key del request entrante.
    • Verificar si el valor existe en un Set construido desde process.env.BACKOFFICE_API_KEY y process.env.INVESTORS_API_KEY.
    • Retornar true si la key es válida.
    • Lanzar UnauthorizedException si la key no existe o el header está ausente.
// apps/core/src/common/guards/api-key.guard.ts
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';

@Injectable()
export class ApiKeyGuard implements CanActivate {
  private readonly validKeys = new Set([
    process.env.BACKOFFICE_API_KEY,
    process.env.INVESTORS_API_KEY,
  ]);

  canActivate(context: ExecutionContext): boolean {
    const request = context.switchToHttp().getRequest();
    const apiKey = request.headers['x-api-key'];

    if (!apiKey || !this.validKeys.has(apiKey)) {
      throw new UnauthorizedException('Invalid or missing API key');
    }

    return true;
  }
}
  • Registrar el guard de forma global en apps/core/src/main.ts para que aplique a todos los endpoints sin necesidad de decorarlo en cada controller:
// apps/core/src/main.ts
import { ApiKeyGuard } from './common/guards/api-key.guard';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalGuards(new ApiKeyGuard());
  await app.listen(3000);
}

En apps/backoffice y apps/investors

  • Agregar la variable CORE_API_KEY al .env de cada aplicación con la key que le corresponde.
  • Agregar CORE_API_KEY= sin valor al .env.example de cada una.
  • Asegurarse de que todos los servicios que realizan HTTP requests hacia core incluyan el header en cada llamada:
headers: {
  'x-api-key': process.env.CORE_API_KEY,
}

Criterios de aceptación

  • Un request a cualquier endpoint de core sin el header x-api-key retorna 401 Unauthorized.
  • Un request con una key inválida o no reconocida retorna 401 Unauthorized.
  • Un request con la key correcta de backoffice o investors pasa el guard y llega al controller.
  • Las keys nunca están hardcodeadas en el código fuente.
  • Las keys no tienen valor en .env.example, solo el nombre de la variable.
  • El guard es global; no se añade ningún decorador en controllers ni endpoints individuales.
  • No se modifica ningún módulo existente más allá de main.ts.

Notas adicionales

  • En caso de que en el futuro se necesite un endpoint público (health check, por ejemplo), se puede implementar un decorador @Public() usando SetMetadata de NestJS que el guard respete, sin necesidad de remover el guard global.
  • La rotación de una key comprometida consiste en generar un nuevo valor con crypto.randomBytes(32), reemplazarlo en el .env del servidor y en la app cliente correspondiente. No requiere downtime ni migraciones.
  • Las keys deben almacenarse en el gestor de secretos del entorno de producción (AWS Secrets Manager, Doppler, o equivalente). El .env solo aplica para desarrollo local.

Metadata

Metadata

Assignees

Type

No type

Projects

Status

Backlog

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions