@cap-js/ord 1.3.14 → 1.4.1

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.
@@ -0,0 +1,516 @@
1
+ /**
2
+ * CF mTLS Subject Validation Module
3
+ *
4
+ * This module provides validation for SAP CloudFoundry mTLS authentication.
5
+ * It validates client certificate issuer, subject DN, and root CA DN from
6
+ * separate HTTP headers that are base64-encoded.
7
+ *
8
+ * IMPORTANT SECURITY CONTEXT:
9
+ * - This module assumes TLS termination and certificate chain validation is handled
10
+ * by the CloudFoundry gorouter or API Gateway
11
+ * - It ONLY validates the certificate information (issuer, subject, root CA) against
12
+ * an allow list of trusted certificate pairs and root CAs
13
+ * - It does NOT perform cryptographic validation or certificate chain verification
14
+ *
15
+ * This implementation aligns with the provider-server's CF mTLS validation approach.
16
+ */
17
+
18
+ /**
19
+ * Checks if the request has valid XFCC (X-Forwarded-Client-Cert) headers
20
+ * indicating the proxy has already verified the client certificate.
21
+ *
22
+ * Conditions:
23
+ * - X-Forwarded-Client-Cert header exists
24
+ * - X-Ssl-Client header equals "1"
25
+ * - X-Ssl-Client-Verify header equals "0" (verification success)
26
+ *
27
+ * @param {Object} req - Request object with headers property
28
+ * @returns {boolean} True if XFCC headers indicate proxy verification success
29
+ */
30
+ function isXfccProxyVerified(req) {
31
+ const { CF_MTLS_HEADERS } = require("../constants");
32
+
33
+ if (!req || !req.headers) {
34
+ return false;
35
+ }
36
+
37
+ // Node.js/Express lowercases all header keys
38
+ const xfccKey = CF_MTLS_HEADERS.XFCC.toLowerCase();
39
+ const clientKey = CF_MTLS_HEADERS.CLIENT.toLowerCase();
40
+ const clientVerifyKey = CF_MTLS_HEADERS.CLIENT_VERIFY.toLowerCase();
41
+
42
+ const xfcc = req.headers[xfccKey];
43
+ const sslClient = req.headers[clientKey];
44
+ const sslVerify = req.headers[clientVerifyKey];
45
+
46
+ // Handle array headers by taking first value
47
+ const sslClientValue = Array.isArray(sslClient) ? sslClient[0] : sslClient;
48
+ const sslVerifyValue = Array.isArray(sslVerify) ? sslVerify[0] : sslVerify;
49
+
50
+ return xfcc !== undefined && sslClientValue === "1" && sslVerifyValue === "0";
51
+ }
52
+
53
+ /**
54
+ * Extracts and decodes certificate information from three separate HTTP headers.
55
+ * All headers are expected to be base64-encoded.
56
+ *
57
+ * @param {Object} req - Request object with headers property
58
+ * @param {Object} headerNames - Header names configuration
59
+ * @param {string} headerNames.issuer - Header name for issuer DN
60
+ * @param {string} headerNames.subject - Header name for subject DN
61
+ * @param {string} headerNames.rootCa - Header name for root CA DN
62
+ * @returns {Object} Certificate information or error
63
+ */
64
+ function extractCertHeaders(req, headerNames) {
65
+ const { CF_MTLS_ERROR_REASON } = require("../constants");
66
+
67
+ if (!req || !req.headers) {
68
+ return { error: CF_MTLS_ERROR_REASON.NO_HEADERS };
69
+ }
70
+
71
+ // Node.js/Express lowercases all header keys
72
+ const issuerKey = headerNames.issuer.toLowerCase();
73
+ const subjectKey = headerNames.subject.toLowerCase();
74
+ const rootCaKey = headerNames.rootCa.toLowerCase();
75
+
76
+ const issuerHeader = req.headers[issuerKey];
77
+ const subjectHeader = req.headers[subjectKey];
78
+ const rootCaHeader = req.headers[rootCaKey];
79
+
80
+ if (!issuerHeader) {
81
+ return { error: CF_MTLS_ERROR_REASON.HEADER_MISSING, missing: headerNames.issuer };
82
+ }
83
+ if (!subjectHeader) {
84
+ return { error: CF_MTLS_ERROR_REASON.HEADER_MISSING, missing: headerNames.subject };
85
+ }
86
+ if (!rootCaHeader) {
87
+ return { error: CF_MTLS_ERROR_REASON.HEADER_MISSING, missing: headerNames.rootCa };
88
+ }
89
+
90
+ // Handle array values (take first element)
91
+ const issuerRaw = Array.isArray(issuerHeader) ? issuerHeader[0] : issuerHeader;
92
+ const subjectRaw = Array.isArray(subjectHeader) ? subjectHeader[0] : subjectHeader;
93
+ const rootCaRaw = Array.isArray(rootCaHeader) ? rootCaHeader[0] : rootCaHeader;
94
+
95
+ // Decode base64-encoded headers (CF always sends base64-encoded strings)
96
+ try {
97
+ const issuer = Buffer.from(issuerRaw, "base64").toString("utf-8");
98
+ const subject = Buffer.from(subjectRaw, "base64").toString("utf-8");
99
+ const rootCaDn = Buffer.from(rootCaRaw, "base64").toString("utf-8");
100
+
101
+ return { issuer, subject, rootCaDn };
102
+ } catch {
103
+ return { error: CF_MTLS_ERROR_REASON.INVALID_ENCODING };
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Tokenizes a Distinguished Name (DN) string into components.
109
+ * Supports both comma-separated and slash-separated formats.
110
+ *
111
+ * Examples:
112
+ * "CN=test, O=SAP SE, C=DE" (comma-separated)
113
+ * → ["CN=test", "O=SAP SE", "C=DE"]
114
+ *
115
+ * "/CN=test/O=SAP SE/C=DE" (slash-separated, e.g., from UCL)
116
+ * → ["CN=test", "O=SAP SE", "C=DE"]
117
+ *
118
+ * @param {string} dn - DN-style string
119
+ * @returns {string[]} Array of DN tokens
120
+ */
121
+ function tokenizeDn(dn) {
122
+ const dnStr = String(dn);
123
+ // Remove leading slash if present
124
+ const cleanDn = dnStr.startsWith("/") ? dnStr.substring(1) : dnStr;
125
+ const separator = dnStr.startsWith("/") ? "/" : ",";
126
+
127
+ return cleanDn
128
+ .split(separator)
129
+ .map((token) => token.trim())
130
+ .filter((token) => token.length > 0);
131
+ }
132
+
133
+ /**
134
+ * Compares two arrays of DN tokens in an order-insensitive way.
135
+ * Uses Set-based comparison for efficiency.
136
+ *
137
+ * @param {string[]} tokens1 - First array of DN tokens
138
+ * @param {string[]} tokens2 - Second array of DN tokens
139
+ * @returns {boolean} True if tokens match
140
+ */
141
+ function dnTokensMatch(tokens1, tokens2) {
142
+ if (tokens1.length !== tokens2.length) {
143
+ return false;
144
+ }
145
+
146
+ const set1 = new Set(tokens1);
147
+ const set2 = new Set(tokens2);
148
+
149
+ if (set1.size !== set2.size) {
150
+ return false;
151
+ }
152
+
153
+ for (const token of set1) {
154
+ if (!set2.has(token)) {
155
+ return false;
156
+ }
157
+ }
158
+
159
+ return true;
160
+ }
161
+
162
+ /**
163
+ * Creates CF mTLS configuration from environment variables or CDS settings.
164
+ * Parses and validates the configuration needed for CF mTLS authentication.
165
+ * Supports dynamic fetching of certificate information from config endpoints.
166
+ *
167
+ * @param {Object} cds - CDS instance with environment configuration
168
+ * @param {Object} Logger - Logger instance for error messages
169
+ * @returns {Promise<Object>} CF mTLS configuration or error object
170
+ */
171
+ async function createCfMtlsConfig(cds, Logger) {
172
+ const { CF_MTLS_HEADERS } = require("../constants");
173
+ const { fetchMtlsTrustedCertsFromEndpoints, mergeTrustedCerts } = require("./mtls-endpoint-service");
174
+
175
+ // Check if running in Cloud Foundry environment
176
+ const cfInstanceGuid = process.env.CF_INSTANCE_GUID;
177
+
178
+ if (!cfInstanceGuid || cfInstanceGuid.trim() === "") {
179
+ Logger.error("CF mTLS requires CF_INSTANCE_GUID environment variable");
180
+ }
181
+
182
+ // Parse configuration from single JSON environment variable or CDS settings
183
+ let config;
184
+
185
+ if (process.env.CF_MTLS_TRUSTED_CERTS) {
186
+ try {
187
+ config = JSON.parse(process.env.CF_MTLS_TRUSTED_CERTS);
188
+ } catch {
189
+ Logger.error("Failed to parse CF_MTLS_TRUSTED_CERTS");
190
+ return {
191
+ error: "Invalid CF_MTLS_TRUSTED_CERTS format. Expected JSON: {certs: [...], rootCaDn: [...], configEndpoints: [...]}",
192
+ };
193
+ }
194
+ } else {
195
+ config = cds.env.ord?.authentication?.cfMtls;
196
+ }
197
+
198
+ if (!config) {
199
+ Logger.error("CF mTLS configuration required. Set CF_MTLS_TRUSTED_CERTS or cds.env.ord.authentication.cfMtls");
200
+ return {
201
+ error: "CF mTLS configuration required",
202
+ };
203
+ }
204
+
205
+ // Extract configuration fields
206
+ let { certs = [], rootCaDn = [], configEndpoints = [] } = config;
207
+
208
+ // Validate configuration structure
209
+ if (!Array.isArray(certs)) {
210
+ Logger.error("Invalid configuration: certs must be an array");
211
+ return { error: "Invalid configuration: certs must be an array" };
212
+ }
213
+
214
+ if (!Array.isArray(rootCaDn)) {
215
+ Logger.error("Invalid configuration: rootCaDn must be an array");
216
+ return { error: "Invalid configuration: rootCaDn must be an array" };
217
+ }
218
+
219
+ if (configEndpoints && !Array.isArray(configEndpoints)) {
220
+ Logger.error("Invalid configuration: configEndpoints must be an array");
221
+ return { error: "Invalid configuration: configEndpoints must be an array" };
222
+ }
223
+
224
+ // Fetch from config endpoints if configured
225
+ if (configEndpoints && configEndpoints.length > 0) {
226
+ Logger.info(`Testing UCL connectivity to ${configEndpoints.length} endpoint(s)`);
227
+
228
+ try {
229
+ const fromEndpoints = await fetchMtlsTrustedCertsFromEndpoints(configEndpoints, Logger);
230
+
231
+ // Strict validation: if configEndpoints are configured, we must get certificates from them
232
+ if (fromEndpoints.certs.length === 0) {
233
+ Logger.error(
234
+ `UCL connectivity failed: No certificates retrieved from ${configEndpoints.length} endpoint(s)`,
235
+ );
236
+ return {
237
+ error: `UCL connectivity failed: No certificates retrieved from any endpoint`,
238
+ };
239
+ }
240
+
241
+ Logger.info(`✓ UCL connectivity verified: retrieved ${fromEndpoints.certs.length} certificate(s)`);
242
+
243
+ const merged = mergeTrustedCerts(
244
+ fromEndpoints,
245
+ {
246
+ certs,
247
+ rootCaDn,
248
+ },
249
+ Logger,
250
+ );
251
+
252
+ certs = merged.certs;
253
+ rootCaDn = merged.rootCaDn;
254
+
255
+ Logger.info(`Configuration merged: ${certs.length} certificate pair(s), ${rootCaDn.length} root CA(s)`);
256
+ } catch {
257
+ Logger.error("UCL connectivity test failed");
258
+
259
+ // Fail-fast: if configEndpoints are configured but unreachable, fail immediately
260
+ // This ensures UCL connectivity issues are discovered at startup
261
+ return {
262
+ error: `UCL connectivity failed. Service startup aborted to prevent runtime authentication failures.`,
263
+ };
264
+ }
265
+ }
266
+
267
+ // Validate final configuration (after merging static and endpoint-fetched certificates)
268
+ if (certs.length === 0) {
269
+ Logger.error(
270
+ "CF mTLS requires at least one certificate pair. " +
271
+ "Provide via certs array or configEndpoints in CF_MTLS_TRUSTED_CERTS or cds.env.ord.cfMtls",
272
+ );
273
+ return {
274
+ error: "CF mTLS requires at least one certificate pair",
275
+ };
276
+ }
277
+
278
+ if (rootCaDn.length === 0) {
279
+ Logger.error(
280
+ "CF mTLS requires at least one root CA. " +
281
+ "Provide via rootCaDn array in CF_MTLS_TRUSTED_CERTS or cds.env.ord.cfMtls",
282
+ );
283
+ return {
284
+ error: "CF mTLS requires at least one root CA",
285
+ };
286
+ }
287
+
288
+ // Use fixed header names from constants (not configurable)
289
+ const headerNames = {
290
+ issuer: CF_MTLS_HEADERS.ISSUER,
291
+ subject: CF_MTLS_HEADERS.SUBJECT,
292
+ rootCa: CF_MTLS_HEADERS.ROOT_CA,
293
+ };
294
+
295
+ try {
296
+ // Create the validator function
297
+ const cfMtlsValidator = createCfMtlsValidator({
298
+ trustedCertPairs: certs,
299
+ trustedRootCaDns: rootCaDn,
300
+ headerNames,
301
+ });
302
+
303
+ return { cfMtlsValidator };
304
+ } catch (error) {
305
+ Logger.error("Failed to create CF mTLS validator");
306
+ return { error: error.message };
307
+ }
308
+ }
309
+
310
+ /**
311
+ * Handles CF mTLS authentication for a request.
312
+ * This function processes the CF mTLS authentication and returns appropriate
313
+ * HTTP response actions based on the validation result.
314
+ *
315
+ * @param {Object} req - Express request object
316
+ * @param {Object} res - Express response object
317
+ * @param {Object} authConfig - Authentication configuration
318
+ * @param {Function} authConfig.cfMtlsValidator - CF mTLS validator function
319
+ * @param {Array} authConfig.types - Array of enabled authentication types
320
+ * @param {Object} Logger - Logger instance for error messages
321
+ * @returns {Object} Result object with success flag and optional next() call
322
+ */
323
+ function handleCfMtlsAuthentication(req, res, authConfig, Logger) {
324
+ const { AUTHENTICATION_TYPE, CF_MTLS_ERROR_REASON, AUTH_STRINGS } = require("../constants");
325
+ const result = authConfig.cfMtlsValidator(req);
326
+
327
+ if (result.ok) {
328
+ // Attach the validated certificate information to the request for potential use downstream
329
+ req.cfMtlsIssuer = result.issuer;
330
+ req.cfMtlsSubject = result.subject;
331
+ req.cfMtlsRootCaDn = result.rootCaDn;
332
+ return { success: true };
333
+ }
334
+
335
+ // Handle different failure reasons with appropriate HTTP status codes
336
+ if (result.reason === CF_MTLS_ERROR_REASON.XFCC_VERIFICATION_FAILED) {
337
+ Logger.error("CF mTLS authentication failed: Missing proxy verification");
338
+ // If Basic auth is also configured, provide fallback
339
+ if (authConfig.types.includes(AUTHENTICATION_TYPE.Basic)) {
340
+ res.status(401)
341
+ .setHeader("WWW-Authenticate", AUTH_STRINGS.WWW_AUTHENTICATE_REALM)
342
+ .send("Authentication required.");
343
+ } else {
344
+ res.status(401).send("Missing proxy verification of mTLS client certificate");
345
+ }
346
+ return { success: false };
347
+ }
348
+
349
+ if (result.reason === CF_MTLS_ERROR_REASON.NO_HEADERS) {
350
+ // If Basic auth is also configured, provide a more informative message
351
+ if (authConfig.types.includes(AUTHENTICATION_TYPE.Basic)) {
352
+ res.status(401)
353
+ .setHeader("WWW-Authenticate", AUTH_STRINGS.WWW_AUTHENTICATE_REALM)
354
+ .send("Authentication required.");
355
+ } else {
356
+ res.status(401).send("Client certificate authentication required");
357
+ }
358
+ return { success: false };
359
+ }
360
+
361
+ if (result.reason === CF_MTLS_ERROR_REASON.HEADER_MISSING) {
362
+ Logger.error(`CF mTLS authentication failed: Missing header ${result.missing}`);
363
+ // If Basic auth is also configured, provide fallback
364
+ if (authConfig.types.includes(AUTHENTICATION_TYPE.Basic)) {
365
+ res.status(401)
366
+ .setHeader("WWW-Authenticate", AUTH_STRINGS.WWW_AUTHENTICATE_REALM)
367
+ .send("Authentication required.");
368
+ } else {
369
+ res.status(401).send("Client certificate authentication required");
370
+ }
371
+ return { success: false };
372
+ }
373
+
374
+ if (result.reason === CF_MTLS_ERROR_REASON.INVALID_ENCODING) {
375
+ Logger.error("CF mTLS authentication failed: Invalid certificate header encoding");
376
+ res.status(400).send("Bad Request: Invalid certificate headers");
377
+ return { success: false };
378
+ }
379
+
380
+ if (result.reason === CF_MTLS_ERROR_REASON.CERT_PAIR_MISMATCH) {
381
+ Logger.error("CF mTLS authentication failed: Certificate pair not trusted");
382
+ res.status(403).send("Forbidden: Invalid client certificate");
383
+ return { success: false };
384
+ }
385
+
386
+ if (result.reason === CF_MTLS_ERROR_REASON.ROOT_CA_MISMATCH) {
387
+ Logger.error("CF mTLS authentication failed: Root CA not trusted");
388
+ res.status(403).send("Forbidden: Untrusted certificate authority");
389
+ return { success: false };
390
+ }
391
+
392
+ res.status(401).send("Client certificate authentication failed");
393
+ return { success: false };
394
+ }
395
+
396
+ /**
397
+ * Creates a validator function that checks whether a request contains
398
+ * valid client certificate information matching the trusted configuration.
399
+ *
400
+ * Validates:
401
+ * 1. Issuer + Subject as a pair (both must match together)
402
+ * 2. Root CA DN (must match one of the trusted root CAs)
403
+ *
404
+ * This is pure Node.js, framework-agnostic. It expects a request object
405
+ * with a headers property.
406
+ *
407
+ * @param {Object} options - Configuration options
408
+ * @param {Array<{issuer: string, subject: string}>} options.trustedCertPairs - Trusted issuer/subject pairs
409
+ * @param {string[]} options.trustedRootCaDns - Trusted root CA DNs
410
+ * @param {Object} options.headerNames - Header names configuration
411
+ * @param {string} options.headerNames.issuer - Header name for issuer DN
412
+ * @param {string} options.headerNames.subject - Header name for subject DN
413
+ * @param {string} options.headerNames.rootCa - Header name for root CA DN
414
+ * @returns {Function} Validator function that accepts a request and returns validation result
415
+ * @throws {Error} If configuration is invalid
416
+ */
417
+ function createCfMtlsValidator({ trustedCertPairs, trustedRootCaDns, headerNames }) {
418
+ // Validate configuration
419
+ if (!Array.isArray(trustedCertPairs) || trustedCertPairs.length === 0) {
420
+ throw new Error("mTLS validation requires at least one trusted certificate (issuer/subject pair)");
421
+ }
422
+
423
+ if (!Array.isArray(trustedRootCaDns) || trustedRootCaDns.length === 0) {
424
+ throw new Error("mTLS validation requires at least one trusted root CA DN");
425
+ }
426
+
427
+ if (!headerNames || !headerNames.issuer || !headerNames.subject || !headerNames.rootCa) {
428
+ throw new Error("headerNames must specify issuer, subject, and rootCa");
429
+ }
430
+
431
+ // Pre-tokenize trusted configuration for efficiency
432
+ const normalizedPairs = trustedCertPairs.map((pair) => ({
433
+ issuerTokens: tokenizeDn(pair.issuer),
434
+ subjectTokens: tokenizeDn(pair.subject),
435
+ }));
436
+
437
+ const normalizedRootCas = trustedRootCaDns.map((dn) => tokenizeDn(dn));
438
+
439
+ /**
440
+ * Validates a request for CF mTLS authentication
441
+ * @param {Object} req - Request object with headers property
442
+ * @returns {Object} Validation result with ok, reason, and optional certificate information
443
+ */
444
+ return function validateRequest(req) {
445
+ const { CF_MTLS_ERROR_REASON } = require("../constants");
446
+
447
+ // Check XFCC proxy-verified path first
448
+ if (!isXfccProxyVerified(req)) {
449
+ return {
450
+ ok: false,
451
+ reason: CF_MTLS_ERROR_REASON.XFCC_VERIFICATION_FAILED,
452
+ };
453
+ }
454
+
455
+ // Extract certificate information from headers
456
+ const certInfo = extractCertHeaders(req, headerNames);
457
+
458
+ if (certInfo.error) {
459
+ return {
460
+ ok: false,
461
+ reason: certInfo.error,
462
+ missing: certInfo.missing,
463
+ };
464
+ }
465
+
466
+ const { issuer, subject, rootCaDn } = certInfo;
467
+
468
+ // Tokenize actual certificate information
469
+ const issuerTokens = tokenizeDn(issuer);
470
+ const subjectTokens = tokenizeDn(subject);
471
+ const rootCaTokens = tokenizeDn(rootCaDn);
472
+
473
+ // Validate issuer + subject pair (both must match together)
474
+ const isTrustedPair = normalizedPairs.some(
475
+ (pair) =>
476
+ dnTokensMatch(issuerTokens, pair.issuerTokens) && dnTokensMatch(subjectTokens, pair.subjectTokens),
477
+ );
478
+
479
+ if (!isTrustedPair) {
480
+ return {
481
+ ok: false,
482
+ reason: CF_MTLS_ERROR_REASON.CERT_PAIR_MISMATCH,
483
+ issuer,
484
+ subject,
485
+ };
486
+ }
487
+
488
+ // Validate root CA DN
489
+ const isTrustedRootCa = normalizedRootCas.some((rootCa) => dnTokensMatch(rootCaTokens, rootCa));
490
+
491
+ if (!isTrustedRootCa) {
492
+ return {
493
+ ok: false,
494
+ reason: CF_MTLS_ERROR_REASON.ROOT_CA_MISMATCH,
495
+ rootCaDn,
496
+ };
497
+ }
498
+
499
+ return {
500
+ ok: true,
501
+ issuer,
502
+ subject,
503
+ rootCaDn,
504
+ };
505
+ };
506
+ }
507
+
508
+ module.exports = {
509
+ isXfccProxyVerified,
510
+ extractCertHeaders,
511
+ tokenizeDn,
512
+ dnTokensMatch,
513
+ createCfMtlsValidator,
514
+ createCfMtlsConfig,
515
+ handleCfMtlsAuthentication,
516
+ };
@@ -0,0 +1,141 @@
1
+ /**
2
+ * mTLS Endpoint Service Module
3
+ *
4
+ * Provides functionality to dynamically fetch trusted certificate information
5
+ * from external configuration endpoints and merge with static configuration.
6
+ */
7
+
8
+ /* global AbortController */
9
+
10
+ const { HTTP_CONFIG } = require("../constants");
11
+
12
+ /**
13
+ * Fetches mTLS certificate information from a single endpoint
14
+ *
15
+ * @param {string} endpoint - URL to fetch certificate info from
16
+ * @param {number} timeoutMs - Request timeout in milliseconds
17
+ * @returns {Promise<{issuer: string, subject: string}>} Certificate information
18
+ * @throws {Error} If fetch fails or response is invalid
19
+ */
20
+ async function fetchMtlsCertInfo(endpoint, timeoutMs) {
21
+ const controller = new AbortController();
22
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
23
+
24
+ try {
25
+ const response = await fetch(endpoint, {
26
+ method: HTTP_CONFIG.METHOD_GET,
27
+ headers: { Accept: HTTP_CONFIG.CONTENT_TYPE_JSON },
28
+ signal: controller.signal,
29
+ });
30
+
31
+ clearTimeout(timeoutId);
32
+
33
+ if (!response.ok) {
34
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
35
+ }
36
+
37
+ const data = await response.json();
38
+
39
+ // Validate response format (must match provider-server format)
40
+ if (!data.certIssuer || !data.certSubject) {
41
+ throw new Error("Invalid response: missing certIssuer or certSubject");
42
+ }
43
+
44
+ return {
45
+ issuer: data.certIssuer,
46
+ subject: data.certSubject,
47
+ };
48
+ } catch (error) {
49
+ clearTimeout(timeoutId);
50
+ if (error.name === "AbortError") {
51
+ throw new Error(`Request timeout after ${timeoutMs}ms`);
52
+ }
53
+ throw error;
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Fetches trusted certificates from multiple endpoints in parallel
59
+ *
60
+ * @param {string[]} endpoints - Array of endpoint URLs to fetch from
61
+ * @param {Object} Logger - Logger instance for logging
62
+ * @param {number} [timeoutMs] - Timeout for each request in milliseconds
63
+ * @returns {Promise<{certs: Array<{issuer: string, subject: string}>, rootCaDn: string[]}>}
64
+ */
65
+ async function fetchMtlsTrustedCertsFromEndpoints(endpoints, Logger, timeoutMs = HTTP_CONFIG.MTLS_TIMEOUT_MS) {
66
+ if (!endpoints || endpoints.length === 0) {
67
+ return { certs: [], rootCaDn: [] };
68
+ }
69
+
70
+ const certPairsMap = new Map();
71
+
72
+ const fetchPromises = endpoints.map(async (endpoint) => {
73
+ try {
74
+ const certInfo = await fetchMtlsCertInfo(endpoint, timeoutMs);
75
+ const key = `${certInfo.issuer}|${certInfo.subject}`;
76
+ certPairsMap.set(key, certInfo);
77
+ Logger.info(`Successfully fetched mTLS cert info from ${endpoint}`);
78
+ } catch {
79
+ Logger.error(`Failed to fetch mTLS cert from ${endpoint}`);
80
+ }
81
+ });
82
+
83
+ await Promise.all(fetchPromises);
84
+
85
+ return {
86
+ certs: Array.from(certPairsMap.values()),
87
+ rootCaDn: [], // Root CAs are never fetched from endpoints (security by design)
88
+ };
89
+ }
90
+
91
+ /**
92
+ * Merges certificate configurations from endpoints and static config
93
+ *
94
+ * @param {Object} fromEndpoints - Configuration from endpoints
95
+ * @param {Array<{issuer: string, subject: string}>} fromEndpoints.certs - Certificate pairs from endpoints
96
+ * @param {string[]} fromEndpoints.rootCaDn - Root CA DNs from endpoints (always empty)
97
+ * @param {Object} fromConfig - Static configuration
98
+ * @param {Array<{issuer: string, subject: string}>} fromConfig.certs - Static certificate pairs
99
+ * @param {string[]} fromConfig.rootCaDn - Static root CA DNs
100
+ * @param {Object} Logger - Logger instance
101
+ * @returns {{certs: Array<{issuer: string, subject: string}>, rootCaDn: string[]}}
102
+ */
103
+ function mergeTrustedCerts(fromEndpoints, fromConfig, Logger) {
104
+ const { tokenizeDn, dnTokensMatch } = require("./cf-mtls");
105
+
106
+ // Merge certificate pairs with deduplication
107
+ const certPairsMap = new Map();
108
+
109
+ const allPairs = [...(fromEndpoints.certs || []), ...(fromConfig.certs || [])];
110
+
111
+ for (const pair of allPairs) {
112
+ const key = `${pair.issuer}|${pair.subject}`;
113
+ certPairsMap.set(key, pair);
114
+ }
115
+
116
+ // Merge root CA DNs with DN-aware deduplication
117
+ const rootCaDns = [];
118
+ const allRootCas = [...(fromEndpoints.rootCaDn || []), ...(fromConfig.rootCaDn || [])];
119
+
120
+ for (const dn of allRootCas) {
121
+ const dnTokens = tokenizeDn(dn);
122
+ const isDuplicate = rootCaDns.some((existing) => dnTokensMatch(tokenizeDn(existing), dnTokens));
123
+ if (!isDuplicate) {
124
+ rootCaDns.push(dn);
125
+ }
126
+ }
127
+
128
+ const merged = {
129
+ certs: Array.from(certPairsMap.values()),
130
+ rootCaDn: rootCaDns,
131
+ };
132
+
133
+ Logger.info(`Merged mTLS config: ${merged.certs.length} certificate pair(s), ${merged.rootCaDn.length} root CA(s)`);
134
+
135
+ return merged;
136
+ }
137
+
138
+ module.exports = {
139
+ fetchMtlsTrustedCertsFromEndpoints,
140
+ mergeTrustedCerts,
141
+ };
package/lib/build.js CHANGED
@@ -1,12 +1,12 @@
1
1
  const cds = require("@sap/cds");
2
- const cds_dk = require("@sap/cds-dk");
3
2
  const path = require("path");
4
3
  const _ = require("lodash");
5
4
  const { ord, getMetadata } = require("./index");
6
5
  const cliProgress = require("cli-progress");
7
6
  const { BUILD_DEFAULT_PATH, ORD_SERVICE_NAME, ORD_DOCUMENT_FILE_NAME } = require("./constants");
7
+ const { isMCPPluginInPackageJson } = require("./mcpAdapter");
8
8
 
9
- module.exports = class OrdBuildPlugin extends cds_dk.build.Plugin {
9
+ module.exports = class OrdBuildPlugin extends cds.build.Plugin {
10
10
  static taskDefaults = { src: cds.env.folders.srv };
11
11
 
12
12
  init() {
@@ -36,7 +36,9 @@ module.exports = class OrdBuildPlugin extends cds_dk.build.Plugin {
36
36
 
37
37
  try {
38
38
  for (const resource of resObj) {
39
- if (resource.ordId.includes(ORD_SERVICE_NAME) || !resource.resourceDefinitions) continue;
39
+ // Generate if has service definitions OR has MCP plugin
40
+ const shouldGenerate = resource.resourceDefinitions || isMCPPluginInPackageJson();
41
+ if (!shouldGenerate) continue;
40
42
  for (const resourceDefinition of resource.resourceDefinitions) {
41
43
  try {
42
44
  const { _, response } = await getMetadata(resourceDefinition.url, model); // eslint-disable-line no-unused-vars
@@ -50,16 +52,11 @@ module.exports = class OrdBuildPlugin extends cds_dk.build.Plugin {
50
52
  warnings.push(`Error writing file ${fileName}: ${err.message}`);
51
53
  }),
52
54
  );
53
- completed++;
54
- progressBar.update(completed);
55
55
  } catch (error) {
56
- completed++;
57
- progressBar.update(completed);
58
56
  warnings.push(`Error getting metadata for ${resourceDefinition.url}: ${error.message}`);
59
- } finally {
60
- completed++;
61
- progressBar.update(completed);
62
57
  }
58
+ completed++;
59
+ progressBar.update(completed);
63
60
  }
64
61
  }
65
62
  await Promise.all(promises);