Spaces:
Runtime error
Runtime error
| 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'; | |
| () | |
| 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<boolean> { | |
| // Check if route is marked as public | |
| const isPublic = this.reflector.getAllAndOverride<boolean>(PUBLIC_KEY, [context.getHandler(), context.getClass()]); | |
| if (isPublic) { | |
| return true; | |
| } | |
| const request = context.switchToHttp().getRequest<Request>(); | |
| 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<boolean> { | |
| const apiKeyHeader = this.extractApiKey(request); | |
| if (!apiKeyHeader) { | |
| throw new UnauthorizedException('API key is required'); | |
| } | |
| const requiredRole = this.reflector.getAllAndOverride<ApiKeyRole>(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<boolean>(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<string[]>('security.trustedProxies') ?? []; | |
| return resolveClientIp(request, trustedProxies); | |
| } | |
| } | |