node-vault-client 3.2.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 CHANGED
@@ -459,6 +459,38 @@ So revoking a client's token no longer locks it out: it logs straight back in wi
459
459
  credential. To cut a client off, also revoke or rotate what it logs in with (the AppRole
460
460
  `secret_id` or role, the Kubernetes or JWT role binding, the IAM role).
461
461
 
462
+ #### Revoking the token on close
463
+
464
+ By default [`close()`](#VaultClient+close) only cancels the renewal timer: the token stays
465
+ valid on the Vault server until its TTL runs out. Set `revokeOnClose: true` on the `auth` block
466
+ (beside `type`, like the renewal keys) and `close()` also revokes the held token with
467
+ `auth/token/revoke-self`:
468
+
469
+ ```javascript
470
+ auth: {
471
+ type: 'appRole',
472
+ revokeOnClose: true,
473
+ config: { /* ... */ },
474
+ }
475
+ ```
476
+
477
+ The revocation covers the token held when `close()` runs and the one a login still in flight is
478
+ about to produce — that login still resolves after a close, and its token would otherwise
479
+ outlive the client by its whole TTL. `close()` returns the revocation as a promise, so a caller
480
+ that wants certainty can await it; ignoring the return value is fine too, since the request
481
+ itself keeps the process open until it lands. The promise never rejects: a failed revocation is
482
+ logged (`revokeSelf on close failed`) and the token is left live on the Vault server.
483
+
484
+ Two cautions:
485
+
486
+ * Under `token` auth the token revoked is the one from your configuration, not one the client
487
+ issued for itself. The client warns about this combination at construction; only enable it
488
+ when that token is not shared with anything else.
489
+ * A revoked token cannot be renewed back to life. Calling `close()` and then using the client
490
+ again logs in once more — with backends that cannot mint a fresh credential (`token`), the
491
+ next call fails with Vault's 403 (`VaultHttpError`) from `lookup-self`. A request still in
492
+ flight when the revocation lands surfaces that 403 instead of logging in again.
493
+
462
494
  #### Tuning renewal instead of disabling it
463
495
 
464
496
  Two further keys, also on `auth`, shape *how* renewal happens. Both are optional, and leaving them
@@ -874,6 +906,11 @@ console.log(secret);
874
906
  vaultClient.close(); // process can now exit
875
907
  ```
876
908
 
909
+ With `auth.revokeOnClose: true` the held token is also revoked on the Vault server
910
+ (`auth/token/revoke-self`), and `close()` returns the revocation as a never-rejecting promise;
911
+ see [Revoking the token on close](#revoking-the-token-on-close). Ignored, the request still
912
+ completes: it keeps the process open until it lands.
913
+
877
914
  **Kind**: instance method of [<code>VaultClient</code>](#VaultClient)
878
915
 
879
916
 
@@ -1181,8 +1218,8 @@ try {
1181
1218
 
1182
1219
  | Class | Extends | When thrown |
1183
1220
  |---|---|---|
1184
- | `VaultError` | `Error` | Base class for every error below. Raised directly when mount detection fails (e.g. permission denied) and no `api.engines` override was provided; the failure is on `cause`, so Vault's HTTP status is `err.cause.statusCode`. Vault answers 403 both for a mount the token may not use and for one that does not exist. |
1185
- | `VaultHttpError` | `VaultError` | Vault answered with a non-2xx status. Carries the status as `statusCode` and the parsed body as `error`. `message` (`"<status> - <raw body text>"`) and `error` hold the full response body, which can include secret material (e.g. a partially failed transit batch), so log `statusCode` and `error.errors` rather than the error object itself. |
1221
+ | `VaultError` | `Error` | Base class for every error below. Raised directly when mount detection fails (e.g. permission denied) and no `api.engines` override was provided; the failure is on `cause`, so Vault's HTTP status is `err.cause.statusCode`, and a rate-limited detection's `Retry-After` is `err.cause.retryAfter` / `err.cause.retryAfterMs`. Vault answers 403 both for a mount the token may not use and for one that does not exist. |
1222
+ | `VaultHttpError` | `VaultError` | Vault answered with a non-2xx status. Carries the status as `statusCode` and the parsed body as `error`. When the response sent a `Retry-After` header (Vault's own rate-limit quotas answer 429 with it, in seconds; a proxy in front of Vault may send it with 429 or 503), it is on `retryAfter` as sent and on `retryAfterMs` in milliseconds: delay-seconds and HTTP-dates are both parsed, the result is capped at 2147483647 (the longest `setTimeout()` delay), and `retryAfterMs` is absent when the value is in neither format. Prefer `retryAfterMs` to parsing `retryAfter` yourself, and clamp it to what your caller can afford to wait. The client does not wait and retry on `Retry-After` itself (token renewal retries do not honour it yet). `message` (`"<status> - <raw body text>"`) and `error` hold the full response body, which can include secret material (e.g. a partially failed transit batch), so log `statusCode` and `error.errors` rather than the error object itself. |
1186
1223
  | `InvalidArgumentsError` | `VaultError` | Bad arguments or configuration: `boot()` called without options, an unknown instance name passed to `get()`, an unsupported `auth.type`, invalid renewal options or `api.timeout`, an invalid node-config substitution map, or a KV method path that fails [path validation](#path-validation). |
1187
1224
  | `InvalidAWSCredentialsError` | `InvalidArgumentsError` | `auth.config.credentials` was supplied but is not a usable `accessKeyId` / `secretAccessKey` pair. |
1188
1225
  | `AuthTokenExpiredError` | `VaultError` | The Vault token expired and the backend cannot obtain a new one. `token` auth can never re-authenticate and reaches this error at max TTL even with renewal enabled; other backends can also reach it when re-authentication is disabled or their credential can no longer be replayed. |
package/index.d.ts CHANGED
@@ -22,7 +22,7 @@ declare class VaultClient {
22
22
 
23
23
  fillNodeConfig(): Promise<any>;
24
24
  getHeaders(token: VaultClient.AuthToken): Record<string, string>;
25
- close(): void;
25
+ close(): Promise<void> | null | undefined;
26
26
  }
27
27
 
28
28
  declare namespace VaultClient {
@@ -103,6 +103,7 @@ declare namespace VaultClient {
103
103
  renewal?: boolean;
104
104
  renewalFraction?: number;
105
105
  renewalIncrement?: number;
106
+ revokeOnClose?: boolean;
106
107
  }
107
108
 
108
109
  type AuthOptions = AuthOptionsCommon &
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-vault-client",
3
- "version": "3.2.0",
3
+ "version": "3.3.0",
4
4
  "description": "A Vault Client implemented in pure javascript for HashiCorp Vault. It supports variety of Auth Backends and performs lease renewal for issued auth token.",
5
5
  "repository": {
6
6
  "url": "git+https://github.com/namecheap/node-vault-client.git"
@@ -390,7 +390,10 @@ class VaultApiClient {
390
390
  }
391
391
 
392
392
  if (!response.ok) {
393
- throw new errors.VaultHttpError(response.status, text, body);
393
+ // headers?.: fetch test doubles in callers' suites often have no headers
394
+ throw new errors.VaultHttpError(response.status, text, body, {
395
+ retryAfter: response.headers?.get('retry-after'),
396
+ });
394
397
  }
395
398
 
396
399
  // Do not log the response body: Vault responses carry secret
@@ -254,6 +254,10 @@ class VaultClient {
254
254
  * @param {number} [options.auth.renewalIncrement] - Seconds of extra TTL to request on each
255
255
  * renewal, sent as `increment` to `auth/token/renew-self`. Vault caps it at the token's
256
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.
257
261
  * @param {Object|false} options.logger - Logger that supports "error", "info", "warn", "trace", "debug" methods. Uses `console` by default. Pass `false` to disable logging.
258
262
  */
259
263
  constructor(options) {
@@ -288,6 +292,13 @@ class VaultClient {
288
292
  // `namespace` to `api.namespace`.
289
293
  this.__auth.configureRenewal(options.auth);
290
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
+
291
302
  // KV v2 support
292
303
  const kvOpts = (options.api && options.api.kv) || {};
293
304
  const autoDetect = kvOpts.autoDetect === true;
@@ -428,7 +439,15 @@ class VaultClient {
428
439
  * login already in flight when `close()` runs still resolves, but arms no timer, and
429
440
  * neither does a call that joins that login after `close()`.
430
441
  *
431
- * @returns {void}
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.
446
+ *
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.
432
451
  */
433
452
  close() {
434
453
  if (this.__auth && typeof this.__auth.__markClosed === 'function') {
@@ -438,6 +457,12 @@ class VaultClient {
438
457
  if (this.__auth && typeof this.__auth.cancelTokenRefresh === 'function') {
439
458
  this.__auth.cancelTokenRefresh();
440
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
+ }
441
466
  }
442
467
 
443
468
  /**
@@ -162,6 +162,19 @@ class VaultBaseAuth {
162
162
  * @type {{id: string, promise: Promise<boolean>}|null}
163
163
  */
164
164
  this.__rejectionCheck = null;
165
+ /**
166
+ * Whether {@link VaultBaseAuth#revokeSelfOnClose} revokes the held token on the Vault
167
+ * server (`auth/token/revoke-self`). Off by default; set `auth.revokeOnClose` (see
168
+ * {@link VaultBaseAuth#configureRenewal}).
169
+ * @type {boolean}
170
+ */
171
+ this.__revokeOnClose = false;
172
+ /**
173
+ * The id of the token whose revocation on close has already been started, so a
174
+ * repeated close() does not revoke it twice.
175
+ * @type {String|null}
176
+ */
177
+ this.__revokedOnCloseId = null;
165
178
  }
166
179
 
167
180
  /**
@@ -173,12 +186,15 @@ class VaultBaseAuth {
173
186
  * @param {boolean} [authConfig.renewal=true]
174
187
  * @param {number} [authConfig.renewalFraction=0.5]
175
188
  * @param {number} [authConfig.renewalIncrement]
189
+ * @param {boolean} [authConfig.revokeOnClose=false] - revoke the held token
190
+ * (`auth/token/revoke-self`) when the client is closed
176
191
  * @returns {void}
177
192
  */
178
193
  configureRenewal(authConfig) {
179
194
  this.__renewalEnabled = VaultBaseAuth.__validateRenewal(authConfig);
180
195
  this.__renewalFraction = VaultBaseAuth.__validateFraction(authConfig);
181
196
  this.__renewalIncrement = VaultBaseAuth.__validateIncrement(authConfig);
197
+ this.__revokeOnClose = VaultBaseAuth.__validateRevokeOnClose(authConfig);
182
198
  }
183
199
 
184
200
  /**
@@ -204,6 +220,29 @@ class VaultBaseAuth {
204
220
  return config.renewal;
205
221
  }
206
222
 
223
+ /**
224
+ * Strict for the same reason as "renewal": node-config's `custom-environment-variables`
225
+ * yields strings, so a loose check would read `VAULT_REVOKE_ON_CLOSE=true` as `'true'`
226
+ * and silently leave revocation off -- the exact behaviour the flag exists to request.
227
+ *
228
+ * @param {Object} [authConfig]
229
+ * @returns {boolean}
230
+ * @private
231
+ */
232
+ static __validateRevokeOnClose(config) {
233
+ if (!config || config.revokeOnClose === undefined) {
234
+ return false;
235
+ }
236
+
237
+ if (typeof config.revokeOnClose !== 'boolean') {
238
+ throw new errors.InvalidArgumentsError(
239
+ `"revokeOnClose" should be a boolean, got ${String(config.revokeOnClose)}`
240
+ );
241
+ }
242
+
243
+ return config.revokeOnClose;
244
+ }
245
+
207
246
  /**
208
247
  * @param {Object} [authConfig]
209
248
  * @returns {number}
@@ -346,6 +385,75 @@ class VaultBaseAuth {
346
385
  this.__cancelCount++;
347
386
  }
348
387
 
388
+ /**
389
+ * Revoke the held token on the Vault server (`POST auth/token/revoke-self`), when
390
+ * `auth.revokeOnClose` is enabled. Called by `VaultClient#close()`.
391
+ *
392
+ * The token to revoke is the one held now, or the one an in-flight login is about to
393
+ * produce: that login still resolves after a close(), and its token would otherwise
394
+ * outlive the client by its whole TTL. A login that fails issued no token. A token that
395
+ * was never obtained, and one whose revocation an earlier close() already started, cost
396
+ * nothing to leave alone.
397
+ *
398
+ * @returns {Promise<void>|null} the revocation, so a caller may await it, or `null` when
399
+ * there is nothing to do: the option is off, or no token was ever obtained. The
400
+ * returned promise never rejects: `VaultClient.clear()` fires it without awaiting,
401
+ * and a failed revocation is logged (`revokeSelf on close failed`) with the token
402
+ * left live on the Vault server instead.
403
+ */
404
+ revokeSelfOnClose() {
405
+ if (!this.__revokeOnClose) {
406
+ return null;
407
+ }
408
+ if (this.__pendingLogin === null && this.__authToken === null) {
409
+ // No token was ever obtained: nothing was issued, so there is nothing to revoke.
410
+ return null;
411
+ }
412
+
413
+ // The token to revoke: the one held now, or the one the in-flight login resolves
414
+ // with. The login's own bookkeeping (the token cache, timer decisions) has run by
415
+ // the time this `then` sees it.
416
+ const held = this.__pendingLogin !== null
417
+ ? this.__pendingLogin.then((authToken) => authToken, () => null)
418
+ : Promise.resolve(this.__authToken);
419
+
420
+ return held.then((authToken) => {
421
+ // An expired token is already dead server-side: revoke-self would answer 403 and log
422
+ // a failure claiming the token was left live.
423
+ if (authToken === null || authToken.isExpired() || authToken.getId() === this.__revokedOnCloseId) {
424
+ return;
425
+ }
426
+ const id = authToken.getId();
427
+ this.__revokedOnCloseId = id;
428
+ try {
429
+ this._log.info('revoking the auth token on close (accessor=%s)', authToken.getAccessor());
430
+ } catch {
431
+ // a throwing logger must not cancel the revocation
432
+ }
433
+ return this.__apiClient
434
+ .makeRequest('POST', '/auth/token/revoke-self', null, { 'X-Vault-Token': authToken.getId() })
435
+ .then(() => {
436
+ // Gone server-side: do not hand it to a call that keeps using the client.
437
+ if (this.__authToken === authToken) {
438
+ this.__authToken = null;
439
+ }
440
+ }, (err) => {
441
+ // Still live: let a later close() try again.
442
+ if (this.__revokedOnCloseId === id) {
443
+ this.__revokedOnCloseId = null;
444
+ }
445
+ try {
446
+ this._log.error(
447
+ 'revokeSelf on close failed (accessor=%s): %s',
448
+ authToken.getAccessor(), safeMessage(err)
449
+ );
450
+ } catch {
451
+ // a throwing logger changes nothing: the failure is already lost to the caller
452
+ }
453
+ });
454
+ });
455
+ }
456
+
349
457
  /**
350
458
  * Called by VaultClient after Vault answered 403 to a request made with `authToken`.
351
459
  * Vault answers 403 both to a token it no longer accepts (revoked, or expired before the
@@ -363,6 +471,12 @@ class VaultBaseAuth {
363
471
  return Promise.resolve(false);
364
472
  }
365
473
  const id = authToken.getId();
474
+ if (id === this.__revokedOnCloseId) {
475
+ // close() revoked this token on purpose (auth.revokeOnClose): a request still in
476
+ // flight that it rejects must not log in again after close() and leave a new,
477
+ // unrevoked token behind.
478
+ return Promise.resolve(false);
479
+ }
366
480
  if (this.__authToken === null || this.__authToken.getId() !== id) {
367
481
  return Promise.resolve(true);
368
482
  }
package/src/errors.d.ts CHANGED
@@ -12,9 +12,16 @@ export declare class AuthTokenExpiredError extends VaultError {}
12
12
  export declare class UnsupportedOperationError extends VaultError {}
13
13
 
14
14
  export declare class VaultHttpError extends VaultError {
15
- constructor(statusCode: number, text: string, body?: unknown);
15
+ constructor(statusCode: number, text: string, body?: unknown, headers?: { retryAfter?: string | null });
16
16
  statusCode: number;
17
17
  error: unknown;
18
+ /** Raw `Retry-After` response header; absent when the response did not send one or it was empty. */
19
+ retryAfter?: string;
20
+ /**
21
+ * `Retry-After` in milliseconds (delay-seconds or HTTP-date), capped at 2147483647;
22
+ * absent when the header was not sent or cannot be parsed.
23
+ */
24
+ retryAfterMs?: number;
18
25
  }
19
26
 
20
27
  export declare class RequestTimeoutError extends VaultError {
package/src/errors.js CHANGED
@@ -19,6 +19,29 @@ class InvalidAWSCredentialsError extends InvalidArgumentsError {}
19
19
  class AuthTokenExpiredError extends VaultError {}
20
20
  class UnsupportedOperationError extends VaultError {}
21
21
 
22
+ // The longest delay setTimeout() honours; a longer one fires after 1 ms (same limit as api.timeout).
23
+ const MAX_DELAY_MS = 2147483647;
24
+
25
+ /**
26
+ * Parses a `Retry-After` header value (RFC 9110 §10.2.3): delay-seconds or an HTTP-date.
27
+ *
28
+ * @param {string} value - the raw header value, trimmed and not empty
29
+ * @param {number} now - current time in milliseconds
30
+ * @returns {number|undefined} milliseconds to wait, at most MAX_DELAY_MS; `undefined` for a
31
+ * value in neither format (e.g. `-5`, or `30, 120` from two headers)
32
+ */
33
+ function parseRetryAfter(value, now) {
34
+ if (/^\d+$/.test(value)) {
35
+ return Math.min(Number(value) * 1000, MAX_DELAY_MS);
36
+ }
37
+ // An HTTP-date always names the day or the month; Date.parse() alone also accepts e.g. "-5".
38
+ const date = /[a-z]/i.test(value) ? Date.parse(value) : NaN;
39
+ if (Number.isNaN(date)) {
40
+ return undefined;
41
+ }
42
+ return Math.min(Math.max(0, date - now), MAX_DELAY_MS);
43
+ }
44
+
22
45
  /**
23
46
  * A non-2xx HTTP response from the Vault server.
24
47
  *
@@ -33,11 +56,26 @@ class VaultHttpError extends VaultError {
33
56
  * @param {string} text - raw response body text, used in the message.
34
57
  * @param {*} [body] - parsed JSON body when the response was valid JSON, the raw text
35
58
  * otherwise, or `undefined` for an empty body. Exposed as `error.error`.
59
+ * @param {Object} [headers] - response headers the error exposes:
60
+ * @param {string|null} [headers.retryAfter] - the raw `Retry-After` header, `null` or
61
+ * `undefined` when it was not sent. Vault's rate-limit quotas answer 429 with it, in
62
+ * seconds; proxies in front of Vault send it with 429 and 503. Exposed as
63
+ * `error.retryAfter` (raw) and `error.retryAfterMs` (parsed); both are absent when the
64
+ * header was not sent or is empty, and `retryAfterMs` also when it cannot be parsed.
36
65
  */
37
- constructor(statusCode, text, body) {
66
+ constructor(statusCode, text, body, { retryAfter } = {}) {
38
67
  super(`${statusCode} - ${text}`);
39
68
  this.statusCode = statusCode;
40
69
  this.error = body;
70
+
71
+ const value = typeof retryAfter === 'string' ? retryAfter.trim() : '';
72
+ if (value !== '') {
73
+ this.retryAfter = value;
74
+ const ms = parseRetryAfter(value, Date.now());
75
+ if (ms !== undefined) {
76
+ this.retryAfterMs = ms;
77
+ }
78
+ }
41
79
  }
42
80
  }
43
81