node-vault-client 3.1.0 → 3.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.
@@ -16,7 +16,13 @@ const safeMessage = require('./safeMessage');
16
16
  const { trimTrailingSlashes } = require('./slashes');
17
17
 
18
18
  const noop = () => {};
19
- const vaultInstances = {};
19
+ // Named instances. A Map, so a name such as "toString" or "constructor" is not found on
20
+ // Object.prototype; keyed exactly as an object property would be, so boot(1) and get('1') still
21
+ // meet and an object that converts to a symbol is keyed by that symbol.
22
+ const vaultInstances = new Map();
23
+ const instanceKey = (name) => Reflect.ownKeys({ [name]: undefined })[0];
24
+ // clear(name) with a non-string name warns once per process (#239); see VaultClient.clear().
25
+ let warnedClearNonString = false;
20
26
 
21
27
  // Functions, and objects that are not plain data (an undici dispatcher, a logger class
22
28
  // instance), compare by identity. Their own enumerable keys say nothing about what they do:
@@ -187,6 +193,32 @@ function assertSafeKvPath(path, allowTrailingSlash) {
187
193
  return trimmed;
188
194
  }
189
195
 
196
+ /**
197
+ * clear() with a name that is not a string closes every instance, which is rarely what the
198
+ * caller meant (#239): `clear(42)`, or `clear` passed as a callback that receives an exit code
199
+ * or a resolved value. The behaviour stays as it was; this makes it visible, once per process.
200
+ * It names only the argument's type, never its value, which could be anything (#239).
201
+ * Node delivers warnings on the next tick, so one emitted from an 'exit' listener is never shown;
202
+ * --no-deprecation silences it, as for any DeprecationWarning.
203
+ * @param {*} name
204
+ * @returns {void}
205
+ * @private
206
+ */
207
+ function warnClearNonString(name) {
208
+ if (warnedClearNonString) {
209
+ return;
210
+ }
211
+ warnedClearNonString = true;
212
+ const type = typeof name;
213
+ const what = name === null ? 'null' : (type === 'object' ? 'an object' : `a ${type}`);
214
+ process.emitWarning(
215
+ `VaultClient.clear() was called with ${what} ` +
216
+ 'instead of a string name, which clears every instance not named by a symbol, as it always ' +
217
+ 'has. Call clear() with no argument to clear them all, or pass the string name of one instance.',
218
+ { type: 'DeprecationWarning', code: 'NVC_DEP_CLEAR_NON_STRING' }
219
+ );
220
+ }
221
+
190
222
  class VaultClient {
191
223
 
192
224
  /**
@@ -194,10 +226,12 @@ class VaultClient {
194
226
  *
195
227
  * @param {Object} options
196
228
  * @param {Object} options.api
197
- * @param {String} options.api.url - the url of the vault server
229
+ * @param {String} options.api.url - the url of the vault server. Plain `http://` to a host
230
+ * other than this machine logs a warning; see "Plain `http://` api.url" in the README
198
231
  * @param {String} [options.api.apiVersion='v1']
199
232
  * @param {Object} [options.api.requestOptions] - extra options merged into every HTTP
200
233
  * request (e.g. an undici `dispatcher` for a proxy/SOCKS agent or custom TLS/CA trust).
234
+ * Redirects are followed only within the origin of `api.url` unless it sets `redirect`.
201
235
  * See {@link VaultApiClient#constructor}.
202
236
  * @param {number} [options.api.timeout=120000] - milliseconds a request may take, response
203
237
  * headers, body and redirects included, before it rejects with RequestTimeoutError.
@@ -220,6 +254,10 @@ class VaultClient {
220
254
  * @param {number} [options.auth.renewalIncrement] - Seconds of extra TTL to request on each
221
255
  * renewal, sent as `increment` to `auth/token/renew-self`. Vault caps it at the token's
222
256
  * max TTL. Omitted by default, which lets Vault apply the token's own period.
257
+ * @param {boolean} [options.auth.revokeOnClose=false] - Set `true` to revoke the held token
258
+ * (`auth/token/revoke-self`) when {@link VaultClient#close} runs. The token the
259
+ * `token` backend was configured with is revoked too, so only enable it when that
260
+ * token is not shared with anything else.
223
261
  * @param {Object|false} options.logger - Logger that supports "error", "info", "warn", "trace", "debug" methods. Uses `console` by default. Pass `false` to disable logging.
224
262
  */
225
263
  constructor(options) {
@@ -254,6 +292,13 @@ class VaultClient {
254
292
  // `namespace` to `api.namespace`.
255
293
  this.__auth.configureRenewal(options.auth);
256
294
 
295
+ if (options.auth && options.auth.revokeOnClose === true && options.auth.type === 'token') {
296
+ this.__log.warn(
297
+ 'auth.revokeOnClose revokes the token from the configuration itself on close(); ' +
298
+ 'enable it only when that token is not shared with anything else'
299
+ );
300
+ }
301
+
257
302
  // KV v2 support
258
303
  const kvOpts = (options.api && options.api.kv) || {};
259
304
  const autoDetect = kvOpts.autoDetect === true;
@@ -304,9 +349,10 @@ class VaultClient {
304
349
  throw new errors.InvalidArgumentsError('Options should be provided');
305
350
  }
306
351
 
307
- let instance = vaultInstances[name];
352
+ let instance = vaultInstances.get(instanceKey(name));
308
353
  if (instance === undefined) {
309
- vaultInstances[name] = instance = new VaultClient(options);
354
+ instance = new VaultClient(options);
355
+ vaultInstances.set(instanceKey(name), instance);
310
356
  instance.__bootDigests = optionsDigests(options);
311
357
  instance.__bootWarned = new Set();
312
358
  return instance;
@@ -338,7 +384,7 @@ class VaultClient {
338
384
  * @return {VaultClient}
339
385
  */
340
386
  static get(name) {
341
- let instance = vaultInstances[name];
387
+ let instance = vaultInstances.get(instanceKey(name));
342
388
 
343
389
  if (instance === undefined) {
344
390
  throw new errors.InvalidArgumentsError('Invalid instance name');
@@ -352,22 +398,33 @@ class VaultClient {
352
398
  *
353
399
  * If no name passed all named instances will be cleared.
354
400
  *
401
+ * Only a string names one instance. Any other argument (a number, `null`, an object) clears
402
+ * every instance, as it always has, and emits a one-time `DeprecationWarning`
403
+ * (`NVC_DEP_CLEAR_NON_STRING`): call `clear()` with no argument to clear them all.
404
+ *
355
405
  * @param {String} [name] - Vault instance name, all instances will be cleared if no name were passed
356
406
  */
357
407
  static clear(name) {
358
408
  if (typeof name === 'string') {
359
- const instance = vaultInstances[name];
409
+ const instance = vaultInstances.get(name);
360
410
  if (instance !== undefined) {
361
411
  instance.close();
362
- delete vaultInstances[name];
412
+ vaultInstances.delete(name);
363
413
  }
364
- } else {
365
- for (let k in vaultInstances) {
366
- if (Object.hasOwn(vaultInstances, k)) {
367
- vaultInstances[k].close();
368
- delete vaultInstances[k];
369
- }
414
+ return;
415
+ }
416
+ if (name !== undefined) {
417
+ warnClearNonString(name);
418
+ }
419
+ for (const [key, instance] of vaultInstances) {
420
+ // Kept for compatibility: the object this replaced was walked with for...in,
421
+ // which skips symbol keys, so a symbol-named instance has always stayed
422
+ // registered, with its renewal timer, through clear(), and callers may rely on it.
423
+ if (typeof key === 'symbol') {
424
+ continue;
370
425
  }
426
+ instance.close();
427
+ vaultInstances.delete(key);
371
428
  }
372
429
  }
373
430
 
@@ -378,14 +435,34 @@ class VaultClient {
378
435
  * alive for renewable tokens. Call this once you are done with the client so a
379
436
  * short-lived script can exit on its own. Safe to call when no timer is armed and
380
437
  * safe to call multiple times. After calling `close()` the client may still be used;
381
- * the next operation that fetches a renewable token will arm a new refresh timer.
438
+ * the next operation that fetches a renewable token will arm a new refresh timer. A
439
+ * login already in flight when `close()` runs still resolves, but arms no timer, and
440
+ * neither does a call that joins that login after `close()`.
441
+ *
442
+ * With `auth.revokeOnClose: true` the held token is also revoked on the Vault server
443
+ * (`auth/token/revoke-self`): the one held now, or the one a login still in flight is
444
+ * about to produce. The revocation request keeps the event loop open until it lands, so
445
+ * a short-lived script that ignores the return value still completes it.
382
446
  *
383
- * @returns {void}
447
+ * @returns {Promise<void>|null|undefined} `null` (or `undefined` for an auth provider
448
+ * without revocation support) when there is nothing to revoke, otherwise a
449
+ * promise for the revocation. It never rejects: a failed revocation is logged and
450
+ * the token is left live on the Vault server.
384
451
  */
385
452
  close() {
453
+ if (this.__auth && typeof this.__auth.__markClosed === 'function') {
454
+ // Directly as well: a subclass may override cancelTokenRefresh() without super.
455
+ this.__auth.__markClosed();
456
+ }
386
457
  if (this.__auth && typeof this.__auth.cancelTokenRefresh === 'function') {
387
458
  this.__auth.cancelTokenRefresh();
388
459
  }
460
+ if (this.__auth && typeof this.__auth.revokeSelfOnClose === 'function') {
461
+ // null unless auth.revokeOnClose started a revocation. Fire-and-forget when
462
+ // ignored (clear() does that): the request holds the event loop open until it
463
+ // lands. Returned so a caller that wants certainty can await it.
464
+ return this.__auth.revokeSelfOnClose();
465
+ }
389
466
  }
390
467
 
391
468
  /**
@@ -468,11 +545,39 @@ class VaultClient {
468
545
  * @private
469
546
  */
470
547
  __detectMount(path) {
471
- return this.__auth.getAuthToken()
472
- .then((token) => {
473
- const detectPath = `sys/internal/ui/mounts/${path}`;
474
- return this.__api.makeRequest('GET', detectPath, null, this.getHeaders(token));
548
+ return this.__withAuthToken((token) => {
549
+ const detectPath = `sys/internal/ui/mounts/${path}`;
550
+ return this.__api.makeRequest('GET', detectPath, null, this.getHeaders(token));
551
+ });
552
+ }
553
+
554
+ /**
555
+ * Calls `send` with the current auth token. When Vault answers 403 and the token turns out
556
+ * to be one Vault no longer accepts (revoked server-side), logs in again and calls `send`
557
+ * once more; see {@link VaultBaseAuth#checkRejectedToken}. A policy denial and a 403 under
558
+ * `token` auth surface that 403; otherwise the retry's own result or error is returned.
559
+ *
560
+ * @param {function(AuthToken): Promise<*>} send
561
+ * @returns {Promise<*>}
562
+ * @private
563
+ */
564
+ __withAuthToken(send) {
565
+ return this.__auth.getAuthToken().then((token) => send(token).catch((err) => {
566
+ if (!(err instanceof errors.VaultHttpError) || err.statusCode !== 403
567
+ || typeof this.__auth.checkRejectedToken !== 'function') {
568
+ throw err;
569
+ }
570
+ return this.__auth.checkRejectedToken(token).then((retry) => {
571
+ if (!retry) {
572
+ throw err;
573
+ }
574
+ // What the retry got, success or failure, is the request's outcome now: a login
575
+ // that fails, a 404 or a timeout says more than the 403 of a token already gone.
576
+ return this.__auth.getAuthToken().then(send);
577
+ }, () => {
578
+ throw err;
475
579
  });
580
+ }));
476
581
  }
477
582
 
478
583
  // -------------------------------------------------------------------------
@@ -517,50 +622,49 @@ class VaultClient {
517
622
  }
518
623
  }
519
624
 
520
- return this.__auth.getAuthToken()
521
- .then((token) => {
522
- const headers = Object.assign({}, this.getHeaders(token), extraHeaders || {});
625
+ return this.__withAuthToken((token) => {
626
+ const headers = Object.assign({}, this.getHeaders(token), extraHeaders || {});
523
627
 
524
- if (raw) {
525
- return this.__api.makeRequest(method, path, data, headers)
526
- .then((body) => ({ body, version: undefined, mount: undefined, apiPath: path }));
527
- }
628
+ if (raw) {
629
+ return this.__api.makeRequest(method, path, data, headers)
630
+ .then((body) => ({ body, version: undefined, mount: undefined, apiPath: path }));
631
+ }
528
632
 
529
- return this.__resolver.resolve(path)
530
- .then(({ mount, version }) => {
531
- if (v2Only && version !== 2) {
532
- throw new errors.UnsupportedOperationError(
533
- `Operation "${op}" is only supported on KV v2 mounts. ` +
534
- `Mount "${mount}" is not a KV v2 engine.`
535
- );
536
- }
537
-
538
- let apiPath;
539
- if (version === 2) {
540
- // Split the path into mount + logical sub-path and rewrite for KV v2
541
- const logicalPath = this.__logicalPath(path, mount);
542
- apiPath = rewritePath(version, op, mount, logicalPath);
543
- } else {
544
- // v1 / disabled: preserve the caller's literal path byte-for-byte,
545
- // including any trailing slash, to maintain the old wire behavior.
546
- apiPath = path;
547
- }
548
-
549
- // Data envelope: wrapData forces { data } on every engine version
550
- // (merge-patch update() semantics — v1 mounts reject PATCH anyway);
551
- // otherwise only v2 write/update wrap non-null data.
552
- let requestData = data;
553
- if (wrapData) {
554
- requestData = { data };
555
- } else if (version === 2 && (op === 'write' || op === 'update')
556
- && data !== null && data !== undefined) {
557
- requestData = { data };
558
- }
559
-
560
- return this.__api.makeRequest(method, apiPath, requestData, headers)
561
- .then((body) => ({ body, version, mount, apiPath }));
562
- });
563
- });
633
+ return this.__resolver.resolve(path)
634
+ .then(({ mount, version }) => {
635
+ if (v2Only && version !== 2) {
636
+ throw new errors.UnsupportedOperationError(
637
+ `Operation "${op}" is only supported on KV v2 mounts. ` +
638
+ `Mount "${mount}" is not a KV v2 engine.`
639
+ );
640
+ }
641
+
642
+ let apiPath;
643
+ if (version === 2) {
644
+ // Split the path into mount + logical sub-path and rewrite for KV v2
645
+ const logicalPath = this.__logicalPath(path, mount);
646
+ apiPath = rewritePath(version, op, mount, logicalPath);
647
+ } else {
648
+ // v1 / disabled: preserve the caller's literal path byte-for-byte,
649
+ // including any trailing slash, to maintain the old wire behavior.
650
+ apiPath = path;
651
+ }
652
+
653
+ // Data envelope: wrapData forces { data } on every engine version
654
+ // (merge-patch update() semantics — v1 mounts reject PATCH anyway);
655
+ // otherwise only v2 write/update wrap non-null data.
656
+ let requestData = data;
657
+ if (wrapData) {
658
+ requestData = { data };
659
+ } else if (version === 2 && (op === 'write' || op === 'update')
660
+ && data !== null && data !== undefined) {
661
+ requestData = { data };
662
+ }
663
+
664
+ return this.__api.makeRequest(method, apiPath, requestData, headers)
665
+ .then((body) => ({ body, version, mount, apiPath }));
666
+ });
667
+ });
564
668
  }
565
669
 
566
670
  /**
@@ -56,19 +56,25 @@ class AuthToken {
56
56
  const hasPreciseExpiry = unadjustedExpiresAtMilliseconds !== null;
57
57
  const expiresAtMilliseconds = AuthToken.__resolveExpiresAtMilliseconds(data, unadjustedExpiresAtMilliseconds);
58
58
 
59
- return new AuthToken(
59
+ const renewable = data.renewable !== undefined ? data.renewable : false;
60
+ const authToken = new AuthToken(
60
61
  data.id,
61
62
  data.accessor,
62
63
  data.creation_time,
63
64
  null,
64
65
  data.explicit_max_ttl,
65
66
  data.num_uses,
66
- data.renewable !== undefined ? data.renewable : false,
67
+ renewable,
67
68
  expiresAtMilliseconds,
68
69
  data.ttl,
69
70
  hasPreciseExpiry,
70
71
  unadjustedExpiresAtMilliseconds
71
72
  );
73
+ // Without an expiry the token is not renewable. Remember that Vault said it was, so the
74
+ // renewal timer can say why it stops (VaultBaseAuth); a ttl of 0 means it never expires.
75
+ authToken.__renewableWithoutExpiry = Boolean(renewable) && data.ttl !== 0
76
+ && expiresAtMilliseconds === null;
77
+ return authToken;
72
78
  }
73
79
 
74
80
  /**
@@ -85,7 +91,8 @@ class AuthToken {
85
91
  * @private
86
92
  * @param {Object} data - the `data` object of a token lookup response
87
93
  * @param {number|null} expireTime - parsed Vault expiry in milliseconds
88
- * @returns {int|null} client expiry in milliseconds, or null when ttl is 0 without expire_time
94
+ * @returns {int|null} client expiry in milliseconds, or null when there is none to use: ttl 0
95
+ * without expire_time, or neither expire_time nor a numeric ttl and creation time
89
96
  */
90
97
  static __resolveExpiresAtMilliseconds(data, expireTime) {
91
98
  const networkLatency = 60;
@@ -106,7 +113,9 @@ class AuthToken {
106
113
  // Fallback for responses without a usable `expire_time`.
107
114
  const creationTime = parseInt(data.last_renewal_time !== undefined ? data.last_renewal_time : data.creation_time, 10);
108
115
  const ttl = data.ttl > networkLatency ? data.ttl - networkLatency : data.ttl;
109
- return (creationTime + parseInt(ttl, 10)) * 1000;
116
+ const expiresAt = (creationTime + parseInt(ttl, 10)) * 1000;
117
+ // A missing ttl or creation time made this NaN, a deadline that never passes.
118
+ return Number.isFinite(expiresAt) ? expiresAt : null;
110
119
  }
111
120
 
112
121
  /**