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
|
@@ -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
|
+
}
|