sap-ai-dev-toolkit 0.9.3 → 0.9.5

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.
@@ -1,182 +0,0 @@
1
- # Windows Integrated Authentication helper for sap-ai-dev-toolkit.
2
- # Reads one JSON object from stdin with: url, challenge, mechanism.
3
- # Writes only the base64 Negotiate token to stdout.
4
- # Errors are written to stderr and exit non-zero.
5
-
6
- $ErrorActionPreference = 'Stop'
7
-
8
- $sspiSource = @'
9
- using System;
10
- using System.ComponentModel;
11
- using System.Runtime.InteropServices;
12
-
13
- public static class SapAiDevSspiNegotiate
14
- {
15
- const int SECPKG_CRED_OUTBOUND = 2;
16
- const int SECBUFFER_VERSION = 0;
17
- const int SECBUFFER_TOKEN = 2;
18
- const int SECURITY_NATIVE_DREP = 0x10;
19
- const int SEC_E_OK = 0;
20
- const int SEC_I_CONTINUE_NEEDED = 0x00090312;
21
-
22
- [StructLayout(LayoutKind.Sequential)]
23
- struct SecHandle
24
- {
25
- public IntPtr dwLower;
26
- public IntPtr dwUpper;
27
- }
28
-
29
- [StructLayout(LayoutKind.Sequential)]
30
- struct SecBuffer
31
- {
32
- public int cbBuffer;
33
- public int BufferType;
34
- public IntPtr pvBuffer;
35
- }
36
-
37
- [StructLayout(LayoutKind.Sequential)]
38
- struct SecBufferDesc
39
- {
40
- public int ulVersion;
41
- public int cBuffers;
42
- public IntPtr pBuffers;
43
- }
44
-
45
- [StructLayout(LayoutKind.Sequential)]
46
- struct SECURITY_INTEGER
47
- {
48
- public uint LowPart;
49
- public int HighPart;
50
- }
51
-
52
- [DllImport("secur32.dll", CharSet = CharSet.Unicode, SetLastError = false)]
53
- static extern int AcquireCredentialsHandle(
54
- string pszPrincipal,
55
- string pszPackage,
56
- int fCredentialUse,
57
- IntPtr pvLogonID,
58
- IntPtr pAuthData,
59
- IntPtr pGetKeyFn,
60
- IntPtr pvGetKeyArgument,
61
- out SecHandle phCredential,
62
- out SECURITY_INTEGER ptsExpiry);
63
-
64
- [DllImport("secur32.dll", CharSet = CharSet.Unicode, SetLastError = false)]
65
- static extern int InitializeSecurityContext(
66
- ref SecHandle phCredential,
67
- IntPtr phContext,
68
- string pszTargetName,
69
- int fContextReq,
70
- int Reserved1,
71
- int TargetDataRep,
72
- IntPtr pInput,
73
- int Reserved2,
74
- out SecHandle phNewContext,
75
- IntPtr pOutput,
76
- out int pfContextAttr,
77
- out SECURITY_INTEGER ptsExpiry);
78
-
79
- [DllImport("secur32.dll", SetLastError = false)]
80
- static extern int FreeCredentialsHandle(ref SecHandle phCredential);
81
-
82
- [DllImport("secur32.dll", SetLastError = false)]
83
- static extern int DeleteSecurityContext(ref SecHandle phContext);
84
-
85
- static void Check(int status, string operation)
86
- {
87
- if (status == SEC_E_OK || status == SEC_I_CONTINUE_NEEDED) return;
88
- throw new Win32Exception(status, operation + " failed with SSPI status 0x" + status.ToString("X8"));
89
- }
90
-
91
- static IntPtr BufferDesc(byte[] token, out IntPtr tokenMemory, out IntPtr bufferMemory)
92
- {
93
- tokenMemory = IntPtr.Zero;
94
- SecBuffer buffer = new SecBuffer();
95
- buffer.BufferType = SECBUFFER_TOKEN;
96
- if (token != null && token.Length > 0)
97
- {
98
- tokenMemory = Marshal.AllocHGlobal(token.Length);
99
- Marshal.Copy(token, 0, tokenMemory, token.Length);
100
- buffer.cbBuffer = token.Length;
101
- buffer.pvBuffer = tokenMemory;
102
- }
103
- bufferMemory = Marshal.AllocHGlobal(Marshal.SizeOf(typeof(SecBuffer)));
104
- Marshal.StructureToPtr(buffer, bufferMemory, false);
105
- SecBufferDesc desc = new SecBufferDesc();
106
- desc.ulVersion = SECBUFFER_VERSION;
107
- desc.cBuffers = 1;
108
- desc.pBuffers = bufferMemory;
109
- IntPtr descMemory = Marshal.AllocHGlobal(Marshal.SizeOf(typeof(SecBufferDesc)));
110
- Marshal.StructureToPtr(desc, descMemory, false);
111
- return descMemory;
112
- }
113
-
114
- static void FreeDesc(IntPtr descMemory, IntPtr tokenMemory, IntPtr bufferMemory)
115
- {
116
- if (descMemory != IntPtr.Zero) Marshal.FreeHGlobal(descMemory);
117
- if (bufferMemory != IntPtr.Zero) Marshal.FreeHGlobal(bufferMemory);
118
- if (tokenMemory != IntPtr.Zero) Marshal.FreeHGlobal(tokenMemory);
119
- }
120
-
121
- public static string CreateToken(string targetName, string incomingBase64)
122
- {
123
- SecHandle credential;
124
- SECURITY_INTEGER expiry;
125
- int status = AcquireCredentialsHandle(null, "Negotiate", SECPKG_CRED_OUTBOUND, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero, out credential, out expiry);
126
- Check(status, "AcquireCredentialsHandle");
127
-
128
- SecHandle context = new SecHandle();
129
- IntPtr inputDesc = IntPtr.Zero, inputToken = IntPtr.Zero, inputBuffer = IntPtr.Zero;
130
- IntPtr outputDesc = IntPtr.Zero, outputToken = IntPtr.Zero, outputBuffer = IntPtr.Zero;
131
- try
132
- {
133
- byte[] incoming = null;
134
- if (!String.IsNullOrWhiteSpace(incomingBase64)) incoming = Convert.FromBase64String(incomingBase64);
135
- if (incoming != null && incoming.Length > 0) inputDesc = BufferDesc(incoming, out inputToken, out inputBuffer);
136
-
137
- outputDesc = BufferDesc(new byte[12288], out outputToken, out outputBuffer);
138
- int attrs;
139
- status = InitializeSecurityContext(ref credential, IntPtr.Zero, targetName, 0, 0, SECURITY_NATIVE_DREP, inputDesc, 0, out context, outputDesc, out attrs, out expiry);
140
- Check(status, "InitializeSecurityContext");
141
-
142
- SecBuffer outBuffer = (SecBuffer) Marshal.PtrToStructure(outputBuffer, typeof(SecBuffer));
143
- if (outBuffer.cbBuffer <= 0 || outBuffer.pvBuffer == IntPtr.Zero) throw new Exception("SSPI returned an empty Negotiate token.");
144
- byte[] bytes = new byte[outBuffer.cbBuffer];
145
- Marshal.Copy(outBuffer.pvBuffer, bytes, 0, bytes.Length);
146
- return Convert.ToBase64String(bytes);
147
- }
148
- finally
149
- {
150
- FreeDesc(inputDesc, inputToken, inputBuffer);
151
- FreeDesc(outputDesc, outputToken, outputBuffer);
152
- DeleteSecurityContext(ref context);
153
- FreeCredentialsHandle(ref credential);
154
- }
155
- }
156
- }
157
- '@
158
-
159
- try {
160
- $raw = [Console]::In.ReadToEnd()
161
- if ([string]::IsNullOrWhiteSpace($raw)) { throw 'Missing helper request on stdin.' }
162
- $request = $raw | ConvertFrom-Json
163
- if (-not $request.url) { throw 'Missing target URL.' }
164
-
165
- $uri = [Uri] [string] $request.url
166
- if ($uri.Scheme -ne 'http' -and $uri.Scheme -ne 'https') { throw 'Target URL must use HTTP or HTTPS.' }
167
- if ($uri.UserInfo) { throw 'Target URL must not contain user information.' }
168
-
169
- Add-Type -TypeDefinition $sspiSource -Language CSharp
170
-
171
- $incoming = $null
172
- $challenge = [string] $request.challenge
173
- if ($challenge -match '(?i)Negotiate\s+([^,\s]+)') { $incoming = $Matches[1] }
174
- $targetName = 'HTTP/' + $uri.DnsSafeHost
175
- $token = [SapAiDevSspiNegotiate]::CreateToken($targetName, $incoming)
176
- if ([string]::IsNullOrWhiteSpace($token)) { throw 'Negotiate token generation returned an empty token.' }
177
- [Console]::Out.WriteLine($token.Trim())
178
- exit 0
179
- } catch {
180
- [Console]::Error.WriteLine($_.Exception.Message)
181
- exit 1
182
- }
@@ -1,257 +0,0 @@
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
- const interactiveCredentialUi = env.SAP_AI_DEV_TOOLKIT_WINDOWS_CREDENTIAL_UI === 'true' || env.SAP_WINDOWS_CREDENTIAL_UI === 'true';
103
- return {
104
- command: env.SystemRoot ? join(env.SystemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe') : 'powershell.exe',
105
- args: ['-NoLogo', '-NoProfile', ...(interactiveCredentialUi ? [] : ['-NonInteractive']), '-ExecutionPolicy', 'Bypass', '-File', join(root, 'scripts', 'windows-negotiate-helper.ps1')]
106
- };
107
- }
108
-
109
- function tokenLooksLikeNtlm(token) {
110
- try {
111
- return Buffer.from(token, 'base64').subarray(0, 8).toString('ascii') === 'NTLMSSP\0';
112
- } catch {
113
- return false;
114
- }
115
- }
116
-
117
- function execHelper(helper, payload, execFileImpl = nodeExecFile, env = process.env) {
118
- return new Promise((resolve, reject) => {
119
- const child = execFileImpl(helper.command, helper.args, {
120
- windowsHide: true,
121
- timeout: 30_000,
122
- maxBuffer: MAX_HELPER_OUTPUT,
123
- env: { ...process.env, ...env }
124
- }, (error, stdout) => {
125
- // execFile errors can contain helper stdout/stderr, including tokens.
126
- if (error) return reject(safeError('Windows SSO helper failed; verify the ADT endpoint supports HTTP Negotiate/SPNEGO for the signed-in Windows identity (including smart-card-backed Windows logon) and that Kerberos/SPN configuration is correct. The toolkit cannot prompt for or accept manually pasted bearer/SAML tokens.'));
127
- const token = String(stdout || '').trim();
128
- if (!/^[A-Za-z0-9+/=_-]+$/.test(token)) return reject(safeError('Windows SSO helper returned an invalid Negotiate token'));
129
- 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.'));
130
- resolve(token);
131
- });
132
- child.stdin?.end(`${JSON.stringify(payload)}\n`);
133
- });
134
- }
135
-
136
- async function acquireNegotiateToken({ targetUrl, challenge, env, execFileImpl }) {
137
- const helper = helperCommand(env);
138
- if (!helper) {
139
- throw safeError('Windows SSO requires an SSPI/Negotiate helper that uses the current Windows logon session (including smart-card-backed logon). Set SAP_AI_DEV_TOOLKIT_NEGOTIATE_HELPER to a trusted helper executable, or rerun sap-ai-dev --setup and choose username/password. Manual token entry is not supported.');
140
- }
141
- return execHelper(helper, { url: targetUrl, challenge, mechanism: 'Negotiate' }, execFileImpl, env);
142
- }
143
-
144
- function basicFallbackCredentials(env) {
145
- const mode = String(env.SAP_AUTH_FALLBACK_MODE || env.SAP_AI_DEV_TOOLKIT_AUTH_FALLBACK_MODE || '').toLowerCase();
146
- const enabled = mode === 'basic' || env.SAP_AI_DEV_TOOLKIT_SSO_BASIC_FALLBACK === 'true';
147
- const user = String(env.SAP_USER || env.SAP_USERNAME || '').trim();
148
- const password = String(env.SAP_PASSWORD || env.SAP_PASS || '');
149
- if (!enabled || !user || !password) return null;
150
- return { user, password };
151
- }
152
-
153
- function hasBasicChallenge(headers) {
154
- const value = headers.get?.('www-authenticate') || '';
155
- return /(?:^|,)\s*Basic\b/i.test(value);
156
- }
157
-
158
- async function retryWithBasicFallback({ req, target, headers, body, fetchImpl, env }) {
159
- const credentials = basicFallbackCredentials(env);
160
- if (!credentials) return null;
161
- return forward(req, target, {
162
- ...headers,
163
- Authorization: `Basic ${Buffer.from(`${credentials.user}:${credentials.password}`).toString('base64')}`
164
- }, body, fetchImpl);
165
- }
166
-
167
- async function forward(req, targetUrl, headers, body, fetchImpl) {
168
- return fetchImpl(targetUrl, {
169
- method: req.method,
170
- headers,
171
- body,
172
- duplex: body ? 'half' : undefined,
173
- redirect: 'manual'
174
- });
175
- }
176
-
177
- function writeFetchResponse(res, response, base, proxyOrigin) {
178
- res.writeHead(response.status, response.statusText, copyResponseHeaders(response, { proxyOrigin, targetOrigin: base.origin }));
179
- if (!response.body) return res.end();
180
- response.body.pipeTo(new WritableStream({
181
- write(chunk) { res.write(chunk); },
182
- close() { res.end(); },
183
- abort() { res.destroy(); }
184
- })).catch(() => res.destroy());
185
- }
186
-
187
- export async function createWindowsSsoAdtProxy({ destinationUrl, env = process.env, fetchImpl = globalThis.fetch, execFileImpl = nodeExecFile } = {}) {
188
- if (process.platform !== 'win32' && env.SAP_AI_DEV_TOOLKIT_ALLOW_SSO_PROXY_ON_NON_WINDOWS !== 'true') {
189
- throw safeError('Windows SSO for local ADT destinations is only supported on Windows');
190
- }
191
- if (typeof fetchImpl !== 'function') throw safeError('HTTP fetch implementation is unavailable');
192
- const base = validateTarget(destinationUrl);
193
- const sockets = new Set();
194
- let closed = false;
195
- let proxyOrigin = '';
196
-
197
- const server = http.createServer(async (req, res) => {
198
- if (closed) return res.destroy();
199
- let target;
200
- try {
201
- target = new URL(req.url || '/', base);
202
- if (target.origin !== base.origin) throw safeError('Cross-origin Windows SSO proxy request rejected');
203
- const headers = headerObject(req.headers);
204
- const body = await readRequestBody(req);
205
- let response = await forward(req, target, headers, body, fetchImpl);
206
- if (response.status === 401 && hasNegotiateChallenge(response.headers)) {
207
- const challenge = response.headers.get('www-authenticate') || '';
208
- const cookies = mergeCookieHeader(headers.cookie || headers.Cookie, setCookies(response.headers));
209
- const cancel = response.body?.cancel?.();
210
- await cancel?.catch?.(() => {});
211
- try {
212
- const token = await acquireNegotiateToken({ targetUrl: target.href, challenge, env, execFileImpl });
213
- const retryHeaders = { ...headers, Authorization: `Negotiate ${token}` };
214
- delete retryHeaders.cookie;
215
- delete retryHeaders.Cookie;
216
- if (cookies) retryHeaders.Cookie = cookies;
217
- response = await forward(req, target, retryHeaders, body, fetchImpl);
218
- } catch (error) {
219
- response = await retryWithBasicFallback({ req, target, headers: { ...headers, ...(cookies ? { Cookie: cookies } : {}) }, body, fetchImpl, env });
220
- if (!response) throw error;
221
- }
222
- } else if (response.status === 401 && hasBasicChallenge(response.headers) && basicFallbackCredentials(env)) {
223
- const cancel = response.body?.cancel?.();
224
- await cancel?.catch?.(() => {});
225
- response = await retryWithBasicFallback({ req, target, headers, body, fetchImpl, env }) || response;
226
- }
227
- writeFetchResponse(res, response, base, proxyOrigin);
228
- } catch (error) {
229
- if (!res.headersSent) {
230
- res.writeHead(502, { 'content-type': 'text/plain; charset=utf-8', connection: 'close' });
231
- res.end(`${error.message || 'Windows SSO proxy request failed'}\n`);
232
- } else {
233
- res.destroy();
234
- }
235
- }
236
- });
237
-
238
- server.on('connection', socket => {
239
- sockets.add(socket);
240
- socket.on('close', () => sockets.delete(socket));
241
- });
242
-
243
- server.listen(0, '127.0.0.1');
244
- await once(server, 'listening');
245
- const address = server.address();
246
- proxyOrigin = `http://127.0.0.1:${address.port}`;
247
- return {
248
- url: proxyOrigin,
249
- close: async () => {
250
- if (closed) return;
251
- closed = true;
252
- for (const socket of sockets) socket.destroy();
253
- server.close();
254
- await once(server, 'close').catch(() => {});
255
- }
256
- };
257
- }