@discover-cloud/shared 1.2.2 → 1.2.3
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/jwt/index.d.ts +1 -0
- package/dist/jwt/index.js +1 -0
- package/dist/jwt/machine-token-client.d.ts +12 -0
- package/dist/jwt/machine-token-client.js +55 -0
- package/dist/middleware/authorize.middleware.js +6 -4
- package/dist/middleware/index.d.ts +1 -0
- package/dist/middleware/index.js +1 -0
- package/dist/middleware/require-internal.middleware.d.ts +18 -0
- package/dist/middleware/require-internal.middleware.js +175 -0
- package/dist/types/express.types.d.ts +6 -1
- package/package.json +1 -1
package/dist/jwt/index.d.ts
CHANGED
package/dist/jwt/index.js
CHANGED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { ILogger } from "../utils";
|
|
2
|
+
export declare class MachineTokenClient {
|
|
3
|
+
private readonly gatewayUrl;
|
|
4
|
+
private readonly serviceId;
|
|
5
|
+
private readonly logger;
|
|
6
|
+
private cache;
|
|
7
|
+
private fetchPromise;
|
|
8
|
+
constructor(gatewayUrl: string, serviceId: string, logger?: ILogger);
|
|
9
|
+
getToken(requestId?: string): Promise<string>;
|
|
10
|
+
private isValid;
|
|
11
|
+
private fetchToken;
|
|
12
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.MachineTokenClient = void 0;
|
|
4
|
+
const utils_1 = require("../utils");
|
|
5
|
+
const REFRESH_BUFFER_S = 10;
|
|
6
|
+
class MachineTokenClient {
|
|
7
|
+
constructor(gatewayUrl, serviceId, logger = utils_1.noopLogger) {
|
|
8
|
+
this.gatewayUrl = gatewayUrl;
|
|
9
|
+
this.serviceId = serviceId;
|
|
10
|
+
this.logger = logger;
|
|
11
|
+
this.cache = null;
|
|
12
|
+
this.fetchPromise = null;
|
|
13
|
+
}
|
|
14
|
+
async getToken(requestId) {
|
|
15
|
+
if (this.isValid()) {
|
|
16
|
+
return this.cache.token;
|
|
17
|
+
}
|
|
18
|
+
if (!this.fetchPromise) {
|
|
19
|
+
this.fetchPromise = this.fetchToken(requestId).finally(() => {
|
|
20
|
+
this.fetchPromise = null;
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
return this.fetchPromise;
|
|
24
|
+
}
|
|
25
|
+
isValid() {
|
|
26
|
+
if (!this.cache)
|
|
27
|
+
return false;
|
|
28
|
+
const nowS = Math.floor(Date.now() / 1000);
|
|
29
|
+
return this.cache.expiresAt - REFRESH_BUFFER_S > nowS;
|
|
30
|
+
}
|
|
31
|
+
async fetchToken(requestId) {
|
|
32
|
+
const url = `${this.gatewayUrl}/internal/machine-token`;
|
|
33
|
+
this.logger.debug({ serviceId: this.serviceId, requestId }, "Fetching machine token");
|
|
34
|
+
const response = await fetch(url, {
|
|
35
|
+
method: "POST",
|
|
36
|
+
headers: {
|
|
37
|
+
"content-type": "application/json",
|
|
38
|
+
},
|
|
39
|
+
body: JSON.stringify({ serviceId: this.serviceId, requestId }),
|
|
40
|
+
});
|
|
41
|
+
if (!response.ok) {
|
|
42
|
+
const text = await response.text().catch(() => "(unreadable)");
|
|
43
|
+
throw new Error(`MachineTokenClient: gateway returned ${response.status} fetching machine token: ${text}`);
|
|
44
|
+
}
|
|
45
|
+
const body = (await response.json());
|
|
46
|
+
const nowS = Math.floor(Date.now() / 1000);
|
|
47
|
+
this.cache = {
|
|
48
|
+
token: body.data.token,
|
|
49
|
+
expiresAt: nowS + body.data.expiresIn,
|
|
50
|
+
};
|
|
51
|
+
this.logger.info({ serviceId: this.serviceId, expiresAt: this.cache.expiresAt }, "Machine token cached");
|
|
52
|
+
return this.cache.token;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
exports.MachineTokenClient = MachineTokenClient;
|
|
@@ -58,11 +58,12 @@ exports.authorize = authorize;
|
|
|
58
58
|
*/
|
|
59
59
|
const authorizeAny = (...permissions) => {
|
|
60
60
|
return (req, res, next) => {
|
|
61
|
-
|
|
61
|
+
const ctx = req.accessContext;
|
|
62
|
+
if (!ctx) {
|
|
62
63
|
(0, utils_1.failure)(res, req, "Unauthorized: No access context", "UNAUTHORIZED", 401);
|
|
63
64
|
return;
|
|
64
65
|
}
|
|
65
|
-
const hasAny = permissions.some((p) => internal_jwt_verifier_1.InternalJwtVerifier.hasPermission(
|
|
66
|
+
const hasAny = permissions.some((p) => internal_jwt_verifier_1.InternalJwtVerifier.hasPermission(ctx, p));
|
|
66
67
|
if (!hasAny) {
|
|
67
68
|
(0, utils_1.failure)(res, req, `Forbidden: Missing one of [${permissions.join(", ")}]`, "FORBIDDEN", 403);
|
|
68
69
|
return;
|
|
@@ -87,11 +88,12 @@ exports.authorizeAny = authorizeAny;
|
|
|
87
88
|
*/
|
|
88
89
|
const authorizeAll = (...permissions) => {
|
|
89
90
|
return (req, res, next) => {
|
|
90
|
-
|
|
91
|
+
const ctx = req.accessContext;
|
|
92
|
+
if (!ctx) {
|
|
91
93
|
(0, utils_1.failure)(res, req, "Unauthorized: No access context", "UNAUTHORIZED", 401);
|
|
92
94
|
return;
|
|
93
95
|
}
|
|
94
|
-
const missingPermissions = permissions.filter((p) => !internal_jwt_verifier_1.InternalJwtVerifier.hasPermission(
|
|
96
|
+
const missingPermissions = permissions.filter((p) => !internal_jwt_verifier_1.InternalJwtVerifier.hasPermission(ctx, p));
|
|
95
97
|
if (missingPermissions.length > 0) {
|
|
96
98
|
(0, utils_1.failure)(res, req, `Forbidden: Missing permissions [${missingPermissions.join(", ")}]`, "FORBIDDEN", 403);
|
|
97
99
|
return;
|
package/dist/middleware/index.js
CHANGED
|
@@ -19,3 +19,4 @@ __exportStar(require("./validate.middleware"), exports);
|
|
|
19
19
|
__exportStar(require("./request-id.middleware"), exports);
|
|
20
20
|
__exportStar(require("./authorize.middleware"), exports);
|
|
21
21
|
__exportStar(require("./validated-merge.middleware"), exports);
|
|
22
|
+
__exportStar(require("./require-internal.middleware"), exports);
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Request, Response, NextFunction } from "express";
|
|
2
|
+
import { ILogger } from "../utils/logger.utils";
|
|
3
|
+
export interface RequireInternalOptions {
|
|
4
|
+
/**
|
|
5
|
+
* Gateway JWKS URI.
|
|
6
|
+
* Defaults to GATEWAY_JWKS_URI env var, then the docker-compose default.
|
|
7
|
+
* Must point to the gateway, not any individual service.
|
|
8
|
+
*/
|
|
9
|
+
gatewayJwksUri?: string;
|
|
10
|
+
/**
|
|
11
|
+
* Permitted serviceIds. Reject any machine token whose serviceId is
|
|
12
|
+
* not in this list, even if the signature is valid.
|
|
13
|
+
* Omit to accept any valid machine token (not recommended in production).
|
|
14
|
+
*/
|
|
15
|
+
allowedServices?: string[];
|
|
16
|
+
logger?: ILogger;
|
|
17
|
+
}
|
|
18
|
+
export declare function requireInternal(options?: RequireInternalOptions): (req: Request, res: Response, next: NextFunction) => Promise<void>;
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.requireInternal = requireInternal;
|
|
37
|
+
const jose = __importStar(require("jose"));
|
|
38
|
+
const logger_utils_1 = require("../utils/logger.utils");
|
|
39
|
+
/**
|
|
40
|
+
* REQUIRE INTERNAL MIDDLEWARE (@discover-cloud/shared)
|
|
41
|
+
* ─────────────────────────────────────────────────────────────────────────
|
|
42
|
+
* Protects /internal/* routes by verifying a gateway-signed machine JWT.
|
|
43
|
+
* Replaces X-Internal-Secret on all service-to-service endpoints.
|
|
44
|
+
*
|
|
45
|
+
* Trust model:
|
|
46
|
+
* All machine tokens are signed by the gateway's private key and verified
|
|
47
|
+
* against the gateway's JWKS endpoint — the same trust root used for
|
|
48
|
+
* human tokens. There is exactly one signing authority in the system.
|
|
49
|
+
*
|
|
50
|
+
* Verification:
|
|
51
|
+
* 1. Extract Bearer token from Authorization header
|
|
52
|
+
* 2. Verify RS256 signature against gateway JWKS
|
|
53
|
+
* 3. Assert typ === "internal" (token confusion defence)
|
|
54
|
+
* 4. Assert isMachine === true (rejects human tokens on internal routes)
|
|
55
|
+
* 5. Assert serviceId is in the allowlist (optional but recommended)
|
|
56
|
+
* 6. Attach MachineAccessContext to req.accessContext
|
|
57
|
+
*
|
|
58
|
+
* JWKS caching:
|
|
59
|
+
* One RemoteJWKSet instance is created per middleware instance (i.e. per
|
|
60
|
+
* service process), cached internally by jose for JWKS_CACHE_MAX_AGE_MS.
|
|
61
|
+
* No per-request network call under normal operation.
|
|
62
|
+
*
|
|
63
|
+
* Allowlist:
|
|
64
|
+
* Pass allowedServices to restrict which services can call this endpoint.
|
|
65
|
+
* Example: requireInternal({ allowedServices: ["cloud-service"] })
|
|
66
|
+
* Defaults to accepting any valid machine token if omitted.
|
|
67
|
+
* Always set this in production — it limits blast radius if a service
|
|
68
|
+
* is compromised.
|
|
69
|
+
*
|
|
70
|
+
* Usage:
|
|
71
|
+
* // insights-service internal routes
|
|
72
|
+
* router.use("/internal", requireInternal({
|
|
73
|
+
* gatewayJwksUri: process.env.GATEWAY_JWKS_URI,
|
|
74
|
+
* allowedServices: ["cloud-service"],
|
|
75
|
+
* logger,
|
|
76
|
+
* }));
|
|
77
|
+
*/
|
|
78
|
+
const CLOCK_TOLERANCE_S = 30;
|
|
79
|
+
const JWKS_CACHE_MAX_AGE_MS = 10 * 60 * 1000;
|
|
80
|
+
const JWKS_FETCH_TIMEOUT_MS = 5000;
|
|
81
|
+
function requireInternal(options = {}) {
|
|
82
|
+
const logger = options.logger ?? logger_utils_1.noopLogger;
|
|
83
|
+
const jwksUri = options.gatewayJwksUri ??
|
|
84
|
+
process.env["GATEWAY_JWKS_URI"] ??
|
|
85
|
+
"http://api-gateway:3000/.well-known/jwks.json";
|
|
86
|
+
const issuer = process.env["INTERNAL_JWT_ISSUER"] ?? "discover-cloud:api-gateway";
|
|
87
|
+
const audience = process.env["INTERNAL_JWT_AUDIENCE"] ?? "discover-cloud:internal";
|
|
88
|
+
// One JWKS instance per middleware — jose caches the key set internally
|
|
89
|
+
const jwks = jose.createRemoteJWKSet(new URL(jwksUri), {
|
|
90
|
+
cacheMaxAge: JWKS_CACHE_MAX_AGE_MS,
|
|
91
|
+
timeoutDuration: JWKS_FETCH_TIMEOUT_MS,
|
|
92
|
+
});
|
|
93
|
+
const { allowedServices } = options;
|
|
94
|
+
return async (req, res, next) => {
|
|
95
|
+
const header = req.headers.authorization;
|
|
96
|
+
if (!header?.startsWith("Bearer ")) {
|
|
97
|
+
res.status(401).json({
|
|
98
|
+
success: false,
|
|
99
|
+
error: { code: "UNAUTHORIZED", message: "Missing Authorization header" },
|
|
100
|
+
});
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const token = header.slice(7);
|
|
104
|
+
try {
|
|
105
|
+
const { payload } = await jose.jwtVerify(token, jwks, {
|
|
106
|
+
issuer,
|
|
107
|
+
audience,
|
|
108
|
+
algorithms: ["RS256"],
|
|
109
|
+
clockTolerance: CLOCK_TOLERANCE_S,
|
|
110
|
+
});
|
|
111
|
+
// Token confusion defence — reject human tokens that somehow
|
|
112
|
+
// reach an internal route (wrong header forwarded by caller)
|
|
113
|
+
if (payload["typ"] !== "internal") {
|
|
114
|
+
res.status(401).json({
|
|
115
|
+
success: false,
|
|
116
|
+
error: { code: "INVALID_TOKEN", message: "Token type must be 'internal'" },
|
|
117
|
+
});
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
// Machine guard — reject human internal tokens on machine-only routes
|
|
121
|
+
if (payload["isMachine"] !== true) {
|
|
122
|
+
res.status(403).json({
|
|
123
|
+
success: false,
|
|
124
|
+
error: { code: "FORBIDDEN", message: "Machine token required for internal routes" },
|
|
125
|
+
});
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const serviceId = payload["serviceId"];
|
|
129
|
+
if (typeof serviceId !== "string") {
|
|
130
|
+
res.status(401).json({
|
|
131
|
+
success: false,
|
|
132
|
+
error: { code: "INVALID_TOKEN", message: "Token missing serviceId" },
|
|
133
|
+
});
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
// Allowlist — tightest possible scope per endpoint
|
|
137
|
+
if (allowedServices && !allowedServices.includes(serviceId)) {
|
|
138
|
+
logger.warn({ requestId: req.id, serviceId }, "[requireInternal] serviceId not in allowlist");
|
|
139
|
+
res.status(403).json({
|
|
140
|
+
success: false,
|
|
141
|
+
error: { code: "FORBIDDEN", message: "Service not permitted to call this endpoint" },
|
|
142
|
+
});
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
const context = { kind: "machine", serviceId };
|
|
146
|
+
req.accessContext = context;
|
|
147
|
+
logger.debug({ requestId: req.id, serviceId, jti: payload.jti }, "[requireInternal] Machine token verified");
|
|
148
|
+
next();
|
|
149
|
+
}
|
|
150
|
+
catch (err) {
|
|
151
|
+
if (err instanceof jose.errors.JWTExpired) {
|
|
152
|
+
res.status(401).json({
|
|
153
|
+
success: false,
|
|
154
|
+
error: { code: "TOKEN_EXPIRED", message: "Machine token has expired" },
|
|
155
|
+
});
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
if (err instanceof jose.errors.JWSSignatureVerificationFailed ||
|
|
159
|
+
err instanceof jose.errors.JWTClaimValidationFailed ||
|
|
160
|
+
err instanceof jose.errors.JWSInvalid ||
|
|
161
|
+
err instanceof jose.errors.JWTInvalid) {
|
|
162
|
+
res.status(401).json({
|
|
163
|
+
success: false,
|
|
164
|
+
error: { code: "INVALID_TOKEN", message: "Machine token is invalid" },
|
|
165
|
+
});
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
logger.error({ err, requestId: req.id }, "[requireInternal] Unexpected error verifying machine token");
|
|
169
|
+
res.status(503).json({
|
|
170
|
+
success: false,
|
|
171
|
+
error: { code: "SERVICE_UNAVAILABLE", message: "Authentication temporarily unavailable" },
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
}
|
|
@@ -60,7 +60,8 @@ export interface HumanInternalJwtPayload extends BaseInternalJwtPayload {
|
|
|
60
60
|
isMachine: false;
|
|
61
61
|
accountId: string;
|
|
62
62
|
accountRole: AccountRole;
|
|
63
|
-
externalJti
|
|
63
|
+
externalJti: string;
|
|
64
|
+
externalExp: number;
|
|
64
65
|
}
|
|
65
66
|
/**
|
|
66
67
|
* MachineInternalJwtPayload
|
|
@@ -73,6 +74,10 @@ export interface MachineInternalJwtPayload extends BaseInternalJwtPayload {
|
|
|
73
74
|
}
|
|
74
75
|
/** Full internal JWT payload union — use in verifiers and auth middleware */
|
|
75
76
|
export type InternalJwtPayload = HumanInternalJwtPayload | MachineInternalJwtPayload;
|
|
77
|
+
export interface TokenRevocationContext {
|
|
78
|
+
jti: string;
|
|
79
|
+
exp: number;
|
|
80
|
+
}
|
|
76
81
|
export interface HumanAccessContext {
|
|
77
82
|
kind: "human";
|
|
78
83
|
accountId: string;
|