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.
- package/README.md +145 -22
- package/index.d.ts +3 -1
- package/package.json +9 -6
- package/src/Lease.js +10 -4
- package/src/MountResolver.js +30 -8
- package/src/VaultApiClient.js +291 -3
- package/src/VaultClient.js +239 -88
- package/src/auth/AuthToken.js +13 -4
- package/src/auth/VaultBaseAuth.js +142 -9
- package/src/auth/VaultIAMAuth.js +54 -0
- package/src/auth/VaultKubernetesAuth.js +21 -14
- package/src/errors.d.ts +5 -0
- package/src/errors.js +18 -0
- package/src/warnOnce.js +44 -0
|
@@ -35,6 +35,17 @@ function humanizeMs(ms) {
|
|
|
35
35
|
* @returns {number|null}
|
|
36
36
|
* @private
|
|
37
37
|
*/
|
|
38
|
+
/**
|
|
39
|
+
* True for a token Vault marked renewable whose lookup gave nothing to schedule a renewal from
|
|
40
|
+
* (see {@link AuthToken.fromResponse}). It is not renewable, but the renewal timer still reports
|
|
41
|
+
* it, as it did while such a lookup resolved to a NaN deadline.
|
|
42
|
+
* @param {AuthToken} authToken
|
|
43
|
+
* @returns {boolean}
|
|
44
|
+
*/
|
|
45
|
+
function renewableWithoutExpiry(authToken) {
|
|
46
|
+
return authToken.__renewableWithoutExpiry === true;
|
|
47
|
+
}
|
|
48
|
+
|
|
38
49
|
function tokenExpiryMilliseconds(authToken) {
|
|
39
50
|
if (typeof authToken.getExpiresAtMilliseconds === 'function') {
|
|
40
51
|
const expiry = authToken.getExpiresAtMilliseconds();
|
|
@@ -138,6 +149,19 @@ class VaultBaseAuth {
|
|
|
138
149
|
this.__pendingLogin = null;
|
|
139
150
|
this.__refreshTimeout = null;
|
|
140
151
|
this.__renewalEpoch = 0;
|
|
152
|
+
/**
|
|
153
|
+
* Bumped by every {@link VaultBaseAuth#cancelTokenRefresh} call (`VaultClient#close()`
|
|
154
|
+
* makes one) and by nothing else. A login compares it before and after, so a login that
|
|
155
|
+
* was in flight when the client was closed does not arm a refresh timer.
|
|
156
|
+
* @type {number}
|
|
157
|
+
*/
|
|
158
|
+
this.__cancelCount = 0;
|
|
159
|
+
/**
|
|
160
|
+
* The in-flight {@link VaultBaseAuth#checkRejectedToken} lookup, shared by every
|
|
161
|
+
* request that was rejected with the same token.
|
|
162
|
+
* @type {{id: string, promise: Promise<boolean>}|null}
|
|
163
|
+
*/
|
|
164
|
+
this.__rejectionCheck = null;
|
|
141
165
|
}
|
|
142
166
|
|
|
143
167
|
/**
|
|
@@ -252,12 +276,20 @@ class VaultBaseAuth {
|
|
|
252
276
|
|
|
253
277
|
this._log.info('getting auth token (mount=%s)', this._mount);
|
|
254
278
|
|
|
255
|
-
|
|
279
|
+
// Read before _authenticate() runs: a close() from then on, even from inside it, means
|
|
280
|
+
// this login arms no refresh timer.
|
|
281
|
+
const cancelCountAtStart = this.__cancelCount;
|
|
282
|
+
// A backend that throws instead of rejecting, or returns something that is not a
|
|
283
|
+
// promise, must not throw out of every operation. The executor still calls
|
|
284
|
+
// _authenticate() synchronously, as before.
|
|
285
|
+
const authentication = new Promise((resolve) => resolve(this._authenticate()));
|
|
286
|
+
|
|
287
|
+
const pendingLogin = authentication.then(authToken => {
|
|
256
288
|
this.__pendingLogin = null;
|
|
257
289
|
this.__authToken = authToken;
|
|
258
290
|
this.__renewalEpoch++;
|
|
259
291
|
|
|
260
|
-
if (authToken.isRenewable()) {
|
|
292
|
+
if (authToken.isRenewable() || renewableWithoutExpiry(authToken)) {
|
|
261
293
|
// __setupTokenRefreshTimer logs once it has actually armed a timer, and warns
|
|
262
294
|
// when it cannot; this covers the remaining case, the one diagnostic an operator
|
|
263
295
|
// reads when a `renewal: false` process still hangs.
|
|
@@ -267,7 +299,9 @@ class VaultBaseAuth {
|
|
|
267
299
|
authToken.getAccessor()
|
|
268
300
|
);
|
|
269
301
|
}
|
|
270
|
-
|
|
302
|
+
// close() ran while this login was in flight: every check and warning runs as
|
|
303
|
+
// before, but no timer is armed to hold the event loop.
|
|
304
|
+
this.__setupTokenRefreshTimer(authToken, this.__cancelCount !== cancelCountAtStart);
|
|
271
305
|
}
|
|
272
306
|
|
|
273
307
|
return authToken;
|
|
@@ -293,6 +327,7 @@ class VaultBaseAuth {
|
|
|
293
327
|
* @returns {void}
|
|
294
328
|
*/
|
|
295
329
|
cancelTokenRefresh() {
|
|
330
|
+
this.__markClosed();
|
|
296
331
|
this.__renewalEpoch++;
|
|
297
332
|
if (this.__refreshTimeout !== null) {
|
|
298
333
|
lt.clearTimeout(this.__refreshTimeout);
|
|
@@ -300,6 +335,87 @@ class VaultBaseAuth {
|
|
|
300
335
|
}
|
|
301
336
|
}
|
|
302
337
|
|
|
338
|
+
/**
|
|
339
|
+
* Record a close(), so a login in flight arms no refresh timer. Called by
|
|
340
|
+
* {@link VaultBaseAuth#cancelTokenRefresh} and directly by `VaultClient#close()`, so a
|
|
341
|
+
* subclass that overrides cancelTokenRefresh() without calling super keeps the behaviour.
|
|
342
|
+
* @returns {void}
|
|
343
|
+
* @private
|
|
344
|
+
*/
|
|
345
|
+
__markClosed() {
|
|
346
|
+
this.__cancelCount++;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Called by VaultClient after Vault answered 403 to a request made with `authToken`.
|
|
351
|
+
* Vault answers 403 both to a token it no longer accepts (revoked, or expired before the
|
|
352
|
+
* local clock says so) and to a policy that denies the request. `lookup-self`, which every
|
|
353
|
+
* login here already makes, tells the two apart, so a policy denial costs no login. A dead
|
|
354
|
+
* token is dropped, and its renewal timer cancelled, so the next getAuthToken() logs in.
|
|
355
|
+
*
|
|
356
|
+
* @param {AuthToken} authToken - the token the rejected request carried
|
|
357
|
+
* @returns {Promise<boolean>} true when the request may succeed if sent once more with
|
|
358
|
+
* the token getAuthToken() now gives: the token was dead and has been dropped, or it
|
|
359
|
+
* had been replaced already
|
|
360
|
+
*/
|
|
361
|
+
checkRejectedToken(authToken) {
|
|
362
|
+
if (!this._reauthenticationAllowed()) {
|
|
363
|
+
return Promise.resolve(false);
|
|
364
|
+
}
|
|
365
|
+
const id = authToken.getId();
|
|
366
|
+
if (this.__authToken === null || this.__authToken.getId() !== id) {
|
|
367
|
+
return Promise.resolve(true);
|
|
368
|
+
}
|
|
369
|
+
if (this.__rejectionCheck !== null && this.__rejectionCheck.id === id) {
|
|
370
|
+
return this.__rejectionCheck.promise;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const check = {
|
|
374
|
+
id,
|
|
375
|
+
promise: this.__apiClient.makeRequest('GET', '/auth/token/lookup-self', null, {'X-Vault-Token': id})
|
|
376
|
+
.then(() => false, err => {
|
|
377
|
+
if (err instanceof errors.VaultHttpError && err.statusCode === 403) {
|
|
378
|
+
return true;
|
|
379
|
+
}
|
|
380
|
+
// Neither answer: the 403 is returned as it is, so say why no login follows.
|
|
381
|
+
try {
|
|
382
|
+
this._log.debug(
|
|
383
|
+
'token rejection check inconclusive (accessor=%s): %s; returning the 403',
|
|
384
|
+
authToken.getAccessor(), safeMessage(err)
|
|
385
|
+
);
|
|
386
|
+
} catch {
|
|
387
|
+
// a throwing logger changes nothing here
|
|
388
|
+
}
|
|
389
|
+
return false;
|
|
390
|
+
})
|
|
391
|
+
.then(dead => {
|
|
392
|
+
if (this.__rejectionCheck === check) {
|
|
393
|
+
this.__rejectionCheck = null;
|
|
394
|
+
}
|
|
395
|
+
if (dead && this.__authToken !== null && this.__authToken.getId() === id) {
|
|
396
|
+
this.__authToken = null;
|
|
397
|
+
// As before, through the public method, so an override still hears of it.
|
|
398
|
+
// Dropping a dead token is not a close(): the next login's token must be
|
|
399
|
+
// renewed, so the close count is put back.
|
|
400
|
+
const cancelCount = this.__cancelCount;
|
|
401
|
+
this.cancelTokenRefresh();
|
|
402
|
+
this.__cancelCount = cancelCount;
|
|
403
|
+
try {
|
|
404
|
+
this._log.warn(
|
|
405
|
+
'Vault no longer accepts the token (accessor=%s); logging in again',
|
|
406
|
+
authToken.getAccessor()
|
|
407
|
+
);
|
|
408
|
+
} catch {
|
|
409
|
+
// a throwing logger must not keep the dead token in use
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
return dead;
|
|
413
|
+
}),
|
|
414
|
+
};
|
|
415
|
+
this.__rejectionCheck = check;
|
|
416
|
+
return check.promise;
|
|
417
|
+
}
|
|
418
|
+
|
|
303
419
|
/**
|
|
304
420
|
* @protected
|
|
305
421
|
* @returns {Promise<AuthToken>}
|
|
@@ -321,9 +437,11 @@ class VaultBaseAuth {
|
|
|
321
437
|
|
|
322
438
|
/**
|
|
323
439
|
* @param {AuthToken} authToken
|
|
440
|
+
* @param {boolean} [closedDuringLogin=false] - close() ran while the login that brought
|
|
441
|
+
* `authToken` was in flight: check and warn as usual, but arm no timer
|
|
324
442
|
* @private
|
|
325
443
|
*/
|
|
326
|
-
__setupTokenRefreshTimer(authToken) {
|
|
444
|
+
__setupTokenRefreshTimer(authToken, closedDuringLogin = false) {
|
|
327
445
|
if (this.__refreshTimeout !== null) {
|
|
328
446
|
lt.clearTimeout(this.__refreshTimeout);
|
|
329
447
|
this.__refreshTimeout = null;
|
|
@@ -333,11 +451,13 @@ class VaultBaseAuth {
|
|
|
333
451
|
return;
|
|
334
452
|
}
|
|
335
453
|
if (!authToken.isRenewable() || authToken.isExpired()) {
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
454
|
+
let reason = 'token is not renewable';
|
|
455
|
+
if (authToken.isExpired()) {
|
|
456
|
+
reason = 'token already expired';
|
|
457
|
+
} else if (renewableWithoutExpiry(authToken)) {
|
|
458
|
+
reason = 'token has no usable expiry';
|
|
459
|
+
}
|
|
460
|
+
warnRenewalStopped(this, authToken, reason);
|
|
341
461
|
return;
|
|
342
462
|
}
|
|
343
463
|
|
|
@@ -362,6 +482,19 @@ class VaultBaseAuth {
|
|
|
362
482
|
return;
|
|
363
483
|
}
|
|
364
484
|
|
|
485
|
+
if (closedDuringLogin) {
|
|
486
|
+
// The token is cached and returned as after any close(); nothing holds the loop.
|
|
487
|
+
try {
|
|
488
|
+
this._log.debug(
|
|
489
|
+
'client closed during login; not arming a refresh timer (accessor=%s)',
|
|
490
|
+
authToken.getAccessor()
|
|
491
|
+
);
|
|
492
|
+
} catch {
|
|
493
|
+
// a throwing logger must not fail a login that succeeded
|
|
494
|
+
}
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
|
|
365
498
|
// A renewal that is still in flight when the token is replaced or cancelled must not
|
|
366
499
|
// act on what it finds when it lands: it would overwrite the newer token, or clear
|
|
367
500
|
// the newer token's timer and then bail on its own expired one, leaving a live token
|
package/src/auth/VaultIAMAuth.js
CHANGED
|
@@ -4,6 +4,23 @@ const VaultBaseAuth = require('./VaultBaseAuth');
|
|
|
4
4
|
const aws4 = require('aws4');
|
|
5
5
|
const { fromNodeProviderChain } = require('@aws-sdk/credential-providers');
|
|
6
6
|
const errors = require('../errors');
|
|
7
|
+
const warnOnce = require('../warnOnce');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The Vault server part of the warning key: origin and path of `api.url`, so spellings such as a
|
|
11
|
+
* trailing slash or an upper-case host name the same server. An unparsable value is used as is.
|
|
12
|
+
* @param {*} url
|
|
13
|
+
* @returns {string}
|
|
14
|
+
* @private
|
|
15
|
+
*/
|
|
16
|
+
function serverKey(url) {
|
|
17
|
+
try {
|
|
18
|
+
const parsed = new URL(url);
|
|
19
|
+
return parsed.origin + parsed.pathname.replace(/\/+$/, '');
|
|
20
|
+
} catch {
|
|
21
|
+
return String(url);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
7
24
|
|
|
8
25
|
/**
|
|
9
26
|
* Implementation of AWS Auth Backend :: IAM Authentication Method
|
|
@@ -51,6 +68,8 @@ class VaultIAMAuth extends VaultBaseAuth {
|
|
|
51
68
|
* @property {String} [namespace] - Optional. Vault namespace. Applied as the X-Vault-Namespace
|
|
52
69
|
* header to every request by {@link VaultApiClient}; see {@link VaultClient#constructor}.
|
|
53
70
|
* @property {String} [iam_server_id_header_value] - Optional. Header's value X-Vault-AWS-IAM-Server-ID.
|
|
71
|
+
* Without it the signed request names no Vault server, and the first login logs a warning,
|
|
72
|
+
* once per Vault server, namespace and mount in a process.
|
|
54
73
|
* @property {String} [region] - Optional. AWS region used to sign the STS GetCallerIdentity
|
|
55
74
|
* request. When set, the request is signed against the regional STS endpoint
|
|
56
75
|
* (`sts.<region>.amazonaws.com`) and the SigV4 credential scope is bound to that region.
|
|
@@ -80,10 +99,45 @@ class VaultIAMAuth extends VaultBaseAuth {
|
|
|
80
99
|
this.__credentialChain = credentials ? () => Promise.resolve(credentials) : fromNodeProviderChain();
|
|
81
100
|
}
|
|
82
101
|
|
|
102
|
+
/**
|
|
103
|
+
* Without `X-Vault-AWS-IAM-Server-ID` the signed STS request names no Vault server, so a
|
|
104
|
+
* server that receives it (or anyone who captures it) can replay it to another Vault server
|
|
105
|
+
* that trusts the same AWS principal until the signature expires. Warn once per Vault server,
|
|
106
|
+
* namespace and mount for each logger (once per process on the default logger) when the
|
|
107
|
+
* header will not be sent or holds only whitespace. Called on login, not in the constructor,
|
|
108
|
+
* so a client whose construction fails later (in VaultClient) logs nothing. Never throws: a
|
|
109
|
+
* failure here must not fail the login.
|
|
110
|
+
* @returns {void}
|
|
111
|
+
* @private
|
|
112
|
+
*/
|
|
113
|
+
__warnOnMissingServerId() {
|
|
114
|
+
try {
|
|
115
|
+
const value = this.__iam_server_id_header_value;
|
|
116
|
+
// A value of only whitespace is sent, but names no server either.
|
|
117
|
+
if (typeof value === 'string' ? value.trim() !== '' : value) {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const api = this.__apiClient || {};
|
|
121
|
+
const key = [serverKey(api.__config && api.__config.url), String(api.__namespace), this._mount]
|
|
122
|
+
.join('\u0000');
|
|
123
|
+
warnOnce(
|
|
124
|
+
this._log,
|
|
125
|
+
key,
|
|
126
|
+
'IAM login on mount "%s" is not configured with "iam_server_id_header_value", so the signed '
|
|
127
|
+
+ 'STS request names no Vault server and could be replayed to another one that trusts the same '
|
|
128
|
+
+ 'AWS principal. Set it to the same value as iam_server_id_header_value on auth/%s/config/client',
|
|
129
|
+
this._mount, this._mount
|
|
130
|
+
);
|
|
131
|
+
} catch {
|
|
132
|
+
// an odd api object changes nothing here
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
83
136
|
/**
|
|
84
137
|
* @inheritDoc
|
|
85
138
|
*/
|
|
86
139
|
_authenticate() {
|
|
140
|
+
this.__warnOnMissingServerId();
|
|
87
141
|
this._log.info(
|
|
88
142
|
'making authentication request: role=%s',
|
|
89
143
|
this.__role
|
|
@@ -33,20 +33,27 @@ class VaultKubernetesAuth extends VaultBaseAuth {
|
|
|
33
33
|
this.__role, this.__tokenPath
|
|
34
34
|
);
|
|
35
35
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
36
|
+
// Inside the chain, as in VaultJwtAuth and VaultIAMAuth: a missing or unreadable token
|
|
37
|
+
// file rejects the login instead of throwing. getAuthToken() turns any backend throw
|
|
38
|
+
// into a rejection too; this keeps the backend's own _authenticate() contract async.
|
|
39
|
+
return Promise.resolve()
|
|
40
|
+
.then(() => {
|
|
41
|
+
const k8sJwtToken = fs.readFileSync(this.__tokenPath).toString();
|
|
42
|
+
this._log.debug(
|
|
43
|
+
'read K8s service-account JWT from "%s" (%d bytes)',
|
|
44
|
+
this.__tokenPath, k8sJwtToken.length
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
return this.__apiClient.makeRequest('POST', `/auth/${this._mount}/login`, {
|
|
48
|
+
role: this.__role,
|
|
49
|
+
jwt: k8sJwtToken,
|
|
50
|
+
});
|
|
51
|
+
})
|
|
52
|
+
.then((res) => {
|
|
53
|
+
this._log.debug('received Vault client token from Kubernetes login');
|
|
54
|
+
|
|
55
|
+
return this._getTokenEntity(res.auth.client_token);
|
|
56
|
+
});
|
|
50
57
|
}
|
|
51
58
|
}
|
|
52
59
|
|
package/src/errors.d.ts
CHANGED
package/src/errors.js
CHANGED
|
@@ -41,6 +41,23 @@ class VaultHttpError extends VaultError {
|
|
|
41
41
|
}
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
/**
|
|
45
|
+
* A request to Vault did not complete within `api.timeout` milliseconds: response headers,
|
|
46
|
+
* body and any redirects included. The `fetch()` rejection (a `TimeoutError` DOMException)
|
|
47
|
+
* is on `cause`.
|
|
48
|
+
*/
|
|
49
|
+
class RequestTimeoutError extends VaultError {
|
|
50
|
+
/**
|
|
51
|
+
* @param {number} timeout - the `api.timeout` that elapsed, in milliseconds. Exposed as
|
|
52
|
+
* `error.timeout`.
|
|
53
|
+
* @param {{cause?: *}} [options]
|
|
54
|
+
*/
|
|
55
|
+
constructor(timeout, options) {
|
|
56
|
+
super(`Vault request timed out after ${timeout} ms (api.timeout)`, options);
|
|
57
|
+
this.timeout = timeout;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
44
61
|
module.exports = {
|
|
45
62
|
VaultError,
|
|
46
63
|
InvalidArgumentsError,
|
|
@@ -48,4 +65,5 @@ module.exports = {
|
|
|
48
65
|
AuthTokenExpiredError,
|
|
49
66
|
UnsupportedOperationError,
|
|
50
67
|
VaultHttpError,
|
|
68
|
+
RequestTimeoutError,
|
|
51
69
|
};
|
package/src/warnOnce.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Warnings already logged, kept per `logger.warn` function: the default console logger of every
|
|
5
|
+
* client shares `console.warn`, so the process logs a warning once however many clients it
|
|
6
|
+
* creates, while a client with a logger of its own still gets it, and the no-op logger of
|
|
7
|
+
* `logger: false` uses up nobody else's. Each set holds one short string per distinct warning.
|
|
8
|
+
* @type {WeakMap<Function, Set<string>>}
|
|
9
|
+
*/
|
|
10
|
+
const logged = new WeakMap();
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Log `message` with `args` through `logger.warn`, unless that same `logger.warn` has already
|
|
14
|
+
* logged `message` for `key`. It is recorded only once it has been logged, so a throwing logger
|
|
15
|
+
* uses nothing up, and this never throws.
|
|
16
|
+
* @param {Object} logger
|
|
17
|
+
* @param {string} key - what the warning is about, such as a host or a mount
|
|
18
|
+
* @param {string} message - the format string
|
|
19
|
+
* @param {...*} args
|
|
20
|
+
* @returns {boolean} true when the warning is logged now or was logged before; false when the
|
|
21
|
+
* logger threw, so the caller can try again later
|
|
22
|
+
* @private
|
|
23
|
+
*/
|
|
24
|
+
function warnOnce(logger, key, message, ...args) {
|
|
25
|
+
try {
|
|
26
|
+
const warn = logger.warn;
|
|
27
|
+
const id = `${message}\u0000${key}`;
|
|
28
|
+
let seen = logged.get(warn);
|
|
29
|
+
if (seen && seen.has(id)) {
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
logger.warn(message, ...args);
|
|
33
|
+
if (!seen) {
|
|
34
|
+
seen = new Set();
|
|
35
|
+
logged.set(warn, seen);
|
|
36
|
+
}
|
|
37
|
+
seen.add(id);
|
|
38
|
+
return true;
|
|
39
|
+
} catch {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
module.exports = warnOnce;
|