import { CanActivate, ExecutionContext, ForbiddenException, Injectable, Logger, UnauthorizedException, } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import { IntegrationScope } from '@prisma/client'; import { createHash, timingSafeEqual } from 'crypto'; import { Request } from 'express'; import { REQUIRE_SCOPE_KEY } from '../decorators/require-scope.decorator'; import { IntegrationApiKeyForGuard, IntegrationApiKeysRepository, } from '../../modules/integration-api-keys/integration-api-keys.repository'; export interface RequestWithApiKey extends Request { apiKey?: IntegrationApiKeyForGuard; } @Injectable() export class ApiKeyGuard implements CanActivate { private readonly logger = new Logger(ApiKeyGuard.name); constructor( private readonly reflector: Reflector, private readonly repository: IntegrationApiKeysRepository, ) {} async canActivate(context: ExecutionContext): Promise { const request = context.switchToHttp().getRequest(); const headerValue = this.extractHeader(request); if (!headerValue) { throw new UnauthorizedException('API Key ausente'); } const computedHash = createHash('sha256').update(headerValue).digest('hex'); const apiKey = await this.repository.findByHashedKey(computedHash); if (!apiKey) { throw new UnauthorizedException('API Key inválida'); } if (!this.constantTimeEqualsHex(computedHash, apiKey.hashedKey)) { throw new UnauthorizedException('API Key inválida'); } if (!apiKey.isActive) { throw new UnauthorizedException('API Key revogada'); } if (apiKey.expiresAt && apiKey.expiresAt.getTime() < Date.now()) { throw new UnauthorizedException('API Key expirada'); } const requiredScope = this.reflector.getAllAndOverride( REQUIRE_SCOPE_KEY, [context.getHandler(), context.getClass()], ); if (!requiredScope) { throw new ForbiddenException('Endpoint sem escopo declarado'); } if (!apiKey.scopes.includes(requiredScope)) { throw new ForbiddenException('Escopo insuficiente'); } request.apiKey = apiKey; void this.repository.touchLastUsed(apiKey.id).catch((err: unknown) => { this.logger.warn(`Falha ao atualizar lastUsedAt: ${(err as Error).message}`); }); return true; } private extractHeader(request: Request): string | null { const raw = request.headers['x-api-key']; if (!raw) return null; if (Array.isArray(raw)) return raw[0] ?? null; return raw; } private constantTimeEqualsHex(a: string, b: string): boolean { if (a.length !== b.length) return false; try { return timingSafeEqual(Buffer.from(a, 'hex'), Buffer.from(b, 'hex')); } catch { return false; } } }