Skip to content

Instantly share code, notes, and snippets.

@Walid-Azur
Created September 8, 2026 10:13
Show Gist options
  • Select an option

  • Save Walid-Azur/0cecc9fd0a00a64c7270b229eb562686 to your computer and use it in GitHub Desktop.

Select an option

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
// =============================================================================
// 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