@heavybit/pendoadmin-shared-lib 1.6.0 → 1.8.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.
- package/dist/common/gateway-signature.d.ts +49 -0
- package/dist/common/gateway-signature.d.ts.map +1 -0
- package/dist/common/gateway-signature.js +99 -0
- package/dist/common/gateway-signature.js.map +1 -0
- package/dist/common/index.d.ts +1 -0
- package/dist/common/index.d.ts.map +1 -1
- package/dist/common/index.js +1 -0
- package/dist/common/index.js.map +1 -1
- package/dist/express/auth.middleware.d.ts +5 -4
- package/dist/express/auth.middleware.d.ts.map +1 -1
- package/dist/express/auth.middleware.js +49 -65
- package/dist/express/auth.middleware.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/logging/index.d.ts +1 -0
- package/dist/logging/index.d.ts.map +1 -1
- package/dist/logging/index.js +5 -2
- package/dist/logging/index.js.map +1 -1
- package/dist/logging/redact.d.ts +16 -0
- package/dist/logging/redact.d.ts.map +1 -0
- package/dist/logging/redact.js +83 -0
- package/dist/logging/redact.js.map +1 -0
- package/dist/nestjs/auth.guard.d.ts +2 -0
- package/dist/nestjs/auth.guard.d.ts.map +1 -1
- package/dist/nestjs/auth.guard.js +11 -3
- package/dist/nestjs/auth.guard.js.map +1 -1
- package/dist/nestjs/permission.guard.js +5 -4
- package/dist/nestjs/permission.guard.js.map +1 -1
- package/dist/rate-limit/create-rate-limiter.d.ts +21 -2
- package/dist/rate-limit/create-rate-limiter.d.ts.map +1 -1
- package/dist/rate-limit/create-rate-limiter.js +29 -3
- package/dist/rate-limit/create-rate-limiter.js.map +1 -1
- package/dist/rate-limit/index.d.ts +1 -1
- package/dist/rate-limit/index.d.ts.map +1 -1
- package/dist/rate-limit/index.js +1 -1
- package/dist/rate-limit/index.js.map +1 -1
- package/dist/redis/index.d.ts +3 -0
- package/dist/redis/index.d.ts.map +1 -0
- package/dist/redis/index.js +2 -0
- package/dist/redis/index.js.map +1 -0
- package/dist/redis/keyspace.d.ts +46 -0
- package/dist/redis/keyspace.d.ts.map +1 -0
- package/dist/redis/keyspace.js +82 -0
- package/dist/redis/keyspace.js.map +1 -0
- package/dist/types/index.d.ts +2 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js +2 -0
- package/dist/types/index.js.map +1 -1
- package/package.json +7 -3
- package/src/common/gateway-signature.ts +124 -0
- package/src/common/index.ts +1 -0
- package/src/express/auth.middleware.ts +46 -64
- package/src/index.ts +3 -0
- package/src/logging/index.ts +6 -2
- package/src/logging/redact.ts +84 -0
- package/src/nestjs/auth.guard.ts +12 -3
- package/src/nestjs/permission.guard.ts +5 -5
- package/src/rate-limit/create-rate-limiter.ts +44 -5
- package/src/rate-limit/index.ts +1 -1
- package/src/redis/index.ts +10 -0
- package/src/redis/keyspace.ts +127 -0
- package/src/types/index.ts +2 -0
- package/tests/auth-middleware.test.mjs +91 -0
- package/tests/gateway-signature.test.mjs +93 -0
- package/tests/nestjs-guards.test.mjs +45 -0
- package/tests/rate-limit-key.test.mjs +25 -0
- package/tests/redact.test.mjs +61 -0
- package/tests/redis-keyspace.test.mjs +149 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Request, Response, NextFunction } from 'express';
|
|
2
2
|
import { verifyToken, extractBearerToken } from '../common/jwt.utils.js';
|
|
3
|
+
import { isTrustedGatewayIdentity, isDirectJwtFallbackAllowed } from '../common/gateway-signature.js';
|
|
3
4
|
import { GATEWAY_HEADERS, type IAuthUser } from '../types/index.js';
|
|
4
5
|
|
|
5
6
|
/** Parse a header value that may be a JSON array string or comma-separated. */
|
|
@@ -21,83 +22,64 @@ declare global {
|
|
|
21
22
|
}
|
|
22
23
|
}
|
|
23
24
|
|
|
25
|
+
/** The caller from signed gateway headers, or null when absent or unsigned/forged. */
|
|
26
|
+
function userFromGatewayHeaders(req: Request): IAuthUser | null {
|
|
27
|
+
const userId = req.headers[GATEWAY_HEADERS.USER_ID] as string;
|
|
28
|
+
if (!userId || !isTrustedGatewayIdentity(req.headers)) return null;
|
|
29
|
+
return {
|
|
30
|
+
id: userId,
|
|
31
|
+
email: req.headers[GATEWAY_HEADERS.USER_EMAIL] as string || '',
|
|
32
|
+
companyId: req.headers[GATEWAY_HEADERS.COMPANY_ID] as string || '',
|
|
33
|
+
roles: parseArrayHeader(req.headers[GATEWAY_HEADERS.USER_ROLES] as string),
|
|
34
|
+
permissions: parseArrayHeader(req.headers[GATEWAY_HEADERS.USER_PERMISSIONS] as string),
|
|
35
|
+
isSuperAdmin: req.headers[GATEWAY_HEADERS.IS_SUPERADMIN] === 'true',
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The caller from a bearer JWT — development only (direct service access). */
|
|
40
|
+
function userFromBearerToken(req: Request, jwtSecret?: string): IAuthUser | null {
|
|
41
|
+
if (!jwtSecret || !isDirectJwtFallbackAllowed()) return null;
|
|
42
|
+
const token = extractBearerToken(req.headers.authorization);
|
|
43
|
+
if (!token) return null;
|
|
44
|
+
try {
|
|
45
|
+
const payload = verifyToken(token, jwtSecret);
|
|
46
|
+
return {
|
|
47
|
+
id: payload.sub,
|
|
48
|
+
email: payload.email,
|
|
49
|
+
companyId: payload.companyId,
|
|
50
|
+
roles: payload.roles || [],
|
|
51
|
+
permissions: payload.permissions || [],
|
|
52
|
+
isSuperAdmin: payload.isSuperAdmin || false,
|
|
53
|
+
};
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
24
59
|
/**
|
|
25
60
|
* Auth middleware for downstream services (behind API gateway).
|
|
26
|
-
* Reads user context from gateway-injected headers
|
|
27
|
-
*
|
|
61
|
+
* Reads user context from gateway-injected headers, trusted only with a valid
|
|
62
|
+
* gateway signature (GATEWAY_SIGNING_SECRET). Outside production it falls back
|
|
63
|
+
* to direct JWT validation for local development.
|
|
28
64
|
*/
|
|
29
65
|
export function authenticateFromGateway(jwtSecret?: string) {
|
|
30
|
-
return (req: Request,
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
req.user = {
|
|
35
|
-
id: userId,
|
|
36
|
-
email: req.headers[GATEWAY_HEADERS.USER_EMAIL] as string || '',
|
|
37
|
-
companyId: req.headers[GATEWAY_HEADERS.COMPANY_ID] as string || '',
|
|
38
|
-
roles: parseArrayHeader(req.headers[GATEWAY_HEADERS.USER_ROLES] as string),
|
|
39
|
-
permissions: parseArrayHeader(req.headers[GATEWAY_HEADERS.USER_PERMISSIONS] as string),
|
|
40
|
-
isSuperAdmin: req.headers[GATEWAY_HEADERS.IS_SUPERADMIN] === 'true',
|
|
41
|
-
};
|
|
66
|
+
return (req: Request, res: Response, next: NextFunction): void => {
|
|
67
|
+
const user = userFromGatewayHeaders(req) ?? userFromBearerToken(req, jwtSecret);
|
|
68
|
+
if (user) {
|
|
69
|
+
req.user = user;
|
|
42
70
|
return next();
|
|
43
71
|
}
|
|
44
|
-
|
|
45
|
-
// Method 2: Direct JWT validation (for development / direct access)
|
|
46
|
-
if (jwtSecret) {
|
|
47
|
-
const token = extractBearerToken(req.headers.authorization);
|
|
48
|
-
if (token) {
|
|
49
|
-
try {
|
|
50
|
-
const payload = verifyToken(token, jwtSecret);
|
|
51
|
-
req.user = {
|
|
52
|
-
id: payload.sub,
|
|
53
|
-
email: payload.email,
|
|
54
|
-
companyId: payload.companyId,
|
|
55
|
-
roles: payload.roles || [],
|
|
56
|
-
permissions: payload.permissions || [],
|
|
57
|
-
isSuperAdmin: payload.isSuperAdmin || false,
|
|
58
|
-
};
|
|
59
|
-
return next();
|
|
60
|
-
} catch {
|
|
61
|
-
// Fall through to 401
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
_res.status(401).json({ success: false, message: 'Authentication required' });
|
|
72
|
+
res.status(401).json({ success: false, message: 'Authentication required' });
|
|
67
73
|
};
|
|
68
74
|
}
|
|
69
75
|
|
|
70
76
|
/**
|
|
71
|
-
* Optional auth — attaches user if present, continues if not.
|
|
77
|
+
* Optional auth — attaches user if present (and trusted), continues if not.
|
|
72
78
|
*/
|
|
73
79
|
export function optionalAuthFromGateway(jwtSecret?: string) {
|
|
74
80
|
return (req: Request, _res: Response, next: NextFunction): void => {
|
|
75
|
-
const
|
|
76
|
-
if (
|
|
77
|
-
req.user = {
|
|
78
|
-
id: userId,
|
|
79
|
-
email: req.headers[GATEWAY_HEADERS.USER_EMAIL] as string || '',
|
|
80
|
-
companyId: req.headers[GATEWAY_HEADERS.COMPANY_ID] as string || '',
|
|
81
|
-
roles: parseArrayHeader(req.headers[GATEWAY_HEADERS.USER_ROLES] as string),
|
|
82
|
-
permissions: parseArrayHeader(req.headers[GATEWAY_HEADERS.USER_PERMISSIONS] as string),
|
|
83
|
-
isSuperAdmin: req.headers[GATEWAY_HEADERS.IS_SUPERADMIN] === 'true',
|
|
84
|
-
};
|
|
85
|
-
} else if (jwtSecret) {
|
|
86
|
-
const token = extractBearerToken(req.headers.authorization);
|
|
87
|
-
if (token) {
|
|
88
|
-
try {
|
|
89
|
-
const payload = verifyToken(token, jwtSecret);
|
|
90
|
-
req.user = {
|
|
91
|
-
id: payload.sub,
|
|
92
|
-
email: payload.email,
|
|
93
|
-
companyId: payload.companyId,
|
|
94
|
-
roles: payload.roles || [],
|
|
95
|
-
permissions: payload.permissions || [],
|
|
96
|
-
isSuperAdmin: payload.isSuperAdmin || false,
|
|
97
|
-
};
|
|
98
|
-
} catch { /* ignore */ }
|
|
99
|
-
}
|
|
100
|
-
}
|
|
81
|
+
const user = userFromGatewayHeaders(req) ?? userFromBearerToken(req, jwtSecret);
|
|
82
|
+
if (user) req.user = user;
|
|
101
83
|
next();
|
|
102
84
|
};
|
|
103
85
|
}
|
package/src/index.ts
CHANGED
package/src/logging/index.ts
CHANGED
|
@@ -2,6 +2,9 @@ import winston from 'winston';
|
|
|
2
2
|
import DailyRotateFile from 'winston-daily-rotate-file';
|
|
3
3
|
import { mkdirSync } from 'fs';
|
|
4
4
|
import { resolve } from 'path';
|
|
5
|
+
import { redactFormat } from './redact.js';
|
|
6
|
+
|
|
7
|
+
export { redactFormat, redactSensitive, isSensitiveKey, REDACTED } from './redact.js';
|
|
5
8
|
|
|
6
9
|
const { combine, timestamp, json, errors, colorize, printf, splat } = winston.format;
|
|
7
10
|
|
|
@@ -118,7 +121,8 @@ export function createLogger(service: string, level?: string): winston.Logger {
|
|
|
118
121
|
return winston.createLogger({
|
|
119
122
|
level: resolvedLevel,
|
|
120
123
|
defaultMeta: { service },
|
|
121
|
-
|
|
124
|
+
// redactFormat runs after splat so metadata passed as extra arguments is covered too.
|
|
125
|
+
format: combine(errors({ stack: true }), splat(), redactFormat(), timestamp()),
|
|
122
126
|
transports,
|
|
123
127
|
});
|
|
124
128
|
}
|
|
@@ -137,7 +141,7 @@ export function createAuditLogger(service: string): winston.Logger {
|
|
|
137
141
|
return winston.createLogger({
|
|
138
142
|
level: 'info',
|
|
139
143
|
defaultMeta: { service, type: 'audit' },
|
|
140
|
-
format: combine(splat(), timestamp(), json()),
|
|
144
|
+
format: combine(splat(), redactFormat(), timestamp(), json()),
|
|
141
145
|
transports,
|
|
142
146
|
});
|
|
143
147
|
}
|
|
@@ -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
|
+
});
|
package/src/nestjs/auth.guard.ts
CHANGED
|
@@ -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
|
|
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
|
|
44
|
-
// is overly restrictive
|
|
45
|
-
|
|
46
|
-
|
|
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,8 @@
|
|
|
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";
|
|
10
|
+
import type { RedisKeyspace } from "../redis/keyspace.js";
|
|
9
11
|
|
|
10
12
|
// ─── Types ──────────────────────────────────────────────
|
|
11
13
|
|
|
@@ -14,11 +16,17 @@ export interface RateLimitConfig {
|
|
|
14
16
|
maxRequests?: number;
|
|
15
17
|
/** Window duration in milliseconds (default: 60 000) */
|
|
16
18
|
windowMs?: number;
|
|
17
|
-
/**
|
|
19
|
+
/**
|
|
20
|
+
* Service keyspace (G5). With it, keys are `{app}:{env}:{service}:rl:{keyPrefix}:…`
|
|
21
|
+
* and `keyPrefix` is only the limiter name. Required with a Redis client
|
|
22
|
+
* in NODE_ENV production/staging.
|
|
23
|
+
*/
|
|
24
|
+
keyspace?: RedisKeyspace;
|
|
25
|
+
/** Limiter name with a keyspace; the raw Redis key prefix without one (default: "rl:service") */
|
|
18
26
|
keyPrefix?: string;
|
|
19
27
|
/** Optional Redis client — falls back to in-memory if omitted */
|
|
20
28
|
redisClient?: Redis | null;
|
|
21
|
-
/** Extract the rate-limit key from the request. Default:
|
|
29
|
+
/** Extract the rate-limit key from the request. Default: signed gateway user id → IP → "unknown" */
|
|
22
30
|
keyExtractor?: (req: Request) => string;
|
|
23
31
|
}
|
|
24
32
|
|
|
@@ -35,10 +43,11 @@ export function createRateLimiter(cfg: RateLimitConfig = {}): RateLimitResult {
|
|
|
35
43
|
const {
|
|
36
44
|
maxRequests = 100,
|
|
37
45
|
windowMs = 60_000,
|
|
38
|
-
|
|
46
|
+
keyspace,
|
|
39
47
|
redisClient = null,
|
|
40
48
|
keyExtractor = defaultKeyExtractor,
|
|
41
49
|
} = cfg;
|
|
50
|
+
const keyPrefix = resolveLimiterKeyPrefix(cfg.keyPrefix, keyspace, Boolean(redisClient));
|
|
42
51
|
|
|
43
52
|
const durationSecs = Math.max(1, Math.round(windowMs / 1000));
|
|
44
53
|
|
|
@@ -100,8 +109,38 @@ export function createRateLimiter(cfg: RateLimitConfig = {}): RateLimitResult {
|
|
|
100
109
|
return { limiter, middleware };
|
|
101
110
|
}
|
|
102
111
|
|
|
112
|
+
// ─── Key prefix ─────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
const DEPLOYED_NODE_ENVS = new Set(["production", "staging"]);
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* With a keyspace: `{prefix}rl:{name}`. Without one the legacy raw prefix is
|
|
118
|
+
* kept for in-memory or local use only — staging and production share Redis
|
|
119
|
+
* with each other and with other apps, so unscoped Redis keys are refused there.
|
|
120
|
+
*/
|
|
121
|
+
export function resolveLimiterKeyPrefix(
|
|
122
|
+
keyPrefix: string | undefined,
|
|
123
|
+
keyspace: RedisKeyspace | undefined,
|
|
124
|
+
usesRedis: boolean,
|
|
125
|
+
): string {
|
|
126
|
+
if (keyspace) return keyspace.key("rl", keyPrefix || "default");
|
|
127
|
+
if (usesRedis && DEPLOYED_NODE_ENVS.has(process.env.NODE_ENV ?? "")) {
|
|
128
|
+
throw new Error("createRateLimiter: a keyspace is required for Redis-backed limiters in production/staging");
|
|
129
|
+
}
|
|
130
|
+
return keyPrefix || "rl:service";
|
|
131
|
+
}
|
|
132
|
+
|
|
103
133
|
// ─── Defaults ───────────────────────────────────────────
|
|
104
134
|
|
|
105
|
-
|
|
106
|
-
|
|
135
|
+
/**
|
|
136
|
+
* The gateway user id only when its signature verifies — an unsigned
|
|
137
|
+
* x-user-id is caller-controlled, and a fresh value per request would get a
|
|
138
|
+
* fresh budget. Otherwise the client IP.
|
|
139
|
+
*/
|
|
140
|
+
export function defaultKeyExtractor(req: Request): string {
|
|
141
|
+
const userId = req.headers["x-user-id"];
|
|
142
|
+
if (typeof userId === "string" && userId && isTrustedGatewayIdentity(req.headers)) {
|
|
143
|
+
return `user:${userId}`;
|
|
144
|
+
}
|
|
145
|
+
return `ip:${req.ip || "unknown"}`;
|
|
107
146
|
}
|
package/src/rate-limit/index.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { createRateLimiter } from "./create-rate-limiter.js";
|
|
1
|
+
export { createRateLimiter, defaultKeyExtractor, resolveLimiterKeyPrefix } from "./create-rate-limiter.js";
|
|
2
2
|
export type { RateLimitConfig, RateLimitResult } from "./create-rate-limiter.js";
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export {
|
|
2
|
+
createRedisKeyspace,
|
|
3
|
+
resolveRedisKeyApp,
|
|
4
|
+
resolveRedisKeyEnv,
|
|
5
|
+
encodeRedisKeyPart,
|
|
6
|
+
hashRedisKeyPart,
|
|
7
|
+
DEFAULT_REDIS_KEY_APP,
|
|
8
|
+
DEFAULT_REDIS_KEY_ENV,
|
|
9
|
+
} from "./keyspace.js";
|
|
10
|
+
export type { RedisKeyspace, RedisKeyspaceOptions, RedisKeyPart } from "./keyspace.js";
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// pendoadmin-shared-lib/src/redis/keyspace.ts
|
|
2
|
+
//
|
|
3
|
+
// One Redis installation is shared by other applications and by PendoAdmin
|
|
4
|
+
// staging and production, so every PendoAdmin key is namespaced by app,
|
|
5
|
+
// environment and owning service:
|
|
6
|
+
//
|
|
7
|
+
// {app}:{env}:{service}:{purpose}[:{part}...]
|
|
8
|
+
// e.g. pendoadmin:prod:api-gateway:authz:018ea4b0-…
|
|
9
|
+
//
|
|
10
|
+
// Build every key and SCAN pattern through createRedisKeyspace(); never
|
|
11
|
+
// hand-write prefixes (tracker decisions G5 / D39).
|
|
12
|
+
|
|
13
|
+
import { createHash } from "node:crypto";
|
|
14
|
+
|
|
15
|
+
// ─── Types ──────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
export type RedisKeyPart = string | number;
|
|
18
|
+
|
|
19
|
+
export interface RedisKeyspaceOptions {
|
|
20
|
+
/** Canonical owning service name (the service that writes the key), e.g. "api-gateway", "sms". */
|
|
21
|
+
service: string;
|
|
22
|
+
/** Defaults to REDIS_KEY_APP, then "pendoadmin". */
|
|
23
|
+
app?: string;
|
|
24
|
+
/** Defaults to resolveRedisKeyEnv(). */
|
|
25
|
+
env?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface RedisKeyspace {
|
|
29
|
+
readonly app: string;
|
|
30
|
+
readonly env: string;
|
|
31
|
+
readonly service: string;
|
|
32
|
+
/** `{app}:{env}:{service}:` — every key and pattern starts with this. */
|
|
33
|
+
readonly prefix: string;
|
|
34
|
+
/**
|
|
35
|
+
* The prefix without its trailing ':' — pass as `environment` to
|
|
36
|
+
* @heavybit/cache-package so its `${environment}|k:v` keys are anchored too.
|
|
37
|
+
*/
|
|
38
|
+
readonly cacheEnvironment: string;
|
|
39
|
+
/** `{prefix}{purpose}[:{part}...]`, each part encoded so it can't fake extra segments. */
|
|
40
|
+
key(purpose: string, ...parts: RedisKeyPart[]): string;
|
|
41
|
+
/** SCAN pattern anchored on the prefix: `{prefix}{purpose}:{suffix}` (suffix defaults to "*"). */
|
|
42
|
+
pattern(purpose: string, suffix?: string): string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
type EnvSource = Record<string, string | undefined>;
|
|
46
|
+
|
|
47
|
+
// ─── Constants ──────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
export const DEFAULT_REDIS_KEY_APP = "pendoadmin";
|
|
50
|
+
export const DEFAULT_REDIS_KEY_ENV = "dev";
|
|
51
|
+
|
|
52
|
+
/** NODE_ENV values that must name their key environment explicitly. */
|
|
53
|
+
const DEPLOYED_NODE_ENVS = new Set(["production", "staging"]);
|
|
54
|
+
|
|
55
|
+
const SEGMENT_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
56
|
+
|
|
57
|
+
// ─── Segment resolution ─────────────────────────────────
|
|
58
|
+
|
|
59
|
+
function assertSegment(kind: string, value: string): string {
|
|
60
|
+
if (!SEGMENT_RE.test(value)) {
|
|
61
|
+
throw new Error(`Redis keyspace: invalid ${kind} "${value}" (lower-case kebab-case [a-z0-9-] only)`);
|
|
62
|
+
}
|
|
63
|
+
return value;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function resolveRedisKeyApp(source: EnvSource = process.env): string {
|
|
67
|
+
return assertSegment("app", source.REDIS_KEY_APP?.trim() || DEFAULT_REDIS_KEY_APP);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* REDIS_KEY_ENV, else CACHE_KEY_PREFIX ("prod" / "staging" in compose), else
|
|
72
|
+
* "dev". Throws in NODE_ENV production/staging when neither is set, so a
|
|
73
|
+
* deployed service can never write unscoped or "dev" keys.
|
|
74
|
+
*/
|
|
75
|
+
export function resolveRedisKeyEnv(source: EnvSource = process.env): string {
|
|
76
|
+
const explicit = source.REDIS_KEY_ENV?.trim() || source.CACHE_KEY_PREFIX?.trim();
|
|
77
|
+
if (explicit) return assertSegment("env", explicit);
|
|
78
|
+
|
|
79
|
+
const nodeEnv = source.NODE_ENV?.trim() ?? "";
|
|
80
|
+
if (DEPLOYED_NODE_ENVS.has(nodeEnv)) {
|
|
81
|
+
throw new Error(`Redis keyspace: REDIS_KEY_ENV or CACHE_KEY_PREFIX must be set when NODE_ENV=${nodeEnv}`);
|
|
82
|
+
}
|
|
83
|
+
return DEFAULT_REDIS_KEY_ENV;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ─── Part encoding ──────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Percent-encodes a variable key part. Beyond encodeURIComponent this also
|
|
90
|
+
* encodes the glob characters SCAN patterns interpret, so a part can never
|
|
91
|
+
* add segments or widen a pattern.
|
|
92
|
+
*/
|
|
93
|
+
export function encodeRedisKeyPart(part: RedisKeyPart): string {
|
|
94
|
+
const value = String(part);
|
|
95
|
+
if (!value) throw new Error("Redis keyspace: key parts must not be empty");
|
|
96
|
+
return encodeURIComponent(value).replace(/[*?[\]!'()]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** sha256 hex of a long or sensitive part (URLs, tokens). Never put a raw token in a key. */
|
|
100
|
+
export function hashRedisKeyPart(value: string): string {
|
|
101
|
+
return createHash("sha256").update(value).digest("hex");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ─── Factory ────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
export function createRedisKeyspace(options: RedisKeyspaceOptions): RedisKeyspace {
|
|
107
|
+
const app = options.app !== undefined ? assertSegment("app", options.app) : resolveRedisKeyApp();
|
|
108
|
+
const env = options.env !== undefined ? assertSegment("env", options.env) : resolveRedisKeyEnv();
|
|
109
|
+
const service = assertSegment("service", options.service);
|
|
110
|
+
const cacheEnvironment = `${app}:${env}:${service}`;
|
|
111
|
+
const prefix = `${cacheEnvironment}:`;
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
app,
|
|
115
|
+
env,
|
|
116
|
+
service,
|
|
117
|
+
prefix,
|
|
118
|
+
cacheEnvironment,
|
|
119
|
+
key(purpose, ...parts) {
|
|
120
|
+
const head = `${prefix}${assertSegment("purpose", purpose)}`;
|
|
121
|
+
return parts.length ? `${head}:${parts.map(encodeRedisKeyPart).join(":")}` : head;
|
|
122
|
+
},
|
|
123
|
+
pattern(purpose, suffix = "*") {
|
|
124
|
+
return `${prefix}${assertSegment("purpose", purpose)}:${suffix}`;
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|
package/src/types/index.ts
CHANGED
|
@@ -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
|
+
});
|