vvauth 2.1.1 → 2.3.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,77 @@
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/tokens/<session>.creds`, 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
+ Each session file is armored using `ssh-agent-crypt` and your SSH agent’s first
71
+ key. Without an SSH agent, disk caching is disabled.
72
+
73
+ Set `token_cache: false` in `.vauthrc` to disable disk cache reads and writes.
74
+
75
+ ## Force reauthentication
76
+
77
+ `venv --renew` forces authentication, generates and caches a new token.
package/index.js CHANGED
@@ -5,9 +5,12 @@ 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');
9
+ const {encrypt, decrypt} = require('ssh-agent-crypt');
8
10
  const {spawn, execFileSync} = require('child_process');
9
11
  const passthru = require('nyks/child_process/passthru');
10
12
  const wait = require('nyks/child_process/wait');
13
+ const boolPrompt = require('cnyks/prompt/bool');
11
14
 
12
15
  const {parse} = require('yaml');
13
16
  const semver = require('semver');
@@ -15,6 +18,7 @@ const trim = require('mout/string/trim');
15
18
  const get = require('mout/object/get');
16
19
  const eachLimit = require('nyks/async/eachLimit');
17
20
  const walk = require('nyks/object/walk');
21
+ const humanDiff = require('nyks/date/humanDiff');
18
22
 
19
23
  const request = require('nyks/http/request');
20
24
  const drain = require('nyks/stream/drain');
@@ -35,7 +39,7 @@ const logger = {
35
39
 
36
40
  const VAUTH_RC = [process.env.VAUTHRC, path.join(process.cwd(), ".vauthrc"), path.join(os.homedir(), ".vauthrc")];
37
41
  const FUNCTION_NAME = "venv";
38
- const FUNCTION_DECL = `function ${FUNCTION_NAME}() { source <(/usr/bin/env vauth env --source); }`;
42
+ const FUNCTION_DECL = `function ${FUNCTION_NAME}() { source <(/usr/bin/env vauth env --source "$@"); }`;
39
43
 
40
44
  class vvauth {
41
45
  constructor() {
@@ -86,12 +90,13 @@ class vvauth {
86
90
  }
87
91
 
88
92
 
93
+ if(!process.env.SSH_AUTH_SOCK)
94
+ this.rc.token_cache = false;
95
+
89
96
  this.VAULT_ADDR = this.rc.vault_addr || process.env.VAULT_ADDR; //might be null
90
97
  this.VAULT_TOKEN = process.env.VAULT_TOKEN;
91
98
 
92
- if(this.VAULT_ADDR)
93
- console.error("vauth bound to '%s'", this.VAULT_ADDR);
94
- else
99
+ if(!this.VAULT_ADDR)
95
100
  console.error("Not bound to any vault");
96
101
 
97
102
  }
@@ -103,22 +108,133 @@ class vvauth {
103
108
  process.exit();
104
109
  }
105
110
 
106
- async connect() {
107
- let VAULT_TOKEN, {rc : {ssh_auth, jwt_auth}} = this;
111
+ _token_cache_key() {
112
+ const {ssh_auth, jwt_auth} = this.rc;
113
+ return createHash('sha256').update(JSON.stringify({
114
+ vault_addr : trim(this.VAULT_ADDR || '', '/'), ssh_auth, jwt_auth,
115
+ })).digest('hex');
116
+ }
117
+
118
+ _token_cache_file() {
119
+ return path.join(os.homedir(), '.vauth', 'tokens', this._token_cache_key() + '.creds');
120
+ }
121
+
122
+ async _read_token_cache() {
123
+ if(this.rc.token_cache === false)
124
+ return;
125
+ try {
126
+ const armor = fs.readFileSync(this._token_cache_file(), 'utf8');
127
+ return JSON.parse(await decrypt(armor));
128
+ } catch(err) {}
129
+ }
130
+
131
+ async _save_cached_token(token, metadata) {
132
+ if(this.rc.token_cache === false)
133
+ return;
134
+ const ttl = metadata.ttl === undefined ? metadata.lease_duration : metadata.ttl;
135
+ const file = this._token_cache_file();
136
+ const temporary = file + '.next';
137
+ try {
138
+ const {lease_duration} = metadata;
139
+ const cache = {
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
+ const identity = process.env.VAUTH_USER_IDENTITY || process.env.VAUTH_USER_MAIL;
145
+ const armor = await encrypt(JSON.stringify(cache), identity);
146
+ fs.writeFileSync(temporary, armor, {mode : 0o600});
147
+ fs.renameSync(temporary, file);
148
+ } catch(err) {
149
+ // Cache failures must not prevent authentication or expose credentials.
150
+ console.error('Could not encrypt/write token cache; token not persisted');
151
+ } finally {
152
+ try { fs.unlinkSync(temporary); } catch(err) {}
153
+ }
154
+ }
155
+
156
+ _token_status(operation, metadata) {
157
+ const ttl = metadata.ttl === undefined ? metadata.lease_duration : metadata.ttl;
158
+ const validity = ttl === 0 ? 'valid indefinitely' : humanDiff(ttl, 2);
159
+ console.error('Vauth token: %s · %s · %s', operation, this.VAULT_ADDR, validity);
160
+ }
161
+
162
+ async connect(renew = false) {
163
+ if(this._connected && !renew)
164
+ return this.VAULT_TOKEN;
165
+ this._connected = false;
166
+
167
+ const cached = await this._read_token_cache();
168
+ let token = this.VAULT_TOKEN || (cached && cached.token);
169
+ const had_token = !!token;
170
+ let metadata, operation = 'reused';
171
+ if(renew || (cached && token === cached.token && cached.expires_at !== null && cached.expires_at <= Date.now()))
172
+ token = undefined;
108
173
 
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);
174
+ if(token) {
175
+ try {
176
+ if(cached && cached.token === token) {
177
+ const ttl = cached.expires_at === null ? 0 : (cached.expires_at - Date.now()) / 1000;
178
+ metadata = {...cached, ttl};
179
+ } else {
180
+ metadata = await this._lookup_token(token);
181
+ }
182
+ const {ttl, renewable, lease_duration} = metadata;
183
+ if(ttl > 0 && ttl <= lease_duration / 3) {
184
+ if(renewable) {
185
+ metadata = await this._renew_token(token);
186
+ token = metadata.client_token;
187
+ operation = 'renewed';
188
+ } else {
189
+ token = undefined;
190
+ }
191
+ }
192
+ } catch(err) {
193
+ // Reauthenticate on invalid tokens, not network/server failures.
194
+ const status = err.res && err.res.statusCode;
195
+ if(![400, 403].includes(status))
196
+ throw err;
197
+ token = undefined;
198
+ }
199
+ }
112
200
 
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);
201
+ if(!token) {
202
+ const {ssh_auth, jwt_auth} = this.rc;
203
+ const agent_path = process.env.SSH_AUTH_SOCK || (process.platform === 'win32' ? 'pageant' : null);
204
+ if(ssh_auth && agent_path) {
205
+ metadata = await this._login_vault_ssh({...ssh_auth}, agent_path);
206
+ } else if(jwt_auth && jwt_auth.jwt) {
207
+ const {path, jwt, role} = jwt_auth;
208
+ metadata = await this._login_vault(path, {jwt, role});
209
+ }
210
+ token = metadata && metadata.client_token;
211
+ if(!token && (renew || had_token))
212
+ throw new Error('Could not login to vault: no authentication method available');
213
+ operation = 'generated';
214
+ }
215
+
216
+ if(token) {
217
+ if(operation !== 'reused' || !cached || cached.token !== token)
218
+ await this._save_cached_token(token, metadata);
219
+ this._token_status(operation, metadata);
116
220
  }
117
- this.VAULT_TOKEN = VAULT_TOKEN;
221
+ this.VAULT_TOKEN = token;
222
+ this._connected = true;
223
+ return token;
118
224
  }
119
225
 
120
- async login(source = false) {
121
- await this.connect();
226
+ async _renew_token(token) {
227
+ const remote_url = `${trim(this.VAULT_ADDR, '/')}/v1/auth/token/renew-self`;
228
+ const query = {...url.parse(remote_url), headers : {'x-vault-token' : token}, expect : 200, json : true};
229
+ const res = await request(query, {});
230
+ const auth = JSON.parse(String(await drain(res))).auth;
231
+ if(!auth.client_token || !Number.isFinite(auth.lease_duration) || auth.lease_duration < 0)
232
+ throw new Error('Invalid Vault token renewal response');
233
+ return auth;
234
+ }
235
+
236
+ async login(source = false, renew = false) {
237
+ await this.connect(renew);
122
238
  if(source) {
123
239
  let env = {VAULT_TOKEN : this.VAULT_TOKEN};
124
240
  this._publish_env(env);
@@ -154,15 +270,15 @@ class vvauth {
154
270
  return {...database, ...profile};
155
271
  }
156
272
 
157
- async _vault_get_profile() {
158
- await this.connect();
273
+ async _vault_get_profile(renew = false) {
274
+ await this.connect(renew);
159
275
 
160
276
  if(!this.VAULT_TOKEN)
161
277
  return {};
162
278
 
163
279
  let {entity_id} = await this._lookup_token(this.VAULT_TOKEN);
164
280
  let identity = await this._lookup_identity(this.VAULT_TOKEN, entity_id);
165
- let profile = {...(identity.metadata || {})};
281
+ let profile = {...(identity.metadata)};
166
282
  let database = {};
167
283
 
168
284
  if(profile.VAUTH_USER_LOGIN)
@@ -171,8 +287,8 @@ class vvauth {
171
287
  return {entity_id, identity, profile, database};
172
288
  }
173
289
 
174
- async _get_env() {
175
- let {profile, database} = await this._vault_get_profile();
290
+ async _get_env(renew = false) {
291
+ let {profile, database} = await this._vault_get_profile(renew);
176
292
  profile = {...database, ...profile};
177
293
 
178
294
  let env = {}, secrets = {},
@@ -234,8 +350,8 @@ class vvauth {
234
350
  process.exit();
235
351
  }
236
352
 
237
- async env(source = false) {
238
- const env = await this._get_env();
353
+ async env(source = false, renew = false) {
354
+ const env = await this._get_env(renew);
239
355
 
240
356
  if(source) {
241
357
  this._publish_env(env);
@@ -280,9 +396,9 @@ class vvauth {
280
396
  let keys = await promiser(chain => agent.getIdentities(chain));
281
397
 
282
398
 
283
- let token;
399
+ let auth;
284
400
  await eachLimit(keys, 1, async (pubKey) => {
285
- if(token)
401
+ if(auth)
286
402
  return;
287
403
 
288
404
  let remote_url = `${trim(this.VAULT_ADDR, '/')}/v1/auth/${path}/nonce`;
@@ -294,17 +410,17 @@ class vvauth {
294
410
  const public_key = pubKey.type + ' ' + pubKey.getPublicSSH().toString('base64');
295
411
  const payload = {public_key, role, nonce : Buffer.from(nonce).toString('base64'), signature};
296
412
  try {
297
- token = await this._login_vault(path, payload);
413
+ auth = await this._login_vault(path, payload);
298
414
  } catch(err) {
299
415
  logger.debug("ssh : invalid challenge for public key", pubKey.comment);
300
416
  }
301
417
  });
302
418
 
303
419
 
304
- if(!token)
420
+ if(!auth || !auth.client_token)
305
421
  throw `Could not login to vault`;
306
422
 
307
- return token;
423
+ return auth;
308
424
  }
309
425
  async _function_exists(alias) {
310
426
  let child = spawn('bash', ["-lc", `declare -F ${alias}`]);
@@ -314,12 +430,11 @@ class vvauth {
314
430
  async install() {
315
431
  const bashrc_path = path.resolve(os.homedir(), ".bashrc");
316
432
  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;
433
+ if(await this._function_exists(FUNCTION_NAME) === 0) {
434
+ if(!await boolPrompt("Function already defined, override current definition? ", false))
435
+ return;
321
436
  }
322
- console.error("Alias %s not installed, pushing it to %s", FUNCTION_NAME, bashrc_path);
437
+ console.error("Installing function %s in %s", FUNCTION_NAME, bashrc_path);
323
438
 
324
439
  fs.writeFileSync(bashrc_path, [bashrc, FUNCTION_DECL, ""].join("\n"));
325
440
  console.error(`Installation ok, please \nsource ${bashrc_path}`);
@@ -330,6 +445,13 @@ class vvauth {
330
445
  let query = {...url.parse(remote_url), headers : {'x-vault-token' : token}, expect : 200};
331
446
  let res = await request(query);
332
447
  let response = JSON.parse(await drain(res)).data;
448
+ if(!Number.isFinite(response.ttl) || response.ttl < 0)
449
+ throw new Error('Invalid Vault token TTL');
450
+ const cached = await this._read_token_cache();
451
+ if(cached && cached.token === token)
452
+ response.lease_duration = cached.lease_duration;
453
+ else
454
+ response.lease_duration = response.creation_ttl;
333
455
  return response;
334
456
  }
335
457
 
@@ -358,9 +480,10 @@ class vvauth {
358
480
  if(res.statusCode !== 200)
359
481
  throw `Could not login to vault : ${response}`;
360
482
 
361
- response = JSON.parse(response);
362
- let token = get(response, 'auth.client_token');
363
- return token;
483
+ const auth = JSON.parse(response).auth;
484
+ if(!auth.client_token || !Number.isFinite(auth.lease_duration) || auth.lease_duration < 0)
485
+ throw new Error('Invalid Vault login response');
486
+ return auth;
364
487
  }
365
488
 
366
489
  }
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "vvauth",
3
- "version": "2.1.1",
3
+ "version": "2.3.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": "",
@@ -17,7 +18,7 @@
17
18
  "mout": "^1.2.4",
18
19
  "nyks": "^6.15.0",
19
20
  "semver": "^7.5.4",
20
- "ssh-agent-crypt": "^1.0.1",
21
+ "ssh-agent-crypt": "^1.2.0",
21
22
  "ssh2": "^1.16.0",
22
23
  "yaml": "^2.6.1"
23
24
  },
@@ -0,0 +1,738 @@
1
+ 'use strict';
2
+
3
+ const assert = require('node:assert/strict');
4
+ const {test, before, after} = 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, spawnSync} = require('child_process');
10
+ const {promisify, format} = require('util');
11
+ const exec = promisify(execFile);
12
+ const Vauth = require('../index');
13
+
14
+ const {encrypt, decrypt} = require('ssh-agent-crypt');
15
+ const readCache = async file => JSON.parse(await decrypt(fs.readFileSync(file, 'utf8')));
16
+ const writeCache = async (file, cache) => fs.writeFileSync(file, await encrypt(JSON.stringify(cache)));
17
+ let agentDirectory, agentPid;
18
+ const previousSocket = process.env.SSH_AUTH_SOCK;
19
+ const previousIdentity = process.env.VAUTH_USER_IDENTITY;
20
+ const previousMail = process.env.VAUTH_USER_MAIL;
21
+
22
+ before(() => {
23
+ delete process.env.VAUTH_USER_IDENTITY;
24
+ delete process.env.VAUTH_USER_MAIL;
25
+ agentDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'vvauth-agent-'));
26
+ const key = path.join(agentDirectory, 'key');
27
+ assert.equal(spawnSync('ssh-keygen', ['-q', '-t', 'ed25519', '-N', '', '-f', key]).status, 0);
28
+ const agent = spawnSync('ssh-agent', ['-s'], {encoding : 'utf8'});
29
+ assert.equal(agent.status, 0);
30
+ process.env.SSH_AUTH_SOCK = agent.stdout.match(/SSH_AUTH_SOCK=([^;]+)/)[1];
31
+ agentPid = agent.stdout.match(/SSH_AGENT_PID=(\d+)/)[1];
32
+ assert.equal(spawnSync('ssh-add', [key]).status, 0);
33
+ });
34
+
35
+ after(() => {
36
+ for(const [name, value] of [['VAUTH_USER_IDENTITY', previousIdentity], ['VAUTH_USER_MAIL', previousMail]]) {
37
+ if(value === undefined)
38
+ delete process.env[name];
39
+ else
40
+ process.env[name] = value;
41
+ }
42
+ if(agentPid)
43
+ process.kill(Number(agentPid));
44
+ if(previousSocket === undefined)
45
+ delete process.env.SSH_AUTH_SOCK;
46
+ else
47
+ process.env.SSH_AUTH_SOCK = previousSocket;
48
+ fs.rmSync(agentDirectory, {recursive : true, force : true});
49
+ });
50
+
51
+ const HOUR = 60 * 60;
52
+
53
+ async function fixture(t, options = {}) {
54
+ const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'vvauth-test-'));
55
+ let file;
56
+ const requests = [];
57
+ const server = http.createServer(async (req, res) => {
58
+ let body = '';
59
+ for await(const chunk of req)
60
+ body += chunk;
61
+ requests.push({path : req.url, method : req.method, token : req.headers['x-vault-token'], body});
62
+ res.setHeader('content-type', 'application/json');
63
+ if(req.url === '/v1/auth/jwt/login') {
64
+ res.end(JSON.stringify({auth : {client_token : 'fresh-token', lease_duration : 24 * HOUR, renewable : true}}));
65
+ } else if(req.url === '/v1/auth/token/lookup-self') {
66
+ res.statusCode = options.lookup_status || 200;
67
+ 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'}}));
68
+ } else if(req.url === '/v1/auth/token/renew-self') {
69
+ res.statusCode = options.renew_status || 200;
70
+ res.end(JSON.stringify({auth : {client_token : req.headers['x-vault-token'], lease_duration : 24 * HOUR, renewable : true}}));
71
+ } else if(req.url === '/v1/identity/entity/id/test-entity') {
72
+ res.end(JSON.stringify({data : {metadata : {}}}));
73
+ } else {
74
+ res.statusCode = 404;
75
+ res.end('{}');
76
+ }
77
+ });
78
+ await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
79
+ t.after(async () => {
80
+ await new Promise(resolve => server.close(resolve));
81
+ fs.rmSync(directory, {recursive : true, force : true});
82
+ });
83
+ const client = () => {
84
+ // Avoid reading the real user's environment/configuration/cache.
85
+ const instance = Object.create(Vauth.prototype);
86
+ instance.rc = {jwt_auth : {path : 'jwt', jwt : 'test-jwt', role : 'test-role'}};
87
+ instance.VAULT_ADDR = `http://127.0.0.1:${server.address().port}`;
88
+ instance._token_cache_file = () => path.join(directory, '.vauth', 'tokens', instance._token_cache_key() + '.creds');
89
+ return instance;
90
+ };
91
+ file = client()._token_cache_file();
92
+ const remaining = async (instance, ttl) => {
93
+ const file = instance._token_cache_file();
94
+ const cache = (await readCache(file));
95
+ cache.expires_at = ttl === 0 ? null : Date.now() + ttl * 1000;
96
+ await writeCache(file, cache);
97
+ };
98
+ const seed = async (instance, lease = 12 * HOUR) => {
99
+ await instance._save_cached_token('cached-token', {lease_duration : lease, renewable : options.renewable !== false});
100
+ if(options.ttl !== undefined)
101
+ await remaining(instance, options.ttl);
102
+ };
103
+ const read = async instance => readCache(instance._token_cache_file());
104
+ return {client, seed, read, requests, file, remaining};
105
+ }
106
+
107
+ test('login persists a token; a new instance reuses it without logging in', async t => {
108
+ const {client, read, requests, file} = await fixture(t);
109
+ const first = client();
110
+ assert.equal(await first.connect(), 'fresh-token');
111
+ assert.equal((await read(first)).token, 'fresh-token');
112
+ assert.ok((await read(first)).expires_at > Date.now() + 23 * HOUR * 1000);
113
+ assert.equal(fs.statSync(file).mode & 0o777, 0o600);
114
+ assert.equal(fs.statSync(path.dirname(file)).mode & 0o777, 0o700);
115
+ const second = client();
116
+ assert.equal(await second.connect(), 'fresh-token');
117
+ assert.deepEqual(requests.map(req => req.path), ['/v1/auth/jwt/login']);
118
+ assert.deepEqual(fs.readdirSync(path.dirname(file)), [path.basename(file)]);
119
+ });
120
+
121
+ test('a token in the last third of its lease is renewed and saved', async t => {
122
+ const {client, seed, read, requests} = await fixture(t, {ttl : HOUR});
123
+ const instance = client();
124
+ await seed(instance, 12 * HOUR);
125
+ assert.equal(await instance.connect(), 'cached-token');
126
+ assert.deepEqual(requests.map(req => req.path), ['/v1/auth/token/renew-self']);
127
+ assert.equal(requests[0].method, 'POST');
128
+ assert.equal(requests[0].token, 'cached-token');
129
+ assert.ok((await read(instance)).expires_at > Date.now() + 23 * HOUR * 1000);
130
+ });
131
+
132
+ test('more than one third remaining does not trigger renewal', async t => {
133
+ const {client, seed, requests} = await fixture(t, {ttl : 4 * HOUR + 1});
134
+ const instance = client();
135
+ await seed(instance);
136
+ assert.equal(await instance.connect(), 'cached-token');
137
+ assert.equal(requests.length, 0);
138
+ });
139
+
140
+ test('expired cached tokens are replaced with a new persisted login', async t => {
141
+ const {client, seed, read, requests, file} = await fixture(t);
142
+ const instance = client();
143
+ await seed(instance);
144
+ const cache = (await readCache(file));
145
+ cache.expires_at = Date.now() - 1000;
146
+ await writeCache(file, cache);
147
+ assert.equal(await instance.connect(), 'fresh-token');
148
+ assert.equal((await read(instance)).token, 'fresh-token');
149
+ assert.deepEqual(requests.map(req => req.path), ['/v1/auth/jwt/login']);
150
+ });
151
+
152
+ test('cached tokens are reused without checking revocation', async t => {
153
+ const {client, seed, requests} = await fixture(t, {lookup_status : 403});
154
+ const instance = client();
155
+ await seed(instance);
156
+ assert.equal(await instance.connect(), 'cached-token');
157
+ assert.deepEqual(requests, []);
158
+ await assert.rejects(instance._lookup_token(instance.VAULT_TOKEN));
159
+ });
160
+
161
+ test('nonrenewable tokens nearing expiry trigger reauthentication', async t => {
162
+ const {client, seed, requests} = await fixture(t, {ttl : HOUR, renewable : false});
163
+ const instance = client();
164
+ await seed(instance);
165
+ assert.equal(await instance.connect(), 'fresh-token');
166
+ assert.deepEqual(requests.map(req => req.path), ['/v1/auth/jwt/login']);
167
+ });
168
+
169
+ for(const status of [400, 403]) {
170
+ test(`rejected renewal (${status}) triggers a new persisted login`, async t => {
171
+ const {client, seed, read, requests} = await fixture(t, {ttl : HOUR, renew_status : status});
172
+ const instance = client();
173
+ await seed(instance);
174
+ assert.equal(await instance.connect(), 'fresh-token');
175
+ assert.equal((await read(instance)).token, 'fresh-token');
176
+ assert.deepEqual(requests.map(req => req.path), ['/v1/auth/token/renew-self', '/v1/auth/jwt/login']);
177
+ });
178
+ }
179
+
180
+ test('server failures do not generate extra tokens, and connect can be retried', async t => {
181
+ const {client, seed, requests} = await fixture(t, {lookup_status : 500});
182
+ const instance = client();
183
+ await seed(instance);
184
+ instance.VAULT_TOKEN = 'uncached-token';
185
+ await assert.rejects(instance.connect());
186
+ await assert.rejects(instance.connect());
187
+ assert.deepEqual(requests.map(req => req.path), ['/v1/auth/token/lookup-self', '/v1/auth/token/lookup-self']);
188
+ });
189
+
190
+ test('renewal server failures do not generate extra tokens', async t => {
191
+ const {client, seed, requests} = await fixture(t, {ttl : HOUR, renew_status : 500});
192
+ const instance = client();
193
+ await seed(instance);
194
+ await assert.rejects(instance.connect());
195
+ assert.deepEqual(requests.map(req => req.path), ['/v1/auth/token/renew-self']);
196
+ });
197
+
198
+ test('an existing environment token is respected and cached', async t => {
199
+ const {client, seed, read, requests} = await fixture(t);
200
+ const instance = client();
201
+ await seed(instance);
202
+ instance.VAULT_TOKEN = 'environment-token';
203
+ assert.equal(await instance.connect(), 'environment-token');
204
+ assert.equal((await read(instance)).token, 'environment-token');
205
+ assert.equal(requests[0].token, 'environment-token');
206
+ assert.equal(requests.length, 1);
207
+ });
208
+
209
+ test('nonexpiring tokens are reused without renewal', async t => {
210
+ const {client, seed, read, requests} = await fixture(t, {ttl : 0});
211
+ const instance = client();
212
+ await seed(instance, 0);
213
+ assert.equal(await instance.connect(), 'cached-token');
214
+ assert.equal((await read(instance)).expires_at, null);
215
+ assert.equal(requests.length, 0);
216
+ });
217
+
218
+ test('corrupt caches are replaced with a valid cache', async t => {
219
+ const {client, read, file} = await fixture(t);
220
+ fs.mkdirSync(path.dirname(file), {recursive : true});
221
+ fs.writeFileSync(file, '{broken');
222
+ const instance = client();
223
+ assert.equal(await instance.connect(), 'fresh-token');
224
+ assert.equal((await read(instance)).token, 'fresh-token');
225
+ });
226
+
227
+ test('cache entries are isolated by Vault address and authentication configuration', async t => {
228
+ const {client, seed, read, file} = await fixture(t);
229
+ const first = client();
230
+ await seed(first);
231
+ const second = client();
232
+ second.rc.jwt_auth.role = 'other-role';
233
+ assert.equal(await second.connect(), 'fresh-token');
234
+ assert.notEqual(first._token_cache_file(), second._token_cache_file());
235
+ assert.equal((await read(first)).token, 'cached-token');
236
+ assert.equal((await read(second)).token, 'fresh-token');
237
+ const third = client();
238
+ third.VAULT_ADDR = 'http://other-vault.test';
239
+ assert.notEqual(third._token_cache_key(), first._token_cache_key());
240
+ assert.equal(fs.readdirSync(path.dirname(file)).length, 2);
241
+ assert.ok(!fs.readFileSync(file, 'utf8').includes('test-jwt'));
242
+ });
243
+
244
+ test('repeated connect calls reuse the established connection', async t => {
245
+ const {client, requests} = await fixture(t);
246
+ const instance = client();
247
+ assert.equal(await instance.connect(), 'fresh-token');
248
+ assert.equal(await instance.connect(), 'fresh-token');
249
+ assert.equal(requests.length, 1);
250
+ });
251
+
252
+ test('an unwritable cache does not prevent login', async t => {
253
+ const {client, file} = await fixture(t);
254
+ fs.mkdirSync(path.dirname(path.dirname(file)), {recursive : true});
255
+ fs.writeFileSync(path.dirname(file), 'not a directory');
256
+ assert.equal(await client().connect(), 'fresh-token');
257
+ });
258
+
259
+ test('an invalid token without an auth method fails rather than exporting undefined', async t => {
260
+ const {client, seed} = await fixture(t, {lookup_status : 403});
261
+ const instance = client();
262
+ instance.rc = {};
263
+ await seed(instance);
264
+ instance.VAULT_TOKEN = 'uncached-token';
265
+ await assert.rejects(instance.connect(), /no authentication method/);
266
+ });
267
+
268
+ test('--renew ignores both environment and cached tokens and saves a new token', async t => {
269
+ const {client, seed, read, requests} = await fixture(t);
270
+ const instance = client();
271
+ await seed(instance);
272
+ instance.VAULT_TOKEN = 'environment-token';
273
+ assert.equal(await instance.connect(true), 'fresh-token');
274
+ assert.deepEqual(requests.map(req => req.path), ['/v1/auth/jwt/login']);
275
+ assert.equal((await read(instance)).token, 'fresh-token');
276
+ assert.ok((await read(instance)).expires_at > Date.now() + 23 * HOUR * 1000);
277
+ });
278
+
279
+ test('--renew replaces the token of a previously connected instance', async t => {
280
+ const {client, seed, read, requests} = await fixture(t);
281
+ const instance = client();
282
+ await seed(instance);
283
+ await instance.connect();
284
+ assert.equal(await instance.connect(true), 'fresh-token');
285
+ assert.deepEqual(requests.map(req => req.path), ['/v1/auth/jwt/login']);
286
+ assert.equal((await read(instance)).token, 'fresh-token');
287
+ });
288
+
289
+ test('each successive --renew call forces a new login', async t => {
290
+ const {client, requests} = await fixture(t);
291
+ const instance = client();
292
+ assert.equal(await instance.connect(true), 'fresh-token');
293
+ assert.equal(await instance.connect(true), 'fresh-token');
294
+ assert.equal(requests.length, 2);
295
+ });
296
+
297
+ test('--renew without an authentication method fails even with no cached token', async t => {
298
+ const {client} = await fixture(t);
299
+ const instance = client();
300
+ instance.rc = {};
301
+ await assert.rejects(instance.connect(true), /no authentication method/);
302
+ });
303
+
304
+ for(const flag of ['renew']) {
305
+ test(`env forwards --${flag} into forced reauthentication`, async t => {
306
+ const {client} = await fixture(t);
307
+ const instance = client();
308
+ let forced;
309
+ instance.connect = async renew => { forced = renew; };
310
+ assert.deepEqual(await instance.env(false, true), {VAULT_ADDR : instance.VAULT_ADDR});
311
+ assert.equal(forced, true);
312
+ });
313
+
314
+ for(const command of ['env', 'login']) {
315
+ test(`real CLI ${command} --${flag} generates and caches a new token`, async t => {
316
+ const {client, seed, read, requests, file} = await fixture(t);
317
+ const instance = client();
318
+ await seed(instance);
319
+ const home = path.dirname(path.dirname(path.dirname(file)));
320
+ const rc = path.join(home, '.vauthrc');
321
+ fs.writeFileSync(rc, JSON.stringify({vault_addr : instance.VAULT_ADDR, ...instance.rc}));
322
+ const options = {
323
+ cwd : home,
324
+ env : {...process.env, HOME : home, VAUTHRC : rc, VAULT_TOKEN : 'environment-token', SSH_AUTH_SOCK : process.env.SSH_AUTH_SOCK},
325
+ };
326
+ const cli = path.resolve(__dirname, '..', 'index.js');
327
+ const output = await exec(process.execPath, [cli, command, '--source', `--${flag}`], options);
328
+ assert.ok(output.stdout.includes("export VAULT_TOKEN='fresh-token'"));
329
+ assert.ok(!output.stdout.includes('Vauth token: '));
330
+ assert.equal(output.stderr.split('\n').filter(line => line === `Vauth token: generated · ${instance.VAULT_ADDR} · 1d`).length, 1);
331
+ assert.ok(!output.stderr.includes('Vauth token: renewed'));
332
+ assert.ok(!output.stderr.includes('fresh-token'));
333
+ assert.ok(!output.stderr.includes('environment-token'));
334
+ assert.deepEqual(requests.filter(req => !req.path.startsWith('/v1/identity/')).map(req => req.path), command === 'env'
335
+ ? ['/v1/auth/jwt/login', '/v1/auth/token/lookup-self'] : ['/v1/auth/jwt/login']);
336
+ assert.equal((await read(instance)).token, 'fresh-token');
337
+ assert.equal(fs.statSync(file).mode & 0o777, 0o600);
338
+ requests.length = 0;
339
+ const reused = await exec(process.execPath, [cli, command, '--source'], {
340
+ ...options, env : {...options.env, VAULT_TOKEN : ''},
341
+ });
342
+ assert.ok(reused.stdout.includes("export VAULT_TOKEN='fresh-token'"));
343
+ assert.equal(reused.stderr.split('\n').filter(line => line.startsWith('Vauth token: ')).length, 1);
344
+ assert.ok(reused.stderr.includes(`Vauth token: reused · ${instance.VAULT_ADDR} · 1d`));
345
+ assert.ok(!requests.some(req => req.path.endsWith('/login') || req.path.endsWith('/renew-self')));
346
+ });
347
+ }
348
+ }
349
+
350
+ test('installed venv forwards --renew through the real CLI', async t => {
351
+ const {client, seed, read, requests, file} = await fixture(t);
352
+ const instance = client();
353
+ await seed(instance);
354
+ const home = path.dirname(path.dirname(path.dirname(file)));
355
+ const original_home = os.homedir;
356
+ instance._function_exists = async () => 1;
357
+ try {
358
+ os.homedir = () => home;
359
+ await instance.install();
360
+ } finally {
361
+ os.homedir = original_home;
362
+ }
363
+ const declaration = fs.readFileSync(path.join(home, '.bashrc'), 'utf8');
364
+ assert.ok(declaration.includes('vauth env --source "$@"'));
365
+ const bin = path.join(home, 'bin');
366
+ fs.mkdirSync(bin);
367
+ fs.symlinkSync(path.resolve(__dirname, '..', 'index.js'), path.join(bin, 'vauth'));
368
+ const rc = path.join(home, '.vauthrc');
369
+ fs.writeFileSync(rc, JSON.stringify({vault_addr : instance.VAULT_ADDR, ...instance.rc}));
370
+ const options = {
371
+ cwd : home,
372
+ env : {...process.env, HOME : home, VAUTHRC : rc, VAULT_TOKEN : '', SSH_AUTH_SOCK : process.env.SSH_AUTH_SOCK, PATH : `${bin}:${process.env.PATH}`},
373
+ };
374
+ const initial = await exec('bash', ['-c', `${declaration}\nvenv; printf '%s' "$VAULT_TOKEN"`], options);
375
+ assert.equal(initial.stdout, 'cached-token');
376
+ assert.equal(initial.stderr.split('\n').filter(line => line.startsWith('Vauth token: ')).length, 1);
377
+ assert.ok(initial.stderr.includes(`Vauth token: reused · ${instance.VAULT_ADDR} · 12h`));
378
+ assert.ok(!requests.some(req => req.path.endsWith('/login') || req.path.endsWith('/renew-self')));
379
+ for(const flag of ['--renew']) {
380
+ const output = await exec('bash', ['-c', `${declaration}\nvenv ${flag}; printf '%s' "$VAULT_TOKEN"`], options);
381
+ assert.equal(output.stdout, 'fresh-token');
382
+ assert.equal(output.stderr.split('\n').filter(line => line === `Vauth token: generated · ${instance.VAULT_ADDR} · 1d`).length, 1);
383
+ assert.ok(!output.stderr.includes('Vauth token: renewed'));
384
+ assert.ok(!output.stderr.includes('fresh-token'));
385
+ assert.equal((await read(instance)).token, 'fresh-token');
386
+ }
387
+ assert.equal(requests.filter(req => req.path.endsWith('/login')).length, 1);
388
+ assert.ok(!requests.some(req => req.path.endsWith('/renew-self')));
389
+ });
390
+
391
+ for(const answer of ['yes', 'no', '']) {
392
+ test(`install asks before overriding an existing function: ${JSON.stringify(answer)}`, async t => {
393
+ const {file} = await fixture(t);
394
+ const home = path.dirname(path.dirname(path.dirname(file)));
395
+ const bashrc = path.join(home, '.bashrc');
396
+ const original = '# before\nfunction venv() { source <(/usr/bin/env vauth env --source); }\n# after\n';
397
+ fs.writeFileSync(bashrc, original);
398
+ const module_path = path.resolve(__dirname, '..', 'index.js');
399
+ const script = `const Vauth = require(${JSON.stringify(module_path)});
400
+ const instance = Object.create(Vauth.prototype);
401
+ instance._function_exists = async () => 0;
402
+ instance.install().catch(() => process.exit(1));`;
403
+ const child = spawn(process.execPath, ['-e', script], {cwd : home, env : {...process.env, HOME : home}});
404
+ let output = '', prompted = false;
405
+ child.stdout.on('data', chunk => {
406
+ output += chunk;
407
+ if(!prompted && output.includes('[y/N]')) {
408
+ prompted = true;
409
+ child.stdin.end(answer + '\n');
410
+ }
411
+ });
412
+ child.stderr.resume();
413
+ const exit = await new Promise((resolve, reject) => {
414
+ child.on('error', reject);
415
+ child.on('exit', resolve);
416
+ });
417
+ assert.equal(exit, 0);
418
+ assert.ok(output.includes('Function already defined, override current definition?'));
419
+ const declaration = 'function venv() { source <(/usr/bin/env vauth env --source "$@"); }\n';
420
+ assert.equal(fs.readFileSync(bashrc, 'utf8'), answer === 'yes' ? original + declaration : original);
421
+ });
422
+ }
423
+
424
+ function captureStatus(callback) {
425
+ const lines = [];
426
+ const original = console.error;
427
+ console.error = (...args) => lines.push(format(...args));
428
+ try {
429
+ callback();
430
+ } finally {
431
+ console.error = original;
432
+ }
433
+ return lines;
434
+ }
435
+
436
+ test('token status uses humanDiff with two significant units', () => {
437
+ const instance = Object.create(Vauth.prototype);
438
+ instance.VAULT_ADDR = 'https://vault.example.test';
439
+ const cases = [
440
+ [2 * 86400 + 3 * HOUR + 20 * 60 + 5, '2d 3h'],
441
+ [12 * HOUR + 30 * 60 + 5, '12h 30m'],
442
+ [5 * 60 + 20, '5m 20s'],
443
+ [86400, '1d'],
444
+ [30 * 60, '30m'],
445
+ [45, '45s'],
446
+ [86400 + 20, '1d 20s'],
447
+ [HOUR + 5, '1h 5s'],
448
+ [400 * 86400 + 2 * HOUR, '1y 1month'],
449
+ [60.9, '1m 1s'],
450
+ ];
451
+ for(const [ttl, duration] of cases) {
452
+ assert.deepEqual(captureStatus(() => instance._token_status('reused', {ttl})), [
453
+ `Vauth token: reused · ${instance.VAULT_ADDR} · ${duration}`,
454
+ ]);
455
+ }
456
+ assert.deepEqual(captureStatus(() => instance._token_status('renewed', {lease_duration : HOUR + 60})), [
457
+ `Vauth token: renewed · ${instance.VAULT_ADDR} · 1h 1m`,
458
+ ]);
459
+ assert.deepEqual(captureStatus(() => instance._token_status('generated', {lease_duration : 0})), [
460
+ `Vauth token: generated · ${instance.VAULT_ADDR} · valid indefinitely`,
461
+ ]);
462
+ });
463
+
464
+ for(const options of [
465
+ {ttl : 12 * HOUR, operation : 'reused', duration : '12h'},
466
+ {ttl : HOUR, operation : 'renewed', duration : '1d'},
467
+ {ttl : HOUR, renew_status : 400, operation : 'generated', duration : '1d'},
468
+ {ttl : HOUR, renew_status : 403, operation : 'generated', duration : '1d'},
469
+ {ttl : 0, operation : 'reused', duration : null},
470
+ ]) {
471
+ test(`one final status per connection: ${JSON.stringify(options)}`, async t => {
472
+ const {client, seed} = await fixture(t, options);
473
+ const instance = client();
474
+ await seed(instance);
475
+ const calls = [];
476
+ instance._token_status = (operation, metadata) => {
477
+ calls.push(...captureStatus(() => Vauth.prototype._token_status.call(instance, operation, metadata)));
478
+ };
479
+ await instance.connect();
480
+ await instance.connect();
481
+ const validity = options.duration ? `valid for ${options.duration}` : 'valid indefinitely';
482
+ assert.deepEqual(calls, [`Vauth token: ${options.operation} · ${instance.VAULT_ADDR} · ${validity.replace(/^valid for /, '')}`]);
483
+ });
484
+ }
485
+
486
+ test('failed connections never announce successful token status', async t => {
487
+ const {client, seed} = await fixture(t, {lookup_status : 500});
488
+ const instance = client();
489
+ await seed(instance);
490
+ instance.VAULT_TOKEN = 'uncached-token';
491
+ const calls = [];
492
+ instance._token_status = operation => calls.push(operation);
493
+ await assert.rejects(instance.connect());
494
+ assert.deepEqual(calls, []);
495
+ });
496
+
497
+ for(const [lease, remaining, renew] of [
498
+ [24 * HOUR, 8 * HOUR, true],
499
+ [24 * HOUR, 8 * HOUR + 1, false],
500
+ [HOUR, 20 * 60, true],
501
+ [HOUR, 20 * 60 + 1, false],
502
+ [60, 20, true],
503
+ [60, 21, false],
504
+ ]) {
505
+ test(`proportional threshold: lease=${lease}, remaining=${remaining}`, async t => {
506
+ const {client, seed, read, requests} = await fixture(t, {ttl : remaining});
507
+ const instance = client();
508
+ await seed(instance, lease);
509
+ await instance.connect();
510
+ assert.equal(requests.some(req => req.path.endsWith('/renew-self')), renew);
511
+ assert.equal((await read(instance)).lease_duration, renew ? 24 * HOUR : lease);
512
+ });
513
+ }
514
+
515
+ test('lookups never reset the original lease duration', async t => {
516
+ const options = {ttl : 20 * HOUR};
517
+ const {client, seed, read, requests, remaining} = await fixture(t, options);
518
+ const instance = client();
519
+ await seed(instance, 24 * HOUR);
520
+ await instance.connect();
521
+ assert.equal((await read(instance)).lease_duration, 24 * HOUR);
522
+ await remaining(instance, 12 * HOUR);
523
+ await client().connect();
524
+ assert.equal((await read(instance)).lease_duration, 24 * HOUR);
525
+ await remaining(instance, 8 * HOUR);
526
+ await client().connect();
527
+ assert.equal(requests.filter(req => req.path.endsWith('/renew-self')).length, 1);
528
+ });
529
+
530
+ test('renewal updates the lease baseline to the newly granted duration', async t => {
531
+ const options = {ttl : 20 * 60};
532
+ const {client, seed, read, requests, remaining} = await fixture(t, options);
533
+ const instance = client();
534
+ await seed(instance, HOUR);
535
+ await instance.connect();
536
+ assert.equal((await read(instance)).lease_duration, 24 * HOUR);
537
+ await remaining(instance, 8 * HOUR);
538
+ await client().connect();
539
+ assert.equal(requests.filter(req => req.path.endsWith('/renew-self')).length, 2);
540
+ });
541
+
542
+ test('normal reuse never writes the token cache', async t => {
543
+ const {client, seed, file, requests} = await fixture(t);
544
+ const instance = client();
545
+ await seed(instance);
546
+ instance.VAULT_TOKEN = 'cached-token';
547
+ const before = fs.readFileSync(file, 'utf8');
548
+ const stat = fs.statSync(file);
549
+ instance._save_cached_token = () => { throw new Error('Reuse must not write'); };
550
+ assert.equal(await instance.connect(), 'cached-token');
551
+ assert.equal(fs.readFileSync(file, 'utf8'), before);
552
+ assert.equal(fs.statSync(file).ino, stat.ino);
553
+ assert.equal(fs.statSync(file).mtimeMs, stat.mtimeMs);
554
+ assert.deepEqual(requests, []);
555
+ });
556
+
557
+
558
+ test('lookup returns the lease duration and preserves a cached renewal baseline', async t => {
559
+ const {client, seed, requests} = await fixture(t, {ttl : HOUR, creation_ttl : 24 * HOUR});
560
+ const instance = client();
561
+ const uncached = await instance._lookup_token('environment-token');
562
+ assert.equal(uncached.lease_duration, 24 * HOUR);
563
+ assert.equal(uncached.ttl, HOUR);
564
+ await seed(instance, 12 * HOUR);
565
+ const cached = await instance._lookup_token('cached-token');
566
+ assert.equal(cached.lease_duration, 12 * HOUR);
567
+ assert.equal(requests.length, 2);
568
+ });
569
+
570
+ test('token_cache false skips all cache filesystem access', async t => {
571
+ const {client, requests, file} = await fixture(t);
572
+ const instance = client();
573
+ instance.rc.token_cache = false;
574
+ instance._token_cache_file = () => { throw new Error('Disabled cache must not access disk'); };
575
+ assert.equal(await instance.connect(), 'fresh-token');
576
+ assert.equal(fs.existsSync(file), false);
577
+ assert.deepEqual(requests.map(req => req.path), ['/v1/auth/jwt/login']);
578
+ });
579
+
580
+ test('token_cache false ignores existing tokens on disk without deleting them', async t => {
581
+ const {client, seed, requests, file} = await fixture(t);
582
+ const instance = client();
583
+ await seed(instance);
584
+ const before = fs.readFileSync(file, 'utf8');
585
+ instance.rc.token_cache = false;
586
+ assert.equal(await instance.connect(), 'fresh-token');
587
+ assert.equal(fs.readFileSync(file, 'utf8'), before);
588
+ const second = client();
589
+ second.rc.token_cache = false;
590
+ assert.equal(await second.connect(), 'fresh-token');
591
+ assert.equal(requests.filter(req => req.path.endsWith('/login')).length, 2);
592
+ });
593
+
594
+ test('token_cache false still respects an environment token without persisting it', async t => {
595
+ const {client, requests, file} = await fixture(t);
596
+ const instance = client();
597
+ instance.rc.token_cache = false;
598
+ instance.VAULT_TOKEN = 'environment-token';
599
+ assert.equal(await instance.connect(), 'environment-token');
600
+ assert.deepEqual(requests.map(req => req.path), ['/v1/auth/token/lookup-self']);
601
+ assert.equal(fs.existsSync(file), false);
602
+ });
603
+
604
+ test('real CLI reads token_cache false from .vauthrc', async t => {
605
+ const {client, file, requests} = await fixture(t);
606
+ const instance = client();
607
+ const home = path.dirname(path.dirname(path.dirname(file)));
608
+ const rc = path.join(home, '.vauthrc');
609
+ fs.writeFileSync(rc, JSON.stringify({vault_addr : instance.VAULT_ADDR, ...instance.rc, token_cache : false}));
610
+ const cli = path.resolve(__dirname, '..', 'index.js');
611
+ for(let i = 0; i < 2; i++) {
612
+ const output = await exec(process.execPath, [cli, 'login', '--source'], {
613
+ cwd : home,
614
+ env : {...process.env, HOME : home, VAUTHRC : rc, VAULT_TOKEN : '', SSH_AUTH_SOCK : process.env.SSH_AUTH_SOCK},
615
+ });
616
+ assert.ok(output.stdout.includes("export VAULT_TOKEN='fresh-token'"));
617
+ assert.ok(output.stderr.includes('Vauth token: generated'));
618
+ }
619
+ assert.equal(fs.existsSync(file), false);
620
+ assert.equal(requests.filter(req => req.path.endsWith('/login')).length, 2);
621
+ });
622
+
623
+ test('disk cache contains only armor, interoperable with ssh-agent-crypt CLI', async t => {
624
+ const {client, file} = await fixture(t);
625
+ const instance = client();
626
+ await instance.connect();
627
+ const armor = fs.readFileSync(file, 'utf8');
628
+ assert.match(armor, /^ssh-agent-crypt:v2:/);
629
+ assert.ok(!armor.includes('fresh-token'));
630
+ assert.ok(!armor.includes('lease_duration'));
631
+ assert.ok(!armor.includes('test-jwt'));
632
+ const cli = require.resolve('ssh-agent-crypt/bin/ssh-agent-crypt');
633
+ const decoded = spawnSync('bash', [cli, '-decrypt'], {input : armor, encoding : 'utf8'});
634
+ assert.equal(decoded.status, 0);
635
+ assert.equal(JSON.parse(decoded.stdout).token, 'fresh-token');
636
+ });
637
+
638
+ test('a plaintext cache is not accepted as a token cache', async t => {
639
+ const {client, file, requests} = await fixture(t);
640
+ const instance = client();
641
+ fs.mkdirSync(path.dirname(file), {recursive : true});
642
+ fs.writeFileSync(file, JSON.stringify({token : 'plaintext-token', lease_duration : 86400, expires_at : Date.now() + 86400000}));
643
+ assert.equal(await instance.connect(), 'fresh-token');
644
+ assert.deepEqual(requests.map(req => req.path), ['/v1/auth/jwt/login']);
645
+ assert.match(fs.readFileSync(file, 'utf8'), /^ssh-agent-crypt:v2:/);
646
+ });
647
+
648
+ test('without an SSH agent cache is skipped silently', async t => {
649
+ const {client, file} = await fixture(t);
650
+ const instance = client();
651
+ instance._token_cache_file = () => { throw new Error('No agent: no disk access'); };
652
+ const socket = process.env.SSH_AUTH_SOCK;
653
+ const original = console.error;
654
+ const messages = [];
655
+ try {
656
+ delete process.env.SSH_AUTH_SOCK;
657
+ instance.rc.token_cache = false;
658
+ console.error = (...args) => messages.push(format(...args));
659
+ assert.equal(await instance.connect(), 'fresh-token');
660
+ } finally {
661
+ process.env.SSH_AUTH_SOCK = socket;
662
+ console.error = original;
663
+ }
664
+ assert.equal(fs.existsSync(file), false);
665
+ assert.equal(fs.existsSync(file + '.next'), false);
666
+ assert.ok(!messages.some(message => message.includes('token not persisted')));
667
+ assert.ok(!messages.join('\n').includes('fresh-token'));
668
+ });
669
+
670
+ test('without an SSH agent existing armor is left untouched', async t => {
671
+ const {client, seed, file} = await fixture(t);
672
+ const instance = client();
673
+ await seed(instance);
674
+ const before = fs.readFileSync(file, 'utf8');
675
+ const socket = process.env.SSH_AUTH_SOCK;
676
+ try {
677
+ delete process.env.SSH_AUTH_SOCK;
678
+ instance.rc.token_cache = false;
679
+ await instance._save_cached_token('replacement-token', {lease_duration : 86400, renewable : true});
680
+ } finally {
681
+ process.env.SSH_AUTH_SOCK = socket;
682
+ }
683
+ assert.equal(fs.readFileSync(file, 'utf8'), before);
684
+ assert.equal(fs.existsSync(file + '.next'), false);
685
+ });
686
+
687
+
688
+ test('constructor disables token caching when no SSH agent is configured', async t => {
689
+ const {client, file} = await fixture(t);
690
+ const instance = client();
691
+ const home = path.dirname(path.dirname(path.dirname(file)));
692
+ const rc = path.join(home, '.vauthrc');
693
+ fs.writeFileSync(rc, JSON.stringify({vault_addr : instance.VAULT_ADDR, ...instance.rc, token_cache : true}));
694
+ const module_path = path.resolve(__dirname, '..', 'index.js');
695
+ const script = `const Vauth = require(${JSON.stringify(module_path)});
696
+ const instance = new Vauth();
697
+ process.stdout.write(String(instance.rc.token_cache));
698
+ instance.login().catch(() => process.exit(1));`;
699
+ const output = await exec(process.execPath, ['-e', script], {
700
+ cwd : home,
701
+ env : {...process.env, HOME : home, VAUTHRC : rc, VAULT_TOKEN : '', SSH_AUTH_SOCK : ''},
702
+ });
703
+ assert.equal(output.stdout.trim(), 'false');
704
+ assert.ok(!output.stderr.includes('token not persisted'));
705
+ assert.equal(fs.existsSync(file), false);
706
+ });
707
+
708
+
709
+ for(const selected of ['identity', 'mail', 'default']) {
710
+ test(`cache encryption selects ${selected}; decryption uses embedded fingerprint`, async t => {
711
+ const {client, file} = await fixture(t);
712
+ const instance = client();
713
+ const directory = path.dirname(path.dirname(path.dirname(file)));
714
+ const key = path.join(directory, 'selected-key');
715
+ const comment = 'selected-cache@test';
716
+ assert.equal(spawnSync('ssh-keygen', ['-q', '-t', 'ed25519', '-N', '', '-C', comment, '-f', key]).status, 0);
717
+ assert.equal(spawnSync('ssh-add', [key]).status, 0);
718
+ try {
719
+ if(selected === 'identity') {
720
+ process.env.VAUTH_USER_IDENTITY = comment;
721
+ process.env.VAUTH_USER_MAIL = 'missing-key@test';
722
+ } else if(selected === 'mail') {
723
+ process.env.VAUTH_USER_MAIL = comment;
724
+ }
725
+ assert.equal(await instance.connect(), 'fresh-token');
726
+ const fingerprint = spawnSync('ssh-keygen', ['-lf', key + '.pub'], {encoding : 'utf8'}).stdout.split(/\s+/)[1];
727
+ const armor = fs.readFileSync(file, 'utf8');
728
+ assert.equal(armor.startsWith(`ssh-agent-crypt:v2:${fingerprint}.`), selected !== 'default');
729
+ delete process.env.VAUTH_USER_IDENTITY;
730
+ delete process.env.VAUTH_USER_MAIL;
731
+ assert.equal(await client().connect(), 'fresh-token');
732
+ } finally {
733
+ spawnSync('ssh-add', ['-d', key]);
734
+ delete process.env.VAUTH_USER_IDENTITY;
735
+ delete process.env.VAUTH_USER_MAIL;
736
+ }
737
+ });
738
+ }