@heavybit/pendoadmin-shared-lib 1.5.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/dist/common/gateway-signature.d.ts +49 -0
  2. package/dist/common/gateway-signature.d.ts.map +1 -0
  3. package/dist/common/gateway-signature.js +99 -0
  4. package/dist/common/gateway-signature.js.map +1 -0
  5. package/dist/common/index.d.ts +2 -0
  6. package/dist/common/index.d.ts.map +1 -1
  7. package/dist/common/index.js +2 -0
  8. package/dist/common/index.js.map +1 -1
  9. package/dist/common/permission.utils.d.ts +11 -0
  10. package/dist/common/permission.utils.d.ts.map +1 -0
  11. package/dist/common/permission.utils.js +16 -0
  12. package/dist/common/permission.utils.js.map +1 -0
  13. package/dist/express/auth.middleware.d.ts +5 -4
  14. package/dist/express/auth.middleware.d.ts.map +1 -1
  15. package/dist/express/auth.middleware.js +49 -65
  16. package/dist/express/auth.middleware.js.map +1 -1
  17. package/dist/express/permission.guard.d.ts.map +1 -1
  18. package/dist/express/permission.guard.js +2 -5
  19. package/dist/express/permission.guard.js.map +1 -1
  20. package/dist/logging/index.d.ts +1 -0
  21. package/dist/logging/index.d.ts.map +1 -1
  22. package/dist/logging/index.js +5 -2
  23. package/dist/logging/index.js.map +1 -1
  24. package/dist/logging/redact.d.ts +16 -0
  25. package/dist/logging/redact.d.ts.map +1 -0
  26. package/dist/logging/redact.js +83 -0
  27. package/dist/logging/redact.js.map +1 -0
  28. package/dist/nestjs/auth.guard.d.ts +2 -0
  29. package/dist/nestjs/auth.guard.d.ts.map +1 -1
  30. package/dist/nestjs/auth.guard.js +11 -3
  31. package/dist/nestjs/auth.guard.js.map +1 -1
  32. package/dist/nestjs/permission.guard.js +5 -4
  33. package/dist/nestjs/permission.guard.js.map +1 -1
  34. package/dist/rate-limit/create-rate-limiter.d.ts +7 -1
  35. package/dist/rate-limit/create-rate-limiter.d.ts.map +1 -1
  36. package/dist/rate-limit/create-rate-limiter.js +12 -2
  37. package/dist/rate-limit/create-rate-limiter.js.map +1 -1
  38. package/dist/rate-limit/index.d.ts +1 -1
  39. package/dist/rate-limit/index.d.ts.map +1 -1
  40. package/dist/rate-limit/index.js +1 -1
  41. package/dist/rate-limit/index.js.map +1 -1
  42. package/dist/types/index.d.ts +2 -0
  43. package/dist/types/index.d.ts.map +1 -1
  44. package/dist/types/index.js +2 -0
  45. package/dist/types/index.js.map +1 -1
  46. package/package.json +2 -2
  47. package/src/common/gateway-signature.ts +124 -0
  48. package/src/common/index.ts +2 -0
  49. package/src/common/permission.utils.ts +18 -0
  50. package/src/express/auth.middleware.ts +46 -64
  51. package/src/express/permission.guard.ts +2 -5
  52. package/src/logging/index.ts +6 -2
  53. package/src/logging/redact.ts +84 -0
  54. package/src/nestjs/auth.guard.ts +12 -3
  55. package/src/nestjs/permission.guard.ts +5 -5
  56. package/src/rate-limit/create-rate-limiter.ts +13 -3
  57. package/src/rate-limit/index.ts +1 -1
  58. package/src/types/index.ts +2 -0
  59. package/tests/auth-middleware.test.mjs +91 -0
  60. package/tests/gateway-signature.test.mjs +93 -0
  61. package/tests/nestjs-guards.test.mjs +45 -0
  62. package/tests/permission.utils.test.mjs +59 -0
  63. package/tests/rate-limit-key.test.mjs +25 -0
  64. package/tests/redact.test.mjs +61 -0
@@ -0,0 +1,84 @@
1
+ import winston from 'winston';
2
+
3
+ export const REDACTED = '[REDACTED]';
4
+
5
+ // Exact keys (compared lower-cased, with '-' and '_' removed) and key fragments
6
+ // whose values must never reach a log: credentials, tokens, OTPs, secrets.
7
+ const EXACT_KEYS = new Set([
8
+ 'authorization',
9
+ 'proxyauthorization',
10
+ 'cookie',
11
+ 'setcookie',
12
+ 'password',
13
+ 'passwd',
14
+ 'currentpassword',
15
+ 'newpassword',
16
+ 'apikey',
17
+ 'otp',
18
+ 'pin',
19
+ 'smtppassword',
20
+ 'encryptionkey',
21
+ 'privatekey',
22
+ 'xinternalservicesecret',
23
+ 'xgatewaysignature',
24
+ ]);
25
+ const KEY_FRAGMENTS = ['secret', 'password', 'passphrase', 'credential'];
26
+ // accessToken, refresh_token, captchaToken… but not counters like promptTokens.
27
+ const KEY_SUFFIXES = ['token'];
28
+
29
+ const MAX_DEPTH = 8;
30
+
31
+ /** True for keys whose values are redacted (case/separator-insensitive). */
32
+ export function isSensitiveKey(key: string): boolean {
33
+ const normalized = key.toLowerCase().replace(/[-_]/g, '');
34
+ return EXACT_KEYS.has(normalized)
35
+ || KEY_FRAGMENTS.some((fragment) => normalized.includes(fragment))
36
+ || KEY_SUFFIXES.some((suffix) => normalized.endsWith(suffix));
37
+ }
38
+
39
+ // Bearer/Basic credentials embedded in free text (e.g. a logged header line or error message).
40
+ const INLINE_CREDENTIAL = /\b(Bearer|Basic)\s+[A-Za-z0-9\-._~+/]+=*/g;
41
+
42
+ function redactString(value: string): string {
43
+ return value.replace(INLINE_CREDENTIAL, `$1 ${REDACTED}`);
44
+ }
45
+
46
+ /**
47
+ * Deep copy of `value` with sensitive keys replaced by [REDACTED] and inline
48
+ * bearer/basic credentials masked. Errors keep their message and stack.
49
+ * Handles cycles and caps depth.
50
+ */
51
+ export function redactSensitive(value: unknown, depth = 0, seen: WeakSet<object> = new WeakSet()): unknown {
52
+ if (typeof value === 'string') return redactString(value);
53
+ if (value === null || typeof value !== 'object') return value;
54
+ if (depth >= MAX_DEPTH) return '[Truncated]';
55
+ if (seen.has(value)) return '[Circular]';
56
+ seen.add(value);
57
+
58
+ if (Array.isArray(value)) return value.map((item) => redactSensitive(item, depth + 1, seen));
59
+ if (value instanceof Date || Buffer.isBuffer(value)) return value;
60
+
61
+ const source = value as Record<string, unknown>;
62
+ const result: Record<string, unknown> = {};
63
+ if (value instanceof Error) {
64
+ result.name = value.name;
65
+ result.message = redactString(value.message);
66
+ if (value.stack) result.stack = redactString(value.stack);
67
+ }
68
+ for (const key of Object.keys(source)) {
69
+ result[key] = isSensitiveKey(key) ? REDACTED : redactSensitive(source[key], depth + 1, seen);
70
+ }
71
+ return result;
72
+ }
73
+
74
+ /**
75
+ * Winston format that redacts every log entry's message and metadata in place.
76
+ * Winston's internal Symbol keys (level/message/splat) are left untouched.
77
+ */
78
+ export const redactFormat = winston.format((info) => {
79
+ for (const key of Object.keys(info)) {
80
+ if (key === 'level') continue;
81
+ info[key] = isSensitiveKey(key) ? REDACTED : redactSensitive(info[key]);
82
+ }
83
+ return info;
84
+ });
@@ -6,6 +6,7 @@ import {
6
6
  } from '@nestjs/common';
7
7
  import { Reflector } from '@nestjs/core';
8
8
  import { verifyToken } from '../common/jwt.utils.js';
9
+ import { isTrustedGatewayIdentity, isDirectJwtFallbackAllowed } from '../common/gateway-signature.js';
9
10
  import { GATEWAY_HEADERS, type IAuthUser } from '../types/index.js';
10
11
 
11
12
  /** Parse a header value that may be a JSON array string or comma-separated. */
@@ -20,6 +21,9 @@ function parseArrayHeader(value: string | undefined): string[] {
20
21
 
21
22
  export const IS_PUBLIC_KEY = 'isPublic';
22
23
 
24
+ /** Request property AuthGuard sets when the caller came from signed gateway headers. */
25
+ export const GATEWAY_VERIFIED_FLAG = 'gatewayVerified';
26
+
23
27
  /**
24
28
  * NestJS Guard: validates JWT or trusts gateway-injected headers.
25
29
  */
@@ -36,9 +40,10 @@ export class AuthGuard implements CanActivate {
36
40
 
37
41
  const request = context.switchToHttp().getRequest();
38
42
 
39
- // Trust gateway-injected headers first
43
+ // Trust gateway-injected headers only with a valid gateway signature
40
44
  const gatewayUserId = request.headers[GATEWAY_HEADERS.USER_ID];
41
- if (gatewayUserId) {
45
+ if (gatewayUserId && isTrustedGatewayIdentity(request.headers)) {
46
+ request[GATEWAY_VERIFIED_FLAG] = true;
42
47
  request.user = {
43
48
  id: gatewayUserId,
44
49
  companyId: request.headers[GATEWAY_HEADERS.COMPANY_ID] || undefined,
@@ -51,7 +56,11 @@ export class AuthGuard implements CanActivate {
51
56
  return true;
52
57
  }
53
58
 
54
- // Fallback to direct JWT validation
59
+ // Fallback to direct JWT validation — development only; production
60
+ // requests must arrive through the gateway.
61
+ if (!isDirectJwtFallbackAllowed()) {
62
+ throw new UnauthorizedException('Authentication required');
63
+ }
55
64
  const authHeader = request.headers.authorization;
56
65
  if (!authHeader?.startsWith('Bearer ')) {
57
66
  throw new UnauthorizedException('Missing authentication token');
@@ -10,7 +10,7 @@ import {
10
10
  REQUIRE_ALL_KEY,
11
11
  ROLES_KEY,
12
12
  } from './permission.decorator.js';
13
- import { IS_PUBLIC_KEY } from './auth.guard.js';
13
+ import { IS_PUBLIC_KEY, GATEWAY_VERIFIED_FLAG } from './auth.guard.js';
14
14
  import type { IAuthUser } from '../types/index.js';
15
15
 
16
16
  /**
@@ -40,10 +40,10 @@ export class PermissionGuard implements CanActivate {
40
40
  if (user.isSuperAdmin) return true;
41
41
 
42
42
  // M2M / gateway-authenticated clients: the gateway already verified the JWT
43
- // and injected headers. Requiring human roles on top of scoped permissions
44
- // is overly restrictive for API access.
45
- const isGatewayAuth =
46
- request.headers?.['x-gateway-auth'] === 'verified';
43
+ // and signed the injected headers (AuthGuard checked the signature).
44
+ // Requiring human roles on top of scoped permissions is overly restrictive
45
+ // for API access.
46
+ const isGatewayAuth = request[GATEWAY_VERIFIED_FLAG] === true;
47
47
 
48
48
  // Check permissions first — they are the primary authorization mechanism
49
49
  const requiredPermissions = this.reflector.getAllAndOverride<string[]>(
@@ -6,6 +6,7 @@
6
6
  import { Request, Response, NextFunction } from "express";
7
7
  import { RateLimiterRedis, RateLimiterMemory, RateLimiterAbstract } from "rate-limiter-flexible";
8
8
  import type Redis from "ioredis";
9
+ import { isTrustedGatewayIdentity } from "../common/gateway-signature.js";
9
10
 
10
11
  // ─── Types ──────────────────────────────────────────────
11
12
 
@@ -18,7 +19,7 @@ export interface RateLimitConfig {
18
19
  keyPrefix?: string;
19
20
  /** Optional Redis client — falls back to in-memory if omitted */
20
21
  redisClient?: Redis | null;
21
- /** Extract the rate-limit key from the request. Default: X-User-Id header → IP → "unknown" */
22
+ /** Extract the rate-limit key from the request. Default: signed gateway user id → IP → "unknown" */
22
23
  keyExtractor?: (req: Request) => string;
23
24
  }
24
25
 
@@ -102,6 +103,15 @@ export function createRateLimiter(cfg: RateLimitConfig = {}): RateLimitResult {
102
103
 
103
104
  // ─── Defaults ───────────────────────────────────────────
104
105
 
105
- function defaultKeyExtractor(req: Request): string {
106
- return (req.headers["x-user-id"] as string) || req.ip || "unknown";
106
+ /**
107
+ * The gateway user id only when its signature verifies — an unsigned
108
+ * x-user-id is caller-controlled, and a fresh value per request would get a
109
+ * fresh budget. Otherwise the client IP.
110
+ */
111
+ export function defaultKeyExtractor(req: Request): string {
112
+ const userId = req.headers["x-user-id"];
113
+ if (typeof userId === "string" && userId && isTrustedGatewayIdentity(req.headers)) {
114
+ return `user:${userId}`;
115
+ }
116
+ return `ip:${req.ip || "unknown"}`;
107
117
  }
@@ -1,2 +1,2 @@
1
- export { createRateLimiter } from "./create-rate-limiter.js";
1
+ export { createRateLimiter, defaultKeyExtractor } from "./create-rate-limiter.js";
2
2
  export type { RateLimitConfig, RateLimitResult } from "./create-rate-limiter.js";
@@ -283,4 +283,6 @@ export const GATEWAY_HEADERS = {
283
283
  IS_SUPERADMIN: 'x-is-superadmin',
284
284
  CORRELATION_ID: 'x-correlation-id',
285
285
  SERVICE_NAME: 'x-service-name',
286
+ GATEWAY_SIGNATURE: 'x-gateway-signature',
287
+ GATEWAY_TIMESTAMP: 'x-gateway-ts',
286
288
  } as const;
@@ -0,0 +1,91 @@
1
+ import { test, describe, beforeEach, afterEach } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { authenticateFromGateway, optionalAuthFromGateway } from '../dist/express/index.js';
4
+ import { signGatewayIdentity, signAccessToken } from '../dist/common/index.js';
5
+
6
+ const SECRET = 'gateway-secret';
7
+ const JWT_SECRET = 'jwt-secret';
8
+ const identity = {
9
+ 'x-user-id': 'user-1',
10
+ 'x-company-id': 'comp-1',
11
+ 'x-user-roles': '["admin"]',
12
+ 'x-user-permissions': '["users:list"]',
13
+ 'x-is-superadmin': 'false',
14
+ };
15
+
16
+ function run(middleware, headers) {
17
+ const req = { headers };
18
+ const res = { statusCode: 0, body: null, status(c) { this.statusCode = c; return this; }, json(b) { this.body = b; return this; } };
19
+ let nextCalled = false;
20
+ middleware(req, res, () => { nextCalled = true; });
21
+ return { req, res, nextCalled };
22
+ }
23
+
24
+ describe('authenticateFromGateway', () => {
25
+ const saved = { secret: process.env.GATEWAY_SIGNING_SECRET, env: process.env.NODE_ENV };
26
+ beforeEach(() => { process.env.GATEWAY_SIGNING_SECRET = SECRET; process.env.NODE_ENV = 'production'; });
27
+ afterEach(() => {
28
+ if (saved.secret === undefined) delete process.env.GATEWAY_SIGNING_SECRET; else process.env.GATEWAY_SIGNING_SECRET = saved.secret;
29
+ if (saved.env === undefined) delete process.env.NODE_ENV; else process.env.NODE_ENV = saved.env;
30
+ });
31
+
32
+ test('accepts signed gateway headers and populates req.user', () => {
33
+ const headers = { ...identity, ...signGatewayIdentity(identity, SECRET) };
34
+ const { req, nextCalled } = run(authenticateFromGateway(JWT_SECRET), headers);
35
+ assert.equal(nextCalled, true);
36
+ assert.equal(req.user.id, 'user-1');
37
+ assert.equal(req.user.companyId, 'comp-1');
38
+ assert.deepEqual(req.user.permissions, ['users:list']);
39
+ });
40
+
41
+ test('rejects forged (unsigned) headers with 401', () => {
42
+ const { res, nextCalled } = run(authenticateFromGateway(JWT_SECRET), { ...identity, 'x-is-superadmin': 'true', 'x-gateway-auth': 'verified' });
43
+ assert.equal(nextCalled, false);
44
+ assert.equal(res.statusCode, 401);
45
+ });
46
+
47
+ test('rejects a signed request whose headers were altered afterwards', () => {
48
+ const headers = { ...identity, ...signGatewayIdentity(identity, SECRET), 'x-is-superadmin': 'true' };
49
+ const { res } = run(authenticateFromGateway(JWT_SECRET), headers);
50
+ assert.equal(res.statusCode, 401);
51
+ });
52
+
53
+ test('rejects an expired signature', () => {
54
+ const headers = { ...identity, ...signGatewayIdentity(identity, SECRET, Date.now() - 5 * 60_000) };
55
+ const { res } = run(authenticateFromGateway(JWT_SECRET), headers);
56
+ assert.equal(res.statusCode, 401);
57
+ });
58
+
59
+ test('ignores the direct-JWT fallback in production', () => {
60
+ const token = signAccessToken({ sub: 'user-1', email: 'a@b.c', roles: [], permissions: [] }, JWT_SECRET);
61
+ const { res } = run(authenticateFromGateway(JWT_SECRET), { authorization: `Bearer ${token}` });
62
+ assert.equal(res.statusCode, 401);
63
+ });
64
+
65
+ test('allows the direct-JWT fallback outside production', () => {
66
+ process.env.NODE_ENV = 'development';
67
+ const token = signAccessToken({ sub: 'user-9', email: 'a@b.c', roles: [], permissions: [] }, JWT_SECRET);
68
+ const { req, nextCalled } = run(authenticateFromGateway(JWT_SECRET), { authorization: `Bearer ${token}` });
69
+ assert.equal(nextCalled, true);
70
+ assert.equal(req.user.id, 'user-9');
71
+ });
72
+
73
+ test('production without a signing secret fails closed', () => {
74
+ delete process.env.GATEWAY_SIGNING_SECRET;
75
+ const { res } = run(authenticateFromGateway(JWT_SECRET), identity);
76
+ assert.equal(res.statusCode, 401);
77
+ });
78
+ });
79
+
80
+ describe('optionalAuthFromGateway', () => {
81
+ test('leaves forged headers anonymous', () => {
82
+ process.env.GATEWAY_SIGNING_SECRET = SECRET;
83
+ try {
84
+ const { req, nextCalled } = run(optionalAuthFromGateway(), { ...identity });
85
+ assert.equal(nextCalled, true);
86
+ assert.equal(req.user, undefined);
87
+ } finally {
88
+ delete process.env.GATEWAY_SIGNING_SECRET;
89
+ }
90
+ });
91
+ });
@@ -0,0 +1,93 @@
1
+ import { test, describe, beforeEach, afterEach } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { signGatewayIdentity, verifyGatewayIdentity, isTrustedGatewayIdentity } from '../dist/common/index.js';
4
+
5
+ // Shared with api-gateway/tests/utils/gateway-signature.test.ts: the gateway's
6
+ // own signer must produce this exact signature.
7
+ const VECTOR = {
8
+ secret: 'test-signing-secret',
9
+ nowMs: 1_790_000_000_000,
10
+ headers: {
11
+ 'x-correlation-id': 'corr-1',
12
+ 'x-user-id': 'user-1',
13
+ 'x-user-email': 'alice@example.com',
14
+ 'x-company-id': 'comp-1',
15
+ 'x-company-slug': 'acme',
16
+ 'x-user-roles': '["admin"]',
17
+ 'x-user-permissions': '["users:list"]',
18
+ 'x-is-superadmin': 'false',
19
+ 'x-otp-verified': 'true',
20
+ },
21
+ signature: '681c8ff3354db858c618a257601cced23ed694f824db116eb200c724470fe55d',
22
+ timestamp: '1790000000',
23
+ };
24
+
25
+ const signed = (overrides = {}) => ({
26
+ ...VECTOR.headers,
27
+ 'x-gateway-signature': VECTOR.signature,
28
+ 'x-gateway-ts': VECTOR.timestamp,
29
+ ...overrides,
30
+ });
31
+ const verify = (headers, opts = {}) => verifyGatewayIdentity(headers, VECTOR.secret, { nowMs: VECTOR.nowMs, ...opts });
32
+
33
+ describe('gateway signature', () => {
34
+ test('matches the api-gateway signer test vector', () => {
35
+ assert.deepEqual(signGatewayIdentity(VECTOR.headers, VECTOR.secret, VECTOR.nowMs), {
36
+ 'x-gateway-signature': VECTOR.signature,
37
+ 'x-gateway-ts': VECTOR.timestamp,
38
+ });
39
+ });
40
+
41
+ test('accepts a valid, fresh signature', () => {
42
+ assert.equal(verify(signed()), true);
43
+ });
44
+
45
+ test('rejects a missing or malformed signature', () => {
46
+ assert.equal(verify(VECTOR.headers), false);
47
+ assert.equal(verify(signed({ 'x-gateway-signature': 'nothex' })), false);
48
+ assert.equal(verify(signed({ 'x-gateway-ts': 'soon' })), false);
49
+ });
50
+
51
+ test('rejects tampered identity headers', () => {
52
+ assert.equal(verify(signed({ 'x-is-superadmin': 'true' })), false);
53
+ assert.equal(verify(signed({ 'x-company-id': 'other-company' })), false);
54
+ assert.equal(verify(signed({ 'x-user-permissions': '["*"]' })), false);
55
+ });
56
+
57
+ test('rejects a timestamp outside the ±60 s window', () => {
58
+ assert.equal(verify(signed(), { nowMs: VECTOR.nowMs + 61_000 }), false);
59
+ assert.equal(verify(signed(), { nowMs: VECTOR.nowMs - 61_000 }), false);
60
+ assert.equal(verify(signed(), { nowMs: VECTOR.nowMs + 59_000 }), true);
61
+ });
62
+
63
+ test('rejects a signature made with a different secret, or an empty secret', () => {
64
+ assert.equal(verifyGatewayIdentity(signed(), 'other-secret', { nowMs: VECTOR.nowMs }), false);
65
+ assert.equal(verifyGatewayIdentity(signed(), '', { nowMs: VECTOR.nowMs }), false);
66
+ });
67
+ });
68
+
69
+ describe('isTrustedGatewayIdentity', () => {
70
+ const saved = { secret: process.env.GATEWAY_SIGNING_SECRET, env: process.env.NODE_ENV };
71
+ beforeEach(() => { delete process.env.GATEWAY_SIGNING_SECRET; });
72
+ afterEach(() => {
73
+ if (saved.secret === undefined) delete process.env.GATEWAY_SIGNING_SECRET; else process.env.GATEWAY_SIGNING_SECRET = saved.secret;
74
+ if (saved.env === undefined) delete process.env.NODE_ENV; else process.env.NODE_ENV = saved.env;
75
+ });
76
+
77
+ test('with a secret configured, requires a valid signature', () => {
78
+ process.env.GATEWAY_SIGNING_SECRET = VECTOR.secret;
79
+ const fresh = { ...VECTOR.headers, ...signGatewayIdentity(VECTOR.headers, VECTOR.secret) };
80
+ assert.equal(isTrustedGatewayIdentity(fresh), true);
81
+ assert.equal(isTrustedGatewayIdentity(VECTOR.headers), false);
82
+ });
83
+
84
+ test('without a secret, never trusts unsigned identity in production', () => {
85
+ process.env.NODE_ENV = 'production';
86
+ assert.equal(isTrustedGatewayIdentity(VECTOR.headers), false);
87
+ });
88
+
89
+ test('without a secret, trusts unsigned identity in local development', () => {
90
+ process.env.NODE_ENV = 'development';
91
+ assert.equal(isTrustedGatewayIdentity(VECTOR.headers), true);
92
+ });
93
+ });
@@ -0,0 +1,45 @@
1
+ import 'reflect-metadata';
2
+ import { test, describe, beforeEach, afterEach } from 'node:test';
3
+ import assert from 'node:assert/strict';
4
+ import { AuthGuard, PermissionGuard } from '../dist/nestjs/index.js';
5
+ import { signGatewayIdentity } from '../dist/common/index.js';
6
+
7
+ const SECRET = 'gateway-secret';
8
+ const identity = { 'x-user-id': 'key-1', 'x-company-id': 'comp-1', 'x-user-roles': '["api-client"]', 'x-user-permissions': '["ussd.sessions:list"]', 'x-is-superadmin': 'false' };
9
+
10
+ const reflector = (values = {}) => ({ getAllAndOverride: (key) => values[key] });
11
+ const contextFor = (request) => ({
12
+ getHandler: () => null,
13
+ getClass: () => null,
14
+ switchToHttp: () => ({ getRequest: () => request }),
15
+ });
16
+
17
+ describe('NestJS AuthGuard + PermissionGuard', () => {
18
+ const saved = { secret: process.env.GATEWAY_SIGNING_SECRET, env: process.env.NODE_ENV };
19
+ beforeEach(() => { process.env.GATEWAY_SIGNING_SECRET = SECRET; process.env.NODE_ENV = 'production'; });
20
+ afterEach(() => {
21
+ if (saved.secret === undefined) delete process.env.GATEWAY_SIGNING_SECRET; else process.env.GATEWAY_SIGNING_SECRET = saved.secret;
22
+ if (saved.env === undefined) delete process.env.NODE_ENV; else process.env.NODE_ENV = saved.env;
23
+ });
24
+
25
+ test('AuthGuard trusts signed gateway headers', async () => {
26
+ const request = { headers: { ...identity, ...signGatewayIdentity(identity, SECRET) } };
27
+ assert.equal(await new AuthGuard(reflector()).canActivate(contextFor(request)), true);
28
+ assert.equal(request.user.id, 'key-1');
29
+ assert.equal(request.gatewayVerified, true);
30
+ });
31
+
32
+ test('AuthGuard rejects forged superadmin headers', async () => {
33
+ const request = { headers: { ...identity, 'x-is-superadmin': 'true', 'x-gateway-auth': 'verified' } };
34
+ await assert.rejects(new AuthGuard(reflector()).canActivate(contextFor(request)), { status: 401 });
35
+ assert.equal(request.user, undefined);
36
+ });
37
+
38
+ test('PermissionGuard role bypass needs a verified signature, not the x-gateway-auth header', () => {
39
+ const guard = new PermissionGuard(reflector({ permissions: ['ussd.sessions:list'], roles: ['company_admin'] }));
40
+ const user = { id: 'key-1', roles: ['api-client'], permissions: ['ussd.sessions:list'], isSuperAdmin: false };
41
+
42
+ assert.throws(() => guard.canActivate(contextFor({ user, headers: { 'x-gateway-auth': 'verified' } })), { status: 403 });
43
+ assert.equal(guard.canActivate(contextFor({ user, headers: {}, gatewayVerified: true })), true);
44
+ });
45
+ });
@@ -0,0 +1,59 @@
1
+ import { test, describe } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { hasPermission, ALL_PERMISSIONS_SCOPE } from '../dist/common/index.js';
4
+ import { requirePermission } from '../dist/express/index.js';
5
+
6
+ const user = (permissions, isSuperAdmin = false) => ({ permissions, isSuperAdmin });
7
+
8
+ describe('hasPermission', () => {
9
+ test('grants when any listed permission is held', () => {
10
+ assert.equal(hasPermission(user(['users:read']), 'users.security:read', 'users:read'), true);
11
+ });
12
+
13
+ test('denies when none of the permissions is held', () => {
14
+ assert.equal(hasPermission(user(['users:read']), 'users.security:read'), false);
15
+ assert.equal(hasPermission(user([]), 'users:read'), false);
16
+ });
17
+
18
+ test('super admins always pass', () => {
19
+ assert.equal(hasPermission(user([], true), 'anything'), true);
20
+ });
21
+
22
+ test("the '*' API-key scope grants every permission", () => {
23
+ assert.equal(ALL_PERMISSIONS_SCOPE, '*');
24
+ assert.equal(hasPermission(user(['*']), 'users.security:read'), true);
25
+ });
26
+
27
+ test('no user, or missing permissions array, is denied', () => {
28
+ assert.equal(hasPermission(null, 'users:read'), false);
29
+ assert.equal(hasPermission(undefined, 'users:read'), false);
30
+ assert.equal(hasPermission({ isSuperAdmin: false }, 'users:read'), false);
31
+ });
32
+
33
+ test('no permissions listed is denied for a normal user', () => {
34
+ assert.equal(hasPermission(user(['users:read'])), false);
35
+ });
36
+ });
37
+
38
+ describe('requirePermission uses the same rule', () => {
39
+ function run(reqUser, ...perms) {
40
+ const res = { code: 200, status(c) { this.code = c; return this; }, json() { return this; } };
41
+ let nextCalled = false;
42
+ requirePermission(...perms)({ user: reqUser }, res, () => { nextCalled = true; });
43
+ return { nextCalled, code: res.code };
44
+ }
45
+
46
+ test('401 without a user', () => {
47
+ assert.deepEqual(run(undefined, 'x'), { nextCalled: false, code: 401 });
48
+ });
49
+
50
+ test('403 without the permission', () => {
51
+ assert.deepEqual(run(user(['y']), 'x'), { nextCalled: false, code: 403 });
52
+ });
53
+
54
+ test('passes with the permission, the * scope, or as super admin', () => {
55
+ assert.equal(run(user(['x']), 'x').nextCalled, true);
56
+ assert.equal(run(user(['*']), 'x').nextCalled, true);
57
+ assert.equal(run(user([], true), 'x').nextCalled, true);
58
+ });
59
+ });
@@ -0,0 +1,25 @@
1
+ import { test, describe, afterEach } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { defaultKeyExtractor } from '../dist/rate-limit/index.js';
4
+ import { signGatewayIdentity } from '../dist/common/index.js';
5
+
6
+ describe('defaultKeyExtractor', () => {
7
+ afterEach(() => { delete process.env.GATEWAY_SIGNING_SECRET; });
8
+
9
+ test('ignores an unsigned, caller-supplied x-user-id when a signing secret is set', () => {
10
+ process.env.GATEWAY_SIGNING_SECRET = 's';
11
+ assert.equal(defaultKeyExtractor({ ip: '81.2.69.160', headers: { 'x-user-id': 'rotating-1' } }), 'ip:81.2.69.160');
12
+ });
13
+
14
+ test('keys on the user id when the gateway signature verifies', () => {
15
+ process.env.GATEWAY_SIGNING_SECRET = 's';
16
+ const identity = { 'x-user-id': 'u1' };
17
+ const headers = { ...identity, ...signGatewayIdentity(identity, 's') };
18
+ assert.equal(defaultKeyExtractor({ ip: '10.0.0.1', headers }), 'user:u1');
19
+ });
20
+
21
+ test('falls back to "unknown" with no IP', () => {
22
+ process.env.GATEWAY_SIGNING_SECRET = 's';
23
+ assert.equal(defaultKeyExtractor({ headers: {} }), 'ip:unknown');
24
+ });
25
+ });
@@ -0,0 +1,61 @@
1
+ import { test, describe } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { redactSensitive, redactFormat, isSensitiveKey, REDACTED } from '../dist/logging/index.js';
4
+
5
+ describe('redactSensitive', () => {
6
+ test('redacts credential keys at any depth, case- and separator-insensitively', () => {
7
+ const out = redactSensitive({
8
+ headers: { Authorization: 'Bearer abc.def', cookie: 'sid=1', 'x-internal-service-secret': 's' },
9
+ body: { email: 'a@b.c', password: 'hunter2', otp: '123456', pin: '0000' },
10
+ client_secret: 'cs', apiKey: 'k', smtpPassword: 'p', encryptionKey: 'e',
11
+ accessToken: 't', refresh_token: 'r', captchaToken: 'c',
12
+ nested: [{ privateKey: '-----BEGIN' }],
13
+ });
14
+ assert.equal(out.headers.Authorization, REDACTED);
15
+ assert.equal(out.headers.cookie, REDACTED);
16
+ assert.equal(out.headers['x-internal-service-secret'], REDACTED);
17
+ assert.equal(out.body.email, 'a@b.c');
18
+ for (const key of ['password', 'otp', 'pin']) assert.equal(out.body[key], REDACTED);
19
+ for (const key of ['client_secret', 'apiKey', 'smtpPassword', 'encryptionKey', 'accessToken', 'refresh_token', 'captchaToken']) {
20
+ assert.equal(out[key], REDACTED, key);
21
+ }
22
+ assert.equal(out.nested[0].privateKey, REDACTED);
23
+ });
24
+
25
+ test('keeps non-sensitive fields, including token counters', () => {
26
+ const out = redactSensitive({ userId: 'u1', promptTokens: 12, tokenType: 'Bearer', status: 200 });
27
+ assert.deepEqual(out, { userId: 'u1', promptTokens: 12, tokenType: 'Bearer', status: 200 });
28
+ });
29
+
30
+ test('masks bearer/basic credentials inside free text and error messages', () => {
31
+ assert.equal(redactSensitive('auth header was Bearer eyJhbGciOi.x.y'), `auth header was Bearer ${REDACTED}`);
32
+ const err = redactSensitive(new Error('upstream rejected Basic dXNlcjpwYXNz'));
33
+ assert.equal(err.message, `upstream rejected Basic ${REDACTED}`);
34
+ });
35
+
36
+ test('survives cycles without throwing', () => {
37
+ const a = { name: 'a' };
38
+ a.self = a;
39
+ assert.equal(redactSensitive(a).self, '[Circular]');
40
+ });
41
+
42
+ test('isSensitiveKey', () => {
43
+ assert.equal(isSensitiveKey('X-Gateway-Signature'), true);
44
+ assert.equal(isSensitiveKey('correlationId'), false);
45
+ });
46
+ });
47
+
48
+ describe('redactFormat', () => {
49
+ test('redacts log entry metadata and message', () => {
50
+ const info = redactFormat().transform({
51
+ level: 'error',
52
+ message: 'login failed for Bearer abc',
53
+ password: 'p',
54
+ request: { headers: { authorization: 'Bearer abc' } },
55
+ });
56
+ assert.equal(info.password, REDACTED);
57
+ assert.equal(info.request.headers.authorization, REDACTED);
58
+ assert.equal(info.message, `login failed for Bearer ${REDACTED}`);
59
+ assert.equal(info.level, 'error');
60
+ });
61
+ });