@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.
- package/README.md +98 -13
- package/cds-plugin.js +0 -6
- package/lib/access-strategies.js +172 -0
- package/lib/auth/authentication.js +351 -0
- package/lib/auth/cf-mtls.js +516 -0
- package/lib/auth/mtls-endpoint-service.js +141 -0
- package/lib/build.js +7 -10
- package/lib/constants.js +45 -0
- package/lib/defaults.js +14 -10
- package/lib/extendOrdWithCustom.js +39 -7
- package/lib/index.js +8 -5
- package/lib/logger.js +3 -12
- package/lib/mcpAdapter.js +132 -0
- package/lib/metaData.js +26 -5
- package/lib/ord-service.js +20 -6
- package/lib/ord.js +35 -4
- package/lib/templates.js +128 -16
- package/package.json +11 -8
- package/lib/authentication.js +0 -153
|
@@ -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
|
|
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
|
|
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);
|