vvauth 2.1.0 → 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');
@@ -23,7 +26,7 @@ const promiser = require('nyks/function/promiser');
23
26
  const {args} = require('nyks/process/parseArgs')();
24
27
 
25
28
 
26
- const {OpenSSHAgent} = require('ssh2/lib/agent');
29
+ const {createAgent} = require('ssh2/lib/agent');
27
30
  const debug = require('debug');
28
31
 
29
32
  const logger = {
@@ -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() {
@@ -48,7 +51,7 @@ class vvauth {
48
51
  for(let [module_name, module_version] of Object.entries(dependencies)) {
49
52
 
50
53
  let {version} = require(require.resolve(`${module_name}/package.json`, {
51
- paths : ['.', ...require.main.paths]
54
+ paths : ['.', ...(require.main && require.main.paths || module.paths)]
52
55
  }));
53
56
 
54
57
  if(!semver.satisfies(version, module_version))
@@ -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,21 +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
- if(!VAULT_TOKEN && ssh_auth && process.env.SSH_AUTH_SOCK)
110
- VAULT_TOKEN = await this._login_vault_ssh({...ssh_auth});
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
+ }
111
213
 
112
- if(!VAULT_TOKEN && jwt_auth && jwt_auth.jwt) {
113
- let {path, jwt, role} = jwt_auth, payload = {jwt, role};
114
- 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);
115
218
  }
116
- this.VAULT_TOKEN = VAULT_TOKEN;
219
+ this.VAULT_TOKEN = token;
220
+ this._connected = true;
221
+ return token;
117
222
  }
118
223
 
119
- async login(source = false) {
120
- 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);
121
236
  if(source) {
122
237
  let env = {VAULT_TOKEN : this.VAULT_TOKEN};
123
238
  this._publish_env(env);
@@ -153,15 +268,15 @@ class vvauth {
153
268
  return {...database, ...profile};
154
269
  }
155
270
 
156
- async _vault_get_profile() {
157
- await this.connect();
271
+ async _vault_get_profile(renew = false) {
272
+ await this.connect(renew);
158
273
 
159
274
  if(!this.VAULT_TOKEN)
160
275
  return {};
161
276
 
162
277
  let {entity_id} = await this._lookup_token(this.VAULT_TOKEN);
163
278
  let identity = await this._lookup_identity(this.VAULT_TOKEN, entity_id);
164
- let profile = {...(identity.metadata || {})};
279
+ let profile = {...(identity.metadata)};
165
280
  let database = {};
166
281
 
167
282
  if(profile.VAUTH_USER_LOGIN)
@@ -170,8 +285,8 @@ class vvauth {
170
285
  return {entity_id, identity, profile, database};
171
286
  }
172
287
 
173
- async _get_env() {
174
- let {profile, database} = await this._vault_get_profile();
288
+ async _get_env(renew = false) {
289
+ let {profile, database} = await this._vault_get_profile(renew);
175
290
  profile = {...database, ...profile};
176
291
 
177
292
  let env = {}, secrets = {},
@@ -233,8 +348,8 @@ class vvauth {
233
348
  process.exit();
234
349
  }
235
350
 
236
- async env(source = false) {
237
- const env = await this._get_env();
351
+ async env(source = false, renew = false) {
352
+ const env = await this._get_env(renew);
238
353
 
239
354
  if(source) {
240
355
  this._publish_env(env);
@@ -272,16 +387,16 @@ class vvauth {
272
387
  }
273
388
 
274
389
 
275
- async _login_vault_ssh({path = 'ssh', role}) {
390
+ async _login_vault_ssh({path = 'ssh', role}, agent_path = process.env.SSH_AUTH_SOCK) {
276
391
  logger.info("Trying to auth as '%s'", role);
277
392
 
278
- let agent = new OpenSSHAgent(process.env.SSH_AUTH_SOCK);
393
+ let agent = createAgent(agent_path);
279
394
  let keys = await promiser(chain => agent.getIdentities(chain));
280
395
 
281
396
 
282
- let token;
397
+ let auth;
283
398
  await eachLimit(keys, 1, async (pubKey) => {
284
- if(token)
399
+ if(auth)
285
400
  return;
286
401
 
287
402
  let remote_url = `${trim(this.VAULT_ADDR, '/')}/v1/auth/${path}/nonce`;
@@ -293,17 +408,17 @@ class vvauth {
293
408
  const public_key = pubKey.type + ' ' + pubKey.getPublicSSH().toString('base64');
294
409
  const payload = {public_key, role, nonce : Buffer.from(nonce).toString('base64'), signature};
295
410
  try {
296
- token = await this._login_vault(path, payload);
411
+ auth = await this._login_vault(path, payload);
297
412
  } catch(err) {
298
413
  logger.debug("ssh : invalid challenge for public key", pubKey.comment);
299
414
  }
300
415
  });
301
416
 
302
417
 
303
- if(!token)
418
+ if(!auth || !auth.client_token)
304
419
  throw `Could not login to vault`;
305
420
 
306
- return token;
421
+ return auth;
307
422
  }
308
423
  async _function_exists(alias) {
309
424
  let child = spawn('bash', ["-lc", `declare -F ${alias}`]);
@@ -313,12 +428,11 @@ class vvauth {
313
428
  async install() {
314
429
  const bashrc_path = path.resolve(os.homedir(), ".bashrc");
315
430
  let bashrc = fs.existsSync(bashrc_path) ? fs.readFileSync(bashrc_path, 'utf-8').trim() : '';
316
- let exists = await this._function_exists(FUNCTION_NAME);
317
- if(exists == 0) {
318
- console.error("Function %s already installed", FUNCTION_NAME);
319
- return;
431
+ if(await this._function_exists(FUNCTION_NAME) === 0) {
432
+ if(!await boolPrompt("Function already defined, override current definition? ", false))
433
+ return;
320
434
  }
321
- 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);
322
436
 
323
437
  fs.writeFileSync(bashrc_path, [bashrc, FUNCTION_DECL, ""].join("\n"));
324
438
  console.error(`Installation ok, please \nsource ${bashrc_path}`);
@@ -329,6 +443,13 @@ class vvauth {
329
443
  let query = {...url.parse(remote_url), headers : {'x-vault-token' : token}, expect : 200};
330
444
  let res = await request(query);
331
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;
332
453
  return response;
333
454
  }
334
455
 
@@ -357,9 +478,10 @@ class vvauth {
357
478
  if(res.statusCode !== 200)
358
479
  throw `Could not login to vault : ${response}`;
359
480
 
360
- response = JSON.parse(response);
361
- let token = get(response, 'auth.client_token');
362
- 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;
363
485
  }
364
486
 
365
487
  }
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "vvauth",
3
- "version": "2.1.0",
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
+ });