import { CanActivate, ExecutionContext, Injectable, Logger, UnauthorizedException, } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { timingSafeEqual } from "node:crypto"; import { Request } from "express"; /** * Shared-secret service-to-service auth for the internal surface (/payments/*). * Callers send `x-service-token: ` (or `Authorization: Bearer …`). * Webhook endpoints are intentionally NOT behind this guard — they are provider-facing and * authenticate via signature verification instead. */ @Injectable() export class ServiceAuthGuard implements CanActivate { private readonly logger = new Logger(ServiceAuthGuard.name); private readonly token: string; private warned = false; constructor(config: ConfigService) { this.token = config.get("app.serviceAuthToken") ?? ""; if (!this.token && process.env.NODE_ENV === "production") { throw new Error("SERVICE_AUTH_TOKEN must be set in production"); } } canActivate(context: ExecutionContext): boolean { if (!this.token) { if (!this.warned) { this.logger.warn( "SERVICE_AUTH_TOKEN unset — internal endpoints are UNGUARDED (dev only)", ); this.warned = true; } return true; } const request = context.switchToHttp().getRequest(); const header = request.headers["x-service-token"]; const bearer = request.headers.authorization?.replace(/^Bearer\s+/i, ""); const presented = (Array.isArray(header) ? header[0] : header) ?? bearer ?? ""; const expected = Buffer.from(this.token); const actual = Buffer.from(presented); const valid = expected.length === actual.length && timingSafeEqual(expected, actual); if (!valid) throw new UnauthorizedException("Invalid service token"); return true; } }