vvauth 2.1.1 → 2.2.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.
@@ -19,4 +19,5 @@ jobs:
19
19
  registry-url: 'https://registry.npmjs.org'
20
20
  - run: npm install
21
21
  - run: npm run eslint
22
+ - run: npm test
22
23
  - run: npm publish
package/README.md CHANGED
@@ -1,62 +1,74 @@
1
- Vault Creds manager
2
-
3
- This projects helps you log yourself in a HCL vault and retrieve VAULT_TOKEN through different auth methods
4
- * jwt login
5
- * ssh (with agent) login
6
-
7
-
8
- # Vauth configuration
9
- ## Vauth configuration file location
10
- vauth configuration file lies on a `.vauthrc` file (this name can be controlled by the VAUTHRC env var).
11
- vauth will try to find
12
- * if specified, the VAUTHRC file
13
- * fallback to a .vauthrc file in the current directory
14
- * fallback to a .vauthrc file in the current user home directory
15
-
16
- ## Vauth configuration format
17
- vauth configuration file is a simple yaml file with a specific macro expansion syntax for dynamic parts.
18
- The configuration file should abide the following schema
19
-
20
- ### configuration macro expansion set
21
- * $${profile.XXX} expand to vault entity metadata and user `.vauth_database` vars
22
- * $${env.XXX} expand to local environement vars
23
- * $${secrets.XXX} expand to remote scrapped secrets (see the env.paths)
24
-
25
- ```
26
- # vauth URL
27
- vault_addr: https://vauth.myserver.org
28
-
29
- # for vauth-auth-plugin-ssh, configure the binding role here
30
- ssh_auth:
31
- role: $${env.VAUTH_USER_LOGIN}
32
- env:
33
- map:
34
- TF_HTTP_USERNAME: $${profile.VAUTH_USER_LOGIN}
35
- TF_HTTP_PASSWORD: $${profile.GITLAB_API_TOKEN}
36
- AWS_ACCESS_KEY_ID: $${secrets.AWS_ACCESS_KEY_ID}
37
- AWS_SECRET_ACCESS_KEY: $${secrets.AWS_SECRET_ACCESS_KEY}
38
-
39
- # remote secrets mecanism
40
- # set the secrets mount point - default to secrets
41
- [path: secrets]
42
- # list extra secrets to be reached and populated into the $${secrets.XXX} macro
43
- paths:
44
- - /some/pa4-backend.creds
45
-
46
- ```
47
-
48
-
49
- # Credits
50
- * [Francois Leurent](https://github.com/131)
51
-
52
-
53
- # Machine-readable environment
54
-
55
- `vauth env --ir://json` resolves the same environment as `vauth env --source`
56
- and asks the `cnyks` runner to write one JSON object to stdout. Diagnostic
57
- messages remain on stderr, so a caller can parse stdout directly.
58
-
59
- ```bash
60
- vauth env --ir://json
61
- ```
62
-
1
+ Vault Creds manager
2
+
3
+ This projects helps you log yourself in a HCL vault and retrieve VAULT_TOKEN through different auth methods
4
+ * jwt login
5
+ * ssh (with agent) login
6
+
7
+
8
+ # Vauth configuration
9
+ ## Vauth configuration file location
10
+ vauth configuration file lies on a `.vauthrc` file (this name can be controlled by the VAUTHRC env var).
11
+ vauth will try to find
12
+ * if specified, the VAUTHRC file
13
+ * fallback to a .vauthrc file in the current directory
14
+ * fallback to a .vauthrc file in the current user home directory
15
+
16
+ ## Vauth configuration format
17
+ vauth configuration file is a simple yaml file with a specific macro expansion syntax for dynamic parts.
18
+ The configuration file should abide the following schema
19
+
20
+ ### configuration macro expansion set
21
+ * $${profile.XXX} expand to vault entity metadata and user `.vauth_database` vars
22
+ * $${env.XXX} expand to local environement vars
23
+ * $${secrets.XXX} expand to remote scrapped secrets (see the env.paths)
24
+
25
+ ```
26
+ # vauth URL
27
+ vault_addr: https://vauth.myserver.org
28
+
29
+ # for vauth-auth-plugin-ssh, configure the binding role here
30
+ ssh_auth:
31
+ role: $${env.VAUTH_USER_LOGIN}
32
+ env:
33
+ map:
34
+ TF_HTTP_USERNAME: $${profile.VAUTH_USER_LOGIN}
35
+ TF_HTTP_PASSWORD: $${profile.GITLAB_API_TOKEN}
36
+ AWS_ACCESS_KEY_ID: $${secrets.AWS_ACCESS_KEY_ID}
37
+ AWS_SECRET_ACCESS_KEY: $${secrets.AWS_SECRET_ACCESS_KEY}
38
+
39
+ # remote secrets mecanism
40
+ # set the secrets mount point - default to secrets
41
+ [path: secrets]
42
+ # list extra secrets to be reached and populated into the $${secrets.XXX} macro
43
+ paths:
44
+ - /some/pa4-backend.creds
45
+
46
+ ```
47
+
48
+
49
+ # Credits
50
+ * [Francois Leurent](https://github.com/131)
51
+
52
+
53
+ # Machine-readable environment
54
+
55
+ `vauth env --ir://json` resolves the same environment as `vauth env --source`
56
+ and asks the `cnyks` runner to write one JSON object to stdout. Diagnostic
57
+ messages remain on stderr, so a caller can parse stdout directly.
58
+
59
+ ```bash
60
+ vauth env --ir://json
61
+ ```
62
+
63
+
64
+ # Token cache
65
+
66
+ Tokens are cached in `~/.vauth/token.json`, isolated by Vault and
67
+ authentication. Tokens are reused, renewed within their lease’s final third, or
68
+ replaced when expired. Existing `VAULT_TOKEN` takes precedence over cached tokens.
69
+
70
+ Set `token_cache: false` in `.vauthrc` to disable disk cache reads and writes.
71
+
72
+ ## Force reauthentication
73
+
74
+ `venv --renew` forces authentication, generates and caches a new token.
package/index.js CHANGED
@@ -5,9 +5,11 @@ const os = require('os');
5
5
  const fs = require('fs');
6
6
  const path = require('path');
7
7
  const url = require('url');
8
+ const {createHash} = require('crypto');
8
9
  const {spawn, execFileSync} = require('child_process');
9
10
  const passthru = require('nyks/child_process/passthru');
10
11
  const wait = require('nyks/child_process/wait');
12
+ const boolPrompt = require('cnyks/prompt/bool');
11
13
 
12
14
  const {parse} = require('yaml');
13
15
  const semver = require('semver');
@@ -15,6 +17,7 @@ const trim = require('mout/string/trim');
15
17
  const get = require('mout/object/get');
16
18
  const eachLimit = require('nyks/async/eachLimit');
17
19
  const walk = require('nyks/object/walk');
20
+ const humanDiff = require('nyks/date/humanDiff');
18
21
 
19
22
  const request = require('nyks/http/request');
20
23
  const drain = require('nyks/stream/drain');
@@ -35,7 +38,7 @@ const logger = {
35
38
 
36
39
  const VAUTH_RC = [process.env.VAUTHRC, path.join(process.cwd(), ".vauthrc"), path.join(os.homedir(), ".vauthrc")];
37
40
  const FUNCTION_NAME = "venv";
38
- const FUNCTION_DECL = `function ${FUNCTION_NAME}() { source <(/usr/bin/env vauth env --source); }`;
41
+ const FUNCTION_DECL = `function ${FUNCTION_NAME}() { source <(/usr/bin/env vauth env --source "$@"); }`;
39
42
 
40
43
  class vvauth {
41
44
  constructor() {
@@ -89,9 +92,7 @@ class vvauth {
89
92
  this.VAULT_ADDR = this.rc.vault_addr || process.env.VAULT_ADDR; //might be null
90
93
  this.VAULT_TOKEN = process.env.VAULT_TOKEN;
91
94
 
92
- if(this.VAULT_ADDR)
93
- console.error("vauth bound to '%s'", this.VAULT_ADDR);
94
- else
95
+ if(!this.VAULT_ADDR)
95
96
  console.error("Not bound to any vault");
96
97
 
97
98
  }
@@ -103,22 +104,135 @@ class vvauth {
103
104
  process.exit();
104
105
  }
105
106
 
106
- async connect() {
107
- let VAULT_TOKEN, {rc : {ssh_auth, jwt_auth}} = this;
107
+ _token_cache_key() {
108
+ const {ssh_auth, jwt_auth} = this.rc;
109
+ return createHash('sha256').update(JSON.stringify({
110
+ vault_addr : trim(this.VAULT_ADDR || '', '/'), ssh_auth, jwt_auth,
111
+ })).digest('hex');
112
+ }
113
+
114
+ _token_cache_file() {
115
+ return path.join(os.homedir(), '.vauth', 'token.json');
116
+ }
117
+
118
+ _read_token_cache() {
119
+ if(this.rc.token_cache === false)
120
+ return {version : 1, tokens : {}};
121
+ try {
122
+ const cache = JSON.parse(fs.readFileSync(this._token_cache_file(), 'utf8'));
123
+ if(cache.version === 1 && cache.tokens && typeof cache.tokens === 'object' && !Array.isArray(cache.tokens))
124
+ return cache;
125
+ } catch(err) {}
126
+ return {version : 1, tokens : {}};
127
+ }
128
+
129
+ _save_cached_token(token, metadata) {
130
+ if(this.rc.token_cache === false)
131
+ return;
132
+ const ttl = metadata.ttl === undefined ? metadata.lease_duration : metadata.ttl;
133
+ const file = this._token_cache_file();
134
+ const temporary = file + '.next';
135
+ try {
136
+ const cache = this._read_token_cache();
137
+ const key = this._token_cache_key();
138
+ const {lease_duration} = metadata;
139
+ cache.tokens[key] = {
140
+ token, lease_duration, renewable : metadata.renewable === true,
141
+ expires_at : ttl === 0 ? null : Date.now() + ttl * 1000,
142
+ };
143
+ fs.mkdirSync(path.dirname(file), {recursive : true, mode : 0o700});
144
+ fs.writeFileSync(temporary, JSON.stringify(cache, null, 2) + '\n', {mode : 0o600});
145
+ fs.renameSync(temporary, file);
146
+ } catch(err) {
147
+ // Cache failures must not prevent authentication or expose credentials.
148
+ logger.error('Could not write token cache (%s)', err.code || 'invalid cache');
149
+ } finally {
150
+ try { fs.unlinkSync(temporary); } catch(err) {}
151
+ }
152
+ }
153
+
154
+ _token_status(operation, metadata) {
155
+ const ttl = metadata.ttl === undefined ? metadata.lease_duration : metadata.ttl;
156
+ const validity = ttl === 0 ? 'valid indefinitely' : humanDiff(ttl, 2);
157
+ console.error('Vauth token: %s · %s · %s', operation, this.VAULT_ADDR, validity);
158
+ }
159
+
160
+ async connect(renew = false) {
161
+ if(this._connected && !renew)
162
+ return this.VAULT_TOKEN;
163
+ this._connected = false;
164
+
165
+ const cached = this._read_token_cache().tokens[this._token_cache_key()];
166
+ let token = this.VAULT_TOKEN || (cached && cached.token);
167
+ const had_token = !!token;
168
+ let metadata, operation = 'reused';
169
+ if(renew || (cached && token === cached.token && cached.expires_at !== null && cached.expires_at <= Date.now()))
170
+ token = undefined;
171
+
172
+ if(token) {
173
+ try {
174
+ if(cached && cached.token === token) {
175
+ const ttl = cached.expires_at === null ? 0 : (cached.expires_at - Date.now()) / 1000;
176
+ metadata = {...cached, ttl};
177
+ } else {
178
+ metadata = await this._lookup_token(token);
179
+ }
180
+ const {ttl, renewable, lease_duration} = metadata;
181
+ if(ttl > 0 && ttl <= lease_duration / 3) {
182
+ if(renewable) {
183
+ metadata = await this._renew_token(token);
184
+ token = metadata.client_token;
185
+ operation = 'renewed';
186
+ } else {
187
+ token = undefined;
188
+ }
189
+ }
190
+ } catch(err) {
191
+ // Reauthenticate on invalid tokens, not network/server failures.
192
+ const status = err.res && err.res.statusCode;
193
+ if(![400, 403].includes(status))
194
+ throw err;
195
+ token = undefined;
196
+ }
197
+ }
108
198
 
109
- const agent_path = process.env.SSH_AUTH_SOCK || (process.platform === 'win32' ? 'pageant' : null);
110
- if(!VAULT_TOKEN && ssh_auth && agent_path)
111
- VAULT_TOKEN = await this._login_vault_ssh({...ssh_auth}, agent_path);
199
+ if(!token) {
200
+ const {ssh_auth, jwt_auth} = this.rc;
201
+ const agent_path = process.env.SSH_AUTH_SOCK || (process.platform === 'win32' ? 'pageant' : null);
202
+ if(ssh_auth && agent_path) {
203
+ metadata = await this._login_vault_ssh({...ssh_auth}, agent_path);
204
+ } else if(jwt_auth && jwt_auth.jwt) {
205
+ const {path, jwt, role} = jwt_auth;
206
+ metadata = await this._login_vault(path, {jwt, role});
207
+ }
208
+ token = metadata && metadata.client_token;
209
+ if(!token && (renew || had_token))
210
+ throw new Error('Could not login to vault: no authentication method available');
211
+ operation = 'generated';
212
+ }
112
213
 
113
- if(!VAULT_TOKEN && jwt_auth && jwt_auth.jwt) {
114
- let {path, jwt, role} = jwt_auth, payload = {jwt, role};
115
- VAULT_TOKEN = await this._login_vault(path, payload);
214
+ if(token) {
215
+ if(operation !== 'reused' || !cached || cached.token !== token)
216
+ this._save_cached_token(token, metadata);
217
+ this._token_status(operation, metadata);
116
218
  }
117
- this.VAULT_TOKEN = VAULT_TOKEN;
219
+ this.VAULT_TOKEN = token;
220
+ this._connected = true;
221
+ return token;
118
222
  }
119
223
 
120
- async login(source = false) {
121
- await this.connect();
224
+ async _renew_token(token) {
225
+ const remote_url = `${trim(this.VAULT_ADDR, '/')}/v1/auth/token/renew-self`;
226
+ const query = {...url.parse(remote_url), headers : {'x-vault-token' : token}, expect : 200, json : true};
227
+ const res = await request(query, {});
228
+ const auth = JSON.parse(String(await drain(res))).auth;
229
+ if(!auth.client_token || !Number.isFinite(auth.lease_duration) || auth.lease_duration < 0)
230
+ throw new Error('Invalid Vault token renewal response');
231
+ return auth;
232
+ }
233
+
234
+ async login(source = false, renew = false) {
235
+ await this.connect(renew);
122
236
  if(source) {
123
237
  let env = {VAULT_TOKEN : this.VAULT_TOKEN};
124
238
  this._publish_env(env);
@@ -154,15 +268,15 @@ class vvauth {
154
268
  return {...database, ...profile};
155
269
  }
156
270
 
157
- async _vault_get_profile() {
158
- await this.connect();
271
+ async _vault_get_profile(renew = false) {
272
+ await this.connect(renew);
159
273
 
160
274
  if(!this.VAULT_TOKEN)
161
275
  return {};
162
276
 
163
277
  let {entity_id} = await this._lookup_token(this.VAULT_TOKEN);
164
278
  let identity = await this._lookup_identity(this.VAULT_TOKEN, entity_id);
165
- let profile = {...(identity.metadata || {})};
279
+ let profile = {...(identity.metadata)};
166
280
  let database = {};
167
281
 
168
282
  if(profile.VAUTH_USER_LOGIN)
@@ -171,8 +285,8 @@ class vvauth {
171
285
  return {entity_id, identity, profile, database};
172
286
  }
173
287
 
174
- async _get_env() {
175
- let {profile, database} = await this._vault_get_profile();
288
+ async _get_env(renew = false) {
289
+ let {profile, database} = await this._vault_get_profile(renew);
176
290
  profile = {...database, ...profile};
177
291
 
178
292
  let env = {}, secrets = {},
@@ -234,8 +348,8 @@ class vvauth {
234
348
  process.exit();
235
349
  }
236
350
 
237
- async env(source = false) {
238
- const env = await this._get_env();
351
+ async env(source = false, renew = false) {
352
+ const env = await this._get_env(renew);
239
353
 
240
354
  if(source) {
241
355
  this._publish_env(env);
@@ -280,9 +394,9 @@ class vvauth {
280
394
  let keys = await promiser(chain => agent.getIdentities(chain));
281
395
 
282
396
 
283
- let token;
397
+ let auth;
284
398
  await eachLimit(keys, 1, async (pubKey) => {
285
- if(token)
399
+ if(auth)
286
400
  return;
287
401
 
288
402
  let remote_url = `${trim(this.VAULT_ADDR, '/')}/v1/auth/${path}/nonce`;
@@ -294,17 +408,17 @@ class vvauth {
294
408
  const public_key = pubKey.type + ' ' + pubKey.getPublicSSH().toString('base64');
295
409
  const payload = {public_key, role, nonce : Buffer.from(nonce).toString('base64'), signature};
296
410
  try {
297
- token = await this._login_vault(path, payload);
411
+ auth = await this._login_vault(path, payload);
298
412
  } catch(err) {
299
413
  logger.debug("ssh : invalid challenge for public key", pubKey.comment);
300
414
  }
301
415
  });
302
416
 
303
417
 
304
- if(!token)
418
+ if(!auth || !auth.client_token)
305
419
  throw `Could not login to vault`;
306
420
 
307
- return token;
421
+ return auth;
308
422
  }
309
423
  async _function_exists(alias) {
310
424
  let child = spawn('bash', ["-lc", `declare -F ${alias}`]);
@@ -314,12 +428,11 @@ class vvauth {
314
428
  async install() {
315
429
  const bashrc_path = path.resolve(os.homedir(), ".bashrc");
316
430
  let bashrc = fs.existsSync(bashrc_path) ? fs.readFileSync(bashrc_path, 'utf-8').trim() : '';
317
- let exists = await this._function_exists(FUNCTION_NAME);
318
- if(exists == 0) {
319
- console.error("Function %s already installed", FUNCTION_NAME);
320
- return;
431
+ if(await this._function_exists(FUNCTION_NAME) === 0) {
432
+ if(!await boolPrompt("Function already defined, override current definition? ", false))
433
+ return;
321
434
  }
322
- console.error("Alias %s not installed, pushing it to %s", FUNCTION_NAME, bashrc_path);
435
+ console.error("Installing function %s in %s", FUNCTION_NAME, bashrc_path);
323
436
 
324
437
  fs.writeFileSync(bashrc_path, [bashrc, FUNCTION_DECL, ""].join("\n"));
325
438
  console.error(`Installation ok, please \nsource ${bashrc_path}`);
@@ -330,6 +443,13 @@ class vvauth {
330
443
  let query = {...url.parse(remote_url), headers : {'x-vault-token' : token}, expect : 200};
331
444
  let res = await request(query);
332
445
  let response = JSON.parse(await drain(res)).data;
446
+ if(!Number.isFinite(response.ttl) || response.ttl < 0)
447
+ throw new Error('Invalid Vault token TTL');
448
+ const cached = this._read_token_cache().tokens[this._token_cache_key()];
449
+ if(cached && cached.token === token)
450
+ response.lease_duration = cached.lease_duration;
451
+ else
452
+ response.lease_duration = response.creation_ttl;
333
453
  return response;
334
454
  }
335
455
 
@@ -358,9 +478,10 @@ class vvauth {
358
478
  if(res.statusCode !== 200)
359
479
  throw `Could not login to vault : ${response}`;
360
480
 
361
- response = JSON.parse(response);
362
- let token = get(response, 'auth.client_token');
363
- return token;
481
+ const auth = JSON.parse(response).auth;
482
+ if(!auth.client_token || !Number.isFinite(auth.lease_duration) || auth.lease_duration < 0)
483
+ throw new Error('Invalid Vault login response');
484
+ return auth;
364
485
  }
365
486
 
366
487
  }
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "vvauth",
3
- "version": "2.1.1",
3
+ "version": "2.2.0",
4
4
  "description": "Vault Auth helper",
5
5
  "main": "index.js",
6
6
  "bin": {
7
7
  "vauth": "./index.js"
8
8
  },
9
9
  "scripts": {
10
+ "test": "node --test test/token-cache.js",
10
11
  "eslint": "eslint ."
11
12
  },
12
13
  "author": "",
@@ -0,0 +1,580 @@
1
+ 'use strict';
2
+
3
+ const assert = require('node:assert/strict');
4
+ const {test} = require('node:test');
5
+ const fs = require('fs');
6
+ const os = require('os');
7
+ const path = require('path');
8
+ const http = require('http');
9
+ const {execFile, spawn} = require('child_process');
10
+ const {promisify, format} = require('util');
11
+ const exec = promisify(execFile);
12
+ const Vauth = require('../index');
13
+
14
+ const HOUR = 60 * 60;
15
+
16
+ async function fixture(t, options = {}) {
17
+ const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'vvauth-test-'));
18
+ const file = path.join(directory, '.vauth', 'token.json');
19
+ const requests = [];
20
+ const server = http.createServer(async (req, res) => {
21
+ let body = '';
22
+ for await(const chunk of req)
23
+ body += chunk;
24
+ requests.push({path : req.url, method : req.method, token : req.headers['x-vault-token'], body});
25
+ res.setHeader('content-type', 'application/json');
26
+ if(req.url === '/v1/auth/jwt/login') {
27
+ res.end(JSON.stringify({auth : {client_token : 'fresh-token', lease_duration : 24 * HOUR, renewable : true}}));
28
+ } else if(req.url === '/v1/auth/token/lookup-self') {
29
+ res.statusCode = options.lookup_status || 200;
30
+ res.end(JSON.stringify({data : {ttl : options.ttl === undefined ? 12 * HOUR : options.ttl, renewable : options.renewable !== false, creation_ttl : options.creation_ttl === undefined ? 24 * HOUR : options.creation_ttl, entity_id : 'test-entity'}}));
31
+ } else if(req.url === '/v1/auth/token/renew-self') {
32
+ res.statusCode = options.renew_status || 200;
33
+ res.end(JSON.stringify({auth : {client_token : req.headers['x-vault-token'], lease_duration : 24 * HOUR, renewable : true}}));
34
+ } else if(req.url === '/v1/identity/entity/id/test-entity') {
35
+ res.end(JSON.stringify({data : {metadata : {}}}));
36
+ } else {
37
+ res.statusCode = 404;
38
+ res.end('{}');
39
+ }
40
+ });
41
+ await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
42
+ t.after(async () => {
43
+ await new Promise(resolve => server.close(resolve));
44
+ fs.rmSync(directory, {recursive : true, force : true});
45
+ });
46
+ const client = () => {
47
+ // Avoid reading the real user's environment/configuration/cache.
48
+ const instance = Object.create(Vauth.prototype);
49
+ instance.rc = {jwt_auth : {path : 'jwt', jwt : 'test-jwt', role : 'test-role'}};
50
+ instance.VAULT_ADDR = `http://127.0.0.1:${server.address().port}`;
51
+ instance._token_cache_file = () => file;
52
+ return instance;
53
+ };
54
+ const remaining = (instance, ttl) => {
55
+ const cache = JSON.parse(fs.readFileSync(file, 'utf8'));
56
+ cache.tokens[instance._token_cache_key()].expires_at = ttl === 0 ? null : Date.now() + ttl * 1000;
57
+ fs.writeFileSync(file, JSON.stringify(cache));
58
+ };
59
+ const seed = (instance, lease = 12 * HOUR) => {
60
+ instance._save_cached_token('cached-token', {lease_duration : lease, renewable : options.renewable !== false});
61
+ if(options.ttl !== undefined)
62
+ remaining(instance, options.ttl);
63
+ };
64
+ const read = instance => JSON.parse(fs.readFileSync(file, 'utf8')).tokens[instance._token_cache_key()];
65
+ return {client, seed, read, requests, file, remaining};
66
+ }
67
+
68
+ test('login persists a token; a new instance reuses it without logging in', async t => {
69
+ const {client, read, requests, file} = await fixture(t);
70
+ const first = client();
71
+ assert.equal(await first.connect(), 'fresh-token');
72
+ assert.equal(read(first).token, 'fresh-token');
73
+ assert.ok(read(first).expires_at > Date.now() + 23 * HOUR * 1000);
74
+ assert.equal(fs.statSync(file).mode & 0o777, 0o600);
75
+ assert.equal(fs.statSync(path.dirname(file)).mode & 0o777, 0o700);
76
+ const second = client();
77
+ assert.equal(await second.connect(), 'fresh-token');
78
+ assert.deepEqual(requests.map(req => req.path), ['/v1/auth/jwt/login']);
79
+ assert.deepEqual(fs.readdirSync(path.dirname(file)), ['token.json']);
80
+ });
81
+
82
+ test('a token in the last third of its lease is renewed and saved', async t => {
83
+ const {client, seed, read, requests} = await fixture(t, {ttl : HOUR});
84
+ const instance = client();
85
+ seed(instance, 12 * HOUR);
86
+ assert.equal(await instance.connect(), 'cached-token');
87
+ assert.deepEqual(requests.map(req => req.path), ['/v1/auth/token/renew-self']);
88
+ assert.equal(requests[0].method, 'POST');
89
+ assert.equal(requests[0].token, 'cached-token');
90
+ assert.ok(read(instance).expires_at > Date.now() + 23 * HOUR * 1000);
91
+ });
92
+
93
+ test('more than one third remaining does not trigger renewal', async t => {
94
+ const {client, seed, requests} = await fixture(t, {ttl : 4 * HOUR + 1});
95
+ const instance = client();
96
+ seed(instance);
97
+ assert.equal(await instance.connect(), 'cached-token');
98
+ assert.equal(requests.length, 0);
99
+ });
100
+
101
+ test('expired cached tokens are replaced with a new persisted login', async t => {
102
+ const {client, seed, read, requests, file} = await fixture(t);
103
+ const instance = client();
104
+ seed(instance);
105
+ const cache = JSON.parse(fs.readFileSync(file, 'utf8'));
106
+ cache.tokens[instance._token_cache_key()].expires_at = Date.now() - 1000;
107
+ fs.writeFileSync(file, JSON.stringify(cache));
108
+ assert.equal(await instance.connect(), 'fresh-token');
109
+ assert.equal(read(instance).token, 'fresh-token');
110
+ assert.deepEqual(requests.map(req => req.path), ['/v1/auth/jwt/login']);
111
+ });
112
+
113
+ test('cached tokens are reused without checking revocation', async t => {
114
+ const {client, seed, requests} = await fixture(t, {lookup_status : 403});
115
+ const instance = client();
116
+ seed(instance);
117
+ assert.equal(await instance.connect(), 'cached-token');
118
+ assert.deepEqual(requests, []);
119
+ await assert.rejects(instance._lookup_token(instance.VAULT_TOKEN));
120
+ });
121
+
122
+ test('nonrenewable tokens nearing expiry trigger reauthentication', async t => {
123
+ const {client, seed, requests} = await fixture(t, {ttl : HOUR, renewable : false});
124
+ const instance = client();
125
+ seed(instance);
126
+ assert.equal(await instance.connect(), 'fresh-token');
127
+ assert.deepEqual(requests.map(req => req.path), ['/v1/auth/jwt/login']);
128
+ });
129
+
130
+ for(const status of [400, 403]) {
131
+ test(`rejected renewal (${status}) triggers a new persisted login`, async t => {
132
+ const {client, seed, read, requests} = await fixture(t, {ttl : HOUR, renew_status : status});
133
+ const instance = client();
134
+ seed(instance);
135
+ assert.equal(await instance.connect(), 'fresh-token');
136
+ assert.equal(read(instance).token, 'fresh-token');
137
+ assert.deepEqual(requests.map(req => req.path), ['/v1/auth/token/renew-self', '/v1/auth/jwt/login']);
138
+ });
139
+ }
140
+
141
+ test('server failures do not generate extra tokens, and connect can be retried', async t => {
142
+ const {client, seed, requests} = await fixture(t, {lookup_status : 500});
143
+ const instance = client();
144
+ seed(instance);
145
+ instance.VAULT_TOKEN = 'uncached-token';
146
+ await assert.rejects(instance.connect());
147
+ await assert.rejects(instance.connect());
148
+ assert.deepEqual(requests.map(req => req.path), ['/v1/auth/token/lookup-self', '/v1/auth/token/lookup-self']);
149
+ });
150
+
151
+ test('renewal server failures do not generate extra tokens', async t => {
152
+ const {client, seed, requests} = await fixture(t, {ttl : HOUR, renew_status : 500});
153
+ const instance = client();
154
+ seed(instance);
155
+ await assert.rejects(instance.connect());
156
+ assert.deepEqual(requests.map(req => req.path), ['/v1/auth/token/renew-self']);
157
+ });
158
+
159
+ test('an existing environment token is respected and cached', async t => {
160
+ const {client, seed, read, requests} = await fixture(t);
161
+ const instance = client();
162
+ seed(instance);
163
+ instance.VAULT_TOKEN = 'environment-token';
164
+ assert.equal(await instance.connect(), 'environment-token');
165
+ assert.equal(read(instance).token, 'environment-token');
166
+ assert.equal(requests[0].token, 'environment-token');
167
+ assert.equal(requests.length, 1);
168
+ });
169
+
170
+ test('nonexpiring tokens are reused without renewal', async t => {
171
+ const {client, seed, read, requests} = await fixture(t, {ttl : 0});
172
+ const instance = client();
173
+ seed(instance, 0);
174
+ assert.equal(await instance.connect(), 'cached-token');
175
+ assert.equal(read(instance).expires_at, null);
176
+ assert.equal(requests.length, 0);
177
+ });
178
+
179
+ test('corrupt caches are replaced with a valid cache', async t => {
180
+ const {client, read, file} = await fixture(t);
181
+ fs.mkdirSync(path.dirname(file));
182
+ fs.writeFileSync(file, '{broken');
183
+ const instance = client();
184
+ assert.equal(await instance.connect(), 'fresh-token');
185
+ assert.equal(read(instance).token, 'fresh-token');
186
+ });
187
+
188
+ test('cache entries are isolated by Vault address and authentication configuration', async t => {
189
+ const {client, seed, read, file} = await fixture(t);
190
+ const first = client();
191
+ seed(first);
192
+ const second = client();
193
+ second.rc.jwt_auth.role = 'other-role';
194
+ assert.equal(await second.connect(), 'fresh-token');
195
+ assert.equal(read(first).token, 'cached-token');
196
+ assert.equal(read(second).token, 'fresh-token');
197
+ const third = client();
198
+ third.VAULT_ADDR = 'http://other-vault.test';
199
+ assert.notEqual(third._token_cache_key(), first._token_cache_key());
200
+ assert.equal(Object.keys(JSON.parse(fs.readFileSync(file, 'utf8')).tokens).length, 2);
201
+ assert.ok(!fs.readFileSync(file, 'utf8').includes('test-jwt'));
202
+ });
203
+
204
+ test('repeated connect calls reuse the established connection', async t => {
205
+ const {client, requests} = await fixture(t);
206
+ const instance = client();
207
+ assert.equal(await instance.connect(), 'fresh-token');
208
+ assert.equal(await instance.connect(), 'fresh-token');
209
+ assert.equal(requests.length, 1);
210
+ });
211
+
212
+ test('an unwritable cache does not prevent login', async t => {
213
+ const {client, file} = await fixture(t);
214
+ fs.writeFileSync(path.dirname(file), 'not a directory');
215
+ assert.equal(await client().connect(), 'fresh-token');
216
+ });
217
+
218
+ test('an invalid token without an auth method fails rather than exporting undefined', async t => {
219
+ const {client, seed} = await fixture(t, {lookup_status : 403});
220
+ const instance = client();
221
+ instance.rc = {};
222
+ seed(instance);
223
+ instance.VAULT_TOKEN = 'uncached-token';
224
+ await assert.rejects(instance.connect(), /no authentication method/);
225
+ });
226
+
227
+ test('--renew ignores both environment and cached tokens and saves a new token', async t => {
228
+ const {client, seed, read, requests} = await fixture(t);
229
+ const instance = client();
230
+ seed(instance);
231
+ instance.VAULT_TOKEN = 'environment-token';
232
+ assert.equal(await instance.connect(true), 'fresh-token');
233
+ assert.deepEqual(requests.map(req => req.path), ['/v1/auth/jwt/login']);
234
+ assert.equal(read(instance).token, 'fresh-token');
235
+ assert.ok(read(instance).expires_at > Date.now() + 23 * HOUR * 1000);
236
+ });
237
+
238
+ test('--renew replaces the token of a previously connected instance', async t => {
239
+ const {client, seed, read, requests} = await fixture(t);
240
+ const instance = client();
241
+ seed(instance);
242
+ await instance.connect();
243
+ assert.equal(await instance.connect(true), 'fresh-token');
244
+ assert.deepEqual(requests.map(req => req.path), ['/v1/auth/jwt/login']);
245
+ assert.equal(read(instance).token, 'fresh-token');
246
+ });
247
+
248
+ test('each successive --renew call forces a new login', async t => {
249
+ const {client, requests} = await fixture(t);
250
+ const instance = client();
251
+ assert.equal(await instance.connect(true), 'fresh-token');
252
+ assert.equal(await instance.connect(true), 'fresh-token');
253
+ assert.equal(requests.length, 2);
254
+ });
255
+
256
+ test('--renew without an authentication method fails even with no cached token', async t => {
257
+ const {client} = await fixture(t);
258
+ const instance = client();
259
+ instance.rc = {};
260
+ await assert.rejects(instance.connect(true), /no authentication method/);
261
+ });
262
+
263
+ for(const flag of ['renew']) {
264
+ test(`env forwards --${flag} into forced reauthentication`, async t => {
265
+ const {client} = await fixture(t);
266
+ const instance = client();
267
+ let forced;
268
+ instance.connect = async renew => { forced = renew; };
269
+ assert.deepEqual(await instance.env(false, true), {VAULT_ADDR : instance.VAULT_ADDR});
270
+ assert.equal(forced, true);
271
+ });
272
+
273
+ for(const command of ['env', 'login']) {
274
+ test(`real CLI ${command} --${flag} generates and caches a new token`, async t => {
275
+ const {client, seed, read, requests, file} = await fixture(t);
276
+ const instance = client();
277
+ seed(instance);
278
+ const home = path.dirname(path.dirname(file));
279
+ const rc = path.join(home, '.vauthrc');
280
+ fs.writeFileSync(rc, JSON.stringify({vault_addr : instance.VAULT_ADDR, ...instance.rc}));
281
+ const options = {
282
+ cwd : home,
283
+ env : {...process.env, HOME : home, VAUTHRC : rc, VAULT_TOKEN : 'environment-token', SSH_AUTH_SOCK : ''},
284
+ };
285
+ const cli = path.resolve(__dirname, '..', 'index.js');
286
+ const output = await exec(process.execPath, [cli, command, '--source', `--${flag}`], options);
287
+ assert.ok(output.stdout.includes("export VAULT_TOKEN='fresh-token'"));
288
+ assert.ok(!output.stdout.includes('Vauth token: '));
289
+ assert.equal(output.stderr.split('\n').filter(line => line === `Vauth token: generated · ${instance.VAULT_ADDR} · 1d`).length, 1);
290
+ assert.ok(!output.stderr.includes('Vauth token: renewed'));
291
+ assert.ok(!output.stderr.includes('fresh-token'));
292
+ assert.ok(!output.stderr.includes('environment-token'));
293
+ assert.deepEqual(requests.filter(req => !req.path.startsWith('/v1/identity/')).map(req => req.path), command === 'env'
294
+ ? ['/v1/auth/jwt/login', '/v1/auth/token/lookup-self'] : ['/v1/auth/jwt/login']);
295
+ assert.equal(read(instance).token, 'fresh-token');
296
+ assert.equal(fs.statSync(file).mode & 0o777, 0o600);
297
+ requests.length = 0;
298
+ const reused = await exec(process.execPath, [cli, command, '--source'], {
299
+ ...options, env : {...options.env, VAULT_TOKEN : ''},
300
+ });
301
+ assert.ok(reused.stdout.includes("export VAULT_TOKEN='fresh-token'"));
302
+ assert.equal(reused.stderr.split('\n').filter(line => line.startsWith('Vauth token: ')).length, 1);
303
+ assert.ok(reused.stderr.includes(`Vauth token: reused · ${instance.VAULT_ADDR} · 1d`));
304
+ assert.ok(!requests.some(req => req.path.endsWith('/login') || req.path.endsWith('/renew-self')));
305
+ });
306
+ }
307
+ }
308
+
309
+ test('installed venv forwards --renew through the real CLI', async t => {
310
+ const {client, seed, read, requests, file} = await fixture(t);
311
+ const instance = client();
312
+ seed(instance);
313
+ const home = path.dirname(path.dirname(file));
314
+ const original_home = os.homedir;
315
+ instance._function_exists = async () => 1;
316
+ try {
317
+ os.homedir = () => home;
318
+ await instance.install();
319
+ } finally {
320
+ os.homedir = original_home;
321
+ }
322
+ const declaration = fs.readFileSync(path.join(home, '.bashrc'), 'utf8');
323
+ assert.ok(declaration.includes('vauth env --source "$@"'));
324
+ const bin = path.join(home, 'bin');
325
+ fs.mkdirSync(bin);
326
+ fs.symlinkSync(path.resolve(__dirname, '..', 'index.js'), path.join(bin, 'vauth'));
327
+ const rc = path.join(home, '.vauthrc');
328
+ fs.writeFileSync(rc, JSON.stringify({vault_addr : instance.VAULT_ADDR, ...instance.rc}));
329
+ const options = {
330
+ cwd : home,
331
+ env : {...process.env, HOME : home, VAUTHRC : rc, VAULT_TOKEN : '', SSH_AUTH_SOCK : '', PATH : `${bin}:${process.env.PATH}`},
332
+ };
333
+ const initial = await exec('bash', ['-c', `${declaration}\nvenv; printf '%s' "$VAULT_TOKEN"`], options);
334
+ assert.equal(initial.stdout, 'cached-token');
335
+ assert.equal(initial.stderr.split('\n').filter(line => line.startsWith('Vauth token: ')).length, 1);
336
+ assert.ok(initial.stderr.includes(`Vauth token: reused · ${instance.VAULT_ADDR} · 12h`));
337
+ assert.ok(!requests.some(req => req.path.endsWith('/login') || req.path.endsWith('/renew-self')));
338
+ for(const flag of ['--renew']) {
339
+ const output = await exec('bash', ['-c', `${declaration}\nvenv ${flag}; printf '%s' "$VAULT_TOKEN"`], options);
340
+ assert.equal(output.stdout, 'fresh-token');
341
+ assert.equal(output.stderr.split('\n').filter(line => line === `Vauth token: generated · ${instance.VAULT_ADDR} · 1d`).length, 1);
342
+ assert.ok(!output.stderr.includes('Vauth token: renewed'));
343
+ assert.ok(!output.stderr.includes('fresh-token'));
344
+ assert.equal(read(instance).token, 'fresh-token');
345
+ }
346
+ assert.equal(requests.filter(req => req.path.endsWith('/login')).length, 1);
347
+ assert.ok(!requests.some(req => req.path.endsWith('/renew-self')));
348
+ });
349
+
350
+ for(const answer of ['yes', 'no', '']) {
351
+ test(`install asks before overriding an existing function: ${JSON.stringify(answer)}`, async t => {
352
+ const {file} = await fixture(t);
353
+ const home = path.dirname(path.dirname(file));
354
+ const bashrc = path.join(home, '.bashrc');
355
+ const original = '# before\nfunction venv() { source <(/usr/bin/env vauth env --source); }\n# after\n';
356
+ fs.writeFileSync(bashrc, original);
357
+ const module_path = path.resolve(__dirname, '..', 'index.js');
358
+ const script = `const Vauth = require(${JSON.stringify(module_path)});
359
+ const instance = Object.create(Vauth.prototype);
360
+ instance._function_exists = async () => 0;
361
+ instance.install().catch(() => process.exit(1));`;
362
+ const child = spawn(process.execPath, ['-e', script], {cwd : home, env : {...process.env, HOME : home}});
363
+ let output = '', prompted = false;
364
+ child.stdout.on('data', chunk => {
365
+ output += chunk;
366
+ if(!prompted && output.includes('[y/N]')) {
367
+ prompted = true;
368
+ child.stdin.end(answer + '\n');
369
+ }
370
+ });
371
+ child.stderr.resume();
372
+ const exit = await new Promise((resolve, reject) => {
373
+ child.on('error', reject);
374
+ child.on('exit', resolve);
375
+ });
376
+ assert.equal(exit, 0);
377
+ assert.ok(output.includes('Function already defined, override current definition?'));
378
+ const declaration = 'function venv() { source <(/usr/bin/env vauth env --source "$@"); }\n';
379
+ assert.equal(fs.readFileSync(bashrc, 'utf8'), answer === 'yes' ? original + declaration : original);
380
+ });
381
+ }
382
+
383
+ function captureStatus(callback) {
384
+ const lines = [];
385
+ const original = console.error;
386
+ console.error = (...args) => lines.push(format(...args));
387
+ try {
388
+ callback();
389
+ } finally {
390
+ console.error = original;
391
+ }
392
+ return lines;
393
+ }
394
+
395
+ test('token status uses humanDiff with two significant units', () => {
396
+ const instance = Object.create(Vauth.prototype);
397
+ instance.VAULT_ADDR = 'https://vault.example.test';
398
+ const cases = [
399
+ [2 * 86400 + 3 * HOUR + 20 * 60 + 5, '2d 3h'],
400
+ [12 * HOUR + 30 * 60 + 5, '12h 30m'],
401
+ [5 * 60 + 20, '5m 20s'],
402
+ [86400, '1d'],
403
+ [30 * 60, '30m'],
404
+ [45, '45s'],
405
+ [86400 + 20, '1d 20s'],
406
+ [HOUR + 5, '1h 5s'],
407
+ [400 * 86400 + 2 * HOUR, '1y 1month'],
408
+ [60.9, '1m 1s'],
409
+ ];
410
+ for(const [ttl, duration] of cases) {
411
+ assert.deepEqual(captureStatus(() => instance._token_status('reused', {ttl})), [
412
+ `Vauth token: reused · ${instance.VAULT_ADDR} · ${duration}`,
413
+ ]);
414
+ }
415
+ assert.deepEqual(captureStatus(() => instance._token_status('renewed', {lease_duration : HOUR + 60})), [
416
+ `Vauth token: renewed · ${instance.VAULT_ADDR} · 1h 1m`,
417
+ ]);
418
+ assert.deepEqual(captureStatus(() => instance._token_status('generated', {lease_duration : 0})), [
419
+ `Vauth token: generated · ${instance.VAULT_ADDR} · valid indefinitely`,
420
+ ]);
421
+ });
422
+
423
+ for(const options of [
424
+ {ttl : 12 * HOUR, operation : 'reused', duration : '12h'},
425
+ {ttl : HOUR, operation : 'renewed', duration : '1d'},
426
+ {ttl : HOUR, renew_status : 400, operation : 'generated', duration : '1d'},
427
+ {ttl : HOUR, renew_status : 403, operation : 'generated', duration : '1d'},
428
+ {ttl : 0, operation : 'reused', duration : null},
429
+ ]) {
430
+ test(`one final status per connection: ${JSON.stringify(options)}`, async t => {
431
+ const {client, seed} = await fixture(t, options);
432
+ const instance = client();
433
+ seed(instance);
434
+ const calls = [];
435
+ instance._token_status = (operation, metadata) => {
436
+ calls.push(...captureStatus(() => Vauth.prototype._token_status.call(instance, operation, metadata)));
437
+ };
438
+ await instance.connect();
439
+ await instance.connect();
440
+ const validity = options.duration ? `valid for ${options.duration}` : 'valid indefinitely';
441
+ assert.deepEqual(calls, [`Vauth token: ${options.operation} · ${instance.VAULT_ADDR} · ${validity.replace(/^valid for /, '')}`]);
442
+ });
443
+ }
444
+
445
+ test('failed connections never announce successful token status', async t => {
446
+ const {client, seed} = await fixture(t, {lookup_status : 500});
447
+ const instance = client();
448
+ seed(instance);
449
+ instance.VAULT_TOKEN = 'uncached-token';
450
+ const calls = [];
451
+ instance._token_status = operation => calls.push(operation);
452
+ await assert.rejects(instance.connect());
453
+ assert.deepEqual(calls, []);
454
+ });
455
+
456
+ for(const [lease, remaining, renew] of [
457
+ [24 * HOUR, 8 * HOUR, true],
458
+ [24 * HOUR, 8 * HOUR + 1, false],
459
+ [HOUR, 20 * 60, true],
460
+ [HOUR, 20 * 60 + 1, false],
461
+ [60, 20, true],
462
+ [60, 21, false],
463
+ ]) {
464
+ test(`proportional threshold: lease=${lease}, remaining=${remaining}`, async t => {
465
+ const {client, seed, read, requests} = await fixture(t, {ttl : remaining});
466
+ const instance = client();
467
+ seed(instance, lease);
468
+ await instance.connect();
469
+ assert.equal(requests.some(req => req.path.endsWith('/renew-self')), renew);
470
+ assert.equal(read(instance).lease_duration, renew ? 24 * HOUR : lease);
471
+ });
472
+ }
473
+
474
+ test('lookups never reset the original lease duration', async t => {
475
+ const options = {ttl : 20 * HOUR};
476
+ const {client, seed, read, requests, remaining} = await fixture(t, options);
477
+ const instance = client();
478
+ seed(instance, 24 * HOUR);
479
+ await instance.connect();
480
+ assert.equal(read(instance).lease_duration, 24 * HOUR);
481
+ remaining(instance, 12 * HOUR);
482
+ await client().connect();
483
+ assert.equal(read(instance).lease_duration, 24 * HOUR);
484
+ remaining(instance, 8 * HOUR);
485
+ await client().connect();
486
+ assert.equal(requests.filter(req => req.path.endsWith('/renew-self')).length, 1);
487
+ });
488
+
489
+ test('renewal updates the lease baseline to the newly granted duration', async t => {
490
+ const options = {ttl : 20 * 60};
491
+ const {client, seed, read, requests, remaining} = await fixture(t, options);
492
+ const instance = client();
493
+ seed(instance, HOUR);
494
+ await instance.connect();
495
+ assert.equal(read(instance).lease_duration, 24 * HOUR);
496
+ remaining(instance, 8 * HOUR);
497
+ await client().connect();
498
+ assert.equal(requests.filter(req => req.path.endsWith('/renew-self')).length, 2);
499
+ });
500
+
501
+ test('normal reuse never writes the token cache', async t => {
502
+ const {client, seed, file, requests} = await fixture(t);
503
+ const instance = client();
504
+ seed(instance);
505
+ instance.VAULT_TOKEN = 'cached-token';
506
+ const before = fs.readFileSync(file, 'utf8');
507
+ const stat = fs.statSync(file);
508
+ instance._save_cached_token = () => { throw new Error('Reuse must not write'); };
509
+ assert.equal(await instance.connect(), 'cached-token');
510
+ assert.equal(fs.readFileSync(file, 'utf8'), before);
511
+ assert.equal(fs.statSync(file).ino, stat.ino);
512
+ assert.equal(fs.statSync(file).mtimeMs, stat.mtimeMs);
513
+ assert.deepEqual(requests, []);
514
+ });
515
+
516
+
517
+ test('lookup returns the lease duration and preserves a cached renewal baseline', async t => {
518
+ const {client, seed, requests} = await fixture(t, {ttl : HOUR, creation_ttl : 24 * HOUR});
519
+ const instance = client();
520
+ const uncached = await instance._lookup_token('environment-token');
521
+ assert.equal(uncached.lease_duration, 24 * HOUR);
522
+ assert.equal(uncached.ttl, HOUR);
523
+ seed(instance, 12 * HOUR);
524
+ const cached = await instance._lookup_token('cached-token');
525
+ assert.equal(cached.lease_duration, 12 * HOUR);
526
+ assert.equal(requests.length, 2);
527
+ });
528
+
529
+ test('token_cache false skips all cache filesystem access', async t => {
530
+ const {client, requests, file} = await fixture(t);
531
+ const instance = client();
532
+ instance.rc.token_cache = false;
533
+ instance._token_cache_file = () => { throw new Error('Disabled cache must not access disk'); };
534
+ assert.equal(await instance.connect(), 'fresh-token');
535
+ assert.equal(fs.existsSync(file), false);
536
+ assert.deepEqual(requests.map(req => req.path), ['/v1/auth/jwt/login']);
537
+ });
538
+
539
+ test('token_cache false ignores existing tokens on disk without deleting them', async t => {
540
+ const {client, seed, requests, file} = await fixture(t);
541
+ const instance = client();
542
+ seed(instance);
543
+ const before = fs.readFileSync(file, 'utf8');
544
+ instance.rc.token_cache = false;
545
+ assert.equal(await instance.connect(), 'fresh-token');
546
+ assert.equal(fs.readFileSync(file, 'utf8'), before);
547
+ const second = client();
548
+ second.rc.token_cache = false;
549
+ assert.equal(await second.connect(), 'fresh-token');
550
+ assert.equal(requests.filter(req => req.path.endsWith('/login')).length, 2);
551
+ });
552
+
553
+ test('token_cache false still respects an environment token without persisting it', async t => {
554
+ const {client, requests, file} = await fixture(t);
555
+ const instance = client();
556
+ instance.rc.token_cache = false;
557
+ instance.VAULT_TOKEN = 'environment-token';
558
+ assert.equal(await instance.connect(), 'environment-token');
559
+ assert.deepEqual(requests.map(req => req.path), ['/v1/auth/token/lookup-self']);
560
+ assert.equal(fs.existsSync(file), false);
561
+ });
562
+
563
+ test('real CLI reads token_cache false from .vauthrc', async t => {
564
+ const {client, file, requests} = await fixture(t);
565
+ const instance = client();
566
+ const home = path.dirname(path.dirname(file));
567
+ const rc = path.join(home, '.vauthrc');
568
+ fs.writeFileSync(rc, JSON.stringify({vault_addr : instance.VAULT_ADDR, ...instance.rc, token_cache : false}));
569
+ const cli = path.resolve(__dirname, '..', 'index.js');
570
+ for(let i = 0; i < 2; i++) {
571
+ const output = await exec(process.execPath, [cli, 'login', '--source'], {
572
+ cwd : home,
573
+ env : {...process.env, HOME : home, VAUTHRC : rc, VAULT_TOKEN : '', SSH_AUTH_SOCK : ''},
574
+ });
575
+ assert.ok(output.stdout.includes("export VAULT_TOKEN='fresh-token'"));
576
+ assert.ok(output.stderr.includes('Vauth token: generated'));
577
+ }
578
+ assert.equal(fs.existsSync(file), false);
579
+ assert.equal(requests.filter(req => req.path.endsWith('/login')).length, 2);
580
+ });