sap-ai-dev-toolkit 0.7.8 → 0.8.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,122 @@
1
+ import http from 'node:http';
2
+ import https from 'node:https';
3
+ import tls from 'node:tls';
4
+ import { once } from 'node:events';
5
+ import { readFile } from 'node:fs/promises';
6
+ import { isIP } from 'node:net';
7
+
8
+ const HOP_BY_HOP = new Set([
9
+ 'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization',
10
+ 'te', 'trailer', 'transfer-encoding', 'upgrade'
11
+ ]);
12
+
13
+ function filteredHeaders(headers) {
14
+ const result = { ...headers };
15
+ for (const name of Object.keys(result)) {
16
+ if (HOP_BY_HOP.has(name.toLowerCase())) delete result[name];
17
+ }
18
+ return result;
19
+ }
20
+
21
+ function requestHeaders(headers, backend) {
22
+ return { ...filteredHeaders(headers), host: backend.host };
23
+ }
24
+
25
+ function parseSubjectAltName(value) {
26
+ return String(value || '')
27
+ .split(/,\s*/)
28
+ .map(item => item.match(/^DNS:(.+)$/i)?.[1]?.trim())
29
+ .filter(Boolean);
30
+ }
31
+
32
+ export async function discoverCertificateDnsNames(destinationUrl) {
33
+ const backend = new URL(destinationUrl);
34
+ if (backend.protocol !== 'https:') return [];
35
+ const socket = tls.connect({
36
+ host: backend.hostname,
37
+ port: Number(backend.port || 443),
38
+ ...(isIP(backend.hostname) ? {} : { servername: backend.hostname }),
39
+ rejectUnauthorized: false
40
+ });
41
+ try {
42
+ await once(socket, 'secureConnect');
43
+ const certificate = socket.getPeerCertificate();
44
+ return [...new Set(parseSubjectAltName(certificate?.subjectaltname))];
45
+ } finally {
46
+ socket.destroy();
47
+ }
48
+ }
49
+
50
+ function responseHeaders(headers, backend, clientReq) {
51
+ const result = filteredHeaders(headers);
52
+ const location = result.location;
53
+ if (typeof location === 'string') {
54
+ try {
55
+ const parsed = new URL(location, backend);
56
+ if (parsed.origin === backend.origin) {
57
+ result.location = `http://${clientReq.headers.host}${parsed.pathname}${parsed.search}${parsed.hash}`;
58
+ }
59
+ } catch {}
60
+ }
61
+ return result;
62
+ }
63
+
64
+ /**
65
+ * Starts a loopback HTTP reverse proxy for ADT HTTPS endpoints whose network
66
+ * address and certificate identity differ. The proxy dials the configured
67
+ * backend URL but verifies TLS with tlsServerName; CA validation stays enabled.
68
+ */
69
+ export async function createTlsServerNameAdtProxy({ destinationUrl, tlsServerName, tlsServerNames, caFile } = {}) {
70
+ const backend = new URL(destinationUrl);
71
+ if (backend.protocol !== 'https:') throw new Error('TLS server-name override requires an https:// SAP URL.');
72
+ const serverNames = [...new Set([...(Array.isArray(tlsServerNames) ? tlsServerNames : []), ...String(tlsServerName || '').split(',')].map(value => String(value || '').trim()).filter(Boolean))];
73
+ if (!serverNames.length) throw new Error('TLS server-name override requires a DNS name.');
74
+
75
+ const ca = caFile ? await readFile(caFile) : undefined;
76
+ const server = http.createServer((clientReq, clientRes) => {
77
+ let target;
78
+ try {
79
+ target = new URL(clientReq.url || '/', backend);
80
+ if (target.origin !== backend.origin) throw new Error('cross-origin request rejected');
81
+ } catch (error) {
82
+ clientRes.writeHead(400, { 'content-type': 'text/plain; charset=utf-8' });
83
+ clientRes.end(`ADT TLS proxy rejected request: ${error.message}`);
84
+ return;
85
+ }
86
+ const chunks = [];
87
+ clientReq.on('data', chunk => chunks.push(chunk));
88
+ clientReq.on('end', () => {
89
+ const body = Buffer.concat(chunks);
90
+ const tryServerName = index => {
91
+ const request = https.request({
92
+ protocol: 'https:',
93
+ hostname: backend.hostname,
94
+ port: backend.port || 443,
95
+ method: clientReq.method,
96
+ path: `${target.pathname}${target.search}`,
97
+ headers: requestHeaders(clientReq.headers, backend),
98
+ servername: serverNames[index],
99
+ rejectUnauthorized: true,
100
+ ...(ca ? { ca } : {})
101
+ }, backendRes => {
102
+ clientRes.writeHead(backendRes.statusCode || 502, backendRes.statusMessage, responseHeaders(backendRes.headers, backend, clientReq));
103
+ backendRes.pipe(clientRes);
104
+ });
105
+ request.on('error', error => {
106
+ if (index + 1 < serverNames.length && /certificate|hostname|altname|x509/i.test(error.message)) return tryServerName(index + 1);
107
+ if (!clientRes.headersSent) clientRes.writeHead(502, { 'content-type': 'text/plain; charset=utf-8' });
108
+ clientRes.end(`ADT TLS proxy failed: ${error.message}`);
109
+ });
110
+ request.end(body);
111
+ };
112
+ tryServerName(0);
113
+ });
114
+ });
115
+ server.listen(0, '127.0.0.1');
116
+ await once(server, 'listening');
117
+ const { port } = server.address();
118
+ return {
119
+ url: `http://127.0.0.1:${port}`,
120
+ close: () => new Promise(resolve => server.close(() => resolve()))
121
+ };
122
+ }
@@ -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
+ }