Created
September 8, 2026 10:13
-
-
Save Walid-Azur/0cecc9fd0a00a64c7270b229eb562686 to your computer and use it in GitHub Desktop.
Zero-trust NestJS: HMAC gateway guard where the signed org id is proof of origin, never authorization
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ============================================================================= | |
| // ddx-api — GatewayAuthGuard (HMAC verification, BFF-only entry) | |
| // ============================================================================= | |
| // Dudoxx UG / Acceleate Consulting - Walid Boudabbous <walid@acceleate.com> | |
| // | |
| // Guard chain position 2 (after ThrottlerGuard). Verifies the HMAC-signed | |
| // X-Gateway-* header set every trusted consumer (web BFF, seeder) presents, | |
| // keyed by a PER-CONSUMER secret (GATEWAY_API_KEY_{WEB,SEEDER}) plus the shared | |
| // GATEWAY_SIGNING_SECRET. Rejects unsigned / tampered / replayed sets 401 | |
| // BEFORE any downstream guard runs (invariant #7). | |
| // | |
| // @Public routes are re-checked here (IS_PUBLIC_KEY) so a public auth-flow | |
| // route (register / magic-link / …) is reachable without gateway identity — | |
| // mirrors the fail-closed PermissionGuard's @Public recheck (invariant #2). | |
| // | |
| // @RequireSession routes (CR-005) are ALSO re-checked here: signature | |
| // verification is NEVER weakened, but the orgId-PRESENCE requirement is | |
| // relaxed — a just-registered, membership-less user has no active-org cookie, | |
| // so the web BFF signs a userId-only identity (orgId = ''). Every other route | |
| // class keeps orgId mandatory. | |
| // | |
| // orgId is READ from the verified header but NEVER TRUSTED as authorization: | |
| // PrincipalContextInterceptor re-derives the acting org server-side. This | |
| // guard only proves "these headers were signed by a known consumer". | |
| // ============================================================================= | |
| import { | |
| CanActivate, | |
| ExecutionContext, | |
| Injectable, | |
| Logger, | |
| UnauthorizedException, | |
| } from '@nestjs/common'; | |
| import { ConfigService } from '@nestjs/config'; | |
| import { Reflector } from '@nestjs/core'; | |
| import type { Request } from 'express'; | |
| import type { Env } from '../../config/env'; | |
| import { IS_PUBLIC_KEY } from './public.decorator'; | |
| import { IS_REQUIRE_SESSION_KEY } from './require-session.decorator'; | |
| import { | |
| GATEWAY_HEADERS, | |
| type GatewayIdentity, | |
| verifyGatewaySignature, | |
| } from './gateway-signature'; | |
| /** | |
| * Per-request identity attached by the guard once the signature verifies. | |
| * `orgId` is `''` ONLY for a @RequireSession route carrying a signed | |
| * userId-only identity (CR-005 pre-org window, mirrors how `roles` already | |
| * tolerates '') — every other route class keeps orgId mandatory/non-empty | |
| * (invariant #5 unchanged). Consumers MUST narrow (`if (!principal.orgId)`), | |
| * never assert non-empty. | |
| */ | |
| export interface VerifiedGatewayPrincipal extends GatewayIdentity { | |
| /** Parsed role list (comma-split of the signed `roles` header). */ | |
| readonly roleList: readonly string[]; | |
| } | |
| /** Express request augmented with the verified gateway principal. */ | |
| export interface RequestWithGateway extends Request { | |
| gatewayPrincipal?: VerifiedGatewayPrincipal; | |
| } | |
| /** Replay-skew window: a signed set older/newer than this is rejected. */ | |
| const SKEW_MS = 5 * 60 * 1000; | |
| @Injectable() | |
| export class GatewayAuthGuard implements CanActivate { | |
| private readonly logger = new Logger(GatewayAuthGuard.name); | |
| /** Per-consumer signing keys, resolved once at construction. */ | |
| private readonly consumerKeys: ReadonlyMap<string, string>; | |
| private readonly signingSecret: string; | |
| constructor( | |
| private readonly reflector: Reflector, | |
| config: ConfigService<Env, true>, | |
| ) { | |
| this.signingSecret = config.get('GATEWAY_SIGNING_SECRET', { infer: true }); | |
| this.consumerKeys = new Map([ | |
| ['web', config.get('GATEWAY_API_KEY_WEB', { infer: true })], | |
| ['seeder', config.get('GATEWAY_API_KEY_SEEDER', { infer: true })], | |
| ]); | |
| } | |
| canActivate(context: ExecutionContext): boolean { | |
| const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [ | |
| context.getHandler(), | |
| context.getClass(), | |
| ]); | |
| if (isPublic === true) { | |
| return true; | |
| } | |
| // @RequireSession (CR-005): a verified principal is required but org | |
| // context is NOT — relax the orgId-PRESENCE check only for this route | |
| // class. Signature verification below is completely unaffected. | |
| const requireSession = this.reflector.getAllAndOverride<boolean>( | |
| IS_REQUIRE_SESSION_KEY, | |
| [context.getHandler(), context.getClass()], | |
| ); | |
| const orgIdRequired = requireSession !== true; | |
| const request = context.switchToHttp().getRequest<RequestWithGateway>(); | |
| const identity = this.readIdentity(request, orgIdRequired); | |
| if (!identity) { | |
| throw new UnauthorizedException('Missing gateway identity headers'); | |
| } | |
| const consumerKey = this.consumerKeys.get(identity.consumer); | |
| if (consumerKey === undefined) { | |
| this.logger.warn(`Unknown gateway consumer: ${identity.consumer}`); | |
| throw new UnauthorizedException('Unknown gateway consumer'); | |
| } | |
| const presentedSignature = this.header(request, GATEWAY_HEADERS.signature); | |
| if (!presentedSignature) { | |
| throw new UnauthorizedException('Missing gateway signature'); | |
| } | |
| // The signature MUST be checked against BOTH the shared signing secret AND | |
| // the per-consumer key: both are secrets a caller must hold. We derive the | |
| // effective key by mixing them so rotating either invalidates old sets. | |
| const effectiveSecret = `${this.signingSecret}:${consumerKey}`; | |
| const result = verifyGatewaySignature({ | |
| identity, | |
| presentedSignature, | |
| secret: effectiveSecret, | |
| now: Date.now(), | |
| skewMs: SKEW_MS, | |
| }); | |
| if (!result.ok) { | |
| this.logger.warn( | |
| `Gateway signature rejected (${result.reason}) for consumer=${identity.consumer}`, | |
| ); | |
| throw new UnauthorizedException('Invalid gateway signature'); | |
| } | |
| request.gatewayPrincipal = { | |
| ...identity, | |
| roleList: identity.roles | |
| .split(',') | |
| .map((r) => r.trim()) | |
| .filter((r) => r.length > 0), | |
| }; | |
| return true; | |
| } | |
| /** | |
| * Read + presence-check the five signed identity headers. When | |
| * `orgIdRequired` is false (a @RequireSession route), a missing/empty | |
| * `X-Gateway-Org-Id` is tolerated and normalized to `''` — the signature is | |
| * still verified against whatever orgId value was actually signed (empty or | |
| * not), so a caller cannot forge a non-empty orgId by omitting the header. | |
| */ | |
| private readIdentity( | |
| request: RequestWithGateway, | |
| orgIdRequired: boolean, | |
| ): GatewayIdentity | null { | |
| const userId = this.header(request, GATEWAY_HEADERS.userId); | |
| const orgId = this.header(request, GATEWAY_HEADERS.orgId); | |
| const roles = this.header(request, GATEWAY_HEADERS.roles); | |
| const consumer = this.header(request, GATEWAY_HEADERS.consumer); | |
| const timestamp = this.header(request, GATEWAY_HEADERS.timestamp); | |
| if (!userId || consumer === null || timestamp === null) { | |
| return null; | |
| } | |
| if (orgIdRequired && !orgId) { | |
| return null; | |
| } | |
| // roles may legitimately be empty (a member with no roles) — normalize to | |
| // ''; orgId is empty-tolerant ONLY when the route allowed it above. | |
| return { userId, orgId: orgId ?? '', roles: roles ?? '', consumer, timestamp }; | |
| } | |
| /** Single-value header accessor (arrays collapse to null — reject ambiguity). */ | |
| private header(request: RequestWithGateway, name: string): string | null { | |
| const value = request.headers[name]; | |
| if (typeof value === 'string' && value.length > 0) { | |
| return value; | |
| } | |
| return null; | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment