node-vault-client 3.0.2 → 3.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.
@@ -16,53 +16,113 @@ const safeMessage = require('./safeMessage');
16
16
  const { trimTrailingSlashes } = require('./slashes');
17
17
 
18
18
  const noop = () => {};
19
- const vaultInstances = {};
20
-
21
- // Only credential providers compare by identity: a different provider means the client would
22
- // authenticate differently. Other functions (logger methods, an IAM `credentials` provider
23
- // chain) are often re-created per call with the same behaviour, so they all fingerprint alike.
24
- const IDENTITY_COMPARED_KEYS = new Set(['jwtProvider', 'distributedClaimAccessTokenProvider']);
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;
26
+
27
+ // Functions, and objects that are not plain data (an undici dispatcher, a logger class
28
+ // instance), compare by identity. Their own enumerable keys say nothing about what they do:
29
+ // structurally, any two dispatchers look alike, and so do two loggers built from different
30
+ // functions, so a boot() that passed a different one was ignored without a warning (#224).
31
+ // A Buffer, Date, URL or RegExp compares by value, so an equal one re-created per call still
32
+ // matches. No user code (getters, toJSON, Proxy traps) decides the comparison.
33
+ //
34
+ // Per-process ids, in a WeakMap so that fingerprinting never keeps an option (or the closure
35
+ // state it captures) alive.
36
+ const identityIds = new WeakMap();
37
+ let nextIdentityId = 0;
38
+
39
+ function identityId(value) {
40
+ if (!identityIds.has(value)) {
41
+ identityIds.set(value, ++nextIdentityId);
42
+ }
43
+ return identityIds.get(value);
44
+ }
25
45
 
26
- // Per-process ids for those providers. A WeakMap so that fingerprinting never keeps a
27
- // provider (or the closure state it captures) alive.
28
- const functionIds = new WeakMap();
29
- let nextFunctionId = 0;
46
+ /**
47
+ * How an object takes part in the fingerprint: 'array' and 'plain' by content, 'value' by
48
+ * {@link valueOf}, anything else by identity. Plain includes a null prototype and an
49
+ * Object.prototype from another realm (vm, a test runner's structuredClone). A Proxy whose
50
+ * traps throw compares by identity rather than making boot() throw.
51
+ */
52
+ function kindOf(value) {
53
+ try {
54
+ if (Array.isArray(value)) {
55
+ return 'array';
56
+ }
57
+ if (Buffer.isBuffer(value) || value instanceof Date || value instanceof URL || value instanceof RegExp) {
58
+ return 'value';
59
+ }
60
+ const proto = Object.getPrototypeOf(value);
61
+ return proto === null || Object.getPrototypeOf(proto) === null ? 'plain' : 'identity';
62
+ } catch {
63
+ return 'identity';
64
+ }
65
+ }
30
66
 
31
- function functionId(fn) {
32
- if (!functionIds.has(fn)) {
33
- functionIds.set(fn, ++nextFunctionId);
67
+ function valueOf(value) {
68
+ if (Buffer.isBuffer(value)) {
69
+ return `buffer:${value.toString('base64')}`;
70
+ }
71
+ if (value instanceof Date) {
72
+ return `date:${value.getTime()}`;
34
73
  }
35
- return functionIds.get(fn);
74
+ return `${value instanceof URL ? 'url' : 'regexp'}:${JSON.stringify(String(value))}`;
36
75
  }
37
76
 
38
77
  /**
39
- * Order-insensitive canonical form of the boot options, hashed so that no credential is
40
- * retained just to compare two boot() calls. `jwtProvider` and
41
- * `distributedClaimAccessTokenProvider` compare by identity; every other function is `fn`.
78
+ * Order-insensitive canonical form of a boot option, hashed so that no credential is retained
79
+ * just to compare two boot() calls. Plain objects and arrays compare by content, functions and
80
+ * other objects by identity, and a Buffer, Date, URL or RegExp by value.
42
81
  */
43
- function fingerprintOptions(value, seen, key) {
82
+ function fingerprintOptions(value, seen) {
44
83
  if (typeof value === 'function') {
45
- return IDENTITY_COMPARED_KEYS.has(key) ? `fn#${functionId(value)}` : 'fn';
84
+ return `fn#${identityId(value)}`;
85
+ }
86
+ if (typeof value === 'bigint') {
87
+ return `${value}n`;
46
88
  }
47
89
  if (value === null || typeof value !== 'object') {
48
90
  return JSON.stringify(value) || String(value);
49
91
  }
92
+ const kind = kindOf(value);
93
+ if (kind === 'value') {
94
+ return valueOf(value);
95
+ }
96
+ if (kind === 'identity') {
97
+ return `obj#${identityId(value)}`;
98
+ }
50
99
  seen = seen || new Set();
51
100
  if (seen.has(value)) {
52
101
  return 'circular';
53
102
  }
54
103
  seen.add(value);
55
- if (Array.isArray(value)) {
104
+ if (kind === 'array') {
56
105
  return `[${value.map((item) => fingerprintOptions(item, seen)).join(',')}]`;
57
106
  }
58
107
  const body = Object.keys(value).sort()
59
- .map((key) => `${JSON.stringify(key)}:${fingerprintOptions(value[key], seen, key)}`)
108
+ .map((key) => `${JSON.stringify(key)}:${fingerprintOptions(value[key], seen)}`)
60
109
  .join(',');
61
110
  return `{${body}}`;
62
111
  }
63
112
 
64
- function optionsDigest(options) {
65
- return crypto.createHash('sha256').update(fingerprintOptions(options)).digest('hex');
113
+ /** One digest per top-level option, so a mismatch can name the options that differ. */
114
+ function optionsDigests(options) {
115
+ const digests = new Map();
116
+ for (const key of Object.keys(options)) {
117
+ digests.set(key, crypto.createHash('sha256').update(fingerprintOptions(options[key])).digest('hex'));
118
+ }
119
+ return digests;
120
+ }
121
+
122
+ /** The top-level options whose digests differ, sorted; empty when the options match. */
123
+ function differingOptions(before, after) {
124
+ const keys = new Set([...before.keys(), ...after.keys()]);
125
+ return [...keys].filter((key) => before.get(key) !== after.get(key)).sort();
66
126
  }
67
127
 
68
128
  // fetch() parses the joined URL per the WHATWG URL Standard: '?' starts the query, '#' the
@@ -133,6 +193,32 @@ function assertSafeKvPath(path, allowTrailingSlash) {
133
193
  return trimmed;
134
194
  }
135
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
+
136
222
  class VaultClient {
137
223
 
138
224
  /**
@@ -140,11 +226,16 @@ class VaultClient {
140
226
  *
141
227
  * @param {Object} options
142
228
  * @param {Object} options.api
143
- * @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
144
231
  * @param {String} [options.api.apiVersion='v1']
145
232
  * @param {Object} [options.api.requestOptions] - extra options merged into every HTTP
146
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`.
147
235
  * See {@link VaultApiClient#constructor}.
236
+ * @param {number} [options.api.timeout=120000] - milliseconds a request may take, response
237
+ * headers, body and redirects included, before it rejects with RequestTimeoutError.
238
+ * `0` turns the limit off.
148
239
  * @param {Object} [options.api.kv] - KV engine options
149
240
  * @param {boolean} [options.api.kv.autoDetect=false] - When true, auto-detect KV version per mount
150
241
  * @param {Object} [options.api.engines] - Mount-to-version overrides, e.g. { secret: 2, legacy: 1 }
@@ -227,8 +318,15 @@ class VaultClient {
227
318
  * `options` are used only when the instance is first created. A later call for a name
228
319
  * that already exists returns the existing instance and ignores the options it was
229
320
  * given; when those options differ from the ones the instance was booted with, that is
230
- * logged as a warning rather than passing silently. Use {@link VaultClient.get} to
231
- * fetch an existing instance, or {@link VaultClient.clear} to replace one.
321
+ * logged as a warning, naming the top-level options that differ, rather than passing
322
+ * silently. The warning is logged once per instance for each set of differing options.
323
+ * Use {@link VaultClient.get} to fetch an existing instance, or {@link VaultClient.clear}
324
+ * to replace one.
325
+ *
326
+ * Options are compared as hashes, so no credential is kept for the comparison. Plain
327
+ * objects and arrays compare by content, in any key order. Functions and other objects (a
328
+ * logger class instance, an undici dispatcher) compare by identity, and a Buffer, Date,
329
+ * URL or RegExp by value.
232
330
  *
233
331
  * @param {String} name - Vault instance name
234
332
  * @param {Object} options - options for {@link VaultClient#constructor}. Required on
@@ -240,17 +338,26 @@ class VaultClient {
240
338
  throw new errors.InvalidArgumentsError('Options should be provided');
241
339
  }
242
340
 
243
- let instance = vaultInstances[name];
341
+ let instance = vaultInstances.get(instanceKey(name));
244
342
  if (instance === undefined) {
245
- vaultInstances[name] = instance = new VaultClient(options);
246
- instance.__bootDigest = optionsDigest(options);
247
- } else if (instance.__bootDigest !== optionsDigest(options)) {
343
+ instance = new VaultClient(options);
344
+ vaultInstances.set(instanceKey(name), instance);
345
+ instance.__bootDigests = optionsDigests(options);
346
+ instance.__bootWarned = new Set();
347
+ return instance;
348
+ }
349
+
350
+ // Once per instance for each set of differing options: code that re-creates its logger
351
+ // for every boot() call would otherwise log this on every call.
352
+ const differing = differingOptions(instance.__bootDigests, optionsDigests(options)).join(', ');
353
+ if (differing !== '' && !instance.__bootWarned.has(differing)) {
354
+ instance.__bootWarned.add(differing);
248
355
  instance.__log.warn(
249
356
  'VaultClient.boot("%s") was called with different options than the existing ' +
250
357
  'instance was booted with; the existing instance is returned and these options ' +
251
358
  'are ignored. Use VaultClient.get("%s") to fetch it, or VaultClient.clear("%s") ' +
252
- 'first to replace it.',
253
- name, name, name
359
+ 'first to replace it. Differing options: %s.',
360
+ name, name, name, differing
254
361
  );
255
362
  }
256
363
  return instance;
@@ -266,7 +373,7 @@ class VaultClient {
266
373
  * @return {VaultClient}
267
374
  */
268
375
  static get(name) {
269
- let instance = vaultInstances[name];
376
+ let instance = vaultInstances.get(instanceKey(name));
270
377
 
271
378
  if (instance === undefined) {
272
379
  throw new errors.InvalidArgumentsError('Invalid instance name');
@@ -280,22 +387,33 @@ class VaultClient {
280
387
  *
281
388
  * If no name passed all named instances will be cleared.
282
389
  *
390
+ * Only a string names one instance. Any other argument (a number, `null`, an object) clears
391
+ * every instance, as it always has, and emits a one-time `DeprecationWarning`
392
+ * (`NVC_DEP_CLEAR_NON_STRING`): call `clear()` with no argument to clear them all.
393
+ *
283
394
  * @param {String} [name] - Vault instance name, all instances will be cleared if no name were passed
284
395
  */
285
396
  static clear(name) {
286
397
  if (typeof name === 'string') {
287
- const instance = vaultInstances[name];
398
+ const instance = vaultInstances.get(name);
288
399
  if (instance !== undefined) {
289
400
  instance.close();
290
- delete vaultInstances[name];
401
+ vaultInstances.delete(name);
291
402
  }
292
- } else {
293
- for (let k in vaultInstances) {
294
- if (Object.hasOwn(vaultInstances, k)) {
295
- vaultInstances[k].close();
296
- delete vaultInstances[k];
297
- }
403
+ return;
404
+ }
405
+ if (name !== undefined) {
406
+ warnClearNonString(name);
407
+ }
408
+ for (const [key, instance] of vaultInstances) {
409
+ // Kept for compatibility: the object this replaced was walked with for...in,
410
+ // which skips symbol keys, so a symbol-named instance has always stayed
411
+ // registered, with its renewal timer, through clear(), and callers may rely on it.
412
+ if (typeof key === 'symbol') {
413
+ continue;
298
414
  }
415
+ instance.close();
416
+ vaultInstances.delete(key);
299
417
  }
300
418
  }
301
419
 
@@ -306,11 +424,17 @@ class VaultClient {
306
424
  * alive for renewable tokens. Call this once you are done with the client so a
307
425
  * short-lived script can exit on its own. Safe to call when no timer is armed and
308
426
  * safe to call multiple times. After calling `close()` the client may still be used;
309
- * the next operation that fetches a renewable token will arm a new refresh timer.
427
+ * the next operation that fetches a renewable token will arm a new refresh timer. A
428
+ * login already in flight when `close()` runs still resolves, but arms no timer, and
429
+ * neither does a call that joins that login after `close()`.
310
430
  *
311
431
  * @returns {void}
312
432
  */
313
433
  close() {
434
+ if (this.__auth && typeof this.__auth.__markClosed === 'function') {
435
+ // Directly as well: a subclass may override cancelTokenRefresh() without super.
436
+ this.__auth.__markClosed();
437
+ }
314
438
  if (this.__auth && typeof this.__auth.cancelTokenRefresh === 'function') {
315
439
  this.__auth.cancelTokenRefresh();
316
440
  }
@@ -396,11 +520,39 @@ class VaultClient {
396
520
  * @private
397
521
  */
398
522
  __detectMount(path) {
399
- return this.__auth.getAuthToken()
400
- .then((token) => {
401
- const detectPath = `sys/internal/ui/mounts/${path}`;
402
- return this.__api.makeRequest('GET', detectPath, null, this.getHeaders(token));
523
+ return this.__withAuthToken((token) => {
524
+ const detectPath = `sys/internal/ui/mounts/${path}`;
525
+ return this.__api.makeRequest('GET', detectPath, null, this.getHeaders(token));
526
+ });
527
+ }
528
+
529
+ /**
530
+ * Calls `send` with the current auth token. When Vault answers 403 and the token turns out
531
+ * to be one Vault no longer accepts (revoked server-side), logs in again and calls `send`
532
+ * once more; see {@link VaultBaseAuth#checkRejectedToken}. A policy denial and a 403 under
533
+ * `token` auth surface that 403; otherwise the retry's own result or error is returned.
534
+ *
535
+ * @param {function(AuthToken): Promise<*>} send
536
+ * @returns {Promise<*>}
537
+ * @private
538
+ */
539
+ __withAuthToken(send) {
540
+ return this.__auth.getAuthToken().then((token) => send(token).catch((err) => {
541
+ if (!(err instanceof errors.VaultHttpError) || err.statusCode !== 403
542
+ || typeof this.__auth.checkRejectedToken !== 'function') {
543
+ throw err;
544
+ }
545
+ return this.__auth.checkRejectedToken(token).then((retry) => {
546
+ if (!retry) {
547
+ throw err;
548
+ }
549
+ // What the retry got, success or failure, is the request's outcome now: a login
550
+ // that fails, a 404 or a timeout says more than the 403 of a token already gone.
551
+ return this.__auth.getAuthToken().then(send);
552
+ }, () => {
553
+ throw err;
403
554
  });
555
+ }));
404
556
  }
405
557
 
406
558
  // -------------------------------------------------------------------------
@@ -445,50 +597,49 @@ class VaultClient {
445
597
  }
446
598
  }
447
599
 
448
- return this.__auth.getAuthToken()
449
- .then((token) => {
450
- const headers = Object.assign({}, this.getHeaders(token), extraHeaders || {});
600
+ return this.__withAuthToken((token) => {
601
+ const headers = Object.assign({}, this.getHeaders(token), extraHeaders || {});
451
602
 
452
- if (raw) {
453
- return this.__api.makeRequest(method, path, data, headers)
454
- .then((body) => ({ body, version: undefined, mount: undefined, apiPath: path }));
455
- }
603
+ if (raw) {
604
+ return this.__api.makeRequest(method, path, data, headers)
605
+ .then((body) => ({ body, version: undefined, mount: undefined, apiPath: path }));
606
+ }
456
607
 
457
- return this.__resolver.resolve(path)
458
- .then(({ mount, version }) => {
459
- if (v2Only && version !== 2) {
460
- throw new errors.UnsupportedOperationError(
461
- `Operation "${op}" is only supported on KV v2 mounts. ` +
462
- `Mount "${mount}" is not a KV v2 engine.`
463
- );
464
- }
465
-
466
- let apiPath;
467
- if (version === 2) {
468
- // Split the path into mount + logical sub-path and rewrite for KV v2
469
- const logicalPath = this.__logicalPath(path, mount);
470
- apiPath = rewritePath(version, op, mount, logicalPath);
471
- } else {
472
- // v1 / disabled: preserve the caller's literal path byte-for-byte,
473
- // including any trailing slash, to maintain the old wire behavior.
474
- apiPath = path;
475
- }
476
-
477
- // Data envelope: wrapData forces { data } on every engine version
478
- // (merge-patch update() semantics — v1 mounts reject PATCH anyway);
479
- // otherwise only v2 write/update wrap non-null data.
480
- let requestData = data;
481
- if (wrapData) {
482
- requestData = { data };
483
- } else if (version === 2 && (op === 'write' || op === 'update')
484
- && data !== null && data !== undefined) {
485
- requestData = { data };
486
- }
487
-
488
- return this.__api.makeRequest(method, apiPath, requestData, headers)
489
- .then((body) => ({ body, version, mount, apiPath }));
490
- });
491
- });
608
+ return this.__resolver.resolve(path)
609
+ .then(({ mount, version }) => {
610
+ if (v2Only && version !== 2) {
611
+ throw new errors.UnsupportedOperationError(
612
+ `Operation "${op}" is only supported on KV v2 mounts. ` +
613
+ `Mount "${mount}" is not a KV v2 engine.`
614
+ );
615
+ }
616
+
617
+ let apiPath;
618
+ if (version === 2) {
619
+ // Split the path into mount + logical sub-path and rewrite for KV v2
620
+ const logicalPath = this.__logicalPath(path, mount);
621
+ apiPath = rewritePath(version, op, mount, logicalPath);
622
+ } else {
623
+ // v1 / disabled: preserve the caller's literal path byte-for-byte,
624
+ // including any trailing slash, to maintain the old wire behavior.
625
+ apiPath = path;
626
+ }
627
+
628
+ // Data envelope: wrapData forces { data } on every engine version
629
+ // (merge-patch update() semantics — v1 mounts reject PATCH anyway);
630
+ // otherwise only v2 write/update wrap non-null data.
631
+ let requestData = data;
632
+ if (wrapData) {
633
+ requestData = { data };
634
+ } else if (version === 2 && (op === 'write' || op === 'update')
635
+ && data !== null && data !== undefined) {
636
+ requestData = { data };
637
+ }
638
+
639
+ return this.__api.makeRequest(method, apiPath, requestData, headers)
640
+ .then((body) => ({ body, version, mount, apiPath }));
641
+ });
642
+ });
492
643
  }
493
644
 
494
645
  /**
@@ -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
  /**