sap-ai-dev-toolkit 0.7.5 → 0.7.9
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 +30 -23
- package/package.json +1 -1
- package/scripts/postinstall.mjs +41 -9
- package/scripts/windows-negotiate-helper.ps1 +182 -0
- package/src/harness-mcp-config.mjs +122 -0
- package/src/harnesses.mjs +29 -1
- package/src/install-user-assets.mjs +45 -5
- package/src/launcher.mjs +47 -4
- package/src/local-sap-gui.mjs +217 -0
- package/src/mcp-config.mjs +45 -5
- package/src/mcp-proxy.mjs +1 -0
- package/src/setup.mjs +125 -26
- package/src/windows-sso-adt-proxy.mjs +224 -0
package/src/setup.mjs
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { createInterface } from 'node:readline/promises';
|
|
2
2
|
import { stdin, stdout } from 'node:process';
|
|
3
|
-
import {
|
|
3
|
+
import { platform } from 'node:os';
|
|
4
|
+
import { checkboxPrompt, colorText, formatStatus, textPrompt } from './terminal-ui.mjs';
|
|
4
5
|
import { discoverDestinations, remediation } from './bas-discovery.mjs';
|
|
6
|
+
import { defaultAdtUrl, discoverSapGuiSystems } from './local-sap-gui.mjs';
|
|
5
7
|
import { discoverCloudFoundryDestinations, getCloudFoundryTarget, deleteManagedCloudFoundryServiceKeys, findOrphanedCloudFoundryServiceKeys } from './cf-destination.mjs';
|
|
6
8
|
import { SAP_DEVELOPMENT_MCP_SERVERS, collectCloudFoundryKeyReferencesFromAllEntries, collectManagedCloudFoundryKeyReferences, installMcpConfig, readMcpConfig, resolveMcpConfigPath } from './mcp-config.mjs';
|
|
7
9
|
|
|
@@ -11,6 +13,24 @@ function print(output, message) {
|
|
|
11
13
|
output.write(`${message}\n`);
|
|
12
14
|
}
|
|
13
15
|
|
|
16
|
+
function windowsSsoSetupAvailable(env = process.env) {
|
|
17
|
+
return platform() === 'win32' || env.SAP_AI_DEV_TOOLKIT_ENABLE_WINDOWS_SSO_SETUP === 'true';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function chooseLocalAuthMode(destination, { input = stdin, output = stdout, env = process.env } = {}) {
|
|
21
|
+
if (!windowsSsoSetupAvailable(env)) return 'basic';
|
|
22
|
+
const answer = await textPrompt({
|
|
23
|
+
message: `Authentication for ${destination.name} (password/sso)`,
|
|
24
|
+
placeholder: 'password',
|
|
25
|
+
required: false,
|
|
26
|
+
validate: value => {
|
|
27
|
+
const candidate = String(value || 'password').trim().toLowerCase();
|
|
28
|
+
return ['password', 'basic', 'sso', 'windows-sso'].includes(candidate) || 'Enter password or sso.';
|
|
29
|
+
}
|
|
30
|
+
}, { input, output });
|
|
31
|
+
const normalized = String(answer || 'password').trim().toLowerCase();
|
|
32
|
+
return ['sso', 'windows-sso'].includes(normalized) ? 'windows-sso' : 'basic';
|
|
33
|
+
}
|
|
14
34
|
|
|
15
35
|
async function confirmCloudFoundryImport({ input = stdin, output = stdout } = {}) {
|
|
16
36
|
const readline = createInterface({ input, output });
|
|
@@ -46,6 +66,24 @@ function safeBasDestination(destination) {
|
|
|
46
66
|
};
|
|
47
67
|
}
|
|
48
68
|
|
|
69
|
+
function safeLocalSapGuiDestination(destination) {
|
|
70
|
+
return {
|
|
71
|
+
source: 'sap-gui-local',
|
|
72
|
+
name: destination.name,
|
|
73
|
+
serverName: destination.serverName || destination.name,
|
|
74
|
+
url: destination.url,
|
|
75
|
+
host: destination.host,
|
|
76
|
+
systemId: destination.systemId,
|
|
77
|
+
instance: destination.instance,
|
|
78
|
+
client: destination.client || '001',
|
|
79
|
+
authentication: destination.authentication || 'Basic',
|
|
80
|
+
authMode: destination.authMode,
|
|
81
|
+
proxyType: 'Internet',
|
|
82
|
+
probe: safeProbe(destination.probe),
|
|
83
|
+
childEnv: destination.childEnv || {}
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
49
87
|
function safeCloudFoundryDestination(destination) {
|
|
50
88
|
const cf = destination.cf || {};
|
|
51
89
|
return {
|
|
@@ -99,28 +137,41 @@ export async function runSetup({
|
|
|
99
137
|
output = stdout,
|
|
100
138
|
discover = discoverDestinations,
|
|
101
139
|
discoverCf = discoverCloudFoundryDestinations,
|
|
140
|
+
discoverLocalSapGui = discoverSapGuiSystems,
|
|
102
141
|
confirmCfImport = confirmCloudFoundryImport,
|
|
103
142
|
install = installMcpConfig,
|
|
104
143
|
includeSapDevelopmentToolsPrompt = false
|
|
105
144
|
} = {}) {
|
|
106
|
-
|
|
107
|
-
|
|
145
|
+
const isBas = Boolean(env.H2O_URL);
|
|
146
|
+
if (!isBas && (!input.isTTY || !output.isTTY)) {
|
|
147
|
+
print(output, formatStatus('BAS destination setup was skipped because H2O_URL is not set.', 'info', output, 'SAP AI Dev Toolkit'));
|
|
108
148
|
return { skipped: true, reason: 'non-bas' };
|
|
109
149
|
}
|
|
110
150
|
if (!input.isTTY || !output.isTTY) {
|
|
111
|
-
print(output, formatStatus(`Interactive setup needs a terminal. Rerun ${SETUP_COMMAND} from a
|
|
151
|
+
print(output, formatStatus(`Interactive setup needs a terminal. Rerun ${SETUP_COMMAND} from a terminal.`, 'warning', output, 'SAP AI Dev Toolkit'));
|
|
112
152
|
return { skipped: true, reason: 'non-tty' };
|
|
113
153
|
}
|
|
114
154
|
print(output, '');
|
|
115
|
-
print(output, formatStatus('Contacting BAS to discover destinations; this may take a moment.', 'progress', output, 'Setup'));
|
|
155
|
+
print(output, formatStatus(isBas ? 'Contacting BAS to discover destinations; this may take a moment.' : 'Looking for local SAP GUI system configuration.', 'progress', output, 'Setup'));
|
|
116
156
|
|
|
117
|
-
let
|
|
118
|
-
|
|
119
|
-
basDestinations
|
|
120
|
-
|
|
121
|
-
|
|
157
|
+
let destinations = [];
|
|
158
|
+
if (isBas) {
|
|
159
|
+
let basDestinations;
|
|
160
|
+
try {
|
|
161
|
+
basDestinations = await discover({ env: { ...env, SAP_AI_DEV_TOOLKIT_DESTINATION: '' } });
|
|
162
|
+
} catch (error) {
|
|
163
|
+
throw new Error(`BAS discovery failed: ${error.message}`);
|
|
164
|
+
}
|
|
165
|
+
destinations = basDestinations.map(safeBasDestination);
|
|
166
|
+
} else {
|
|
167
|
+
let localSystems;
|
|
168
|
+
try {
|
|
169
|
+
localSystems = await discoverLocalSapGui({ env });
|
|
170
|
+
} catch (error) {
|
|
171
|
+
throw new Error(`SAP GUI discovery failed: ${error.message}`);
|
|
172
|
+
}
|
|
173
|
+
destinations = localSystems.map(safeLocalSapGuiDestination);
|
|
122
174
|
}
|
|
123
|
-
const destinations = basDestinations.map(safeBasDestination);
|
|
124
175
|
const warnings = [];
|
|
125
176
|
let createdKeys = [];
|
|
126
177
|
let configPath;
|
|
@@ -132,9 +183,9 @@ export async function runSetup({
|
|
|
132
183
|
warnings.push('Existing MCP config could not be read before setup; existing CF service keys will not be reused.');
|
|
133
184
|
}
|
|
134
185
|
|
|
135
|
-
const target = await getCloudFoundryTarget({ env });
|
|
186
|
+
const target = isBas ? await getCloudFoundryTarget({ env }) : { available: false, reason: 'Cloud Foundry import is only available in BAS setup.' };
|
|
136
187
|
if (!target.available) {
|
|
137
|
-
warnings.push(target.reason);
|
|
188
|
+
if (isBas) warnings.push(target.reason);
|
|
138
189
|
} else {
|
|
139
190
|
// Sweep for service keys this toolkit created but no config references
|
|
140
191
|
// (an interrupted earlier setup leaves them behind; a Destination key
|
|
@@ -205,8 +256,8 @@ export async function runSetup({
|
|
|
205
256
|
};
|
|
206
257
|
const selectable = destinations.filter(destination => !destination.disabledReason);
|
|
207
258
|
if (!selectable.length) {
|
|
208
|
-
print(output, formatStatus('No selectable BAS or Cloud Foundry destinations were found.', 'warning', output, 'Setup'));
|
|
209
|
-
print(output, remediation);
|
|
259
|
+
print(output, formatStatus(isBas ? 'No selectable BAS or Cloud Foundry destinations were found.' : 'No SAP GUI systems were found on this computer.', 'warning', output, 'Setup'));
|
|
260
|
+
print(output, isBas ? remediation : 'Install SAP GUI and create SAP Logon entries, or set SAP_AI_DEV_MCP_CONFIG and add systems manually. ADT still needs an HTTP(S) URL; SAP GUI files contain DIAG routing only.');
|
|
210
261
|
await reportWarnings(warnings);
|
|
211
262
|
const cleanupWarnings = await cleanupNewKeysWithoutConfigChange();
|
|
212
263
|
await reportWarnings(cleanupWarnings);
|
|
@@ -222,9 +273,9 @@ export async function runSetup({
|
|
|
222
273
|
const probe = destination.probe?.status || 'unknown';
|
|
223
274
|
const source = destination.source === 'cloud-foundry'
|
|
224
275
|
? `CF ${destination.cf.destinationInstanceName}`
|
|
225
|
-
: 'BAS';
|
|
276
|
+
: (destination.source === 'sap-gui-local' ? `SAP GUI ${destination.systemId || destination.host || ''}`.trim() : 'BAS');
|
|
226
277
|
const disabled = destination.disabledReason;
|
|
227
|
-
const state = disabled ? 'disabled' : (destination.probe?.available === false ? `fail:${probe}` : `ok:${probe}`);
|
|
278
|
+
const state = disabled ? 'disabled' : (probe === 'needs-adt-url' ? probe : (destination.probe?.available === false ? `fail:${probe}` : `ok:${probe}`));
|
|
228
279
|
return {
|
|
229
280
|
value: destination,
|
|
230
281
|
name: `${destination.name} (${source}, client ${destination.client}, ${state})`,
|
|
@@ -245,6 +296,52 @@ export async function runSetup({
|
|
|
245
296
|
shortcuts: { all: 'a' }
|
|
246
297
|
}, { input, output });
|
|
247
298
|
|
|
299
|
+
if (!isBas && selected.length) {
|
|
300
|
+
print(output, '');
|
|
301
|
+
print(output, formatStatus('SAP GUI landscapes do not contain ADT HTTP(S) endpoints. Confirm the ADT URL and authentication for each selected system.', 'step', output, 'Local SAP GUI'));
|
|
302
|
+
if (windowsSsoSetupAvailable(env)) print(output, ' Windows SSO is experimental and only applies to ADT systems configured for HTTP Integrated Authentication. Username/password remains the default.');
|
|
303
|
+
for (const destination of selected) {
|
|
304
|
+
const fallback = defaultAdtUrl(destination);
|
|
305
|
+
const url = await textPrompt({
|
|
306
|
+
message: `ADT URL for ${destination.name}`,
|
|
307
|
+
placeholder: fallback || 'https://host:44300',
|
|
308
|
+
validate: value => {
|
|
309
|
+
const candidate = value || fallback;
|
|
310
|
+
if (!candidate) return 'Enter an ADT base URL, for example https://host:44300.';
|
|
311
|
+
try {
|
|
312
|
+
const parsed = new URL(candidate);
|
|
313
|
+
if (!['http:', 'https:'].includes(parsed.protocol)) return 'Use an http:// or https:// URL.';
|
|
314
|
+
if (parsed.username || parsed.password) return 'Do not embed credentials in the ADT URL; use the login prompts.';
|
|
315
|
+
if (parsed.search || parsed.hash) return 'Enter an ADT base URL without a query or fragment.';
|
|
316
|
+
return true;
|
|
317
|
+
}
|
|
318
|
+
catch { return 'Enter a valid URL.'; }
|
|
319
|
+
},
|
|
320
|
+
required: false
|
|
321
|
+
}, { input, output });
|
|
322
|
+
destination.url = url || fallback;
|
|
323
|
+
const authMode = await chooseLocalAuthMode(destination, { input, output, env });
|
|
324
|
+
destination.authentication = authMode === 'windows-sso' ? 'WindowsSSO' : 'Basic';
|
|
325
|
+
destination.authMode = authMode;
|
|
326
|
+
if (authMode === 'windows-sso') {
|
|
327
|
+
destination.childEnv = { SAP_AUTH_MODE: 'windows-sso' };
|
|
328
|
+
destination.inputs = [];
|
|
329
|
+
} else {
|
|
330
|
+
const userInputId = `sap-ai-dev-${destination.serverName || destination.name}-user`.toLowerCase().replace(/[^a-z0-9_-]+/g, '-');
|
|
331
|
+
const passwordInputId = `sap-ai-dev-${destination.serverName || destination.name}-password`.toLowerCase().replace(/[^a-z0-9_-]+/g, '-');
|
|
332
|
+
destination.childEnv = {
|
|
333
|
+
SAP_AUTH_MODE: 'basic',
|
|
334
|
+
SAP_USER: `\${input:${userInputId}}`,
|
|
335
|
+
SAP_PASSWORD: `\${input:${passwordInputId}}`
|
|
336
|
+
};
|
|
337
|
+
destination.inputs = [
|
|
338
|
+
{ id: userInputId, type: 'promptString', description: `SAP user for ${destination.name}` },
|
|
339
|
+
{ id: passwordInputId, type: 'promptString', description: `SAP password for ${destination.name}`, password: true }
|
|
340
|
+
];
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
248
345
|
let sapDevelopmentServers = [];
|
|
249
346
|
if (includeSapDevelopmentToolsPrompt) {
|
|
250
347
|
print(output, '');
|
|
@@ -269,18 +366,20 @@ export async function runSetup({
|
|
|
269
366
|
const location = result?.path ? ` in ${result.path}` : '';
|
|
270
367
|
print(output, '');
|
|
271
368
|
const configuredCount = selected.length + sapDevelopmentServers.length;
|
|
272
|
-
print(output, formatStatus(`Configured ${configuredCount} MCP server${configuredCount === 1 ? '' : 's'}${location}.`, 'success', output, 'BAS setup'));
|
|
369
|
+
print(output, formatStatus(`Configured ${configuredCount} MCP server${configuredCount === 1 ? '' : 's'}${location}.`, 'success', output, isBas ? 'BAS setup' : 'Local setup'));
|
|
273
370
|
printConnectionInstructions(output, result);
|
|
274
371
|
let cleanupWarnings = [];
|
|
275
372
|
const finalPath = result?.path || configPath;
|
|
276
|
-
if (
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
373
|
+
if (isBas) {
|
|
374
|
+
if (!finalPath) {
|
|
375
|
+
cleanupWarnings.push('CF service-key cleanup skipped because the final MCP config path is unavailable.');
|
|
376
|
+
} else {
|
|
377
|
+
try {
|
|
378
|
+
const finalConfig = await readMcpConfig(finalPath);
|
|
379
|
+
cleanupWarnings = await cleanupKeys([...managedKeys, ...createdKeys], finalConfig);
|
|
380
|
+
} catch {
|
|
381
|
+
cleanupWarnings.push('CF service-key cleanup skipped because the final MCP config could not be read.');
|
|
382
|
+
}
|
|
284
383
|
}
|
|
285
384
|
}
|
|
286
385
|
await reportWarnings(warnings);
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import { once } from 'node:events';
|
|
3
|
+
import { execFile as nodeExecFile } from 'node:child_process';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
|
|
7
|
+
const MAX_HELPER_OUTPUT = 1024 * 1024;
|
|
8
|
+
const HOP_BY_HOP = new Set(['connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailer', 'transfer-encoding', 'upgrade']);
|
|
9
|
+
const root = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
10
|
+
|
|
11
|
+
function safeError(message) { return new Error(message); }
|
|
12
|
+
|
|
13
|
+
function validateTarget(value) {
|
|
14
|
+
let url;
|
|
15
|
+
try { url = new URL(value); }
|
|
16
|
+
catch { throw safeError('Windows SSO target ADT URL is invalid'); }
|
|
17
|
+
if (!['http:', 'https:'].includes(url.protocol) || !url.hostname || url.username || url.password) {
|
|
18
|
+
throw safeError('Windows SSO target ADT URL must be an HTTP(S) URL without embedded user information');
|
|
19
|
+
}
|
|
20
|
+
url.hash = '';
|
|
21
|
+
return url;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function hasNegotiateChallenge(headers) {
|
|
25
|
+
const value = headers.get?.('www-authenticate') || '';
|
|
26
|
+
return /(?:^|,)\s*Negotiate\b/i.test(value);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function headerObject(headers) {
|
|
30
|
+
const result = {};
|
|
31
|
+
for (const [name, value] of Object.entries(headers || {})) {
|
|
32
|
+
const lower = name.toLowerCase();
|
|
33
|
+
if (HOP_BY_HOP.has(lower) || lower === 'host') continue;
|
|
34
|
+
if (lower === 'authorization') continue;
|
|
35
|
+
if (value == null) continue;
|
|
36
|
+
result[name] = Array.isArray(value) ? value.join(', ') : String(value);
|
|
37
|
+
}
|
|
38
|
+
return result;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function copyResponseHeaders(response, { proxyOrigin, targetOrigin } = {}) {
|
|
42
|
+
const headers = {};
|
|
43
|
+
for (const [name, value] of response.headers) {
|
|
44
|
+
const lower = name.toLowerCase();
|
|
45
|
+
if (HOP_BY_HOP.has(lower) || ['content-encoding', 'content-length', 'set-cookie'].includes(lower)) continue;
|
|
46
|
+
// fetch exposes decompressed bytes, so upstream framing is no longer valid.
|
|
47
|
+
if (lower === 'location' && proxyOrigin && targetOrigin) {
|
|
48
|
+
try {
|
|
49
|
+
const location = new URL(value, targetOrigin);
|
|
50
|
+
if (location.origin === targetOrigin) {
|
|
51
|
+
headers[name] = `${proxyOrigin}${location.pathname}${location.search}${location.hash}`;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
} catch {
|
|
55
|
+
// Keep unparsable Location values unchanged; the client/backend can decide.
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
headers[name] = value;
|
|
59
|
+
}
|
|
60
|
+
const cookies = setCookies(response.headers);
|
|
61
|
+
if (cookies.length) headers['set-cookie'] = cookies;
|
|
62
|
+
return headers;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function setCookies(headers) {
|
|
66
|
+
if (typeof headers?.getSetCookie === 'function') return headers.getSetCookie();
|
|
67
|
+
const value = headers?.get?.('set-cookie');
|
|
68
|
+
return value ? [value] : [];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function mergeCookieHeader(existing, cookies) {
|
|
72
|
+
const pairs = new Map();
|
|
73
|
+
for (const part of String(existing || '').split(';')) {
|
|
74
|
+
const trimmed = part.trim();
|
|
75
|
+
const index = trimmed.indexOf('=');
|
|
76
|
+
if (index > 0) pairs.set(trimmed.slice(0, index), trimmed.slice(index + 1));
|
|
77
|
+
}
|
|
78
|
+
for (const cookie of cookies || []) {
|
|
79
|
+
const first = String(cookie || '').split(';', 1)[0]?.trim();
|
|
80
|
+
const index = first.indexOf('=');
|
|
81
|
+
if (index > 0) pairs.set(first.slice(0, index), first.slice(index + 1));
|
|
82
|
+
}
|
|
83
|
+
return [...pairs].map(([name, value]) => `${name}=${value}`).join('; ');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function readRequestBody(req, limit = 50 * 1024 * 1024) {
|
|
87
|
+
if (['GET', 'HEAD'].includes(req.method || 'GET')) return undefined;
|
|
88
|
+
const chunks = [];
|
|
89
|
+
let size = 0;
|
|
90
|
+
for await (const chunk of req) {
|
|
91
|
+
size += chunk.length;
|
|
92
|
+
if (size > limit) throw safeError('Windows SSO proxy request body is too large');
|
|
93
|
+
chunks.push(chunk);
|
|
94
|
+
}
|
|
95
|
+
return Buffer.concat(chunks);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function helperCommand(env) {
|
|
99
|
+
const command = env.SAP_AI_DEV_TOOLKIT_NEGOTIATE_HELPER || env.SAP_AI_DEV_TOOLKIT_SSPI_HELPER;
|
|
100
|
+
if (command) return { command, args: [] };
|
|
101
|
+
if (process.platform !== 'win32') return null;
|
|
102
|
+
return {
|
|
103
|
+
command: env.SystemRoot ? join(env.SystemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe') : 'powershell.exe',
|
|
104
|
+
args: ['-NoLogo', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', join(root, 'scripts', 'windows-negotiate-helper.ps1')]
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function tokenLooksLikeNtlm(token) {
|
|
109
|
+
try {
|
|
110
|
+
return Buffer.from(token, 'base64').subarray(0, 8).toString('ascii') === 'NTLMSSP\0';
|
|
111
|
+
} catch {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function execHelper(helper, payload, execFileImpl = nodeExecFile, env = process.env) {
|
|
117
|
+
return new Promise((resolve, reject) => {
|
|
118
|
+
const child = execFileImpl(helper.command, helper.args, {
|
|
119
|
+
windowsHide: true,
|
|
120
|
+
timeout: 30_000,
|
|
121
|
+
maxBuffer: MAX_HELPER_OUTPUT,
|
|
122
|
+
env: { ...process.env, ...env }
|
|
123
|
+
}, (error, stdout) => {
|
|
124
|
+
// execFile errors can contain helper stdout/stderr, including tokens.
|
|
125
|
+
if (error) return reject(safeError('Windows SSO helper failed; verify the helper and Windows Kerberos configuration.'));
|
|
126
|
+
const token = String(stdout || '').trim();
|
|
127
|
+
if (!/^[A-Za-z0-9+/=_-]+$/.test(token)) return reject(safeError('Windows SSO helper returned an invalid Negotiate token'));
|
|
128
|
+
if (tokenLooksLikeNtlm(token)) return reject(safeError('Windows SSO helper returned an NTLM token. ADT SSO requires Kerberos/SPNEGO; verify the backend SPN and Kerberos configuration, or rerun sap-ai-dev --setup and choose username/password.'));
|
|
129
|
+
resolve(token);
|
|
130
|
+
});
|
|
131
|
+
child.stdin?.end(`${JSON.stringify(payload)}\n`);
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function acquireNegotiateToken({ targetUrl, challenge, env, execFileImpl }) {
|
|
136
|
+
const helper = helperCommand(env);
|
|
137
|
+
if (!helper) {
|
|
138
|
+
throw safeError('Windows SSO requires an SSPI/Negotiate helper. Set SAP_AI_DEV_TOOLKIT_NEGOTIATE_HELPER to a trusted helper executable, or rerun sap-ai-dev --setup and choose username/password.');
|
|
139
|
+
}
|
|
140
|
+
return execHelper(helper, { url: targetUrl, challenge, mechanism: 'Negotiate' }, execFileImpl, env);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function forward(req, targetUrl, headers, body, fetchImpl) {
|
|
144
|
+
return fetchImpl(targetUrl, {
|
|
145
|
+
method: req.method,
|
|
146
|
+
headers,
|
|
147
|
+
body,
|
|
148
|
+
duplex: body ? 'half' : undefined,
|
|
149
|
+
redirect: 'manual'
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function writeFetchResponse(res, response, base, proxyOrigin) {
|
|
154
|
+
res.writeHead(response.status, response.statusText, copyResponseHeaders(response, { proxyOrigin, targetOrigin: base.origin }));
|
|
155
|
+
if (!response.body) return res.end();
|
|
156
|
+
response.body.pipeTo(new WritableStream({
|
|
157
|
+
write(chunk) { res.write(chunk); },
|
|
158
|
+
close() { res.end(); },
|
|
159
|
+
abort() { res.destroy(); }
|
|
160
|
+
})).catch(() => res.destroy());
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export async function createWindowsSsoAdtProxy({ destinationUrl, env = process.env, fetchImpl = globalThis.fetch, execFileImpl = nodeExecFile } = {}) {
|
|
164
|
+
if (process.platform !== 'win32' && env.SAP_AI_DEV_TOOLKIT_ALLOW_SSO_PROXY_ON_NON_WINDOWS !== 'true') {
|
|
165
|
+
throw safeError('Windows SSO for local ADT destinations is only supported on Windows');
|
|
166
|
+
}
|
|
167
|
+
if (typeof fetchImpl !== 'function') throw safeError('HTTP fetch implementation is unavailable');
|
|
168
|
+
const base = validateTarget(destinationUrl);
|
|
169
|
+
const sockets = new Set();
|
|
170
|
+
let closed = false;
|
|
171
|
+
let proxyOrigin = '';
|
|
172
|
+
|
|
173
|
+
const server = http.createServer(async (req, res) => {
|
|
174
|
+
if (closed) return res.destroy();
|
|
175
|
+
let target;
|
|
176
|
+
try {
|
|
177
|
+
target = new URL(req.url || '/', base);
|
|
178
|
+
if (target.origin !== base.origin) throw safeError('Cross-origin Windows SSO proxy request rejected');
|
|
179
|
+
const headers = headerObject(req.headers);
|
|
180
|
+
const body = await readRequestBody(req);
|
|
181
|
+
let response = await forward(req, target, headers, body, fetchImpl);
|
|
182
|
+
if (response.status === 401 && hasNegotiateChallenge(response.headers)) {
|
|
183
|
+
const challenge = response.headers.get('www-authenticate') || '';
|
|
184
|
+
const cancel = response.body?.cancel?.();
|
|
185
|
+
await cancel?.catch?.(() => {});
|
|
186
|
+
const token = await acquireNegotiateToken({ targetUrl: target.href, challenge, env, execFileImpl });
|
|
187
|
+
const retryHeaders = { ...headers, Authorization: `Negotiate ${token}` };
|
|
188
|
+
const cookies = mergeCookieHeader(headers.cookie || headers.Cookie, setCookies(response.headers));
|
|
189
|
+
delete retryHeaders.cookie;
|
|
190
|
+
delete retryHeaders.Cookie;
|
|
191
|
+
if (cookies) retryHeaders.Cookie = cookies;
|
|
192
|
+
response = await forward(req, target, retryHeaders, body, fetchImpl);
|
|
193
|
+
}
|
|
194
|
+
writeFetchResponse(res, response, base, proxyOrigin);
|
|
195
|
+
} catch (error) {
|
|
196
|
+
if (!res.headersSent) {
|
|
197
|
+
res.writeHead(502, { 'content-type': 'text/plain; charset=utf-8', connection: 'close' });
|
|
198
|
+
res.end(`${error.message || 'Windows SSO proxy request failed'}\n`);
|
|
199
|
+
} else {
|
|
200
|
+
res.destroy();
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
server.on('connection', socket => {
|
|
206
|
+
sockets.add(socket);
|
|
207
|
+
socket.on('close', () => sockets.delete(socket));
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
server.listen(0, '127.0.0.1');
|
|
211
|
+
await once(server, 'listening');
|
|
212
|
+
const address = server.address();
|
|
213
|
+
proxyOrigin = `http://127.0.0.1:${address.port}`;
|
|
214
|
+
return {
|
|
215
|
+
url: proxyOrigin,
|
|
216
|
+
close: async () => {
|
|
217
|
+
if (closed) return;
|
|
218
|
+
closed = true;
|
|
219
|
+
for (const socket of sockets) socket.destroy();
|
|
220
|
+
server.close();
|
|
221
|
+
await once(server, 'close').catch(() => {});
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
}
|