node-vault-client 3.1.0 → 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 CHANGED
@@ -116,6 +116,21 @@ when Vault's `sts_region` / `sts_endpoint` is configured for a non-`us-east-1` r
116
116
  `SignatureDoesNotMatch — Credential should be scoped to a valid region`. Omitting `region`
117
117
  preserves the previous (global-endpoint) behavior.
118
118
 
119
+ #### `iam_server_id_header_value`
120
+
121
+ Set it to the same value as `iam_server_id_header_value` in the mount's `auth/<mount>/config/client`.
122
+ The client then sends it in the signed request as `X-Vault-AWS-IAM-Server-ID`, so the signature
123
+ names your Vault server. A Vault server that enforces its own `iam_server_id_header_value` then
124
+ rejects a request captured from, or meant for, a different server; one that does not set the value
125
+ checks nothing. Vault rejects a login with a missing or wrong value only when the mount sets the
126
+ value. The header does not stop a replay to the same server while the signature is valid (about 15
127
+ minutes).
128
+
129
+ When `config.iam_server_id_header_value` is missing, empty or only whitespace, the first login
130
+ logs one warning and logs in as before, sending the value it has. Each logger gets the warning once
131
+ per Vault server, namespace and mount: clients on the default console logger share it, so the
132
+ process logs it once, and a client with `logger: false` logs nothing and leaves it to the others.
133
+
119
134
  ### AppRole auth
120
135
 
121
136
  ```javascript
@@ -423,6 +438,27 @@ that token expires, and this client does not renew secret leases — only the au
423
438
  dynamic credentials (`database/creds/*`, cloud credentials) whose lease outlives the auth token,
424
439
  leaving renewal on is what currently keeps them alive. KV reads are unaffected.
425
440
 
441
+ #### Tokens revoked on the Vault side
442
+
443
+ A token can stop working before its expiry, for example when an operator revokes it
444
+ (`vault token revoke`, `sys/leases/revoke-prefix`). Vault then answers 403 to every request that
445
+ carries it. When a secret request or a mount detection gets a 403, the client asks
446
+ `auth/token/lookup-self`, which every login already calls, whether Vault still accepts the token:
447
+
448
+ * If it does, the 403 was a policy denial. The call rejects with it as before, and no login is made.
449
+ * If it does not, the client drops the token and its renewal timer, logs `Vault no longer accepts
450
+ the token (accessor=…); logging in again` at `warn`, logs in, and sends the request once more.
451
+ The call then returns what that attempt got: its result, or its error (a failed login, a 404, a
452
+ timeout). Like any re-login, it replays the configured credential (see the table above).
453
+
454
+ Concurrent requests rejected with the same token share one lookup and one login. A 403 on the login
455
+ itself is returned as before. Under `token` auth there is no credential to log in with, so the 403
456
+ is returned as before, without a lookup.
457
+
458
+ So revoking a client's token no longer locks it out: it logs straight back in with the same
459
+ credential. To cut a client off, also revoke or rotate what it logs in with (the AppRole
460
+ `secret_id` or role, the Kubernetes or JWT role binding, the IAM role).
461
+
426
462
  #### Tuning renewal instead of disabling it
427
463
 
428
464
  Two further keys, also on `auth`, shape *how* renewal happens. Both are optional, and leaving them
@@ -502,9 +538,9 @@ Client constructor function.
502
538
  | --- | --- | --- | --- |
503
539
  | options | `Object` | | |
504
540
  | options.api | <code>Object</code> | | |
505
- | options.api.url | <code>String</code> | | the url of the vault server |
541
+ | options.api.url | <code>String</code> | | the url of the vault server. Plain `http://` to a host other than this machine logs a warning; see "Plain `http://` api.url" |
506
542
  | [options.api.apiVersion] | <code>String</code> | `v1` | |
507
- | [options.api.requestOptions] | <code>Object</code> | | extra options merged into every HTTP request (see [Custom transport](#custom-transport-proxy--self-signed-tls)) |
543
+ | [options.api.requestOptions] | <code>Object</code> | | extra options merged into every HTTP request (see [Custom transport](#custom-transport-proxy--self-signed-tls)); a `redirect` there replaces the same-origin redirect policy (see [Redirects and the Vault token](#redirects-and-the-vault-token)) |
508
544
  | [options.api.timeout] | <code>number</code> | `120000` | milliseconds a request may take, response headers, body and redirects included, before it rejects with `RequestTimeoutError`; `0` turns the limit off, `null` gives the default (see [Request timeouts](#request-timeouts)) |
509
545
  | [options.api.namespace] | <code>String</code> | | Optional. Vault namespace, sent as the `X-Vault-Namespace` header on **every** request — login, token lookup/renewal, and all secret operations — for every auth type. This is the canonical location; `auth.config.namespace` is still honored for backward compatibility. |
510
546
  | [options.api.kv.autoDetect] | <code>boolean</code> | `false` | auto-detect the KV version of each mount on first use (see [KV v2 & generic backends](#kv-v2--generic-backends)) |
@@ -608,17 +644,48 @@ on shutdown. `fetch()` then receives the client's own per-request signal, which
608
644
  shared by every request the client makes, so it works for the first calls and then aborts all
609
645
  later ones. Use `api.timeout` instead.
610
646
 
647
+ ##### Plain `http://` api.url
648
+
649
+ When `api.url` is `http://` and its host is not this machine, the client logs one warning on its
650
+ first request: `"api.url" uses plain http:// for <host:port>, which is not a loopback address; the
651
+ Vault token and secrets travel unencrypted. …` (on port 80 the message names the bare host).
652
+ Nothing else changes: the client connects and works as before. `localhost`, `127.0.0.0/8` (also
653
+ as `::ffff:127.x.y.z`), `::1`, and the unspecified addresses `0.0.0.0` and `::` count as this
654
+ machine; subdomains of `localhost` do not, since the system resolver can send them anywhere. The
655
+ warning names only the host and port, never credentials or a path from the URL. Each logger gets
656
+ it once per host and port: clients on the default console logger share it, so the process logs it
657
+ once, and a client with `logger: false` logs nothing and leaves the warning to the others. If the
658
+ logger throws, the client tries again on its next request. Only `api.url` is checked, not where a
659
+ proxy `dispatcher` or `requestOptions.redirect: 'follow'` sends the request.
660
+
661
+ If the connection is protected another way (a service mesh with mTLS, a TLS-terminating sidecar on
662
+ another host), filter that message in your `logger`, or switch to `https://`.
663
+
611
664
  ##### Redirects and the Vault token
612
665
 
613
- Requests follow HTTP redirects, because Vault HA clusters answer standby nodes with a 307 to the
614
- active node. Node's `fetch()` strips only `Authorization`, `Cookie` and `Proxy-Authorization` when
615
- a redirect crosses origins — `X-Vault-Token` is **not** on that list and is forwarded. A 307 or 308
616
- also re-sends the request body, so a login is replayed to the redirect target with its credentials:
617
- the AppRole `secret_id`, the Kubernetes or JWT token, the signed IAM request and, with
618
- `distributedClaimAccessToken`, a Microsoft Graph access token that is a credential for systems other
619
- than Vault. A host that can answer for `api.url` can therefore redirect the client and receive its
620
- Vault token and its login credentials, so treat `api.url` (and the DNS and any load balancer in
621
- front of it) as trusted infrastructure.
666
+ Requests follow HTTP redirects only within the origin (scheme, host and port) of `api.url`, the
667
+ way `fetch()` follows them: up to 20 hops, a 307 or 308 re-sends the method and body, and a 303,
668
+ or a 301 or 302 after a `POST`, becomes a `GET` without a body. A redirect to any other origin
669
+ rejects with a `VaultError` ("Refusing to follow a redirect from … to …") and nothing is sent
670
+ there. That includes a change of scheme on the same host: if a load balancer redirects an
671
+ `http://` `api.url` to `https://`, use the `https://` URL. Where `fetch()` itself gives up on a
672
+ redirect (more than 20 hops, or a `Location` that does not parse, is not http(s) or carries
673
+ credentials), the request rejects as it always has, with `TypeError: fetch failed` and the reason
674
+ on `cause`, so catch both if you handle redirect failures.
675
+
676
+ Earlier versions followed every redirect. Node's `fetch()` strips only `Authorization`,
677
+ `Cookie` and `Proxy-Authorization` when a redirect crosses origins, so `X-Vault-Token` went to the
678
+ new origin, a 307 or 308 replayed a login body with its credentials (the AppRole `secret_id`, the
679
+ Kubernetes or JWT token, the signed IAM request, a `distributedClaimAccessToken` Graph token), and
680
+ that origin's answer was returned as Vault's.
681
+
682
+ A Vault HA standby forwards requests to the active node. It answers with a 307 to the active
683
+ node's `api_addr` only when forwarding fails or the client sends `X-Vault-No-Request-Forwarding`.
684
+ If that address is another origin, point `api.url` at a load balancer that routes to the active
685
+ node. A `redirect` in `api.requestOptions` (other than `null`, which reads as not set) is passed
686
+ to `fetch()` as it is: `'follow'` restores
687
+ the old behaviour (then treat `api.url`, its DNS and anything in front of it as trusted with the
688
+ token), `'manual'` returns a redirect as a `VaultHttpError`, and `'error'` fails it.
622
689
 
623
690
  <a name="VaultClient+fillNodeConfig"></a>
624
691
 
@@ -815,6 +882,14 @@ discarded rather than arming a fresh timer, so the client stops holding the even
815
882
  same applies when a token is replaced by a new login — a renewal still running against the previous
816
883
  token cannot overwrite the newer one or cancel its timer.
817
884
 
885
+ The same goes for a login that is in flight when `close()` runs, such as the first `read` of a
886
+ client closed before it settles: the read still resolves and the token is cached for later calls,
887
+ but no refresh timer is armed for it. A call made after `close()` that joins that same login
888
+ shares its token and gets no timer either, just as if `close()` had run right after the login. A
889
+ login that starts after `close()` arms one as before. As after any `close()`, the token is then
890
+ used until it expires; under `token` auth, which cannot log in again, later calls reject with
891
+ `AuthTokenExpiredError`, so close a client only once nothing will use it any more.
892
+
818
893
  <a name="Lease"></a>
819
894
 
820
895
  ### Lease
@@ -822,7 +897,7 @@ token cannot overwrite the newer one or cancel its timer.
822
897
  The object returned by `read()` and `list()` (they resolve to `Promise<Lease>`). Use its
823
898
  accessors to extract the secret data:
824
899
 
825
- * `getValue(key)` ⇒ <code>String</code> — value for a single key. Throws `Requested key does not exist` when the key is absent.
900
+ * `getValue(key)` ⇒ <code>\*</code> — value for a single key, as Vault returned it: usually a string, but `null`, `false`, `0`, objects and arrays come back as they are. Throws `Requested key does not exist` when the key is not an own property of the response data (for an array, such as a `list()` result, that means an index or `length`). A name such as `constructor` or `toString` counts only when Vault sent a field by that name, never as an inherited member.
826
901
  * `getData()` ⇒ <code>Object</code> — a deep-cloned copy of the whole secret data object.
827
902
  * `isRenewable()` ⇒ <code>boolean</code> — whether the underlying lease is renewable.
828
903
  * `getMetadata()` ⇒ <code>Object</code> | <code>undefined</code> — KV v2 version metadata
@@ -881,6 +956,18 @@ Clear named Vault instance
881
956
 
882
957
  If no name passed all named instances will be cleared.
883
958
 
959
+ Only a string names one instance. Any other argument (a number, `null`, an object, a symbol)
960
+ clears every instance not named by a symbol, as it always has. That is rarely what was meant:
961
+ `clear(42)`, or `clear` passed as a callback that receives a code or a resolved value
962
+ (`promise.then(VaultClient.clear)`). So the first such call in a process also emits a
963
+ `DeprecationWarning` with code `NVC_DEP_CLEAR_NON_STRING`, naming the argument's type but not its
964
+ value. Call `clear()` with no argument to clear every instance.
965
+
966
+ Node delivers warnings on the next tick, so the warning is not shown when `clear` runs inside a
967
+ `process.on('exit')` listener, which still clears every instance. `--no-deprecation` silences the
968
+ warning. Under `--throw-deprecation` it becomes an uncaught exception on the next tick, not an
969
+ error thrown by `clear()`.
970
+
884
971
  **Kind**: static method of [<code>VaultClient</code>](#VaultClient)
885
972
 
886
973
  | Param | Type | Description |
@@ -1094,7 +1181,7 @@ try {
1094
1181
 
1095
1182
  | Class | Extends | When thrown |
1096
1183
  |---|---|---|
1097
- | `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. |
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. |
1098
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. |
1099
1186
  | `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). |
1100
1187
  | `InvalidAWSCredentialsError` | `InvalidArgumentsError` | `auth.config.credentials` was supplied but is not a usable `accessKeyId` / `secretAccessKey` pair. |
package/index.d.ts CHANGED
@@ -121,7 +121,7 @@ declare namespace VaultClient {
121
121
  }
122
122
 
123
123
  interface Lease {
124
- getValue<T = any>(key: string): T;
124
+ getValue<T = any>(key: string | number): T;
125
125
  getData<T = Record<string, any>>(): T;
126
126
  isRenewable(): boolean;
127
127
  getMetadata(): Record<string, any> | undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-vault-client",
3
- "version": "3.1.0",
3
+ "version": "3.2.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"
@@ -18,10 +18,10 @@
18
18
  "test": "mocha --exit \"test/**/*.test.mjs\"",
19
19
  "test:unit": "mocha \"test/*.test.mjs\"",
20
20
  "test:types": "tsc -p types/tsconfig.json",
21
- "test:e2e": "mocha --exit \"test/e2e/e2e.test.mjs\"",
22
- "test:e2e:kv2": "mocha --exit \"test/e2e/e2e.kv2.test.mjs\"",
23
- "test:e2e:jwt": "mocha --exit \"test/e2e/e2e.jwt.test.mjs\"",
24
- "test:e2e:generic": "mocha --exit \"test/e2e/e2e.generic.test.mjs\"",
21
+ "test:e2e": "mocha --exit --require test/e2e/renewal-timer-guard.mjs \"test/e2e/e2e.test.mjs\"",
22
+ "test:e2e:kv2": "mocha --exit --require test/e2e/renewal-timer-guard.mjs \"test/e2e/e2e.kv2.test.mjs\"",
23
+ "test:e2e:jwt": "mocha --exit --require test/e2e/renewal-timer-guard.mjs \"test/e2e/e2e.jwt.test.mjs\"",
24
+ "test:e2e:generic": "mocha --exit --require test/e2e/renewal-timer-guard.mjs \"test/e2e/e2e.generic.test.mjs\"",
25
25
  "test:example": "node examples/jwt-auth/vault-jwt-demo.mjs",
26
26
  "coverage": "c8 npm run test:unit",
27
27
  "lint": "eslint src/ test/ examples/",
@@ -65,6 +65,7 @@
65
65
  "@eslint/js": "^10.0.1",
66
66
  "c8": "^12.0.0",
67
67
  "chai": "^6.2.2",
68
+ "config": "^4.4.2",
68
69
  "deep-freeze": "^0.0.1",
69
70
  "eslint": "^10.10.0",
70
71
  "globals": "^17.8.0",
package/src/Lease.js CHANGED
@@ -29,15 +29,21 @@ class Lease {
29
29
  }
30
30
 
31
31
  /**
32
- * @param {String} key
33
- * @returns {String}
32
+ * @param {String|number} key - a field name, or an index when the data is an array (a list)
33
+ * @returns {*} the field's value as Vault returned it: a string for most secrets, but
34
+ * `null`, `false`, `0`, an object or an array are returned as they are
35
+ * @throws {Error} 'Requested key does not exist' unless `key` is an own property of the
36
+ * response data whose value is not `undefined`
34
37
  */
35
38
  getValue(key) {
36
- if (this.__data[key] === undefined) {
39
+ const data = this.__data;
40
+ // Own fields only: an inherited member such as `constructor` or `__proto__` is not
41
+ // something Vault returned.
42
+ if (data === null || typeof data !== 'object' || !Object.hasOwn(data, key) || data[key] === undefined) {
37
43
  throw new Error('Requested key does not exist');
38
44
  }
39
45
 
40
- return this.__data[key];
46
+ return data[key];
41
47
  }
42
48
 
43
49
  /**
@@ -36,6 +36,26 @@ function decodePath(path) {
36
36
  }
37
37
  }
38
38
 
39
+ /**
40
+ * The error a caller gets when the detection it started or joined failed. It is built for each
41
+ * caller, so its message names that caller's own path rather than the first caller's. The
42
+ * failure stays on `cause`.
43
+ * safeMessage: this message is logged by the operation's catch handler, so it must not embed a
44
+ * raw response body.
45
+ *
46
+ * @param {Error} err - why the detection failed
47
+ * @param {string} interimKey - the mount the detection was keyed on
48
+ * @param {string} path - the caller's path
49
+ * @returns {VaultError}
50
+ */
51
+ function detectionError(err, interimKey, path) {
52
+ return new VaultError(
53
+ `Failed to detect KV engine version for mount "${interimKey}" (path: ${path}): ${safeMessage(err)}. ` +
54
+ 'Set api.engines or disable autoDetect to bypass detection.',
55
+ { cause: err }
56
+ );
57
+ }
58
+
39
59
  /**
40
60
  * Resolves the KV engine version for a given secret path.
41
61
  *
@@ -215,6 +235,11 @@ class MountResolver {
215
235
  // The shared detection resolved to a different mount — start our own.
216
236
  // Use the full path as key so it won't collide with the first-segment key.
217
237
  return this.__detectWithCache(path, path);
238
+ }, (err) => {
239
+ // A failed detection is shared, as it always was: one request, whatever it
240
+ // answered, so a rate limit or a failed login is not repeated per caller. Only
241
+ // the error is built per caller, so it names that caller's own path.
242
+ throw detectionError(err, interimKey, path);
218
243
  });
219
244
  }
220
245
 
@@ -255,17 +280,14 @@ class MountResolver {
255
280
  .catch((err) => {
256
281
  // Clear in-flight so callers can retry
257
282
  this.__inflight.delete(interimKey);
258
- // safeMessage: this message is logged by the operation's catch handler, so it
259
- // must not embed a raw response body; the original error stays on `cause`.
260
- throw new VaultError(
261
- `Failed to detect KV engine version for mount "${interimKey}" (path: ${path}): ${safeMessage(err)}. ` +
262
- 'Set api.engines or disable autoDetect to bypass detection.',
263
- { cause: err }
264
- );
283
+ throw err;
265
284
  });
266
285
 
286
+ // Callers that join get the raw failure and build their own error (see above).
267
287
  this.__inflight.set(interimKey, promise);
268
- return promise;
288
+ return promise.catch((err) => {
289
+ throw detectionError(err, interimKey, path);
290
+ });
269
291
  }
270
292
 
271
293
  /**
@@ -3,6 +3,7 @@
3
3
  const { defaultMaxListeners, getMaxListeners, setMaxListeners } = require('events');
4
4
  const errors = require('./errors');
5
5
  const { trimTrailingSlashes } = require('./slashes');
6
+ const warnOnce = require('./warnOnce');
6
7
 
7
8
  // Above Vault's own default max_request_duration (90 s), so no request a default-configured
8
9
  // Vault would still answer is cut short, and below the 300 s headers/body timeouts of the
@@ -12,6 +13,11 @@ const DEFAULT_TIMEOUT_MS = 120000;
12
13
  const MAX_TIMEOUT_MS = 2147483647;
13
14
  // The listener limit fetch() gives a signal it is passed (see deadline()).
14
15
  const FETCH_MAX_LISTENERS = 1500;
16
+ // The statuses fetch() follows, its limit on hops, and the headers it drops when a redirect
17
+ // turns the request into a GET without a body (Fetch Standard, "HTTP-redirect fetch").
18
+ const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
19
+ const MAX_REDIRECTS = 20;
20
+ const BODY_HEADERS = new Set(['content-encoding', 'content-language', 'content-location', 'content-type']);
15
21
 
16
22
  function validateTimeout(timeout) {
17
23
  // null reads as "not set", as it does in a config file.
@@ -81,6 +87,87 @@ function deadline(ms, signal) {
81
87
  };
82
88
  }
83
89
 
90
+ function isPrintableAscii(value) {
91
+ for (let i = 0; i < value.length; i++) {
92
+ const code = value.charCodeAt(i);
93
+ if (code < 0x20 || code > 0x7e) {
94
+ return false;
95
+ }
96
+ }
97
+ return true;
98
+ }
99
+
100
+ /**
101
+ * Follows the redirects of a request sent with `redirect: 'manual'` as fetch()'s 'follow'
102
+ * would, but only within the origin of the URL it was sent to. 'follow' sends every header
103
+ * but Authorization, Cookie and Proxy-Authorization to any origin a Location names, so
104
+ * X-Vault-Token, and on a 307/308 a login body, would reach that origin, and its answer would
105
+ * be read as Vault's.
106
+ *
107
+ * @param {Response} response - the answer to the request
108
+ * @param {string} url - the URL the request was sent to
109
+ * @param {Object} options - the fetch() options it was sent with
110
+ * @param {number} [hops=0] - redirects followed so far
111
+ * @returns {Response|Promise<Response>} the first answer that is not a redirect
112
+ */
113
+ function followSameOrigin(response, url, options, hops = 0) {
114
+ let location = REDIRECT_STATUSES.has(response.status) ? response.headers.get('location') : null;
115
+ if (location === null) {
116
+ return response;
117
+ }
118
+ // A redirect's own body is never read.
119
+ if (response.body) {
120
+ response.body.cancel().catch(() => {});
121
+ }
122
+
123
+ // Where fetch() gives up on a redirect, it rejects with this; so does this, except for the
124
+ // one thing it adds: refusing another origin.
125
+ const fetchFailed = (cause) => new TypeError('fetch failed', { cause });
126
+ // As fetch() does: a Location sent as raw UTF-8 bytes instead of percent-encoded.
127
+ if (!isPrintableAscii(location)) {
128
+ location = Buffer.from(location, 'latin1').toString('utf8');
129
+ }
130
+ let next;
131
+ try {
132
+ next = new URL(location, url);
133
+ } catch (err) {
134
+ throw fetchFailed(err);
135
+ }
136
+ if (next.protocol !== 'http:' && next.protocol !== 'https:') {
137
+ throw fetchFailed(new TypeError('URL scheme must be a HTTP(S) scheme'));
138
+ }
139
+ if (hops === MAX_REDIRECTS) {
140
+ throw fetchFailed(new TypeError('redirect count exceeded'));
141
+ }
142
+ const from = new URL(url);
143
+ if (next.origin !== from.origin) {
144
+ throw new errors.VaultError(
145
+ `Refusing to follow a redirect from ${from.origin} to ${next.origin}: ` +
146
+ 'only redirects within the origin of "api.url" are followed'
147
+ );
148
+ }
149
+ if (next.username !== '' || next.password !== '') {
150
+ throw fetchFailed(new TypeError('redirect location includes credentials'));
151
+ }
152
+
153
+ let nextOptions = options;
154
+ const method = options.method.toUpperCase();
155
+ if ((response.status === 303 && method !== 'GET' && method !== 'HEAD')
156
+ || ((response.status === 301 || response.status === 302) && method === 'POST')) {
157
+ const headers = {};
158
+ for (const [name, value] of Object.entries(options.headers)) {
159
+ if (!BODY_HEADERS.has(name.toLowerCase())) {
160
+ headers[name] = value;
161
+ }
162
+ }
163
+ nextOptions = Object.assign({}, options, { method: 'GET', headers });
164
+ delete nextOptions.body;
165
+ }
166
+
167
+ return fetch(next.href, nextOptions)
168
+ .then((nextResponse) => followSameOrigin(nextResponse, next.href, nextOptions, hops + 1));
169
+ }
170
+
84
171
  /**
85
172
  * Joins URL segments with single slashes while preserving the leading
86
173
  * protocol (e.g. "https://"). Replaces the former `url-join` dependency.
@@ -101,6 +188,66 @@ function joinUrl(...parts) {
101
188
  .join('/');
102
189
  }
103
190
 
191
+ /** `::ffff:127.x.y.z` as WHATWG URL prints it: `[::ffff:7fXX:XXXX]`. */
192
+ const IPV4_MAPPED_LOOPBACK = /^\[::ffff:7f[0-9a-f]{2}:[0-9a-f]{1,4}\]$/;
193
+ const IPV4_LOOPBACK = /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/;
194
+
195
+ /**
196
+ * True for a host that never leaves the machine: `localhost`, 127.0.0.0/8, its IPv4-mapped IPv6
197
+ * form `::ffff:127.0.0.0/104`, `::1`, and the unspecified addresses `0.0.0.0` and `::`, which
198
+ * connect to this machine. Subdomains of `localhost` are not included: Node resolves them through
199
+ * the system resolver, which can send them anywhere. `hostname` is WHATWG-normalised, so IPv4
200
+ * shorthands such as `127.1` already read `127.0.0.1` and IPv6 is bracketed and compressed.
201
+ * @param {string} hostname - `URL#hostname`
202
+ * @returns {boolean}
203
+ * @private
204
+ */
205
+ function isLoopbackHost(hostname) {
206
+ const host = hostname.endsWith('.') ? hostname.slice(0, -1) : hostname;
207
+ return host === 'localhost'
208
+ || host === '[::1]'
209
+ || host === '[::]'
210
+ || host === '0.0.0.0'
211
+ || IPV4_MAPPED_LOOPBACK.test(host)
212
+ || IPV4_LOOPBACK.test(host);
213
+ }
214
+
215
+ /**
216
+ * The host:port to warn about when `api.url` is plain http:// to a host off this machine (the
217
+ * token and every secret would cross the network unencrypted), or null. An unparsable URL gives
218
+ * null and is left to fail where it is used, as before.
219
+ * @param {*} url - `api.url`
220
+ * @returns {string|null}
221
+ * @private
222
+ */
223
+ function plainHttpHost(url) {
224
+ try {
225
+ const parsed = new URL(url);
226
+ return parsed.protocol === 'http:' && !isLoopbackHost(parsed.hostname) ? parsed.host : null;
227
+ } catch {
228
+ return null;
229
+ }
230
+ }
231
+
232
+ /**
233
+ * Log the plain http:// warning for `host` unless this `logger.warn` has logged it already.
234
+ * Only the host and port are named, never credentials or a path from the URL.
235
+ * @param {string} host
236
+ * @param {Object} logger
237
+ * @returns {boolean} true once the warning is logged or was logged before; false when the
238
+ * logger threw, so the caller can try again on its next request
239
+ * @private
240
+ */
241
+ function warnOnPlainHttp(host, logger) {
242
+ return warnOnce(
243
+ logger,
244
+ host,
245
+ '"api.url" uses plain http:// for %s, which is not a loopback address; the Vault token and '
246
+ + 'secrets travel unencrypted. Use https:// unless the network path is protected otherwise',
247
+ host
248
+ );
249
+ }
250
+
104
251
  class VaultApiClient {
105
252
 
106
253
  /**
@@ -112,7 +259,10 @@ class VaultApiClient {
112
259
  * or for custom TLS trust (self-signed / internal CA). Request-specific fields
113
260
  * (`method`, `body`) always take precedence; `headers` are merged with the
114
261
  * per-request headers winning. Stored by reference (not deep-cloned) so live
115
- * objects such as a Dispatcher keep their prototype and remain usable.
262
+ * objects such as a Dispatcher keep their prototype and remain usable. Without a
263
+ * `redirect` there, redirects are followed only within the origin of `config.url`;
264
+ * a `redirect` there other than `null` is passed to `fetch()` as it is (`'follow'`
265
+ * follows them to any origin).
116
266
  * @param {number} [config.timeout=120000] - milliseconds a request may take, response
117
267
  * headers, body and redirects included, before it rejects with
118
268
  * {@link RequestTimeoutError}. `0` turns the limit off; `null` gives the default.
@@ -142,11 +292,18 @@ class VaultApiClient {
142
292
  logger.warn('"api.timeout" is %d ms; it is in milliseconds, not seconds', this.__timeout);
143
293
  }
144
294
 
295
+ // Logged on the first request rather than here, so a VaultClient whose construction
296
+ // fails after building this client logs nothing.
297
+ this.__plainHttpHost = plainHttpHost(this.__config.url);
298
+
145
299
  this.__namespace = namespace !== undefined ? namespace : this.__config.namespace;
146
300
  this._logger = logger;
147
301
  }
148
302
 
149
303
  makeRequest(method, path, data, headers) {
304
+ if (this.__plainHttpHost !== null && warnOnPlainHttp(this.__plainHttpHost, this._logger)) {
305
+ this.__plainHttpHost = null;
306
+ }
150
307
  data = data === undefined ? null : data;
151
308
  headers = headers === undefined ? {} : headers;
152
309
 
@@ -160,7 +317,7 @@ class VaultApiClient {
160
317
  const namespaceHeader = this.__namespace ? { 'X-Vault-Namespace': this.__namespace } : {};
161
318
 
162
319
  const options = Object.assign(
163
- { redirect: 'follow' },
320
+ {},
164
321
  requestOptions,
165
322
  {
166
323
  method: method,
@@ -172,6 +329,13 @@ class VaultApiClient {
172
329
  ),
173
330
  }
174
331
  );
332
+ // Read after the merge, which copies only own enumerable keys: a `redirect` it leaves
333
+ // out, or an explicit `redirect: undefined`, must not turn fetch()'s 'follow' back on.
334
+ // null reads as "not set", as it does for api.timeout (fetch() rejected it).
335
+ const sameOrigin = options.redirect === undefined || options.redirect === null;
336
+ if (sameOrigin) {
337
+ options.redirect = 'manual';
338
+ }
175
339
  if (data !== null) {
176
340
  options.body = JSON.stringify(data);
177
341
  // Only set the default content-type if the caller did not already supply one.
@@ -210,6 +374,10 @@ class VaultApiClient {
210
374
  }
211
375
  }
212
376
 
377
+ if (sameOrigin) {
378
+ pending = pending.then((response) => followSameOrigin(response, uri, options));
379
+ }
380
+
213
381
  const request = pending.then((response) => {
214
382
  return response.text().then((text) => {
215
383
  let body;
@@ -16,7 +16,13 @@ const safeMessage = require('./safeMessage');
16
16
  const { trimTrailingSlashes } = require('./slashes');
17
17
 
18
18
  const noop = () => {};
19
- const vaultInstances = {};
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.
@@ -304,9 +338,10 @@ class VaultClient {
304
338
  throw new errors.InvalidArgumentsError('Options should be provided');
305
339
  }
306
340
 
307
- let instance = vaultInstances[name];
341
+ let instance = vaultInstances.get(instanceKey(name));
308
342
  if (instance === undefined) {
309
- vaultInstances[name] = instance = new VaultClient(options);
343
+ instance = new VaultClient(options);
344
+ vaultInstances.set(instanceKey(name), instance);
310
345
  instance.__bootDigests = optionsDigests(options);
311
346
  instance.__bootWarned = new Set();
312
347
  return instance;
@@ -338,7 +373,7 @@ class VaultClient {
338
373
  * @return {VaultClient}
339
374
  */
340
375
  static get(name) {
341
- let instance = vaultInstances[name];
376
+ let instance = vaultInstances.get(instanceKey(name));
342
377
 
343
378
  if (instance === undefined) {
344
379
  throw new errors.InvalidArgumentsError('Invalid instance name');
@@ -352,22 +387,33 @@ class VaultClient {
352
387
  *
353
388
  * If no name passed all named instances will be cleared.
354
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
+ *
355
394
  * @param {String} [name] - Vault instance name, all instances will be cleared if no name were passed
356
395
  */
357
396
  static clear(name) {
358
397
  if (typeof name === 'string') {
359
- const instance = vaultInstances[name];
398
+ const instance = vaultInstances.get(name);
360
399
  if (instance !== undefined) {
361
400
  instance.close();
362
- delete vaultInstances[name];
401
+ vaultInstances.delete(name);
363
402
  }
364
- } else {
365
- for (let k in vaultInstances) {
366
- if (Object.hasOwn(vaultInstances, k)) {
367
- vaultInstances[k].close();
368
- delete vaultInstances[k];
369
- }
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;
370
414
  }
415
+ instance.close();
416
+ vaultInstances.delete(key);
371
417
  }
372
418
  }
373
419
 
@@ -378,11 +424,17 @@ class VaultClient {
378
424
  * alive for renewable tokens. Call this once you are done with the client so a
379
425
  * short-lived script can exit on its own. Safe to call when no timer is armed and
380
426
  * 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.
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()`.
382
430
  *
383
431
  * @returns {void}
384
432
  */
385
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
+ }
386
438
  if (this.__auth && typeof this.__auth.cancelTokenRefresh === 'function') {
387
439
  this.__auth.cancelTokenRefresh();
388
440
  }
@@ -468,11 +520,39 @@ class VaultClient {
468
520
  * @private
469
521
  */
470
522
  __detectMount(path) {
471
- return this.__auth.getAuthToken()
472
- .then((token) => {
473
- const detectPath = `sys/internal/ui/mounts/${path}`;
474
- 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;
475
554
  });
555
+ }));
476
556
  }
477
557
 
478
558
  // -------------------------------------------------------------------------
@@ -517,50 +597,49 @@ class VaultClient {
517
597
  }
518
598
  }
519
599
 
520
- return this.__auth.getAuthToken()
521
- .then((token) => {
522
- const headers = Object.assign({}, this.getHeaders(token), extraHeaders || {});
600
+ return this.__withAuthToken((token) => {
601
+ const headers = Object.assign({}, this.getHeaders(token), extraHeaders || {});
523
602
 
524
- if (raw) {
525
- return this.__api.makeRequest(method, path, data, headers)
526
- .then((body) => ({ body, version: undefined, mount: undefined, apiPath: path }));
527
- }
603
+ if (raw) {
604
+ return this.__api.makeRequest(method, path, data, headers)
605
+ .then((body) => ({ body, version: undefined, mount: undefined, apiPath: path }));
606
+ }
528
607
 
529
- return this.__resolver.resolve(path)
530
- .then(({ mount, version }) => {
531
- if (v2Only && version !== 2) {
532
- throw new errors.UnsupportedOperationError(
533
- `Operation "${op}" is only supported on KV v2 mounts. ` +
534
- `Mount "${mount}" is not a KV v2 engine.`
535
- );
536
- }
537
-
538
- let apiPath;
539
- if (version === 2) {
540
- // Split the path into mount + logical sub-path and rewrite for KV v2
541
- const logicalPath = this.__logicalPath(path, mount);
542
- apiPath = rewritePath(version, op, mount, logicalPath);
543
- } else {
544
- // v1 / disabled: preserve the caller's literal path byte-for-byte,
545
- // including any trailing slash, to maintain the old wire behavior.
546
- apiPath = path;
547
- }
548
-
549
- // Data envelope: wrapData forces { data } on every engine version
550
- // (merge-patch update() semantics — v1 mounts reject PATCH anyway);
551
- // otherwise only v2 write/update wrap non-null data.
552
- let requestData = data;
553
- if (wrapData) {
554
- requestData = { data };
555
- } else if (version === 2 && (op === 'write' || op === 'update')
556
- && data !== null && data !== undefined) {
557
- requestData = { data };
558
- }
559
-
560
- return this.__api.makeRequest(method, apiPath, requestData, headers)
561
- .then((body) => ({ body, version, mount, apiPath }));
562
- });
563
- });
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
+ });
564
643
  }
565
644
 
566
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
  /**
@@ -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
 
@@ -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;