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/snc-auth.mjs ADDED
@@ -0,0 +1,194 @@
1
+ /**
2
+ * SNC Kerberos Authentication Module
3
+ *
4
+ * Secure Network Communication via Kerberos/SSPI.
5
+ * Uses the existing Kerberos ticket from Windows AD (provisioned by Azure AD +
6
+ * Cloud Trust + Microsoft Authenticator) to open an RFC connection with
7
+ * SNC_MODE=1.
8
+ *
9
+ * How it works:
10
+ * 1. Detects Windows logged-in user and AD domain
11
+ * 2. Builds SNC_MYNAME from the user identity (p:CN=user@REALM, ...)
12
+ * 3. Connects via sapnwrfc.dll with SNC parameters
13
+ * 4. SSPI transparently handles Kerberos ticket acquisition (including
14
+ * Cloud Trust → Azure AD → Authenticator if needed)
15
+ */
16
+
17
+ import { execSync } from 'child_process';
18
+ import os from 'os';
19
+
20
+ // ════════════════════════════════════════════════════════════════════════════
21
+ // Identity Detection
22
+ // ════════════════════════════════════════════════════════════════════════════
23
+
24
+ export function getWindowsIdentity() {
25
+ try {
26
+ const output = execSync('whoami', { encoding: 'utf8' }).trim();
27
+ const [domain, user] = output.split('\\');
28
+ return { domain: domain || '', user: user || output };
29
+ } catch {
30
+ return { domain: '', user: os.userInfo().username };
31
+ }
32
+ }
33
+
34
+ export function getKerberosRealm() {
35
+ try {
36
+ return (process.env.USERDNSDOMAIN || process.env.USERDOMAIN || '').toUpperCase();
37
+ } catch {
38
+ return '';
39
+ }
40
+ }
41
+
42
+ export function getSNCMyName(opts = {}) {
43
+ const identity = opts.domain && opts.user
44
+ ? opts
45
+ : getWindowsIdentity();
46
+
47
+ const realm = opts.realm || getKerberosRealm();
48
+ const cn = `${identity.user}@${realm}`;
49
+
50
+ const parts = [`CN=${cn}`];
51
+ if (opts.ou) parts.push(`OU=${opts.ou}`);
52
+ if (realm) parts.push(`O=${realm}`);
53
+ else if (opts.organization) parts.push(`O=${opts.organization}`);
54
+ if (opts.country) parts.push(`C=${opts.country}`);
55
+ else parts.push('C=BR');
56
+
57
+ return `p:${parts.join(', ')}`;
58
+ }
59
+
60
+ /**
61
+ * Check if Kerberos tickets are available.
62
+ * @returns {{ hasTickets: boolean, output: string }}
63
+ */
64
+ export function checkKerberosTickets() {
65
+ try {
66
+ const output = execSync('klist', { encoding: 'utf8' });
67
+ const hasTickets = output.includes('Tíquetes em Cache') &&
68
+ !output.includes('Tíquetes em Cache: (0)');
69
+ return { hasTickets, output };
70
+ } catch {
71
+ return { hasTickets: false, output: '' };
72
+ }
73
+ }
74
+
75
+ // ════════════════════════════════════════════════════════════════════════════
76
+ // SNC Config Builder
77
+ // ════════════════════════════════════════════════════════════════════════════
78
+
79
+ /**
80
+ * Build SNC configuration for SAPRFCClient.
81
+ *
82
+ * @param {Object} opts
83
+ * @param {string} opts.sncPartnerName - SNC name of the SAP server
84
+ * (e.g. "p:CN=SNC, OU=DITI, O=VALE SA, ...")
85
+ * @param {string} [opts.sncMyName] - Auto-detected from Windows identity if not provided
86
+ * @param {string} [opts.sncQop='3'] - Quality of Protection (1=auth, 2=integrity, 3=privacy)
87
+ * @param {string} [opts.sncLib] - Path to sapcrypto.dll (auto-detected if omitted)
88
+ * @param {string} [opts.user] - Windows user (auto-detected)
89
+ * @param {string} [opts.domain] - Windows domain (auto-detected)
90
+ * @param {string} [opts.realm] - Kerberos realm (auto-detected)
91
+ * @returns {{ sncMode: string, sncMyName: string, sncPartnerName: string, sncQop: string }}
92
+ */
93
+ export function buildSNCConfig(opts) {
94
+ const sncMyName = opts.sncMyName || getSNCMyName({
95
+ user: opts.user,
96
+ domain: opts.domain,
97
+ realm: opts.realm,
98
+ });
99
+
100
+ const config = {
101
+ sncMode: '1',
102
+ sncMyName,
103
+ sncPartnerName: opts.sncPartnerName,
104
+ sncQop: opts.sncQop || '3',
105
+ };
106
+
107
+ if (opts.sncLib) {
108
+ config.sncLib = opts.sncLib;
109
+ }
110
+
111
+ return config;
112
+ }
113
+
114
+ // ════════════════════════════════════════════════════════════════════════════
115
+ // SNC Session — wraps SAPRFCClient with SNC
116
+ // ════════════════════════════════════════════════════════════════════════════
117
+
118
+ /**
119
+ * SNC Session for RFC access via Kerberos SNC.
120
+ *
121
+ * Usage:
122
+ * const s = new SNCSession(rfcConfig, sncOpts);
123
+ * await s.init();
124
+ * const resp = await s.callADT('GET', '/sap/bc/adt/discovery', ...);
125
+ */
126
+ export class SNCSession {
127
+ /**
128
+ * @param {Object} rfcConfig - RFC connection config (host, sysnr, client, ...)
129
+ * @param {Object} sncOpts - SNC options (sncPartnerName, sncMyName, etc.)
130
+ * @param {Object} [SAPRFCClient] - The SAPRFCClient class (imported)
131
+ */
132
+ constructor(rfcConfig, sncOpts, SAPRFCClient) {
133
+ this.rfcConfig = { ...rfcConfig, password: rfcConfig.password || '' };
134
+
135
+ // Merge SNC params into rfcConfig
136
+ const sncConfig = buildSNCConfig(sncOpts);
137
+ Object.assign(this.rfcConfig, sncConfig);
138
+
139
+ this.SAPRFCClient = SAPRFCClient;
140
+ this.rfc = null;
141
+ this.csrfToken = null;
142
+ }
143
+
144
+ /** Initialize RFC connection with SNC. */
145
+ async init() {
146
+ const { hasTickets, output } = checkKerberosTickets();
147
+
148
+ if (!hasTickets) {
149
+ console.log('[SNC] No Kerberos tickets found — SSPI will attempt Cloud Trust');
150
+ console.log('[SNC] If prompted, approve on Microsoft Authenticator');
151
+ } else {
152
+ console.log('[SNC] Kerberos tickets found in cache');
153
+ }
154
+
155
+ this.rfc = new this.SAPRFCClient(this.rfcConfig);
156
+ await this.rfc.connect();
157
+ console.log('[SNC] RFC connection established with SNC Kerberos');
158
+
159
+ // Fetch CSRF token
160
+ try {
161
+ await this.rfc.fetchCsrfToken();
162
+ if (this.rfc.csrfToken) {
163
+ this.csrfToken = this.rfc.csrfToken;
164
+ console.log('[SNC] CSRF token obtained');
165
+ }
166
+ } catch (err) {
167
+ console.warn(`[SNC] Could not fetch CSRF token: ${err.message}`);
168
+ }
169
+
170
+ return this.rfc;
171
+ }
172
+
173
+ /** Call ADT endpoint via RFC tunnel. */
174
+ async callADT(method, uri, headers, body) {
175
+ return this.rfc.callADT(method, uri, headers, body);
176
+ }
177
+
178
+ /** Call any RFC function module. */
179
+ async callFM(fm, importing, tables, tableFields) {
180
+ return this.rfc.callFM(fm, importing, tables, tableFields);
181
+ }
182
+
183
+ /** Close the connection. */
184
+ disconnect() {
185
+ if (this.rfc) {
186
+ this.rfc.disconnect();
187
+ this.rfc = null;
188
+ }
189
+ }
190
+
191
+ get isConnected() {
192
+ return this.rfc && this.rfc.conn;
193
+ }
194
+ }
@@ -0,0 +1,362 @@
1
+ /**
2
+ * SPNego / Kerberos Authentication Module
3
+ *
4
+ * Supports two modes:
5
+ * HTTP Negotiate – WWW-Authenticate: Negotiate (RFC 4559)
6
+ * RFC SNC – Secure Network Communication (X.509 via sapnwrfc)
7
+ *
8
+ * Uses the npm `kerberos` package (GSSAPI on Linux/macOS, SSPI on Windows).
9
+ */
10
+
11
+ import { createRequire } from 'module';
12
+ const require = createRequire(import.meta.url);
13
+
14
+ const kerberos = require('kerberos');
15
+
16
+ // ════════════════════════════════════════════════════════════════════════════
17
+ // SPNEGO HTTP Negotiate Auth
18
+ // ════════════════════════════════════════════════════════════════════════════
19
+
20
+ /**
21
+ * Obtain a SPNEGO Negotiate token for a given service principal.
22
+ *
23
+ * @param {string} servicePrincipal e.g. "HTTP@sapserver.example.com"
24
+ * @param {{ mechOID?: number, user?: string, password?: string }} [opts]
25
+ * @returns {Promise<string>} Base64-encoded SPNEGO token (without "Negotiate " prefix)
26
+ */
27
+ export async function getSPNegoToken(servicePrincipal, opts = {}) {
28
+ const mechOID = opts.mechOID ?? kerberos.GSS_MECH_OID_SPNEGO;
29
+
30
+ const client = await kerberos.initializeClient(servicePrincipal, { mechOID });
31
+
32
+ // Step with empty string to obtain initial token
33
+ const token = await client.step('');
34
+
35
+ return token;
36
+ }
37
+
38
+ /**
39
+ * Build a complete Authorization header value for HTTP SPNEGO.
40
+ *
41
+ * @param {string} servicePrincipal e.g. "HTTP@sapserver.example.com"
42
+ * @returns {Promise<string>} "Negotiate <base64token>"
43
+ */
44
+ export async function buildSPNegoHeader(servicePrincipal) {
45
+ const token = await getSPNegoToken(servicePrincipal);
46
+ return `Negotiate ${token}`;
47
+ }
48
+
49
+ /**
50
+ * Build a SPNEGO HTTP Authorization header and return the full headers object.
51
+ *
52
+ * @param {string} servicePrincipal e.g. "HTTP@sapserver.example.com"
53
+ * @param {Object<string,string>} [extraHeaders] Additional headers to merge
54
+ * @returns {Promise<Object<string,string>>}
55
+ */
56
+ export async function buildSPNegoHeaders(servicePrincipal, extraHeaders = {}) {
57
+ const auth = await buildSPNegoHeader(servicePrincipal);
58
+ return { Authorization: auth, ...extraHeaders };
59
+ }
60
+
61
+ /**
62
+ * Determine the service principal from SAP host and optional realm.
63
+ *
64
+ * @param {string} sapHost e.g. "sapserver.example.com"
65
+ * @param {string} [realm] Kerberos realm (e.g. "EXAMPLE.COM"). If omitted, derived from host.
66
+ * @returns {Promise<string>} e.g. "HTTP@sapserver.example.com"
67
+ */
68
+ export async function resolveServicePrincipal(sapHost, realm) {
69
+ // Use fully-qualified hostname; realm is auto-resolved by GSSAPI/SSPI.
70
+ return `HTTP@${sapHost}`;
71
+ }
72
+
73
+ // ════════════════════════════════════════════════════════════════════════════
74
+ // SPNEGO-aware HTTP client (axios-compatible wrapper)
75
+ // ════════════════════════════════════════════════════════════════════════════
76
+
77
+ /**
78
+ * Make an HTTP request with SPNEGO authentication.
79
+ *
80
+ * Handles the 401 → Negotiate flow automatically:
81
+ * 1. Send request without auth
82
+ * 2. If 401 + WWW-Authenticate: Negotiate → obtain SPNEGO token → retry
83
+ *
84
+ * @param {object} axios - Axios instance
85
+ * @param {object} config - Axios request config (url, method, headers, data, ...)
86
+ * @param {string} sapHost - SAP server hostname (for service principal)
87
+ * @returns {Promise<object>} - Axios response
88
+ */
89
+ export async function spnegoRequest(axios, config, sapHost) {
90
+ const spn = `HTTP@${sapHost}`;
91
+
92
+ // First attempt: no auth
93
+ let response;
94
+ try {
95
+ response = await axios(config);
96
+ return response;
97
+ } catch (err) {
98
+ if (err.response && err.response.status === 401) {
99
+ const authHeaders = err.response.headers['www-authenticate'] || '';
100
+ const negotiate = Array.isArray(authHeaders) ? authHeaders.join(',') : authHeaders;
101
+
102
+ if (negotiate.toLowerCase().includes('negotiate')) {
103
+ // Obtain SPNEGO token and retry
104
+ const token = await getSPNegoToken(spn);
105
+ const config2 = {
106
+ ...config,
107
+ headers: {
108
+ ...(config.headers || {}),
109
+ Authorization: `Negotiate ${token}`,
110
+ },
111
+ };
112
+ response = await axios(config2);
113
+ return response;
114
+ }
115
+ }
116
+ throw err;
117
+ }
118
+ }
119
+
120
+ /**
121
+ * SpnegoSession — manages HTTP session with SPNEGO-authenticated cookies.
122
+ *
123
+ * Usage:
124
+ * const s = new SpnegoSession(axios, sapHost);
125
+ * await s.init(); // authenticate → get session cookies
126
+ * const r = await s.get('/sap/bc/adt/discovery');
127
+ *
128
+ */
129
+ export class SpnegoSession {
130
+ /**
131
+ * @param {object} axios - Axios instance (or `import axios from 'axios'`)
132
+ * @param {string} sapHost - SAP server hostname
133
+ * @param {{ baseUrl?: string, client?: string, language?: string, mechOID?: number }} [opts]
134
+ */
135
+ constructor(axios, sapHost, opts = {}) {
136
+ this.axios = axios;
137
+ this.sapHost = sapHost;
138
+ this.baseUrl = opts.baseUrl || `https://${sapHost}:44301`;
139
+ this.client = opts.client || '100';
140
+ this.language = opts.language || 'EN';
141
+ this.mechOID = opts.mechOID ?? kerberos.GSS_MECH_OID_SPNEGO;
142
+
143
+ this.cookies = null; // Cookie jar after successful auth
144
+ this.csrfToken = null; // CSRF token for mutations
145
+ this.authenticated = false;
146
+ }
147
+
148
+ /** Perform initial SPNEGO handshake and store session cookies. */
149
+ async init() {
150
+ // Step 1: hit a SAP endpoint that triggers 401 + Negotiate
151
+ const endpoint = '/sap/bc/adt/discovery';
152
+ const spn = `HTTP@${this.sapHost}`;
153
+
154
+ let response;
155
+ try {
156
+ response = await this.axios({
157
+ method: 'GET',
158
+ url: `${this.baseUrl}${endpoint}`,
159
+ headers: {
160
+ 'Accept': 'application/atomsvc+xml',
161
+ 'sap-client': this.client,
162
+ 'sap-language': this.language,
163
+ },
164
+ validateStatus: () => true, // don't throw on 401
165
+ maxRedirects: 0,
166
+ });
167
+ } catch (err) {
168
+ response = err.response || { status: 500, headers: {} };
169
+ }
170
+
171
+ // Check if we got 401 with Negotiate
172
+ const wwwAuth = response.headers['www-authenticate'] || '';
173
+ const isNegotiate = (Array.isArray(wwwAuth) ? wwwAuth.join(',') : wwwAuth)
174
+ .toLowerCase().includes('negotiate');
175
+
176
+ if (isNegotiate || response.status === 401) {
177
+ // Get SPNEGO token
178
+ const token = await getSPNegoToken(spn, { mechOID: this.mechOID });
179
+
180
+ response = await this.axios({
181
+ method: 'GET',
182
+ url: `${this.baseUrl}${endpoint}`,
183
+ headers: {
184
+ 'Accept': 'application/atomsvc+xml',
185
+ 'sap-client': this.client,
186
+ 'sap-language': this.language,
187
+ 'Authorization': `Negotiate ${token}`,
188
+ },
189
+ validateStatus: () => true,
190
+ maxRedirects: 0,
191
+ });
192
+ }
193
+
194
+ if (response.status >= 200 && response.status < 300) {
195
+ // Store cookies from Set-Cookie
196
+ const setCookies = response.headers['set-cookie'] || [];
197
+ const cookieList = Array.isArray(setCookies) ? setCookies : [setCookies];
198
+ this.cookies = cookieList
199
+ .map(c => c.split(';')[0])
200
+ .join('; ');
201
+
202
+ // Try to get CSRF token
203
+ const csrf = response.headers['x-csrf-token'];
204
+ if (csrf) {
205
+ this.csrfToken = csrf;
206
+ } else {
207
+ // Fetch explicitly
208
+ try {
209
+ const csrfResp = await this.axios({
210
+ method: 'GET',
211
+ url: `${this.baseUrl}${endpoint}`,
212
+ headers: this._headers({ 'X-CSRF-Token': 'Fetch' }),
213
+ validateStatus: () => true,
214
+ });
215
+ if (csrfResp.headers['x-csrf-token']) {
216
+ this.csrfToken = csrfResp.headers['x-csrf-token'];
217
+ }
218
+ } catch { /* ignore */ }
219
+ }
220
+
221
+ this.authenticated = true;
222
+ return response;
223
+ }
224
+
225
+ throw new Error(`SPNEGO authentication failed: HTTP ${response.status}`);
226
+ }
227
+
228
+ /**
229
+ * Build headers for subsequent requests.
230
+ * @param {Object} [extra]
231
+ * @returns {Object}
232
+ */
233
+ _headers(extra = {}) {
234
+ const h = {
235
+ 'sap-client': this.client,
236
+ 'sap-language': this.language,
237
+ ...extra,
238
+ };
239
+ if (this.cookies) {
240
+ h['Cookie'] = this.cookies;
241
+ }
242
+ if (this.csrfToken && !extra['X-CSRF-Token']) {
243
+ // don't auto-inject CSRF for GET
244
+ }
245
+ return h;
246
+ }
247
+
248
+ /** Refresh CSRF token. */
249
+ async refreshCsrf() {
250
+ const resp = await this.axios({
251
+ method: 'GET',
252
+ url: `${this.baseUrl}/sap/bc/adt/discovery`,
253
+ headers: this._headers({ 'X-CSRF-Token': 'Fetch', 'Accept': 'application/atomsvc+xml' }),
254
+ });
255
+ if (resp.headers['x-csrf-token']) {
256
+ this.csrfToken = resp.headers['x-csrf-token'];
257
+ }
258
+ return this.csrfToken;
259
+ }
260
+
261
+ /** GET request. */
262
+ async get(path, headers = {}) {
263
+ return this.axios({
264
+ method: 'GET',
265
+ url: `${this.baseUrl}${path}`,
266
+ headers: this._headers(headers),
267
+ });
268
+ }
269
+
270
+ /** POST request (auto-injects CSRF token). */
271
+ async post(path, data, headers = {}) {
272
+ if (!this.csrfToken) await this.refreshCsrf();
273
+ return this.axios({
274
+ method: 'POST',
275
+ url: `${this.baseUrl}${path}`,
276
+ headers: this._headers({
277
+ 'Content-Type': 'application/xml',
278
+ 'X-CSRF-Token': this.csrfToken,
279
+ ...headers,
280
+ }),
281
+ data,
282
+ });
283
+ }
284
+
285
+ /** PUT request (auto-injects CSRF token). */
286
+ async put(path, data, headers = {}) {
287
+ if (!this.csrfToken) await this.refreshCsrf();
288
+ return this.axios({
289
+ method: 'PUT',
290
+ url: `${this.baseUrl}${path}`,
291
+ headers: this._headers({
292
+ 'Content-Type': 'application/xml',
293
+ 'X-CSRF-Token': this.csrfToken,
294
+ ...headers,
295
+ }),
296
+ data,
297
+ });
298
+ }
299
+ }
300
+
301
+ // ════════════════════════════════════════════════════════════════════════════
302
+ // RFC SNC Support (X.509-based SNC via sapnwrfc.dll + koffi)
303
+ // ════════════════════════════════════════════════════════════════════════════
304
+
305
+ /**
306
+ * Build SNC connection parameters for RfcOpenConnection.
307
+ *
308
+ * SNC-enabled RFC connection requires:
309
+ * SNC_MODE = 1
310
+ * SNC_MYNAME = p:CN=<common_name>, O=<org>, C=<country>
311
+ * SNC_PARTNERNAME = p:CN=<sap_snc_name>, O=<org>, C=<country>
312
+ * SNC_QOP = 3 (quality of protection: 1=auth, 2=integrity, 3=privacy)
313
+ * SNC_LIB = path to cryptolib DLL (e.g. sapcrypto.dll)
314
+ *
315
+ * @param {Object} config
316
+ * @param {string} config.sncMode - "1" for SNC enabled
317
+ * @param {string} config.sncMyName - SNC name of the caller (e.g. "p:CN=myuser, O=MYORG")
318
+ * @param {string} config.sncPartnerName - SNC name of the SAP server
319
+ * @param {string} [config.sncQop] - Quality of protection (default "3")
320
+ * @param {string} [config.sncLib] - Path to CommonCryptoLib (sapcrypto.dll / libsapcrypto.so)
321
+ * @returns {Array<{name: Buffer, value: Buffer}>}
322
+ */
323
+ export function buildSNCParams(config) {
324
+ // Import sapStr from sap-rfc-client.js is not possible (ESM boundary),
325
+ // so we inline the helper.
326
+ const sapStr = (s) => {
327
+ const buf = Buffer.alloc((s.length + 1) * 2, 0);
328
+ buf.write(s, 0, 'utf16le');
329
+ return buf;
330
+ };
331
+
332
+ const pairs = [];
333
+
334
+ if (config.sncMode) { pairs.push(['SNC_MODE', config.sncMode]); }
335
+ if (config.sncMyName) { pairs.push(['SNC_MYNAME', config.sncMyName]); }
336
+ if (config.sncQop) { pairs.push(['SNC_QOP', config.sncQop]); }
337
+ if (config.sncLib) { pairs.push(['SNC_LIB', config.sncLib]); }
338
+ if (config.sncPartnerName) {
339
+ pairs.push(['SNC_PARTNERNAME', config.sncPartnerName]);
340
+ }
341
+
342
+ return pairs.map(([n, v]) => ({ name: sapStr(n), value: sapStr(v) }));
343
+ }
344
+
345
+ /**
346
+ * Build SNC name string in SAP format from certificate DN components.
347
+ *
348
+ * Format: p:CN=<cn>, O=<org>, C=<country>
349
+ *
350
+ * This matches what SAP expects in STRUST/SU01 SNC tab.
351
+ *
352
+ * @param {{ cn?: string, o?: string, c?: string, ou?: string }} dn
353
+ * @returns {string}
354
+ */
355
+ export function buildSNCName(dn = {}) {
356
+ const parts = [];
357
+ if (dn.cn) parts.push(`CN=${dn.cn}`);
358
+ if (dn.ou) parts.push(`OU=${dn.ou}`);
359
+ if (dn.o) parts.push(`O=${dn.o}`);
360
+ if (dn.c) parts.push(`C=${dn.c}`);
361
+ return `p:${parts.join(', ')}`;
362
+ }