import { Injectable, CanActivate, ExecutionContext, UnauthorizedException, ForbiddenException } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import { ConfigService } from '@nestjs/config'; import { Request } from 'express'; import { AuthService } from '../auth.service'; import { ApiKeyRole } from '../entities/api-key.entity'; import { REQUIRED_ROLE_KEY, PUBLIC_KEY, SESSION_SCOPED_KEY } from '../decorators/auth.decorators'; import { resolveClientIp } from '../../../common/utils/ip'; import { setRequestActor } from '../../../common/services/request-context'; import { AuditService } from '../../audit/audit.service'; import { AuditAction } from '../../audit/entities/audit-log.entity'; @Injectable() export class ApiKeyGuard implements CanActivate { constructor( private readonly authService: AuthService, private readonly reflector: Reflector, private readonly configService: ConfigService, private readonly auditService: AuditService, ) {} async canActivate(context: ExecutionContext): Promise { // Check if route is marked as public const isPublic = this.reflector.getAllAndOverride(PUBLIC_KEY, [context.getHandler(), context.getClass()]); if (isPublic) { return true; } const request = context.switchToHttp().getRequest(); try { return await this.authorize(request, context); } catch (err) { // Record rejected/denied authentication attempts so the audit log has a forensic trail for // credential probing. Fire-and-forget: audit logging is best-effort and must never turn a // 401/403 into a failure of the guard itself. if (err instanceof UnauthorizedException || err instanceof ForbiddenException) { // Stamp at least the IP so the failed-auth audit row below is attributable even though the // key was never resolved. setRequestActor is a no-op outside a request scope. setRequestActor({ ipAddress: this.getClientIp(request) }); void this.auditService.logWarn(AuditAction.API_KEY_AUTH_FAILED, { ipAddress: this.getClientIp(request), method: request.method, path: request.path, errorMessage: err.message, }); } throw err; } } private async authorize(request: Request, context: ExecutionContext): Promise { const apiKeyHeader = this.extractApiKey(request); if (!apiKeyHeader) { throw new UnauthorizedException('API key is required'); } const requiredRole = this.reflector.getAllAndOverride(REQUIRED_ROLE_KEY, [ context.getHandler(), context.getClass(), ]); // Resolve the session id used for the key's allowedSessions scope. `:sessionId` is always a // session; the bare `:id` param is only a session on controllers marked @SessionScoped (i.e. // SessionController) — on other routes `:id` is an unrelated resource id (API key, plugin, …) // and must NOT be fed to the allowedSessions check, which would spuriously deny a scoped key. const sessionScoped = this.reflector.getAllAndOverride(SESSION_SCOPED_KEY, [ context.getHandler(), context.getClass(), ]); const sessionId = (request.params['sessionId'] || (sessionScoped ? request.params['id'] : undefined)) as string | undefined; const clientIp = this.getClientIp(request); // Validate API key const apiKey = await this.authService.validateApiKey(apiKeyHeader, clientIp, sessionId); if (requiredRole && !this.authService.hasPermission(apiKey, requiredRole)) { throw new ForbiddenException(`Insufficient permissions. Required: ${requiredRole}`); } // Attach API key to request for use in controllers (request as Request & { apiKey: typeof apiKey }).apiKey = apiKey; // Expose the trusted-proxy-aware client IP so controllers (e.g. the audit trail on key lifecycle // ops) reuse the already-resolved value instead of re-deriving it. (request as Request & { clientIp?: string }).clientIp = clientIp; // Stamp the resolved actor into the per-request async context so downstream audit log writes — // which fire from services deep in the call stack without DI access to the key — can attribute // the action to this key + IP. Without this every audit row's apiKey/ipAddress column is blank // because call sites pass only { sessionId } etc. setRequestActor({ apiKeyId: apiKey.id, apiKeyName: apiKey.name, ipAddress: clientIp }); return true; } private extractApiKey(request: Request): string | undefined { // Support both X-API-Key header and Authorization Bearer const xApiKey = request.headers['x-api-key'] as string; if (xApiKey) return xApiKey; const authHeader = request.headers['authorization']; if (authHeader?.startsWith('Bearer ')) { return authHeader.substring(7); } return undefined; } /** * Resolve the real client IP used for the API key's allowedIps whitelist. * * X-Forwarded-For is client-controllable, so it is only honored when the * request actually arrives from a configured trusted proxy (TRUSTED_PROXIES). * With no trusted proxies configured, the header is ignored entirely and the * direct socket address is used — preventing IP-whitelist spoofing. */ private getClientIp(request: Request): string { const trustedProxies = this.configService.get('security.trustedProxies') ?? []; return resolveClientIp(request, trustedProxies); } }