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/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
|
+
}
|
package/spnego-auth.mjs
ADDED
|
@@ -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
|
+
}
|