node-vault-client 1.0.2 → 2.0.1

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
@@ -1,7 +1,10 @@
1
1
  # Node.js Vault Client
2
2
 
3
- ![npm](https://img.shields.io/npm/v/node-vault-client)
4
- ![npm](https://img.shields.io/npm/dm/node-vault-client)
3
+ [![Build Status](https://img.shields.io/github/actions/workflow/status/namecheap/node-vault-client/pipeline.yaml?branch=master&style=flat-square)](https://github.com/namecheap/node-vault-client/actions/workflows/pipeline.yaml)
4
+ [![Download Status](https://img.shields.io/npm/dm/node-vault-client.svg?style=flat-square)](https://www.npmjs.com/package/node-vault-client)
5
+ [![NPM Version](https://img.shields.io/npm/v/node-vault-client?style=flat-square)](https://www.npmjs.com/package/node-vault-client)
6
+ [![License](https://img.shields.io/npm/l/node-vault-client?style=flat-square)](https://github.com/namecheap/node-vault-client/blob/master/LICENSE.txt)
7
+ [![Dependency Status](https://img.shields.io/librariesio/release/npm/node-vault-client.svg?style=flat-square)](https://libraries.io/npm/node-vault-client/)
5
8
 
6
9
  A Vault Client implemented in pure javascript for [HashiCorp Vault](https://github.com/hashicorp/vault).
7
10
  It supports variety of Auth Backends and performs lease renewal for issued auth token.
@@ -11,6 +14,10 @@ It supports variety of Auth Backends and performs lease renewal for issued auth
11
14
  npm install --save node-vault-client
12
15
  ```
13
16
 
17
+ ### Requirements
18
+
19
+ Node.js >= 18 — the client uses the native `fetch` API.
20
+
14
21
  ## Example
15
22
 
16
23
  ```javascript
@@ -19,21 +26,102 @@ const VaultClient = require('node-vault-client');
19
26
  const vaultClient = VaultClient.boot('main', {
20
27
  api: { url: 'https://vault.example.com:8200/' },
21
28
  auth: {
22
- type: 'appRole', // or 'token', 'iam'
29
+ type: 'appRole', // one of: 'appRole' | 'token' | 'iam' | 'kubernetes'
23
30
  config: { role_id: '637c065f-c644-5e12-d3d1-e9fa4363af61' }
24
31
  },
25
32
  });
26
33
 
27
- vaultClient.read('secret/tst').then(v => {
28
- console.log(v);
34
+ vaultClient.read('secret/tst').then(lease => {
35
+ console.log(lease.getData()); // read() resolves to a Lease; use getData()/getValue(key)
29
36
  }).catch(e => console.error(e));
30
37
  ```
31
38
 
32
39
  ## Supported Auth Backends
33
40
 
34
- * [AWS IAM](https://www.vaultproject.io/docs/auth/aws.html#iam-auth-method)
35
- * [AppRole](https://www.vaultproject.io/docs/auth/approle.html)
36
- * [Token](https://www.vaultproject.io/docs/auth/token.html)
41
+ * [AWS IAM](https://developer.hashicorp.com/vault/docs/auth/aws)
42
+ * [AppRole](https://developer.hashicorp.com/vault/docs/auth/approle)
43
+ * [Token](https://developer.hashicorp.com/vault/docs/auth/token)
44
+ * [Kubernetes](https://developer.hashicorp.com/vault/docs/auth/kubernetes)
45
+
46
+ ### AWS IAM auth
47
+
48
+ ```javascript
49
+ const vaultClient = VaultClient.boot('main', {
50
+ api: { url: 'https://vault.example.com:8200/' },
51
+ auth: {
52
+ type: 'iam',
53
+ mount: 'aws', // Optional. Vault AWS auth mount point ("aws" by default)
54
+ config: {
55
+ role: 'my_iam_role',
56
+ iam_server_id_header_value: 'https://vault.example.com:8200/', // Optional. X-Vault-AWS-IAM-Server-ID header
57
+ namespace: 'some_namespace', // Optional. X-Vault-Namespace header
58
+ region: 'eu-central-1', // Optional. AWS STS region (see below)
59
+ credentials: { // Optional. Resolved from the AWS provider chain when omitted
60
+ accessKeyId: process.env.AWS_ACCESS_KEY_ID,
61
+ secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
62
+ },
63
+ },
64
+ },
65
+ });
66
+ ```
67
+
68
+ #### `region`
69
+
70
+ By default the signed `GetCallerIdentity` request targets the global STS endpoint
71
+ `sts.amazonaws.com` and the SigV4 credential scope is bound to `us-east-1`. Set
72
+ `config.region` to sign against a regional STS endpoint instead — the request is then sent to
73
+ `sts.<region>.amazonaws.com` and the signature scope is bound to that region. This is required
74
+ when Vault's `sts_region` / `sts_endpoint` is configured for a non-`us-east-1` region (e.g.
75
+ `eu-central-1`); otherwise STS rejects the replayed request with
76
+ `SignatureDoesNotMatch — Credential should be scoped to a valid region`. Omitting `region`
77
+ preserves the previous (global-endpoint) behavior.
78
+
79
+ ### AppRole auth
80
+
81
+ ```javascript
82
+ const vaultClient = VaultClient.boot('main', {
83
+ api: { url: 'https://vault.example.com:8200/' },
84
+ auth: {
85
+ type: 'appRole',
86
+ mount: 'approle', // Optional. Vault AppRole auth mount point ("approle" by default)
87
+ config: {
88
+ role_id: '637c065f-c644-5e12-d3d1-e9fa4363af61', // Required. RoleID of the AppRole
89
+ secret_id: '...', // Optional. Required when bind_secret_id is enabled
90
+ },
91
+ },
92
+ });
93
+ ```
94
+
95
+ ### Token auth
96
+
97
+ ```javascript
98
+ const vaultClient = VaultClient.boot('main', {
99
+ api: { url: 'https://vault.example.com:8200/' },
100
+ auth: {
101
+ type: 'token',
102
+ mount: 'token', // Optional. Vault token auth mount point ("token" by default)
103
+ config: {
104
+ token: 's.xxxxxxxxxxxxxxxxxxxxxxxx', // Required. Vault token
105
+ },
106
+ },
107
+ });
108
+ ```
109
+
110
+ ### Kubernetes auth
111
+
112
+ ```javascript
113
+ const vaultClient = VaultClient.boot('main', {
114
+ api: { url: 'https://vault.example.com:8200/' },
115
+ auth: {
116
+ type: 'kubernetes',
117
+ mount: 'kubernetes', // Optional. Vault Kubernetes auth mount point ("kubernetes" by default)
118
+ config: {
119
+ role: 'my_k8s_role', // Required. Role configured in the Vault Kubernetes auth backend
120
+ tokenPath: '/var/run/secrets/kubernetes.io/serviceaccount/token', // Optional. Defaults to the in-pod service-account token path
121
+ },
122
+ },
123
+ });
124
+ ```
37
125
 
38
126
  ## API
39
127
 
@@ -47,11 +135,13 @@ vaultClient.read('secret/tst').then(v => {
47
135
  * [.fillNodeConfig()](#VaultClient+fillNodeConfig)
48
136
  * [.read(path)](#VaultClient+read) ⇒ <code>Promise.&lt;Lease&gt;</code>
49
137
  * [.list(path)](#VaultClient+list) ⇒ <code>Promise.&lt;Lease&gt;</code>
50
- * [.write(path, data)](#VaultClient+write) ⇒ <code>Promise.&lt;(T\|never)&gt;</code>
138
+ * [.write(path, data)](#VaultClient+write) ⇒ <code>Promise.&lt;Object&gt;</code>
139
+ * [.close()](#VaultClient+close)
51
140
  * _static_
52
- * [.boot(name, [options])](#VaultClient.boot) ⇒
53
- * [.get(name)](#VaultClient.get) ⇒
141
+ * [.boot(name, [options])](#VaultClient.boot) ⇒ <code>VaultClient</code>
142
+ * [.get(name)](#VaultClient.get) ⇒ <code>VaultClient</code>
54
143
  * [.clear([name])](#VaultClient.clear)
144
+ * [Lease](#Lease)
55
145
 
56
146
  <a name="new_VaultClient_new"></a>
57
147
 
@@ -65,14 +155,52 @@ Client constructor function.
65
155
  | options.api | <code>Object</code> | | |
66
156
  | options.api.url | <code>String</code> | | the url of the vault server |
67
157
  | [options.api.apiVersion] | <code>String</code> | `v1` | |
158
+ | [options.api.requestOptions] | <code>Object</code> | | extra options merged into every HTTP request (see [Custom transport](#custom-transport-proxy--self-signed-tls)) |
68
159
  | options.auth | <code>Object</code> | | |
69
160
  | options.auth.type | <code>String</code> | | |
161
+ | [options.auth.mount] | <code>String</code> | | Vault auth backend mount point; default varies per method (e.g. "aws" for iam, "approle", "token", "kubernetes") |
70
162
  | options.auth.config | <code>Object</code> | | auth configuration variables |
163
+ | [options.auth.config.namespace] | <code>String</code> | | Optional. Vault namespace, sent as the `X-Vault-Namespace` header on all secret read/list/write requests. Applies to every auth type, not just IAM. |
71
164
  | options.logger | <code>Object</code> | `false` | | Logger that supports "error", "info", "warn", "trace", "debug" methods. Uses `console` by default. Pass `false` to disable logging. |
72
165
 
73
- #### vaultClient.fillNodeConfig()
166
+ ##### Custom transport (proxy / self-signed TLS)
167
+
168
+ `options.api.requestOptions` is shallow-merged into every underlying `fetch()` call, so you
169
+ can route traffic through a proxy/SOCKS agent or trust a self-signed / internal-CA Vault.
170
+ Pass an [undici](https://undici.nodejs.org/) `dispatcher` (request semantics like `method`
171
+ and `body` always win; `headers` are merged with per-request headers taking precedence):
172
+
173
+ ```javascript
174
+ const { Agent, ProxyAgent } = require('undici');
175
+
176
+ // Trust an internal/self-signed CA (preferred over disabling verification)
177
+ const vaultClient = VaultClient.boot('main', {
178
+ api: {
179
+ url: 'https://vault.internal:8200/',
180
+ requestOptions: {
181
+ dispatcher: new Agent({ connect: { ca: require('fs').readFileSync('/etc/ssl/internal-ca.pem') } }),
182
+ },
183
+ },
184
+ auth: { type: 'token', config: { token: '...' } },
185
+ });
186
+
187
+ // Route through an HTTP proxy / SOCKS agent
188
+ const proxied = VaultClient.boot('proxied', {
189
+ api: { url: 'https://vault.example.com:8200/', requestOptions: { dispatcher: new ProxyAgent('http://proxy:8080') } },
190
+ auth: { type: 'token', config: { token: '...' } },
191
+ });
192
+ ```
193
+
194
+ For the self-signed-CA case you can also use the process-wide `NODE_EXTRA_CA_CERTS=/path/ca.pem`
195
+ env var with no code change. Only disable verification
196
+ (`new Agent({ connect: { rejectUnauthorized: false } })`) in throwaway/dev setups — it removes
197
+ MITM protection.
198
+
199
+ #### vaultClient.fillNodeConfig() ⇒ <code>Promise</code>
74
200
  Populates Vault's values to NPM "config" module
75
201
 
202
+ Resolves once the npm `config` module has been populated from Vault.
203
+
76
204
  **Kind**: instance method of [<code>VaultClient</code>](#VaultClient)
77
205
  <a name="VaultClient+read"></a>
78
206
 
@@ -98,9 +226,12 @@ Retrieves secrets list
98
226
 
99
227
  <a name="VaultClient+write"></a>
100
228
 
101
- #### vaultClient.write(path, data) ⇒ <code>Promise.&lt;(T\|never)&gt;</code>
229
+ #### vaultClient.write(path, data) ⇒ <code>Promise.&lt;Object&gt;</code>
102
230
  Writes data to Vault
103
231
 
232
+ Resolves to the raw parsed Vault response body, which may be empty/undefined for
233
+ `204 No Content` responses.
234
+
104
235
  **Kind**: instance method of [<code>VaultClient</code>](#VaultClient)
105
236
 
106
237
  | Param | Type | Description |
@@ -108,16 +239,48 @@ Writes data to Vault
108
239
  | path | | path used to write data |
109
240
  | data | <code>object</code> | data to write |
110
241
 
242
+ <a name="VaultClient+close"></a>
243
+
244
+ #### vaultClient.close()
245
+ Release resources held by this client.
246
+
247
+ This client performs lease renewal for renewable auth tokens by arming a background timer.
248
+ That timer keeps the Node.js event loop alive, so a short-lived script (e.g. a one-off
249
+ `read`) never exits on its own. Call `close()` once you are done with the client to cancel
250
+ the timer and let the process exit. It is null-safe and safe to call multiple times. The
251
+ client may still be used afterwards — the next operation that fetches a renewable token
252
+ will arm a new refresh timer.
253
+
254
+ ```javascript
255
+ const vaultClient = VaultClient.boot('main', { /* ... */ });
256
+ const secret = await vaultClient.read('secret/tst');
257
+ console.log(secret);
258
+ vaultClient.close(); // process can now exit
259
+ ```
260
+
261
+ **Kind**: instance method of [<code>VaultClient</code>](#VaultClient)
262
+
263
+ <a name="Lease"></a>
264
+
265
+ ### Lease
266
+
267
+ The object returned by `read()` and `list()` (they resolve to `Promise<Lease>`). Use its
268
+ accessors to extract the secret data:
269
+
270
+ * `getValue(key)` ⇒ <code>String</code> — value for a single key. Throws `Requested key does not exist` when the key is absent.
271
+ * `getData()` ⇒ <code>Object</code> — a deep-cloned copy of the whole secret data object.
272
+ * `isRenewable()` ⇒ <code>boolean</code> — whether the underlying lease is renewable.
273
+
111
274
  <a name="VaultClient.boot"></a>
112
275
 
113
- #### VaultClient.boot(name, [options]) ⇒
276
+ #### VaultClient.boot(name, [options]) ⇒ <code>VaultClient</code>
114
277
  Boot an instance of Vault
115
278
 
116
279
  The instance will be stored in a local hash. Calling Vault.boot multiple
117
280
  times with the same name will return the same instance.
118
281
 
119
282
  **Kind**: static method of [<code>VaultClient</code>](#VaultClient)
120
- **Returns**: Vault
283
+ **Returns**: <code>VaultClient</code>
121
284
 
122
285
  | Param | Type | Description |
123
286
  | --- | --- | --- |
@@ -126,14 +289,14 @@ times with the same name will return the same instance.
126
289
 
127
290
  <a name="VaultClient.get"></a>
128
291
 
129
- #### VaultClient.get(name) ⇒
292
+ #### VaultClient.get(name) ⇒ <code>VaultClient</code>
130
293
  Get an instance of Vault
131
294
 
132
295
  The instance will be stored in a local hash. Calling Vault.pop multiple
133
296
  times with the same name will return the same instance.
134
297
 
135
298
  **Kind**: static method of [<code>VaultClient</code>](#VaultClient)
136
- **Returns**: Vault
299
+ **Returns**: <code>VaultClient</code>
137
300
 
138
301
  | Param | Type | Description |
139
302
  | --- | --- | --- |
@@ -151,3 +314,24 @@ If no name passed all named instances will be cleared.
151
314
  | Param | Type | Description |
152
315
  | --- | --- | --- |
153
316
  | [name] | <code>String</code> | Vault instance name, all instances will be cleared if no name were passed |
317
+
318
+ ## Contributing
319
+
320
+ Contributions are welcome! Please read the [contributing guide](CONTRIBUTING.md) to get started,
321
+ and note that this project requires a [DCO sign-off](CONTRIBUTING.md#dco-sign-off) on every commit.
322
+
323
+ ## Getting help
324
+
325
+ Not sure where to start? See [SUPPORT.md](SUPPORT.md).
326
+
327
+ ## Code of Conduct
328
+
329
+ This project adheres to the [Contributor Covenant Code of Conduct](CODE_OF_CONDUCT.md).
330
+
331
+ ## Security
332
+
333
+ To report a security vulnerability, please follow our [Security Policy](SECURITY.md).
334
+
335
+ ## License
336
+
337
+ Licensed under the [Apache License 2.0](LICENSE.txt).
package/package.json CHANGED
@@ -1,44 +1,61 @@
1
1
  {
2
2
  "name": "node-vault-client",
3
- "version": "1.0.2",
3
+ "version": "2.0.1",
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"
7
7
  },
8
8
  "engines": {
9
- "node": ">=14.0.0"
9
+ "node": ">=18.0.0"
10
10
  },
11
11
  "main": "src/VaultClient.js",
12
12
  "files": [
13
13
  "src"
14
14
  ],
15
15
  "scripts": {
16
- "test": "mocha test/**/*.test.js",
16
+ "test": "mocha --exit \"test/**/*.test.mjs\"",
17
+ "test:unit": "mocha \"test/*.test.mjs\"",
18
+ "test:e2e": "mocha --exit \"test/e2e/**/*.test.mjs\"",
19
+ "coverage": "c8 npm run test:unit",
20
+ "lint": "eslint src/",
17
21
  "version": "git add -A ."
18
22
  },
23
+ "c8": {
24
+ "include": [
25
+ "src/**/*.js"
26
+ ],
27
+ "reporter": [
28
+ "text",
29
+ "html",
30
+ "lcov"
31
+ ],
32
+ "all": true
33
+ },
19
34
  "author": "Namecheap <opensource@namecheap.com>",
20
35
  "license": "Apache-2.0",
21
36
  "dependencies": {
22
37
  "@aws-sdk/credential-providers": "^3.382.0",
23
- "assign-deep": "^1.0.1",
24
38
  "aws4": "^1.8.0",
25
- "bluebird": "^3.7.0",
26
- "lodash": "^4.17.15",
27
- "long-timeout": "^0.1.1",
28
- "pretty-ms": "^2.1.0",
29
- "request": "^2.88.0",
30
- "request-promise": "^4.2.4",
31
- "url-join": "^2.0.5"
39
+ "lodash": "^4.17.21",
40
+ "long-timeout": "^0.1.1"
32
41
  },
33
42
  "peerDependencies": {
34
43
  "config": ">=1 <4"
35
44
  },
45
+ "overrides": {
46
+ "serialize-javascript": "^7.0.5",
47
+ "glob": "^13.0.0",
48
+ "diff": "^8.0.3"
49
+ },
36
50
  "devDependencies": {
37
- "chai": "^3.5.0",
38
- "co-mocha": "^1.2.2",
51
+ "@eslint/js": "^9.0.0",
52
+ "c8": "^11.0.0",
53
+ "chai": "^6.0.0",
39
54
  "deep-freeze": "^0.0.1",
40
- "mocha": "^3.5.3",
41
- "sinon": "^2.4.1",
42
- "sinon-chai": "^2.14.0"
55
+ "eslint": "^9.39.4",
56
+ "globals": "^17.6.0",
57
+ "mocha": "^11.7.6",
58
+ "sinon": "^22.0.0",
59
+ "sinon-chai": "^4.0.0"
43
60
  }
44
61
  }
@@ -1,22 +1,52 @@
1
1
  'use strict';
2
2
 
3
- const rp = require('request-promise');
4
- const urljoin = require('url-join');
5
3
  const _ = require('lodash');
6
4
 
5
+ /**
6
+ * Joins URL segments with single slashes while preserving the leading
7
+ * protocol (e.g. "https://"). Replaces the former `url-join` dependency.
8
+ */
9
+ function joinUrl(...parts) {
10
+ return parts
11
+ .map((part, i) => {
12
+ let segment = String(part);
13
+ if (i > 0) {
14
+ segment = segment.replace(/^\/+/, '');
15
+ }
16
+ if (i < parts.length - 1) {
17
+ segment = segment.replace(/\/+$/, '');
18
+ }
19
+ return segment;
20
+ })
21
+ .filter((segment) => segment.length > 0)
22
+ .join('/');
23
+ }
24
+
7
25
  class VaultApiClient {
8
26
 
9
27
  /**
10
28
  * @param {Object} config
11
29
  * @param {String} config.url - the url of the vault server
12
30
  * @param {String} [config.apiVersion='v1']
31
+ * @param {Object} [config.requestOptions] - extra options shallow-merged into every
32
+ * `fetch()` call. Use it to inject an undici `dispatcher` for a proxy/SOCKS agent
33
+ * or for custom TLS trust (self-signed / internal CA). Request-specific fields
34
+ * (`method`, `body`) always take precedence; `headers` are merged with the
35
+ * per-request headers winning. Stored by reference (not deep-cloned) so live
36
+ * objects such as a Dispatcher keep their prototype and remain usable.
13
37
  * @param {Object} logger
14
38
  */
15
39
  constructor(config, logger) {
16
- this.__config = _.defaultsDeep(_.cloneDeep(config), {
40
+ const requestOptions = config && config.requestOptions;
41
+
42
+ this.__config = _.defaultsDeep(_.cloneDeep(_.omit(config, ['requestOptions'])), {
17
43
  apiVersion: 'v1',
18
44
  });
19
45
 
46
+ if (requestOptions !== undefined) {
47
+ this.__config.requestOptions = requestOptions;
48
+ }
49
+
20
50
  this._logger = logger;
21
51
  }
22
52
 
@@ -24,31 +54,61 @@ class VaultApiClient {
24
54
  data = data === undefined ? null : data;
25
55
  headers = headers === undefined ? {} : headers;
26
56
 
27
- const requestOptions = {
28
- method: method,
29
- body: data === null ? undefined : data,
30
- uri: urljoin(this.__config.url, this.__config.apiVersion, path),
31
- followRedirects: true,
32
- followAllRedirects: true,
33
- headers,
34
- json: true,
35
- };
57
+ const uri = joinUrl(this.__config.url, this.__config.apiVersion, path);
58
+
59
+ const requestOptions = this.__config.requestOptions || {};
60
+
61
+ const options = Object.assign(
62
+ { redirect: 'follow' },
63
+ requestOptions,
64
+ {
65
+ method: method,
66
+ headers: Object.assign(
67
+ { Accept: 'application/json' },
68
+ requestOptions.headers,
69
+ headers
70
+ ),
71
+ }
72
+ );
73
+ if (data !== null) {
74
+ options.body = JSON.stringify(data);
75
+ options.headers['Content-Type'] = 'application/json';
76
+ }
36
77
 
37
78
  this._logger.debug(
38
79
  'making request: %s %s',
39
- requestOptions.method,
40
- requestOptions.uri
80
+ method,
81
+ uri
41
82
  );
42
83
 
43
- return rp(requestOptions)
44
- .then((response) => {
45
- this._logger.debug('%s %s response body:\n%s',
46
- requestOptions.method,
47
- requestOptions.uri,
48
- JSON.stringify(response, null, ' ')
84
+ return fetch(uri, options).then((response) => {
85
+ return response.text().then((text) => {
86
+ let body;
87
+ if (text) {
88
+ try {
89
+ body = JSON.parse(text);
90
+ } catch {
91
+ body = text;
92
+ }
93
+ }
94
+
95
+ if (!response.ok) {
96
+ const error = new Error(`${response.status} - ${text}`);
97
+ error.statusCode = response.status;
98
+ error.error = body;
99
+ throw error;
100
+ }
101
+
102
+ // Do not log the response body: Vault responses carry secret
103
+ // material (auth tokens, secret reads). Log status only.
104
+ this._logger.debug('%s %s -> %d',
105
+ method,
106
+ uri,
107
+ response.status
49
108
  );
50
- return response;
109
+ return body;
51
110
  });
111
+ });
52
112
  }
53
113
  }
54
114
 
@@ -20,6 +20,9 @@ class VaultClient {
20
20
  * @param {Object} options.api
21
21
  * @param {String} options.api.url - the url of the vault server
22
22
  * @param {String} [options.api.apiVersion='v1']
23
+ * @param {Object} [options.api.requestOptions] - extra options merged into every HTTP
24
+ * request (e.g. an undici `dispatcher` for a proxy/SOCKS agent or custom TLS/CA trust).
25
+ * See {@link VaultApiClient#constructor}.
23
26
  * @param {Object} options.auth
24
27
  * @param {String} options.auth.type
25
28
  * @param {Object} options.auth.config - auth configuration variables
@@ -52,8 +55,8 @@ class VaultClient {
52
55
  * times with the same name will return the same instance.
53
56
  *
54
57
  * @param {String} name - Vault instance name
55
- * @param {Object} [options] - options for {@link Vault#constructor}.
56
- * @return Vault
58
+ * @param {Object} [options] - options for {@link VaultClient#constructor}.
59
+ * @return {VaultClient}
57
60
  */
58
61
  static boot(name, options) {
59
62
  if (options === undefined) {
@@ -74,7 +77,7 @@ class VaultClient {
74
77
  * times with the same name will return the same instance.
75
78
  *
76
79
  * @param {String} name - Vault instance name
77
- * @return Vault
80
+ * @return {VaultClient}
78
81
  */
79
82
  static get(name) {
80
83
  let instance = vaultInstances[name];
@@ -95,16 +98,38 @@ class VaultClient {
95
98
  */
96
99
  static clear(name) {
97
100
  if (typeof name === 'string') {
98
- delete vaultInstances[name];
101
+ const instance = vaultInstances[name];
102
+ if (instance !== undefined) {
103
+ instance.close();
104
+ delete vaultInstances[name];
105
+ }
99
106
  } else {
100
107
  for (let k in vaultInstances) {
101
- if (vaultInstances.hasOwnProperty(k)) {
108
+ if (Object.hasOwn(vaultInstances, k)) {
109
+ vaultInstances[k].close();
102
110
  delete vaultInstances[k];
103
111
  }
104
112
  }
105
113
  }
106
114
  }
107
115
 
116
+ /**
117
+ * Release resources held by this client.
118
+ *
119
+ * Cancels the background auth-token refresh timer that keeps the Node.js event loop
120
+ * alive for renewable tokens. Call this once you are done with the client so a
121
+ * short-lived script can exit on its own. Safe to call when no timer is armed and
122
+ * safe to call multiple times. After calling `close()` the client may still be used;
123
+ * the next operation that fetches a renewable token will arm a new refresh timer.
124
+ *
125
+ * @returns {void}
126
+ */
127
+ close() {
128
+ if (this.__auth && typeof this.__auth.cancelTokenRefresh === 'function') {
129
+ this.__auth.cancelTokenRefresh();
130
+ }
131
+ }
132
+
108
133
  /**
109
134
  * @protected
110
135
  *
@@ -215,9 +240,9 @@ class VaultClient {
215
240
  /**
216
241
  * Writes data to Vault
217
242
  *
218
- * @param path - path used to write data
243
+ * @param {string} path - path used to write data
219
244
  * @param {object} data - data to write
220
- * @returns {Promise<T | never>}
245
+ * @returns {Promise<Object>} the parsed Vault response body
221
246
  */
222
247
  write(path, data) {
223
248
  this.__log.debug('write secret %s', path);
@@ -1,10 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const path = require('path');
4
- const fs = require('fs');
5
- const Bluebird = require('bluebird');
6
4
  const _ = require('lodash');
7
- const assignDeep = require('assign-deep');
8
5
 
9
6
  const errors = require('./errors');
10
7
 
@@ -15,7 +12,7 @@ class VaultNodeConfig {
15
12
 
16
13
  try {
17
14
  require.resolve('config');
18
- } catch(e) {
15
+ } catch {
19
16
  throw new errors.VaultError(`NPM package "config" isn't installed`);
20
17
  }
21
18
 
@@ -30,50 +27,59 @@ class VaultNodeConfig {
30
27
  let requiredData = {};
31
28
 
32
29
  this.__traverse(substitutionMap, (key, val) => {
33
- const splitRes = val.split('#');
34
- const path = splitRes[0], value = splitRes[1];
35
- if (path === undefined || value === undefined) {
36
- throw new errors.InvalidArgumentsError('Invalid format of substitution value');
37
- }
38
-
39
- requiredData[path] = null;
30
+ const { vaultPath } = this.__parseSubstitutionValue(val);
31
+ requiredData[vaultPath] = null;
40
32
  });
41
33
 
42
- let promises = _.mapValues(requiredData, (value, path) => this.__vault.read(path));
34
+ const vaultPaths = Object.keys(requiredData);
43
35
 
44
- return Bluebird.props(promises).then(results => {
45
- requiredData = _.mapValues(requiredData, (value, path) => results[path].getData());
36
+ return Promise.all(vaultPaths.map((vaultPath) => this.__vault.read(vaultPath))).then((leases) => {
37
+ const results = _.zipObject(vaultPaths, leases);
38
+ requiredData = _.mapValues(requiredData, (value, vaultPath) => results[vaultPath].getData());
46
39
 
47
40
  this.__traverse(substitutionMap, (key, val, obj) => {
48
- const splitRes = val.split('#');
49
- const path = splitRes[0], value = splitRes[1];
50
- const res = requiredData[path][value];
41
+ const { vaultPath, value } = this.__parseSubstitutionValue(val);
42
+ const res = requiredData[vaultPath][value];
51
43
  if (res === undefined) {
52
44
  throw new errors.VaultError(`Can't find substitution for "${val}"`);
53
45
  }
54
46
 
55
- obj[key] = requiredData[path][value];
47
+ obj[key] = requiredData[vaultPath][value];
56
48
  });
57
49
 
58
- return assignDeep(this.__nodeConfig, substitutionMap);
50
+ return _.merge(this.__nodeConfig, substitutionMap);
59
51
  });
60
52
  }
61
53
 
54
+ /**
55
+ * Splits a substitution value of the form "<vaultPath>#<value>".
56
+ *
57
+ * @private
58
+ */
59
+ __parseSubstitutionValue(val) {
60
+ const [vaultPath, value] = val.split('#');
61
+ if (!vaultPath || !value) {
62
+ throw new errors.InvalidArgumentsError('Invalid format of substitution value');
63
+ }
64
+
65
+ return { vaultPath, value };
66
+ }
67
+
62
68
  /**
63
69
  * @private
64
70
  */
65
71
  __getSubstitutionMap() {
66
- let config_dir = this.__nodeConfig.util.initParam('NODE_CONFIG_DIR', path.join( process.cwd(), 'config') );
67
- if (config_dir.indexOf('.') === 0) {
68
- config_dir = path.join(process.cwd() , config_dir);
72
+ let configDir = this.__nodeConfig.util.initParam('NODE_CONFIG_DIR', path.join(process.cwd(), 'config'));
73
+ if (configDir.indexOf('.') === 0) {
74
+ configDir = path.join(process.cwd(), configDir);
69
75
  }
70
76
 
71
- const fullFilename = path.join(config_dir, 'custom-vault-variables.js');
77
+ const fullFilename = path.join(configDir, 'custom-vault-variables.js');
72
78
 
73
79
  let fileContent;
74
80
  try {
75
81
  fileContent = require(fullFilename);
76
- } catch (e) {
82
+ } catch {
77
83
  throw new errors.VaultError('Config file ' + fullFilename + ' cannot be read');
78
84
  }
79
85
 
@@ -85,12 +91,8 @@ class VaultNodeConfig {
85
91
  }
86
92
 
87
93
  __traverse(o, func) {
88
- for (let i in o) {
89
- if (!o.hasOwnProperty(i)) {
90
- continue;
91
- }
92
-
93
- if (o[i] !== null && typeof(o[i]) === "object") {
94
+ for (const i of Object.keys(o)) {
95
+ if (o[i] !== null && typeof o[i] === 'object') {
94
96
  //going one step down in the object tree!!
95
97
  this.__traverse(o[i], func);
96
98
  } else if (typeof o[i] === 'string') {
@@ -1,6 +1,5 @@
1
1
  'use strict';
2
2
 
3
- const _ = require('lodash');
4
3
 
5
4
  class AuthToken {
6
5
 
@@ -35,25 +34,73 @@ class AuthToken {
35
34
  static fromResponse(response) {
36
35
  const data = response.data;
37
36
 
38
- let expiresAt = null;
39
- if (data.ttl !== 0) {
40
- const creationTime = parseInt(data.last_renewal_time !== undefined ? data.last_renewal_time : data.creation_time, 10);
41
- const networkLatency = 60;
42
- const ttl = parseInt(data.ttl > networkLatency ? data.ttl - networkLatency : data.ttl);
43
- expiresAt = creationTime + ttl;
44
- }
45
-
46
37
  return new AuthToken(
47
38
  data.id,
48
39
  data.accessor,
49
40
  data.creation_time,
50
- expiresAt,
41
+ AuthToken.__resolveExpiresAt(data),
51
42
  data.explicit_max_ttl,
52
43
  data.num_uses,
53
44
  data.renewable !== undefined ? data.renewable : false
54
45
  );
55
46
  }
56
47
 
48
+ /**
49
+ * Resolve the UNIX timestamp (seconds) at which the client should treat the token
50
+ * as expired, shifted earlier by a network-latency safety margin so renewal happens
51
+ * before the real expiry.
52
+ *
53
+ * Prefers Vault's authoritative `expire_time` (RFC3339), which is the documented
54
+ * absolute expiry. Falls back to `(last_renewal_time || creation_time) + ttl` for
55
+ * responses that don't carry `expire_time` — note `ttl` is the *remaining* lifetime,
56
+ * so that sum is only accurate when the lookup happens right after issuance/renewal.
57
+ *
58
+ * @private
59
+ * @param {Object} data - the `data` object of a token lookup response
60
+ * @returns {int|null} null when the token never expires (ttl === 0)
61
+ */
62
+ static __resolveExpiresAt(data) {
63
+ if (data.ttl === 0) {
64
+ return null;
65
+ }
66
+
67
+ const networkLatency = 60;
68
+ const margin = data.ttl > networkLatency ? networkLatency : 0;
69
+
70
+ const expireTime = AuthToken.__parseRfc3339Seconds(data.expire_time);
71
+ if (expireTime !== null) {
72
+ return expireTime - margin;
73
+ }
74
+
75
+ // Fallback for responses without a usable `expire_time`.
76
+ const creationTime = parseInt(data.last_renewal_time !== undefined ? data.last_renewal_time : data.creation_time, 10);
77
+ const ttl = data.ttl > networkLatency ? data.ttl - networkLatency : data.ttl;
78
+ return creationTime + parseInt(ttl, 10);
79
+ }
80
+
81
+ /**
82
+ * Parse an RFC3339 timestamp (as returned by Vault, up to nanosecond precision with
83
+ * a timezone offset) into whole UNIX seconds. Returns null for missing, empty,
84
+ * unparseable, or non-positive (e.g. Vault's "0001-01-01T00:00:00Z" zero value) input.
85
+ *
86
+ * @private
87
+ * @param {String} value
88
+ * @returns {int|null}
89
+ */
90
+ static __parseRfc3339Seconds(value) {
91
+ if (typeof value !== 'string' || value === '') {
92
+ return null;
93
+ }
94
+
95
+ const ms = Date.parse(value);
96
+ if (isNaN(ms)) {
97
+ return null;
98
+ }
99
+
100
+ const seconds = Math.floor(ms / 1000);
101
+ return seconds > 0 ? seconds : null;
102
+ }
103
+
57
104
  /**
58
105
  * @returns {string}
59
106
  */
@@ -6,9 +6,11 @@ class VaultAppRoleAuth extends VaultBaseAuth {
6
6
 
7
7
  /**
8
8
  * @param {VaultApiClient} apiClient - see {@link VaultBaseAuth#constructor}
9
+ * @param {Object} logger
9
10
  * @param {Object} config
10
11
  * @param {String} config.role_id - RoleID of the AppRole.
11
12
  * @param {String} [config.secret_id] - required when bind_secret_id is enabled SecretID belonging to AppRole.
13
+ * @param {String} [config.namespace] - Optional. Vault namespace, sent as the X-Vault-Namespace header.
12
14
  * @param {String} mount - Vault's mount point ("approle" by default)
13
15
  */
14
16
  constructor(apiClient, logger, config, mount) {
@@ -1,10 +1,26 @@
1
1
  'use strict';
2
2
 
3
3
  const lt = require('long-timeout');
4
- const prettyMs = require('pretty-ms');
5
4
  const AuthToken = require('./AuthToken');
6
5
  const errors = require('../errors');
7
6
 
7
+ /**
8
+ * Formats a millisecond duration as a short human-readable string
9
+ * (e.g. "1s", "2m 5s", "1h 3m"). Replaces the former `pretty-ms` dependency.
10
+ */
11
+ function humanizeMs(ms) {
12
+ const totalSeconds = Math.round(ms / 1000);
13
+ if (totalSeconds < 60) {
14
+ return `${totalSeconds}s`;
15
+ }
16
+ const totalMinutes = Math.floor(totalSeconds / 60);
17
+ if (totalMinutes < 60) {
18
+ return `${totalMinutes}m ${totalSeconds % 60}s`;
19
+ }
20
+ const hours = Math.floor(totalMinutes / 60);
21
+ return `${hours}h ${totalMinutes % 60}m`;
22
+ }
23
+
8
24
  class VaultBaseAuth {
9
25
 
10
26
  /**
@@ -32,11 +48,15 @@ class VaultBaseAuth {
32
48
  }
33
49
 
34
50
  getAuthToken() {
35
- if (this.__authToken === null || (this.__authToken instanceof AuthToken && this.__authToken.isExpired() && this._reauthenticationAllowed())) {
36
- if (this.__authToken !== null && this.__authToken.isExpired() && !this._reauthenticationAllowed()) {
37
- throw new errors.AuthTokenExpiredError('Auth token has expired & cannot be refreshed since auth method doesn\'t support this.');
38
- }
51
+ const tokenExpired = this.__authToken instanceof AuthToken && this.__authToken.isExpired();
39
52
 
53
+ if (tokenExpired && !this._reauthenticationAllowed()) {
54
+ return Promise.reject(new errors.AuthTokenExpiredError(
55
+ 'Auth token has expired & cannot be refreshed since auth method doesn\'t support this.'
56
+ ));
57
+ }
58
+
59
+ if (this.__authToken === null || tokenExpired) {
40
60
  this._log.info('getting auth token (mount=%s)', this._mount);
41
61
 
42
62
  const tokenPromise = this._authenticate().then(authToken => {
@@ -64,6 +84,24 @@ class VaultBaseAuth {
64
84
  return Promise.resolve(this.__authToken);
65
85
  }
66
86
 
87
+ /**
88
+ * Cancel any pending token-refresh timer.
89
+ *
90
+ * When a renewable token is fetched the client arms a `long-timeout` timer to renew it
91
+ * before it expires. That timer keeps the Node.js event loop alive, so a short-lived
92
+ * script never exits on its own. Call this (or {@link VaultClient#close}) once you are
93
+ * done with the client to release the event loop. Safe to call when no timer is armed
94
+ * and safe to call multiple times.
95
+ *
96
+ * @returns {void}
97
+ */
98
+ cancelTokenRefresh() {
99
+ if (this.__refreshTimeout !== null) {
100
+ lt.clearTimeout(this.__refreshTimeout);
101
+ this.__refreshTimeout = null;
102
+ }
103
+ }
104
+
67
105
  /**
68
106
  * @protected
69
107
  * @returns {Promise<AuthToken>}
@@ -113,7 +151,7 @@ class VaultBaseAuth {
113
151
 
114
152
  this._log.debug(
115
153
  'sleeping for %s',
116
- prettyMs(timer)
154
+ humanizeMs(timer)
117
155
  );
118
156
  }
119
157
 
@@ -29,6 +29,7 @@ const errors = require('../errors');
29
29
  * role: 'my_iam_role',
30
30
  * iam_server_id_header_value: VAULT_ADDR, // Optional
31
31
  * namespace: 'some_namespace', // Optional
32
+ * region: 'eu-central-1', // Optional. AWS STS region.
32
33
  * credentials: { // Optional
33
34
  * accessKeyId: AWS_ACCESS_KEY,
34
35
  * secretAccessKey: AWS_SECRET_KEY,
@@ -47,8 +48,14 @@ class VaultIAMAuth extends VaultBaseAuth {
47
48
  *
48
49
  * @typedef {Object} VaultIAMAuthConfig
49
50
  * @property {String} role - Role name of the auth/{mount}/role/{name} backend.
50
- * @property [AWSCredentials] [credentials] - Optional. AWS Credentials
51
+ * @property {AWSCredentials} [credentials] - Optional. AWS Credentials
52
+ * @property {String} [namespace] - Optional. Vault namespace, sent as the X-Vault-Namespace header.
51
53
  * @property {String} [iam_server_id_header_value] - Optional. Header's value X-Vault-AWS-IAM-Server-ID.
54
+ * @property {String} [region] - Optional. AWS region used to sign the STS GetCallerIdentity
55
+ * request. When set, the request is signed against the regional STS endpoint
56
+ * (`sts.<region>.amazonaws.com`) and the SigV4 credential scope is bound to that region.
57
+ * When omitted, the global endpoint (`sts.amazonaws.com`, scope `us-east-1`) is used,
58
+ * preserving the previous default behavior.
52
59
  *
53
60
  * @param {VaultApiClient} api - see {@link VaultBaseAuth#constructor}
54
61
  * @param {Object} logger
@@ -61,6 +68,7 @@ class VaultIAMAuth extends VaultBaseAuth {
61
68
  this.__role = config.role;
62
69
  this.__iam_server_id_header_value = config.iam_server_id_header_value;
63
70
  this.__namespace = config.namespace;
71
+ this.__region = config.region;
64
72
 
65
73
 
66
74
  const { credentials } = config;
@@ -130,7 +138,11 @@ class VaultIAMAuth extends VaultBaseAuth {
130
138
  JSON.stringify(this.__headersLikeGolangStyle(stsRequest.headers))
131
139
  ),
132
140
  iam_request_body: this.__base64encode(stsRequest.body),
133
- iam_request_url: this.__base64encode(`https://${stsRequest.hostname}${stsRequest.path}`),
141
+ // `aws4` populates `hostname` for the default (global) endpoint, but
142
+ // `host` when an explicit host is supplied (regional endpoint). Use
143
+ // whichever is present so the URL sent to Vault matches the signed
144
+ // Host header.
145
+ iam_request_url: this.__base64encode(`https://${stsRequest.host || stsRequest.hostname}${stsRequest.path}`),
134
146
  role: this.__role
135
147
  }
136
148
  }
@@ -142,14 +154,27 @@ class VaultIAMAuth extends VaultBaseAuth {
142
154
  * @private
143
155
  */
144
156
  __getStsRequest(credentials) {
145
- return aws4.sign({
157
+ const request = {
146
158
  service: 'sts',
147
159
  method: 'POST',
148
160
  body: 'Action=GetCallerIdentity&Version=2011-06-15',
149
161
  headers: this.__iam_server_id_header_value ? {
150
162
  'X-Vault-AWS-IAM-Server-ID': this.__iam_server_id_header_value,
151
163
  } : {}
152
- }, credentials);
164
+ };
165
+
166
+ // When a region is configured, sign the request against the regional STS
167
+ // endpoint. Both the SigV4 credential scope (via `region`) and the signed
168
+ // Host header / request URL (via `host`) must reference the same region,
169
+ // otherwise Vault's STS replay fails with `SignatureDoesNotMatch`.
170
+ // When omitted, `aws4` defaults to the global endpoint (`sts.amazonaws.com`,
171
+ // scope `us-east-1`), preserving the previous behavior.
172
+ if (this.__region) {
173
+ request.region = this.__region;
174
+ request.host = `sts.${this.__region}.amazonaws.com`;
175
+ }
176
+
177
+ return aws4.sign(request, credentials);
153
178
  }
154
179
 
155
180
  /**
@@ -157,7 +182,7 @@ class VaultIAMAuth extends VaultBaseAuth {
157
182
  * @private
158
183
  */
159
184
  __base64encode(string) {
160
- return new Buffer(string).toString('base64')
185
+ return Buffer.from(string).toString('base64')
161
186
  }
162
187
 
163
188
  /**
@@ -4,6 +4,7 @@ const VaultBaseAuth = require('./VaultBaseAuth');
4
4
  class VaultKubernetesAuth extends VaultBaseAuth {
5
5
  /**
6
6
  * @param {VaultApiClient} apiClient - see {@link VaultBaseAuth#constructor}
7
+ * @param {Object} logger
7
8
  * @param {Object} config
8
9
  * @param {String} config.role - Role configured in Vault Kubernetes Auth backend under which we want to issue Vault token.
9
10
  * @param {String} [config.tokenPath] - Path to the Kube JWT token. If omitted - default will be used.
@@ -27,18 +28,15 @@ class VaultKubernetesAuth extends VaultBaseAuth {
27
28
 
28
29
  const k8sJwtToken = fs.readFileSync(this.__tokenPath).toString();
29
30
  this._log.debug(
30
- 'receive K8s token: %s',
31
- k8sJwtToken
31
+ 'read K8s service-account JWT from "%s" (%d bytes)',
32
+ this.__tokenPath, k8sJwtToken.length
32
33
  );
33
34
 
34
35
  return this.__apiClient.makeRequest('POST', `/auth/${this._mount}/login`, {
35
36
  role: this.__role,
36
37
  jwt: k8sJwtToken,
37
38
  }).then((res) => {
38
- this._log.debug(
39
- 'receive token: %s',
40
- res.auth.client_token
41
- );
39
+ this._log.debug('received Vault client token from Kubernetes login');
42
40
 
43
41
  return this._getTokenEntity(res.auth.client_token);
44
42
  });
@@ -6,13 +6,14 @@ const errors = require('../errors');
6
6
  class VaultTokenAuth extends VaultBaseAuth {
7
7
 
8
8
  /**
9
- * @param {Object} connConfig - see {@link VaultBaseAuth#constructor}
9
+ * @param {VaultApiClient} apiClient - see {@link VaultBaseAuth#constructor}
10
+ * @param {Object} logger
10
11
  * @param {Object} config
11
12
  * @param {String} config.token
12
- * @param {String} mount - Vault's mount point ("token" by default)
13
+ * @param {String} mount - Vault's mount point ("token" by default)
13
14
  */
14
- constructor(connConfig, logger, config, mount) {
15
- super(connConfig, logger, mount || 'token');
15
+ constructor(apiClient, logger, config, mount) {
16
+ super(apiClient, logger, mount || 'token');
16
17
 
17
18
  if (!config.token) {
18
19
  throw new errors.InvalidArgumentsError('Auth token should be provided for VaultTokenAuth');
package/src/errors.js CHANGED
@@ -1,12 +1,12 @@
1
1
  'use strict';
2
2
 
3
3
  class VaultError extends Error {
4
- constructor(message, error) {
4
+ constructor(message) {
5
5
  super(message);
6
6
  this.name = this.constructor.name;
7
7
  this.message = message;
8
8
 
9
- Error.captureStackTrace(this, this.constructor.name);
9
+ Error.captureStackTrace(this, this.constructor);
10
10
  }
11
11
  }
12
12
  class InvalidArgumentsError extends VaultError {}