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.
@@ -0,0 +1,278 @@
1
+ /**
2
+ * Configuration Manager — Encrypted local store for MCP API key & SAP systems
3
+ *
4
+ * Uses Windows DPAPI via @primno/dpapi to encrypt/decrypt a JSON file.
5
+ * The file is stored at %APPDATA%/abap-local-client/config.json (or
6
+ * ABAP_CONFIG_DIR if set).
7
+ *
8
+ * Config structure:
9
+ * {
10
+ * "mcpApiKey": "arc_local_...",
11
+ * "systems": {
12
+ * "systemId": {
13
+ * "authMode": "basic|spnego|x509|saml|snc",
14
+ * "connection": { ... mode-specific fields ... },
15
+ * "enabled": true
16
+ * }
17
+ * },
18
+ * "enableArcanumFallback": false, // if true, fall back to Arcanum for unknown systems
19
+ * "reconnectDelayMs": 5000
20
+ * }
21
+ */
22
+
23
+ import { createRequire } from 'module';
24
+ import path from 'path';
25
+ import fs from 'fs';
26
+ import os from 'os';
27
+
28
+ const require = createRequire(import.meta.url);
29
+
30
+ let dpapi = null;
31
+ if (process.platform === 'win32') {
32
+ try {
33
+ dpapi = require('@primno/dpapi');
34
+ } catch (e) {
35
+ console.warn('⚠️ @primno/dpapi not available — config will be stored UNENCRYPTED.');
36
+ }
37
+ } else {
38
+ console.warn('⚠️ DPAPI is Windows-only — config will be stored UNENCRYPTED.');
39
+ }
40
+
41
+ // ════════════════════════════════════════════════════════════════════════════
42
+ // Path resolution
43
+ // ════════════════════════════════════════════════════════════════════════════
44
+
45
+ function getConfigDir() {
46
+ if (process.env.ABAP_CONFIG_DIR) {
47
+ return process.env.ABAP_CONFIG_DIR;
48
+ }
49
+ // Windows: %APPDATA%\abap-local-client
50
+ // Fallback: ~/.abap-local-client
51
+ if (process.env.APPDATA) {
52
+ return path.join(process.env.APPDATA, 'abap-local-client');
53
+ }
54
+ return path.join(os.homedir(), '.abap-local-client');
55
+ }
56
+
57
+ function getConfigPath() {
58
+ return path.join(getConfigDir(), 'config.json');
59
+ }
60
+
61
+ // ════════════════════════════════════════════════════════════════════════════
62
+ // DPAPI helpers
63
+ // ════════════════════════════════════════════════════════════════════════════
64
+
65
+ function dpapiEncrypt(plainText) {
66
+ if (!dpapi) return plainText; // no encryption available
67
+ const buf = Buffer.from(plainText, 'utf8');
68
+ const enc = dpapi.default.protectData(buf, null, 'CurrentUser');
69
+ return enc.toString('base64');
70
+ }
71
+
72
+ function dpapiDecrypt(base64Text) {
73
+ if (!dpapi) return base64Text; // not encrypted
74
+ const buf = Buffer.from(base64Text, 'base64');
75
+ const dec = dpapi.default.unprotectData(buf, null, 'CurrentUser');
76
+ return Buffer.from(dec).toString('utf8');
77
+ }
78
+
79
+ // ════════════════════════════════════════════════════════════════════════════
80
+ // Default config
81
+ // ════════════════════════════════════════════════════════════════════════════
82
+
83
+ function defaultConfig() {
84
+ return {
85
+ mcpApiKey: '',
86
+ wsUrl: 'ws://localhost:8080',
87
+ systems: {},
88
+ enableArcanumFallback: false,
89
+ reconnectDelayMs: 5000,
90
+ };
91
+ }
92
+
93
+ // ════════════════════════════════════════════════════════════════════════════
94
+ // Public API
95
+ // ════════════════════════════════════════════════════════════════════════════
96
+
97
+ export function loadConfig() {
98
+ const configPath = getConfigPath();
99
+
100
+ if (!fs.existsSync(configPath)) {
101
+ return defaultConfig();
102
+ }
103
+
104
+ const encryptedContent = fs.readFileSync(configPath, 'utf8');
105
+ if (!encryptedContent.trim()) {
106
+ return defaultConfig();
107
+ }
108
+
109
+ let jsonText;
110
+ try {
111
+ jsonText = dpapiDecrypt(encryptedContent);
112
+ } catch (e) {
113
+ console.error(`❌ Failed to decrypt config: ${e.message}`);
114
+ console.error(' Possibly from a different user or machine. Starting with empty config.');
115
+ return defaultConfig();
116
+ }
117
+
118
+ try {
119
+ const parsed = JSON.parse(jsonText);
120
+ const defaults = defaultConfig();
121
+ return { ...defaults, ...parsed };
122
+ } catch (e) {
123
+ console.error(`❌ Failed to parse config JSON: ${e.message}`);
124
+ return defaultConfig();
125
+ }
126
+ }
127
+
128
+ function saveConfig(config) {
129
+ const configDir = getConfigDir();
130
+ if (!fs.existsSync(configDir)) {
131
+ fs.mkdirSync(configDir, { recursive: true });
132
+ }
133
+
134
+ const jsonText = JSON.stringify(config, null, 2);
135
+ const encryptedContent = dpapiEncrypt(jsonText);
136
+ fs.writeFileSync(getConfigPath(), encryptedContent, 'utf8');
137
+ }
138
+
139
+ // ── MCP API Key ────────────────────────────────────────────────────────────
140
+
141
+ export function getMcpApiKey() {
142
+ const config = loadConfig();
143
+ return config.mcpApiKey || '';
144
+ }
145
+
146
+ export function setMcpApiKey(key) {
147
+ const config = loadConfig();
148
+ config.mcpApiKey = key;
149
+ saveConfig(config);
150
+ }
151
+
152
+ // ── System management ──────────────────────────────────────────────────────
153
+
154
+ /**
155
+ * Get a single system config by ID.
156
+ * @returns {object|null}
157
+ */
158
+ export function getSystem(systemId) {
159
+ const config = loadConfig();
160
+ return config.systems[systemId] || null;
161
+ }
162
+
163
+ /**
164
+ * List all systems (optionally filtered by enabled status).
165
+ * @returns {{ systemId: string, authMode: string, enabled: boolean }[]}
166
+ */
167
+ export function listSystems(filterEnabled = false) {
168
+ const config = loadConfig();
169
+ return Object.entries(config.systems)
170
+ .filter(([, sys]) => !filterEnabled || sys.enabled !== false)
171
+ .map(([id, sys]) => ({
172
+ systemId: id,
173
+ authMode: sys.authMode || 'basic',
174
+ enabled: sys.enabled !== false,
175
+ }));
176
+ }
177
+
178
+ /**
179
+ * Add or update a system.
180
+ * @param {string} systemId
181
+ * @param {object} systemConfig - { authMode, connection, enabled }
182
+ */
183
+ export function addOrUpdateSystem(systemId, systemConfig) {
184
+ const config = loadConfig();
185
+ config.systems[systemId] = {
186
+ authMode: systemConfig.authMode || 'basic',
187
+ connection: systemConfig.connection || {},
188
+ enabled: systemConfig.enabled !== false,
189
+ };
190
+ saveConfig(config);
191
+ }
192
+
193
+ /**
194
+ * Remove a system by ID.
195
+ * @param {string} systemId
196
+ */
197
+ export function removeSystem(systemId) {
198
+ const config = loadConfig();
199
+ delete config.systems[systemId];
200
+ saveConfig(config);
201
+ }
202
+
203
+ /**
204
+ * Enable or disable a system.
205
+ * @param {string} systemId
206
+ * @param {boolean} enabled
207
+ */
208
+ export function setSystemEnabled(systemId, enabled) {
209
+ const config = loadConfig();
210
+ if (!config.systems[systemId]) {
211
+ throw new Error(`System "${systemId}" not found`);
212
+ }
213
+ config.systems[systemId].enabled = !!enabled;
214
+ saveConfig(config);
215
+ }
216
+
217
+ /**
218
+ * Check if a system is enabled.
219
+ * @param {string} systemId
220
+ * @returns {boolean}
221
+ */
222
+ export function isSystemEnabled(systemId) {
223
+ const sys = getSystem(systemId);
224
+ return sys ? sys.enabled !== false : false;
225
+ }
226
+
227
+ // ── Arcanum fallback ──────────────────────────────────────────────────────
228
+
229
+ export function getArcanumFallback() {
230
+ const config = loadConfig();
231
+ return config.enableArcanumFallback === true;
232
+ }
233
+
234
+ export function setArcanumFallback(enabled) {
235
+ const config = loadConfig();
236
+ config.enableArcanumFallback = !!enabled;
237
+ saveConfig(config);
238
+ }
239
+
240
+ // ── Reconnect delay ────────────────────────────────────────────────────────
241
+
242
+ export function getReconnectDelay() {
243
+ const config = loadConfig();
244
+ return config.reconnectDelayMs || 5000;
245
+ }
246
+
247
+ function setReconnectDelay(ms) {
248
+ const config = loadConfig();
249
+ config.reconnectDelayMs = ms;
250
+ saveConfig(config);
251
+ }
252
+
253
+ // ── WebSocket URL ──────────────────────────────────────────────────────────
254
+
255
+ export function getWsUrl() {
256
+ const config = loadConfig();
257
+ return config.wsUrl || 'ws://localhost:8080';
258
+ }
259
+
260
+ export function setWsUrl(url) {
261
+ const config = loadConfig();
262
+ config.wsUrl = url;
263
+ saveConfig(config);
264
+ }
265
+
266
+ // ── Config path (for diagnostics) ──────────────────────────────────────────
267
+
268
+ export function getConfigFilePath() {
269
+ return getConfigPath();
270
+ }
271
+
272
+ /**
273
+ * Check if a configuration file exists and is initialized.
274
+ * @returns {boolean}
275
+ */
276
+ export function isConfigInitialized() {
277
+ return fs.existsSync(getConfigPath());
278
+ }
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "abap-local-client",
3
+ "version": "1.0.0",
4
+ "description": "SAP Local Client — WebSocket bridge with Basic, SPNEGO, X.509, SAML & SNC authentication",
5
+ "main": "sso-sap-client.mjs",
6
+ "type": "module",
7
+ "scripts": {
8
+ "start": "node sso-sap-client.mjs",
9
+ "dev": "node sso-sap-client.mjs",
10
+ "sso": "node sso-sap-client.mjs",
11
+ "rfc": "node saprouter-client.mjs",
12
+ "kill": "node kill-client.js",
13
+ "test": "node test-integration.mjs",
14
+ "prepublishOnly": "node test-integration.mjs"
15
+ },
16
+ "keywords": [
17
+ "sap",
18
+ "abap",
19
+ "adt",
20
+ "sso",
21
+ "kerberos",
22
+ "spnego",
23
+ "x509",
24
+ "saml",
25
+ "snc",
26
+ "websocket",
27
+ "mcp",
28
+ "s4hana"
29
+ ],
30
+ "author": "NUMEN IT",
31
+ "license": "MIT",
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "https://github.com/fgalastri/abap-client-auth.git"
35
+ },
36
+ "dependencies": {
37
+ "@primno/dpapi": "^2.0.1",
38
+ "axios": "^1.7.9",
39
+ "fast-xml-parser": "^5.3.5",
40
+ "kerberos": "^7.0.0",
41
+ "koffi": "^2.15.3",
42
+ "node-forge": "^1.4.0",
43
+ "puppeteer": "^25.2.0",
44
+ "ws": "^8.18.0"
45
+ }
46
+ }
package/saml-login.mjs ADDED
@@ -0,0 +1,372 @@
1
+ /**
2
+ * SAML 2.0 Browser-Based Login Module (Puppeteer-backed)
3
+ *
4
+ * Uses headless Chrome to complete the SAML SSO flow exactly like Eclipse ADT:
5
+ * 1. Launch Chromium
6
+ * 2. Navigate to SAP Fiori Launchpad (triggers SAML redirect to IdP)
7
+ * 3. User authenticates via IdP (SAP IAS, Azure AD, Okta, etc.)
8
+ * 4. IdP returns SAML assertion → SAP sets session cookies
9
+ * 5. We capture the cookies from the browser context
10
+ * 6. Return cookies for ADT REST calls
11
+ *
12
+ * Supports both headless (CI) and headed (user needs to interact with IdP MFA).
13
+ */
14
+
15
+ import puppeteer from 'puppeteer';
16
+ import https from 'https';
17
+ import { createRequire } from 'module';
18
+ const require = createRequire(import.meta.url);
19
+
20
+ // ════════════════════════════════════════════════════════════════════════════
21
+ // SAML Login — main flow
22
+ // ════════════════════════════════════════════════════════════════════════════
23
+
24
+ /**
25
+ * Perform SAML 2.0 browser-based login and return SAP session cookies.
26
+ *
27
+ * @param {Object} opts
28
+ * @param {string} opts.sapBaseUrl - Full HTTPS URL (e.g. "https://my413614.s4hana.cloud.sap")
29
+ * @param {string} [opts.sapClient] - SAP client/mandant (default "100")
30
+ * @param {string} [opts.sapLanguage] - SAP logon language (default "EN")
31
+ * @param {boolean} [opts.headless] - Run browser headless (default false for initial login)
32
+ * @param {number} [opts.timeoutMs] - Max wait time (default 120000 = 2 min)
33
+ * @param {function} [opts.onStatus] - Callback for status messages (msg => void)
34
+ * @returns {Promise<{ cookies: string, csrfToken: string|null, allCookies: Array }>}
35
+ */
36
+ export async function samlLogin(opts) {
37
+ const {
38
+ sapBaseUrl,
39
+ sapClient = '100',
40
+ sapLanguage = 'EN',
41
+ headless = false,
42
+ timeoutMs = 120_000,
43
+ onStatus = () => {},
44
+ } = opts;
45
+
46
+ const loginStartUrl = `${sapBaseUrl}/sap/bc/ui2/flp?sap-client=${sapClient}&sap-language=${sapLanguage}`;
47
+
48
+ onStatus(`SAP: ${sapBaseUrl} (client ${sapClient})`);
49
+ onStatus(`Launching browser...`);
50
+
51
+ const browser = await puppeteer.launch({
52
+ headless: headless ? 'new' : false,
53
+ args: [
54
+ '--disable-blink-features=AutomationControlled',
55
+ '--disable-infobars',
56
+ '--window-size=1280,800',
57
+ ],
58
+ });
59
+
60
+ try {
61
+ const context = browser.defaultBrowserContext();
62
+ const page = await browser.newPage();
63
+
64
+ // Set a reasonable viewport
65
+ await page.setViewport({ width: 1280, height: 800 });
66
+
67
+ // Listen for all responses — capture Set-Cookie headers at each step
68
+ const cookieMap = new Map();
69
+
70
+ page.on('response', (response) => {
71
+ const headers = response.headers();
72
+ const setCookie = headers['set-cookie'];
73
+ if (setCookie) {
74
+ const cookies = Array.isArray(setCookie) ? setCookie : [setCookie];
75
+ for (const c of cookies) {
76
+ const pair = c.split(';')[0].trim();
77
+ if (!pair) continue;
78
+ const [name, ...rest] = pair.split('=');
79
+ const value = rest.join('=');
80
+ cookieMap.set(name, value);
81
+ }
82
+ }
83
+ });
84
+
85
+ onStatus(`Navigating to SAP Fiori Launchpad...`);
86
+ onStatus(`URL: ${loginStartUrl}`);
87
+
88
+ // Use 'domcontentloaded' instead of 'networkidle2' because SAML redirects
89
+ // across multiple domains (SAP → IdP → SAP) which never stabilizes.
90
+ await page.goto(loginStartUrl, {
91
+ waitUntil: 'domcontentloaded',
92
+ timeout: 30_000,
93
+ });
94
+
95
+ const currentUrl = page.url();
96
+ onStatus(`Landed at: ${currentUrl.substring(0, 120)}...`);
97
+
98
+ // Wait for the user to complete SAML authentication.
99
+ // The SAML flow goes: SAP FLP → redirect to IdP → user authenticates →
100
+ // IdP POST SAML assertion to SAP → SAP sets cookies → redirect to FLP.
101
+ //
102
+ // We detect completion by checking if the page URL is back on SAP
103
+ // AND SAP session cookies exist.
104
+ const extendedTimeout = timeoutMs;
105
+ onStatus(`Waiting for SAML login (timeout: ${extendedTimeout / 1000}s)...`);
106
+ onStatus(` Please complete login in the browser window.`);
107
+
108
+ try {
109
+ await page.waitForFunction(
110
+ (baseUrl) => {
111
+ const url = window.location.href;
112
+ const onSAP = url.includes(baseUrl.replace('https://', ''));
113
+ const hasCookies = document.cookie.length > 0 &&
114
+ (document.cookie.includes('SAP_SESSION') ||
115
+ document.cookie.includes('MYSAPSSO2') ||
116
+ document.cookie.includes('sap-usercontext') ||
117
+ document.cookie.includes('SAP_SSO') ||
118
+ document.cookie.includes('JSESSIONID'));
119
+ if (onSAP && hasCookies) {
120
+ console.log('[puppeteer] Login detected: url=' + url.substring(0,60) + ' cookies=yes');
121
+ return true;
122
+ }
123
+ return false;
124
+ },
125
+ { timeout: extendedTimeout, polling: 2000 },
126
+ sapBaseUrl
127
+ );
128
+ onStatus('SAML/SSO authentication completed.');
129
+ } catch (err) {
130
+ onStatus(`WaitForFunction timed out — checking cookies...`);
131
+ const pageCookies = await context.cookies();
132
+ onStatus(` Browser has ${pageCookies.length} cookies for current domain`);
133
+ if (pageCookies.length === 0) {
134
+ throw new Error(
135
+ 'SAML login did not complete. The browser should be open — ' +
136
+ 'please log in via the IdP login page that appeared.\n' +
137
+ 'After login, the script will detect the session cookies automatically.\n' +
138
+ 'If the IdP page did not load, check the SAP URL: ' + sapBaseUrl
139
+ );
140
+ }
141
+ onStatus('Some cookies were captured — proceeding.');
142
+ }
143
+
144
+ // Wait a moment for all cookies to settle
145
+ await new Promise(r => setTimeout(r, 2000));
146
+
147
+ // Collect all cookies from the browser context
148
+ const allCookies = await context.cookies();
149
+
150
+ // Build cookie string for ADT calls
151
+ const safpCookieNames = [
152
+ 'SAP_SESSION', 'MYSAPSSO2', 'SAP_SSO',
153
+ 'sap-usercontext', 'JSESSIONID',
154
+ 'SAP_SESSIONID_JV9_080', 'SAP_SESSIONID_JV9',
155
+ ];
156
+
157
+ const sapCookies = allCookies
158
+ .filter(c => {
159
+ // Include recognized SAP cookies OR any cookie set by the SAP domain
160
+ const isSAPCookie = safpCookieNames.some(n =>
161
+ c.name.startsWith(n) || c.name === n
162
+ );
163
+ const fromSAPDomain = c.domain &&
164
+ c.domain.includes(new URL(sapBaseUrl).hostname.replace('https://', '').split('.')[0]);
165
+ return isSAPCookie || fromSAPDomain;
166
+ })
167
+ .map(c => `${c.name}=${c.value}`);
168
+
169
+ const cookieString = sapCookies.join('; ');
170
+
171
+ if (!cookieString) {
172
+ // Broader fallback: include all cookies
173
+ const allCookieString = allCookies.map(c => `${c.name}=${c.value}`).join('; ');
174
+ if (!allCookieString) {
175
+ throw new Error('No cookies captured from browser session.');
176
+ }
177
+ onStatus('⚠️ No recognized SAP cookies — using all available cookies.');
178
+ return {
179
+ cookies: allCookieString,
180
+ csrfToken: null,
181
+ allCookies,
182
+ };
183
+ }
184
+
185
+ // Fetch CSRF token using the captured cookies
186
+ let csrfToken = null;
187
+ try {
188
+ const csrfResp = await fetchCsrfTokenViaNode(
189
+ sapBaseUrl, sapClient, sapLanguage, cookieString
190
+ );
191
+ if (csrfResp) {
192
+ csrfToken = csrfResp;
193
+ onStatus(`CSRF token: ${csrfToken.substring(0, 15)}...`);
194
+ }
195
+ } catch (err) {
196
+ onStatus(`⚠️ Could not fetch CSRF token: ${err.message}`);
197
+ }
198
+
199
+ onStatus(`Captured ${sapCookies.length} SAP cookies`);
200
+ for (const c of sapCookies) {
201
+ onStatus(` ${c.split('=')[0]}`);
202
+ }
203
+
204
+ return { cookies: cookieString, csrfToken, allCookies };
205
+
206
+ } finally {
207
+ await browser.close();
208
+ }
209
+ }
210
+
211
+ // ════════════════════════════════════════════════════════════════════════════
212
+ // CSRF token fetch (using Node https, not browser)
213
+ // ════════════════════════════════════════════════════════════════════════════
214
+
215
+ async function fetchCsrfTokenViaNode(baseUrl, client, language, cookieString) {
216
+ const url = new URL('/sap/bc/adt/discovery', baseUrl);
217
+
218
+ return new Promise((resolve, reject) => {
219
+ const req = https.request({
220
+ hostname: url.hostname,
221
+ port: url.port || 443,
222
+ path: url.pathname,
223
+ method: 'GET',
224
+ headers: {
225
+ 'Accept': 'application/atomsvc+xml',
226
+ 'sap-client': client,
227
+ 'sap-language': language,
228
+ 'X-CSRF-Token': 'Fetch',
229
+ 'Cookie': cookieString,
230
+ },
231
+ }, (res) => {
232
+ const token = res.headers['x-csrf-token'];
233
+ if (token) {
234
+ resolve(token);
235
+ } else {
236
+ // Consume body
237
+ res.resume();
238
+ res.on('end', () => resolve(null));
239
+ }
240
+ });
241
+ req.on('error', reject);
242
+ req.setTimeout(15000, () => { req.destroy(); reject(new Error('CSRF fetch timeout')); });
243
+ req.end();
244
+ });
245
+ }
246
+
247
+ // ════════════════════════════════════════════════════════════════════════════
248
+ // SamlSession — HTTP session backed by SAML cookies
249
+ // ════════════════════════════════════════════════════════════════════════════
250
+
251
+ /**
252
+ * SamlSession manages ADT HTTP calls using SAML session cookies.
253
+ *
254
+ * Usage:
255
+ * const s = new SamlSession(axios, sapBaseUrl, sapClient);
256
+ * await s.init(); // opens browser for SAML login (headed or headless)
257
+ * const r = await s.get('/sap/bc/adt/discovery');
258
+ */
259
+ export class SamlSession {
260
+ /**
261
+ * @param {object} axios - Axios instance
262
+ * @param {string} sapBaseUrl - SAP HTTPS base URL
263
+ * @param {string} [sapClient='100']
264
+ * @param {{ language?: string, headless?: boolean, timeoutMs?: number, onStatus?: function }} [opts]
265
+ */
266
+ constructor(axios, sapBaseUrl, sapClient = '100', opts = {}) {
267
+ this.axios = axios;
268
+ this.sapBaseUrl = sapBaseUrl;
269
+ this.sapClient = sapClient;
270
+ this.language = opts.language || 'EN';
271
+ this.headless = opts.headless !== false; // default headless for CI
272
+ this.timeoutMs = opts.timeoutMs || 120_000;
273
+ this.onStatus = opts.onStatus || (() => {});
274
+
275
+ this.cookies = null;
276
+ this.csrfToken = null;
277
+ this.authenticated = false;
278
+ }
279
+
280
+ /** Open browser for SAML login and store session cookies. */
281
+ async init() {
282
+ const result = await samlLogin({
283
+ sapBaseUrl: this.sapBaseUrl,
284
+ sapClient: this.sapClient,
285
+ sapLanguage: this.language,
286
+ headless: this.headless,
287
+ timeoutMs: this.timeoutMs,
288
+ onStatus: this.onStatus,
289
+ });
290
+
291
+ this.cookies = result.cookies;
292
+ this.csrfToken = result.csrfToken;
293
+
294
+ if (!this.cookies) {
295
+ throw new Error('SAML login did not return session cookies');
296
+ }
297
+
298
+ this.authenticated = true;
299
+ return this.cookies;
300
+ }
301
+
302
+ /** Build headers. */
303
+ _headers(extra = {}) {
304
+ const h = {
305
+ 'sap-client': this.sapClient,
306
+ 'sap-language': this.language,
307
+ ...extra,
308
+ };
309
+ if (this.cookies) {
310
+ h['Cookie'] = this.cookies;
311
+ }
312
+ return h;
313
+ }
314
+
315
+ /** Refresh CSRF token. */
316
+ async refreshCsrf() {
317
+ const resp = await this.axios({
318
+ method: 'GET',
319
+ url: `${this.sapBaseUrl}/sap/bc/adt/discovery`,
320
+ headers: this._headers({
321
+ 'Accept': 'application/atomsvc+xml',
322
+ 'X-CSRF-Token': 'Fetch',
323
+ }),
324
+ });
325
+ if (resp.headers['x-csrf-token']) {
326
+ this.csrfToken = resp.headers['x-csrf-token'];
327
+ }
328
+ return this.csrfToken;
329
+ }
330
+
331
+ /** GET request. */
332
+ async get(path, headers = {}) {
333
+ return this.axios({
334
+ method: 'GET',
335
+ url: `${this.sapBaseUrl}${path}`,
336
+ headers: this._headers(headers),
337
+ maxRedirects: 0,
338
+ });
339
+ }
340
+
341
+ /** POST request (auto-injects CSRF token). */
342
+ async post(path, data, headers = {}) {
343
+ if (!this.csrfToken) await this.refreshCsrf();
344
+ return this.axios({
345
+ method: 'POST',
346
+ url: `${this.sapBaseUrl}${path}`,
347
+ headers: this._headers({
348
+ 'Content-Type': 'application/xml',
349
+ 'X-CSRF-Token': this.csrfToken,
350
+ ...headers,
351
+ }),
352
+ data,
353
+ maxRedirects: 0,
354
+ });
355
+ }
356
+
357
+ /** PUT request (auto-injects CSRF token). */
358
+ async put(path, data, headers = {}) {
359
+ if (!this.csrfToken) await this.refreshCsrf();
360
+ return this.axios({
361
+ method: 'PUT',
362
+ url: `${this.sapBaseUrl}${path}`,
363
+ headers: this._headers({
364
+ 'Content-Type': 'application/xml',
365
+ 'X-CSRF-Token': this.csrfToken,
366
+ ...headers,
367
+ }),
368
+ data,
369
+ maxRedirects: 0,
370
+ });
371
+ }
372
+ }