abap-local-client 1.0.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/x509-auth.mjs ADDED
@@ -0,0 +1,395 @@
1
+ /**
2
+ * X.509 Principal Propagation Authentication Module
3
+ *
4
+ * Generates ephemeral X.509 client certificates signed by a trusted CA.
5
+ * The certificate Common Name (CN) carries the login identifier — SAP's
6
+ * CERTRULE transaction maps it to an ABAP user.
7
+ *
8
+ * Implements the same pattern as AWS Q Developer ABAP Accelerator's
9
+ * CertificateAuthProvider, adapted to Node.js using node-forge.
10
+ *
11
+ * Also supports SNC (Secure Network Communication) via RFC:
12
+ * SNC_MODE=1, SNC_MYNAME=p:CN=<cn>, SNC_LIB=<path to sapcrypto.dll>
13
+ */
14
+
15
+ import forge from 'node-forge';
16
+
17
+ const { pki, md, asn1, util } = forge;
18
+
19
+ // ════════════════════════════════════════════════════════════════════════════
20
+ // CA Certificate Management
21
+ // ════════════════════════════════════════════════════════════════════════════
22
+
23
+ /**
24
+ * Load a CA certificate and private key from PEM strings.
25
+ *
26
+ * @param {string} caCertPem - CA certificate in PEM format
27
+ * @param {string} caKeyPem - CA private key in PEM format
28
+ * @returns {{ caCert: forge.pki.Certificate, caKey: forge.pki.PrivateKey }}
29
+ */
30
+ export function loadCA(caCertPem, caKeyPem) {
31
+ const caCert = pki.certificateFromPem(caCertPem);
32
+ const caKey = pki.privateKeyFromPem(caKeyPem);
33
+ return { caCert, caKey };
34
+ }
35
+
36
+ /**
37
+ * Extract DN components from a CA certificate.
38
+ *
39
+ * @param {forge.pki.Certificate} caCert
40
+ * @returns {{ cn?: string, o?: string, ou?: string, c?: string, st?: string, l?: string }}
41
+ */
42
+ export function extractCADN(caCert) {
43
+ const attrs = caCert.subject.attributes.reduce((acc, a) => {
44
+ if (a.name && a.value) acc[a.name.toLowerCase()] = a.value;
45
+ return acc;
46
+ }, {});
47
+ return {
48
+ cn: attrs.cn,
49
+ o: attrs.o,
50
+ ou: attrs.ou,
51
+ c: attrs.c,
52
+ st: attrs.st,
53
+ l: attrs.l,
54
+ };
55
+ }
56
+
57
+ // ════════════════════════════════════════════════════════════════════════════
58
+ // Ephemeral Certificate Generation
59
+ // ════════════════════════════════════════════════════════════════════════════
60
+
61
+ /**
62
+ * Generate an ephemeral X.509 client certificate signed by the CA.
63
+ *
64
+ * Mirroring AWS Q's CertificateAuthProvider.generate_ephemeral_certificate().
65
+ *
66
+ * @param {Object} opts
67
+ * @param {forge.pki.Certificate} opts.caCert - CA certificate
68
+ * @param {forge.pki.PrivateKey} opts.caKey - CA private key
69
+ * @param {string} opts.loginIdentifier - Value for CN (email, UPN, etc.)
70
+ * @param {number} [opts.validityMinutes=5] - Certificate lifetime
71
+ * @param {string} [opts.country] - C (fallback: from CA cert)
72
+ * @param {string} [opts.organization] - O (fallback: from CA cert)
73
+ * @param {string} [opts.organizationalUnit] - OU (fallback: from CA cert)
74
+ * @returns {{ certPem: string, keyPem: string, cn: string }}
75
+ */
76
+ export function generateEphemeralCertificate(opts) {
77
+ const {
78
+ caCert,
79
+ caKey,
80
+ loginIdentifier,
81
+ validityMinutes = 5,
82
+ } = opts;
83
+
84
+ const caDN = extractCADN(caCert);
85
+
86
+ // Generate client key pair (RSA 2048)
87
+ const keys = pki.rsa.generateKeyPair(2048);
88
+ const clientKey = keys.privateKey;
89
+ const clientPub = keys.publicKey;
90
+
91
+ // Build subject DN (inherit from CA, override CN with login identifier)
92
+ const cn = loginIdentifier.substring(0, 64); // SAP limit for CN
93
+ const c = opts.country || caDN.c || 'BR';
94
+ const o = opts.organization || caDN.o || caDN.cn || 'Default Org';
95
+ const ou = opts.organizationalUnit || caDN.ou || '';
96
+
97
+ const subjectAttrs = [
98
+ { name: 'commonName', value: cn },
99
+ { name: 'organizationName', value: o },
100
+ { name: 'countryName', value: c },
101
+ ];
102
+ if (ou) {
103
+ subjectAttrs.splice(1, 0, { name: 'organizationalUnitName', value: ou });
104
+ }
105
+
106
+ // Build certificate
107
+ const cert = pki.createCertificate();
108
+ cert.publicKey = clientPub;
109
+ cert.serialNumber = util.bytesToHex(
110
+ forge.random.getBytesSync(16)
111
+ );
112
+
113
+ const now = new Date();
114
+ cert.validity.notBefore = now;
115
+ cert.validity.notAfter = new Date(
116
+ now.getTime() + validityMinutes * 60 * 1000
117
+ );
118
+
119
+ cert.setSubject(subjectAttrs);
120
+ cert.setIssuer(caCert.subject.attributes);
121
+
122
+ cert.setExtensions([
123
+ {
124
+ name: 'basicConstraints',
125
+ cA: false,
126
+ },
127
+ {
128
+ name: 'keyUsage',
129
+ digitalSignature: true,
130
+ keyEncipherment: true,
131
+ },
132
+ {
133
+ name: 'extKeyUsage',
134
+ clientAuth: true,
135
+ },
136
+ {
137
+ name: 'nsCertType',
138
+ client: true,
139
+ },
140
+ ]);
141
+
142
+ // Sign with CA key (SHA-256)
143
+ cert.sign(caKey, md.sha256.create());
144
+
145
+ return {
146
+ certPem: pki.certificateToPem(cert),
147
+ keyPem: pki.privateKeyToPem(clientKey),
148
+ cn,
149
+ };
150
+ }
151
+
152
+ // ════════════════════════════════════════════════════════════════════════════
153
+ // HTTP Client with Mutual TLS (mTLS)
154
+ // ════════════════════════════════════════════════════════════════════════════
155
+
156
+ /**
157
+ * Create an HTTPS agent configured with a client certificate.
158
+ *
159
+ * @param {string} certPem - Client certificate PEM
160
+ * @param {string} keyPem - Client private key PEM
161
+ * @param {boolean} [rejectUnauthorized=true] - Verify server cert
162
+ * @returns {import('https').Agent}
163
+ */
164
+ export function createMTLSAgent(certPem, keyPem, rejectUnauthorized = true) {
165
+ const https = require('https');
166
+ return new https.Agent({
167
+ cert: certPem,
168
+ key: keyPem,
169
+ rejectUnauthorized,
170
+ keepAlive: true,
171
+ });
172
+ }
173
+
174
+ // ════════════════════════════════════════════════════════════════════════════
175
+ // X509Session — session with X.509 mTLS Principal Propagation
176
+ // ════════════════════════════════════════════════════════════════════════════
177
+
178
+ /**
179
+ * X509Session manages an HTTP session using mutual TLS with ephemeral
180
+ * client certificates. Certificates are regenerated before expiry.
181
+ *
182
+ * Usage:
183
+ * const s = new X509Session(axios, caCertPem, caKeyPem, loginId);
184
+ * await s.init();
185
+ * const r = await s.get('/sap/bc/adt/discovery');
186
+ */
187
+ export class X509Session {
188
+ /**
189
+ * @param {object} axios - Axios instance
190
+ * @param {string} caCertPem - CA certificate PEM
191
+ * @param {string} caKeyPem - CA private key PEM
192
+ * @param {string} loginId - Login identifier (email, UPN, username)
193
+ * @param {{ baseUrl?: string, client?: string, language?: string, validityMinutes?: number, rejectUnauthorized?: boolean }} [opts]
194
+ */
195
+ constructor(axios, caCertPem, caKeyPem, loginId, opts = {}) {
196
+ this.axios = axios;
197
+ this.caCertPem = caCertPem;
198
+ this.caKeyPem = caKeyPem;
199
+ this.loginId = loginId;
200
+ this.baseUrl = opts.baseUrl || '';
201
+ this.client = opts.client || '100';
202
+ this.language = opts.language || 'EN';
203
+ this.validityMinutes = opts.validityMinutes || 5;
204
+ this.rejectUnauthorized = opts.rejectUnauthorized !== false;
205
+
206
+ this.certPem = null;
207
+ this.keyPem = null;
208
+ this.csrfToken = null;
209
+ this.httpsAgent = null;
210
+ this.expiresAt = null;
211
+ }
212
+
213
+ /** Generate a new certificate and create HTTPS agent. */
214
+ _refreshCert() {
215
+ const { caCert, caKey } = loadCA(this.caCertPem, this.caKeyPem);
216
+ const { certPem, keyPem } = generateEphemeralCertificate({
217
+ caCert,
218
+ caKey,
219
+ loginIdentifier: this.loginId,
220
+ validityMinutes: this.validityMinutes,
221
+ });
222
+ this.certPem = certPem;
223
+ this.keyPem = keyPem;
224
+ this.httpsAgent = createMTLSAgent(certPem, keyPem, this.rejectUnauthorized);
225
+ this.expiresAt = Date.now() + this.validityMinutes * 60 * 1000;
226
+ }
227
+
228
+ /** Ensure we have a valid certificate. */
229
+ _ensureCert() {
230
+ if (!this.certPem || (this.expiresAt && Date.now() > this.expiresAt - 60_000)) {
231
+ this._refreshCert();
232
+ }
233
+ }
234
+
235
+ /** Initialize session by hitting the discovery endpoint. */
236
+ async init() {
237
+ this._refreshCert();
238
+
239
+ const resp = await this.axios({
240
+ method: 'GET',
241
+ url: `${this.baseUrl}/sap/bc/adt/discovery`,
242
+ headers: {
243
+ 'Accept': 'application/atomsvc+xml',
244
+ 'sap-client': this.client,
245
+ 'sap-language': this.language,
246
+ },
247
+ httpsAgent: this.httpsAgent,
248
+ validateStatus: () => true,
249
+ });
250
+
251
+ if (resp.status < 200 || resp.status >= 300) {
252
+ throw new Error(
253
+ `X.509 Principal Propagation failed: HTTP ${resp.status}` +
254
+ (resp.data ? ` — ${JSON.stringify(resp.data).substring(0, 200)}` : '')
255
+ );
256
+ }
257
+
258
+ const csrf = resp.headers['x-csrf-token'];
259
+ if (csrf) this.csrfToken = csrf;
260
+
261
+ return resp;
262
+ }
263
+
264
+ /** Build headers for requests. */
265
+ _headers(extra = {}) {
266
+ return {
267
+ 'sap-client': this.client,
268
+ 'sap-language': this.language,
269
+ ...extra,
270
+ };
271
+ }
272
+
273
+ /** Refresh CSRF token. */
274
+ async refreshCsrf() {
275
+ this._ensureCert();
276
+ const resp = await this.axios({
277
+ method: 'GET',
278
+ url: `${this.baseUrl}/sap/bc/adt/discovery`,
279
+ headers: this._headers({
280
+ 'X-CSRF-Token': 'Fetch',
281
+ 'Accept': 'application/atomsvc+xml',
282
+ }),
283
+ httpsAgent: this.httpsAgent,
284
+ });
285
+ if (resp.headers['x-csrf-token']) {
286
+ this.csrfToken = resp.headers['x-csrf-token'];
287
+ }
288
+ return this.csrfToken;
289
+ }
290
+
291
+ /** GET request. */
292
+ async get(path, headers = {}) {
293
+ this._ensureCert();
294
+ return this.axios({
295
+ method: 'GET',
296
+ url: `${this.baseUrl}${path}`,
297
+ headers: this._headers(headers),
298
+ httpsAgent: this.httpsAgent,
299
+ });
300
+ }
301
+
302
+ /** POST request (auto-injects CSRF token). */
303
+ async post(path, data, headers = {}) {
304
+ this._ensureCert();
305
+ if (!this.csrfToken) await this.refreshCsrf();
306
+ return this.axios({
307
+ method: 'POST',
308
+ url: `${this.baseUrl}${path}`,
309
+ headers: this._headers({
310
+ 'Content-Type': 'application/xml',
311
+ 'X-CSRF-Token': this.csrfToken,
312
+ ...headers,
313
+ }),
314
+ data,
315
+ httpsAgent: this.httpsAgent,
316
+ });
317
+ }
318
+
319
+ /** PUT request (auto-injects CSRF token). */
320
+ async put(path, data, headers = {}) {
321
+ this._ensureCert();
322
+ if (!this.csrfToken) await this.refreshCsrf();
323
+ return this.axios({
324
+ method: 'PUT',
325
+ url: `${this.baseUrl}${path}`,
326
+ headers: this._headers({
327
+ 'Content-Type': 'application/xml',
328
+ 'X-CSRF-Token': this.csrfToken,
329
+ ...headers,
330
+ }),
331
+ data,
332
+ httpsAgent: this.httpsAgent,
333
+ });
334
+ }
335
+ }
336
+
337
+ // ════════════════════════════════════════════════════════════════════════════
338
+ // Self-signed CA generation (for testing / development)
339
+ // ════════════════════════════════════════════════════════════════════════════
340
+
341
+ /**
342
+ * Generate a self-signed CA certificate for testing purposes.
343
+ *
344
+ * The CA can be imported into SAP STRUST (transaction code STRUST)
345
+ * and the cert's CN attribute used in CERTRULE to map to ABAP users.
346
+ *
347
+ * @param {Object} opts
348
+ * @param {string} [opts.cn='SAP MCP CA'] - CA Common Name
349
+ * @param {string} [opts.o='SAP MCP'] - Organization
350
+ * @param {string} [opts.c='BR'] - Country
351
+ * @param {number} [opts.validityYears=10] - CA validity
352
+ * @returns {{ caCertPem: string, caKeyPem: string }}
353
+ */
354
+ export function generateCA(opts = {}) {
355
+ const {
356
+ cn = 'SAP MCP CA',
357
+ o = 'SAP MCP',
358
+ c = 'BR',
359
+ validityYears = 10,
360
+ } = opts;
361
+
362
+ const keys = pki.rsa.generateKeyPair(2048);
363
+ const cert = pki.createCertificate();
364
+ cert.publicKey = keys.publicKey;
365
+ cert.serialNumber = '01';
366
+
367
+ const now = new Date();
368
+ cert.validity.notBefore = now;
369
+ cert.validity.notAfter = new Date(
370
+ now.getFullYear() + validityYears, now.getMonth(), now.getDate()
371
+ );
372
+
373
+ cert.setSubject([
374
+ { name: 'commonName', value: cn },
375
+ { name: 'organizationName', value: o },
376
+ { name: 'countryName', value: c },
377
+ ]);
378
+ cert.setIssuer(cert.subject.attributes);
379
+
380
+ cert.setExtensions([
381
+ { name: 'basicConstraints', cA: true, pathLenConstraint: 0 },
382
+ {
383
+ name: 'keyUsage',
384
+ keyCertSign: true,
385
+ cRLSign: true,
386
+ },
387
+ ]);
388
+
389
+ cert.sign(keys.privateKey, md.sha256.create());
390
+
391
+ return {
392
+ caCertPem: pki.certificateToPem(cert),
393
+ caKeyPem: pki.privateKeyToPem(keys.privateKey),
394
+ };
395
+ }