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.
- package/README.md +138 -14
- package/index.d.ts +3 -2
- package/package.json +6 -5
- package/src/Lease.js +10 -4
- package/src/MountResolver.js +30 -8
- package/src/VaultApiClient.js +174 -3
- package/src/VaultClient.js +165 -61
- package/src/auth/AuthToken.js +13 -4
- package/src/auth/VaultBaseAuth.js +256 -9
- package/src/auth/VaultIAMAuth.js +54 -0
- package/src/auth/VaultKubernetesAuth.js +21 -14
- package/src/errors.d.ts +8 -1
- package/src/errors.js +39 -1
- package/src/warnOnce.js +44 -0
package/src/VaultClient.js
CHANGED
|
@@ -16,7 +16,13 @@ const safeMessage = require('./safeMessage');
|
|
|
16
16
|
const { trimTrailingSlashes } = require('./slashes');
|
|
17
17
|
|
|
18
18
|
const noop = () => {};
|
|
19
|
-
|
|
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
|
|
352
|
+
let instance = vaultInstances.get(instanceKey(name));
|
|
308
353
|
if (instance === undefined) {
|
|
309
|
-
|
|
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
|
|
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
|
|
409
|
+
const instance = vaultInstances.get(name);
|
|
360
410
|
if (instance !== undefined) {
|
|
361
411
|
instance.close();
|
|
362
|
-
delete
|
|
412
|
+
vaultInstances.delete(name);
|
|
363
413
|
}
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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.
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
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.
|
|
521
|
-
.
|
|
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
|
-
|
|
525
|
-
|
|
526
|
-
|
|
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
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
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
|
/**
|
package/src/auth/AuthToken.js
CHANGED
|
@@ -56,19 +56,25 @@ class AuthToken {
|
|
|
56
56
|
const hasPreciseExpiry = unadjustedExpiresAtMilliseconds !== null;
|
|
57
57
|
const expiresAtMilliseconds = AuthToken.__resolveExpiresAtMilliseconds(data, unadjustedExpiresAtMilliseconds);
|
|
58
58
|
|
|
59
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
/**
|