Repository: kubernetes-client/javascript (ts)

Issue:
exec_auth spawns the command synchronously 
**Describe the bug**

exec_auth spawns the command synchronously. 

When the exec command sends information to stdout/stderr to inform the user what to do to authenticate, the program is blocked waiting indefinitely, because the spawn is done synchronously.

 

** Client Version **
1.0.0-rc7

** Server Version **

Azure 1.29.9

**To Reproduce**

- Start an Azure cluster with 'Microsoft Entra ID authentication with Kubernetes RBAC' authn & authz
- get the credentials
- do not execute any request to the cluster, so the token is not cached (or you can remove it: `rm ~/.kube/cache/kubelogin/yourID.json`
- make a request to the API
- the program is blocked executing the `kubelogin` app, which is blocked, waiting for the user to authenticate

**Expected behavior**

The call should be done async
bonus: the information provided by kubelogin should be displayed to the user

** Example Code**

```
import { CoreV1Api, KubeConfig } from "@kubernetes/client-node";

const kc = new KubeConfig();
kc.loadFromDefault();

const k8sApi = kc.makeApiClient(CoreV1Api);
const result = k8sApi.listNamespace();
```

**Environment (please complete the following information):**
 - OS: MacOS
 - NodeJS Version: v20.15.1
 - Cloud runtime: Azure

**Additional context**
Add any other context about the problem here.


Pull request:
makes exec_auth spawn the command asynchronously
This PR makes exec_auth calls `spawn` instead of `spawnSync`.

This is cery useful for a GUI to continue working during the plugin is executed.

Fixes #2044 


Patch:
diff --git a/package-lock.json b/package-lock.json
index 475e000a..0c27bff4 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -728,9 +728,9 @@
             "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg=="
         },
         "node_modules/@types/mocha": {
-            "version": "10.0.10",
-            "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.10.tgz",
-            "integrity": "sha512-xPyYSz1cMPnJQhl0CLMH68j3gprKZaTjG3s5Vi+fDgx+uhG9NOXwbVt52eFS8ECyXhyKcjDLCBEqBExKuiZb7Q==",
+            "version": "10.0.9",
+            "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.9.tgz",
+            "integrity": "sha512-sicdRoWtYevwxjOHNMPTl3vSfJM6oyW8o1wXeI7uww6b6xHg8eBznQDNSGBCDJmsE8UMxP05JgZRtsKbTqt//Q==",
             "dev": true
         },
         "node_modules/@types/mock-fs": {
@@ -743,9 +743,9 @@
             }
         },
         "node_modules/@types/node": {
-            "version": "22.9.1",
-            "resolved": "https://registry.npmjs.org/@types/node/-/node-22.9.1.tgz",
-            "integrity": "sha512-p8Yy/8sw1caA8CdRIQBG5tiLHmxtQKObCijiAa9Ez+d4+PRffM4054xbju0msf+cvhJpnFEeNjxmVT/0ipktrg==",
+            "version": "22.9.0",
+            "resolved": "https://registry.npmjs.org/@types/node/-/node-22.9.0.tgz",
+            "integrity": "sha512-vuyHg81vvWA1Z1ELfvLko2c8f34gyA0zaic0+Rllc5lbCnbSyuvb2Oxpm6TAUAC/2xZN3QGqxBNggD1nNR2AfQ==",
             "dependencies": {
                 "undici-types": "~6.19.8"
             }
@@ -1108,9 +1108,9 @@
             "dev": true
         },
         "node_modules/cross-spawn": {
-            "version": "7.0.5",
-            "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.5.tgz",
-            "integrity": "sha512-ZVJrKKYunU38/76t0RMOulHOnUcbU9GbpWKAOZ0mhjr7CX6FVrH+4FrAapSOekrgFQ3f/8gwMEuIft0aKq6Hug==",
+            "version": "7.0.3",
+            "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
+            "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
             "dependencies": {
                 "path-key": "^3.1.0",
                 "shebang-command": "^2.0.0",
@@ -3888,9 +3888,9 @@
             "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg=="
         },
         "@types/mocha": {
-            "version": "10.0.10",
-            "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.10.tgz",
-            "integrity": "sha512-xPyYSz1cMPnJQhl0CLMH68j3gprKZaTjG3s5Vi+fDgx+uhG9NOXwbVt52eFS8ECyXhyKcjDLCBEqBExKuiZb7Q==",
+            "version": "10.0.9",
+            "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.9.tgz",
+            "integrity": "sha512-sicdRoWtYevwxjOHNMPTl3vSfJM6oyW8o1wXeI7uww6b6xHg8eBznQDNSGBCDJmsE8UMxP05JgZRtsKbTqt//Q==",
             "dev": true
         },
         "@types/mock-fs": {
@@ -3903,9 +3903,9 @@
             }
         },
         "@types/node": {
-            "version": "22.9.1",
-            "resolved": "https://registry.npmjs.org/@types/node/-/node-22.9.1.tgz",
-            "integrity": "sha512-p8Yy/8sw1caA8CdRIQBG5tiLHmxtQKObCijiAa9Ez+d4+PRffM4054xbju0msf+cvhJpnFEeNjxmVT/0ipktrg==",
+            "version": "22.9.0",
+            "resolved": "https://registry.npmjs.org/@types/node/-/node-22.9.0.tgz",
+            "integrity": "sha512-vuyHg81vvWA1Z1ELfvLko2c8f34gyA0zaic0+Rllc5lbCnbSyuvb2Oxpm6TAUAC/2xZN3QGqxBNggD1nNR2AfQ==",
             "requires": {
                 "undici-types": "~6.19.8"
             }
@@ -4195,9 +4195,9 @@
             "dev": true
         },
         "cross-spawn": {
-            "version": "7.0.5",
-            "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.5.tgz",
-            "integrity": "sha512-ZVJrKKYunU38/76t0RMOulHOnUcbU9GbpWKAOZ0mhjr7CX6FVrH+4FrAapSOekrgFQ3f/8gwMEuIft0aKq6Hug==",
+            "version": "7.0.3",
+            "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
+            "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
             "requires": {
                 "path-key": "^3.1.0",
                 "shebang-command": "^2.0.0",
diff --git a/package.json b/package.json
index e4164a5f..b6ae1b32 100644
--- a/package.json
+++ b/package.json
@@ -25,7 +25,7 @@
         "generate": "./generate-client.sh",
         "watch": "tsc --watch",
         "test": "c8 mocha",
-        "prepare": "npm run build && husky",
+        "prepare": "npm run build && husky install",
         "prepack": "npm run build",
         "docs": "typedoc src/gen/api"
     },
diff --git a/src/exec_auth.ts b/src/exec_auth.ts
index 34425af5..d6eb0a0b 100644
--- a/src/exec_auth.ts
+++ b/src/exec_auth.ts
@@ -20,10 +20,10 @@ export interface Credential {
 export class ExecAuth implements Authenticator {
     private readonly tokenCache: { [key: string]: Credential | null } = {};
     private execFn: (
-        cmd: string,
-        args: string[],
-        opts: child_process.SpawnOptions,
-    ) => child_process.SpawnSyncReturns<Buffer> = child_process.spawnSync;
+        command: string,
+        args?: readonly string[],
+        options?: child_process.SpawnOptionsWithoutStdio,
+    ) => child_process.ChildProcessWithoutNullStreams = child_process.spawn;
 
     public isAuthProvider(user: User): boolean {
         if (!user) {
@@ -41,7 +41,7 @@ export class ExecAuth implements Authenticator {
     }
 
     public async applyAuthentication(user: User, opts: https.RequestOptions): Promise<void> {
-        const credential = this.getCredential(user);
+        const credential = await this.getCredential(user);
         if (!credential) {
             return;
         }
@@ -70,7 +70,7 @@ export class ExecAuth implements Authenticator {
         return null;
     }
 
-    private getCredential(user: User): Credential | null {
+    private async getCredential(user: User): Promise<Credential | null> {
         // TODO: Add a unit test for token caching.
         const cachedToken = this.tokenCache[user.name];
         if (cachedToken) {
@@ -99,15 +99,45 @@ export class ExecAuth implements Authenticator {
             exec.env.forEach((elt) => (env[elt.name] = elt.value));
             opts = { ...opts, env };
         }
-        const result = this.execFn(exec.command, exec.args, opts);
-        if (result.error) {
-            throw result.error;
-        }
-        if (result.status === 0) {
-            const obj = JSON.parse(result.stdout.toString('utf8')) as Credential;
-            this.tokenCache[user.name] = obj;
-            return obj;
-        }
-        throw new Error(result.stderr.toString('utf8'));
+
+        return new Promise((resolve, reject) => {
+            let stdoutData: string = '';
+            let stderrData: string = '';
+            let savedError: Error | undefined = undefined;
+
+            const subprocess = this.execFn(exec.command, exec.args, opts);
+            subprocess.stdout.setEncoding('utf8');
+            subprocess.stderr.setEncoding('utf8');
+
+            subprocess.stdout.on('data', (data: string) => {
+                stdoutData += data;
+            });
+
+            subprocess.stderr.on('data', (data: string) => {
+                stderrData += data;
+            });
+
+            subprocess.on('error', (error) => {
+                savedError = error;
+            });
+
+            subprocess.on('close', (code) => {
+                if (savedError) {
+                    reject(savedError);
+                    return;
+                }
+                if (code !== 0) {
+                    reject(new Error(stderrData));
+                    return;
+                }
+                try {
+                    const obj = JSON.parse(stdoutData) as Credential;
+                    this.tokenCache[user.name] = obj;
+                    resolve(obj);
+                } catch (error) {
+                    reject(error);
+                }
+            });
+        });
     }
 }


Test patch:
diff --git a/src/exec_auth_test.ts b/src/exec_auth_test.ts
index 759c05e9..fc512310 100644
--- a/src/exec_auth_test.ts
+++ b/src/exec_auth_test.ts
@@ -60,17 +60,30 @@ describe('ExecAuth', () => {
         const auth = new ExecAuth();
         (auth as any).execFn = (
             command: string,
-            args: string[],
-            opts: child_process.SpawnOptions,
-        ): child_process.SpawnSyncReturns<Buffer> => {
+            args?: readonly string[],
+            options?: child_process.SpawnOptionsWithoutStdio,
+        ): child_process.ChildProcessWithoutNullStreams => {
             return {
-                status: 0,
-                stdout: Buffer.from(JSON.stringify({ status: { token: 'foo' } })),
-            } as child_process.SpawnSyncReturns<Buffer>;
+                stdout: {
+                    setEncoding: () => {},
+                    on: (_data: string, f: (data: Buffer | string) => void) => {
+                        f(Buffer.from(JSON.stringify({ status: { token: 'foo' } })));
+                    },
+                },
+                stderr: {
+                    setEncoding: () => {},
+                    on: () => {},
+                },
+                on: (op: string, f: any) => {
+                    if (op === 'close') {
+                        f(0);
+                    }
+                },
+            } as unknown as child_process.ChildProcessWithoutNullStreams;
         };
         const opts = {} as https.RequestOptions;
         opts.headers = {} as OutgoingHttpHeaders;
-        auth.applyAuthentication(
+        await auth.applyAuthentication(
             {
                 name: 'user',
                 authProvider: {
@@ -94,15 +107,32 @@ describe('ExecAuth', () => {
         const auth = new ExecAuth();
         (auth as any).execFn = (
             command: string,
-            args: string[],
-            opts: child_process.SpawnOptions,
-        ): child_process.SpawnSyncReturns<Buffer> => {
+            args?: readonly string[],
+            options?: child_process.SpawnOptionsWithoutStdio,
+        ): child_process.ChildProcessWithoutNullStreams => {
             return {
-                status: 0,
-                stdout: Buffer.from(
-                    JSON.stringify({ status: { clientCertificateData: 'foo', clientKeyData: 'bar' } }),
-                ),
-            } as child_process.SpawnSyncReturns<Buffer>;
+                stdout: {
+                    setEncoding: () => {},
+                    on: (_data: string, f: (data: Buffer | string) => void) => {
+                        f(
+                            Buffer.from(
+                                JSON.stringify({
+                                    status: { clientCertificateData: 'foo', clientKeyData: 'bar' },
+                                }),
+                            ),
+                        );
+                    },
+                },
+                stderr: {
+                    setEncoding: () => {},
+                    on: () => {},
+                },
+                on: (op: string, f: any) => {
+                    if (op === 'close') {
+                        f(0);
+                    }
+                },
+            } as unknown as child_process.ChildProcessWithoutNullStreams;
         };
 
         const user = {
@@ -119,7 +149,7 @@ describe('ExecAuth', () => {
         opts.headers = {} as OutgoingHttpHeaders;
         opts.headers = {} as OutgoingHttpHeaders;
 
-        auth.applyAuthentication(user, opts);
+        await auth.applyAuthentication(user, opts);
         expect(opts.headers.Authorization).to.be.undefined;
         expect(opts.cert).to.equal('foo');
         expect(opts.key).to.equal('bar');
@@ -136,18 +166,33 @@ describe('ExecAuth', () => {
         var tokenValue = 'foo';
         (auth as any).execFn = (
             command: string,
-            args: string[],
-            opts: child_process.SpawnOptions,
-        ): child_process.SpawnSyncReturns<Buffer> => {
+            args?: readonly string[],
+            options?: child_process.SpawnOptionsWithoutStdio,
+        ): child_process.ChildProcessWithoutNullStreams => {
             execCount++;
             return {
-                status: 0,
-                stdout: Buffer.from(
-                    JSON.stringify({
-                        status: { token: tokenValue, expirationTimestamp: expire },
-                    }),
-                ),
-            } as child_process.SpawnSyncReturns<Buffer>;
+                stdout: {
+                    setEncoding: () => {},
+                    on: (_data: string, f: (data: Buffer | string) => void) => {
+                        f(
+                            Buffer.from(
+                                JSON.stringify({
+                                    status: { token: tokenValue, expirationTimestamp: expire },
+                                }),
+                            ),
+                        );
+                    },
+                },
+                stderr: {
+                    setEncoding: () => {},
+                    on: () => {},
+                },
+                on: (op: string, f: any) => {
+                    if (op === 'close') {
+                        f(0);
+                    }
+                },
+            } as unknown as child_process.ChildProcessWithoutNullStreams;
         };
 
         const user = {
@@ -207,6 +252,28 @@ describe('ExecAuth', () => {
             } as child_process.SpawnSyncReturns<Buffer>;
         };
 
+        (auth as any).execFn = (
+            command: string,
+            args?: readonly string[],
+            options?: child_process.SpawnOptionsWithoutStdio,
+        ): child_process.ChildProcessWithoutNullStreams => {
+            return {
+                stdout: {
+                    setEncoding: () => {},
+                    on: (_data: string, f: (data: Buffer | string) => void) => {},
+                },
+                stderr: {
+                    setEncoding: () => {},
+                    on: () => {},
+                },
+                on: (op: string, f: any) => {
+                    if (op === 'error') {
+                        throw new Error('Error: spawnSync /path/to/bin ENOENT');
+                    }
+                },
+            } as unknown as child_process.ChildProcessWithoutNullStreams;
+        };
+
         const user = {
             name: 'user',
             authProvider: {
@@ -230,16 +297,31 @@ describe('ExecAuth', () => {
             return;
         }
         const auth = new ExecAuth();
+
         (auth as any).execFn = (
             command: string,
-            args: string[],
-            opts: child_process.SpawnOptions,
-        ): child_process.SpawnSyncReturns<Buffer> => {
+            args?: readonly string[],
+            options?: child_process.SpawnOptionsWithoutStdio,
+        ): child_process.ChildProcessWithoutNullStreams => {
             return {
-                status: 100,
-                stdout: Buffer.from(JSON.stringify({ status: { token: 'foo' } })),
-                stderr: Buffer.from('Some error!'),
-            } as child_process.SpawnSyncReturns<Buffer>;
+                stdout: {
+                    setEncoding: () => {},
+                    on: (_data: string, f: (data: Buffer | string) => void) => {
+                        f(Buffer.from(JSON.stringify({ status: { token: 'foo' } })));
+                    },
+                },
+                stderr: {
+                    setEncoding: () => {},
+                    on: (_data: string, f: (data: Buffer | string) => void) => {
+                        f(Buffer.from('Some error!'));
+                    },
+                },
+                on: (op: string, f: any) => {
+                    if (op === 'close') {
+                        f(100);
+                    }
+                },
+            } as unknown as child_process.ChildProcessWithoutNullStreams;
         };
 
         const user = {
@@ -265,18 +347,32 @@ describe('ExecAuth', () => {
             return;
         }
         const auth = new ExecAuth();
-        let optsOut: child_process.SpawnOptions = {};
+        let optsOut: child_process.SpawnOptions | undefined = {};
         (auth as any).execFn = (
             command: string,
-            args: string[],
-            opts: child_process.SpawnOptions,
-        ): child_process.SpawnSyncReturns<Buffer> => {
-            optsOut = opts;
+            args?: readonly string[],
+            options?: child_process.SpawnOptionsWithoutStdio,
+        ): child_process.ChildProcessWithoutNullStreams => {
+            optsOut = options;
             return {
-                status: 0,
-                stdout: Buffer.from(JSON.stringify({ status: { token: 'foo' } })),
-            } as child_process.SpawnSyncReturns<Buffer>;
+                stdout: {
+                    setEncoding: () => {},
+                    on: (_data: string, f: (data: Buffer | string) => void) => {
+                        f(Buffer.from(JSON.stringify({ status: { token: 'foo' } })));
+                    },
+                },
+                stderr: {
+                    setEncoding: () => {},
+                    on: () => {},
+                },
+                on: (op: string, f: any) => {
+                    if (op === 'close') {
+                        f(0);
+                    }
+                },
+            } as unknown as child_process.ChildProcessWithoutNullStreams;
         };
+
         process.env.BLABBLE = 'flubble';
         const opts = {} as https.RequestOptions;
         opts.headers = {} as OutgoingHttpHeaders;
@@ -313,16 +409,30 @@ describe('ExecAuth', () => {
         const auth = new ExecAuth();
         (auth as any).execFn = (
             command: string,
-            args: string[],
-            opts: child_process.SpawnOptions,
-        ): child_process.SpawnSyncReturns<Buffer> => {
+            args?: readonly string[],
+            options?: child_process.SpawnOptionsWithoutStdio,
+        ): child_process.ChildProcessWithoutNullStreams => {
             return {
-                status: 0,
-                stdout: Buffer.from(JSON.stringify({ status: { token: 'foo' } })),
-            } as child_process.SpawnSyncReturns<Buffer>;
+                stdout: {
+                    setEncoding: () => {},
+                    on: (_data: string, f: (data: Buffer | string) => void) => {
+                        f(Buffer.from(JSON.stringify({ status: { token: 'foo' } })));
+                    },
+                },
+                stderr: {
+                    setEncoding: () => {},
+                    on: () => {},
+                },
+                on: (op: string, f: any) => {
+                    if (op === 'close') {
+                        f(0);
+                    }
+                },
+            } as unknown as child_process.ChildProcessWithoutNullStreams;
         };
+
         const opts = {} as https.RequestOptions;
-        auth.applyAuthentication(
+        await auth.applyAuthentication(
             {
                 name: 'user',
                 authProvider: {