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.
@@ -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
- const pendingLogin = this._authenticate().then(authToken => {
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
- this.__setupTokenRefreshTimer(authToken);
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
- warnRenewalStopped(
337
- this,
338
- authToken,
339
- authToken.isExpired() ? 'token already expired' : 'token is not renewable'
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
@@ -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
- const k8sJwtToken = fs.readFileSync(this.__tokenPath).toString();
37
- this._log.debug(
38
- 'read K8s service-account JWT from "%s" (%d bytes)',
39
- this.__tokenPath, k8sJwtToken.length
40
- );
41
-
42
- return this.__apiClient.makeRequest('POST', `/auth/${this._mount}/login`, {
43
- role: this.__role,
44
- jwt: k8sJwtToken,
45
- }).then((res) => {
46
- this._log.debug('received Vault client token from Kubernetes login');
47
-
48
- return this._getTokenEntity(res.auth.client_token);
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
@@ -16,3 +16,8 @@ export declare class VaultHttpError extends VaultError {
16
16
  statusCode: number;
17
17
  error: unknown;
18
18
  }
19
+
20
+ export declare class RequestTimeoutError extends VaultError {
21
+ constructor(timeout: number, options?: { cause?: unknown });
22
+ timeout: number;
23
+ }
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
  };
@@ -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;