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/README.md +22 -0
- package/basic-auth.mjs +197 -0
- package/bin/cli.cjs +8 -0
- package/bin/config.cjs +8 -0
- package/config-cli.mjs +325 -0
- package/config-manager.mjs +278 -0
- package/package.json +46 -0
- package/saml-login.mjs +372 -0
- package/sap-rfc-client.js +852 -0
- package/snc-auth.mjs +194 -0
- package/spnego-auth.mjs +362 -0
- package/sso-sap-client.mjs +687 -0
- package/x509-auth.mjs +395 -0
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
|
+
}
|