node-vault-client 2.1.2 → 2.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 +279 -16
- package/index.d.ts +125 -0
- package/package.json +11 -5
- package/src/VaultClient.js +72 -2
- package/src/auth/VaultBaseAuth.js +134 -10
- package/src/auth/VaultJwtAuth.js +95 -0
- package/src/errors.d.ts +18 -0
package/README.md
CHANGED
|
@@ -18,6 +18,38 @@ npm install --save node-vault-client
|
|
|
18
18
|
|
|
19
19
|
Node.js >= 18 — the client uses the native `fetch` API.
|
|
20
20
|
|
|
21
|
+
### TypeScript
|
|
22
|
+
|
|
23
|
+
Type declarations ship with the package, so no separate install is needed:
|
|
24
|
+
|
|
25
|
+
```typescript
|
|
26
|
+
import VaultClient = require('node-vault-client');
|
|
27
|
+
|
|
28
|
+
const client = new VaultClient({
|
|
29
|
+
api: { url: 'http://127.0.0.1:8200' },
|
|
30
|
+
auth: { type: 'appRole', config: { role_id: 'roleId', secret_id: 'secretId' } },
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const lease = await client.read('secret/app');
|
|
34
|
+
const password = lease.getValue<string>('password');
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`auth` is a discriminated union on `type`, so each backend only accepts its own
|
|
38
|
+
configuration, and the three mutually-exclusive JWT sources (`jwt`, `jwtPath`,
|
|
39
|
+
`jwtProvider`) are enforced at compile time.
|
|
40
|
+
|
|
41
|
+
Types that appear in signatures are exported in type space under the `VaultClient`
|
|
42
|
+
namespace — `VaultClient.Lease`, `VaultClient.VaultOptions`, `VaultClient.AuthToken`
|
|
43
|
+
and the per-backend config interfaces. They are types only; the classes behind them
|
|
44
|
+
are not exported at runtime.
|
|
45
|
+
|
|
46
|
+
If you previously installed the third-party `@types/node-vault-client`, remove it —
|
|
47
|
+
it stops at the 1.x API and its declarations take precedence in some setups:
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
npm uninstall @types/node-vault-client
|
|
51
|
+
```
|
|
52
|
+
|
|
21
53
|
## Example
|
|
22
54
|
|
|
23
55
|
```javascript
|
|
@@ -26,7 +58,7 @@ const VaultClient = require('node-vault-client');
|
|
|
26
58
|
const vaultClient = VaultClient.boot('main', {
|
|
27
59
|
api: { url: 'https://vault.example.com:8200/' },
|
|
28
60
|
auth: {
|
|
29
|
-
type: 'appRole', // one of: 'appRole' | 'token' | 'iam' | 'kubernetes'
|
|
61
|
+
type: 'appRole', // one of: 'appRole' | 'token' | 'iam' | 'kubernetes' | 'jwt'
|
|
30
62
|
config: { role_id: '637c065f-c644-5e12-d3d1-e9fa4363af61' }
|
|
31
63
|
},
|
|
32
64
|
});
|
|
@@ -42,6 +74,7 @@ vaultClient.read('secret/tst').then(lease => {
|
|
|
42
74
|
* [AppRole](https://developer.hashicorp.com/vault/docs/auth/approle)
|
|
43
75
|
* [Token](https://developer.hashicorp.com/vault/docs/auth/token)
|
|
44
76
|
* [Kubernetes](https://developer.hashicorp.com/vault/docs/auth/kubernetes)
|
|
77
|
+
* [JWT](https://developer.hashicorp.com/vault/docs/auth/jwt)
|
|
45
78
|
|
|
46
79
|
### AWS IAM auth
|
|
47
80
|
|
|
@@ -125,6 +158,158 @@ const vaultClient = VaultClient.boot('main', {
|
|
|
125
158
|
});
|
|
126
159
|
```
|
|
127
160
|
|
|
161
|
+
### JWT auth
|
|
162
|
+
|
|
163
|
+
```javascript
|
|
164
|
+
const vaultClient = VaultClient.boot('main', {
|
|
165
|
+
api: { url: 'https://vault.example.com:8200/' },
|
|
166
|
+
auth: {
|
|
167
|
+
type: 'jwt',
|
|
168
|
+
mount: 'jwt', // Optional. Vault JWT auth mount point ("jwt" by default)
|
|
169
|
+
config: {
|
|
170
|
+
role: 'my-app', // Optional. Role configured in Vault's JWT auth backend; omitted uses the mount's `default_role`
|
|
171
|
+
jwt: process.env.CI_JOB_JWT, // Exactly one of `jwt` / `jwtPath` / `jwtProvider` is required (see below)
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
Exactly one of three mutually exclusive `config` keys supplies the JWT:
|
|
178
|
+
|
|
179
|
+
* **`jwt`** — a literal token string. Use it for CI jobs and other processes that are guaranteed
|
|
180
|
+
to finish before the token expires. Caveat: the value is fixed at construction, so if the
|
|
181
|
+
client re-authenticates (its Vault-issued token TTL runs out while the process is still up) it
|
|
182
|
+
re-sends that exact same JWT — which works only until the IdP-issued token itself expires,
|
|
183
|
+
after which Vault rejects every further login attempt. Do not use `jwt` in a long-running
|
|
184
|
+
process.
|
|
185
|
+
* **`jwtPath`** — path to a file containing the JWT, re-read on every login (mirrors
|
|
186
|
+
[Kubernetes auth](#kubernetes-auth)'s `tokenPath`). Use it for rotated *projected* tokens, such
|
|
187
|
+
as a Kubernetes projected service-account token the kubelet refreshes on disk, so each login
|
|
188
|
+
picks up whatever is currently on disk instead of a token captured once at startup.
|
|
189
|
+
* **`jwtProvider`** — an (optionally async) function, called fresh at login time (never at
|
|
190
|
+
construction, never cached), returning `string | Promise<string>`. Use it when the JWT has to
|
|
191
|
+
be minted per login — GitHub Actions' `core.getIDToken()`, a cloud metadata endpoint, a
|
|
192
|
+
SPIFFE/SPIRE workload API.
|
|
193
|
+
|
|
194
|
+
#### Authenticating from GitHub Actions
|
|
195
|
+
|
|
196
|
+
```yaml
|
|
197
|
+
permissions:
|
|
198
|
+
id-token: write
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
```javascript
|
|
202
|
+
const core = require('@actions/core');
|
|
203
|
+
const VaultClient = require('node-vault-client');
|
|
204
|
+
|
|
205
|
+
const vaultClient = VaultClient.boot('ci', {
|
|
206
|
+
api: { url: process.env.VAULT_ADDR },
|
|
207
|
+
auth: {
|
|
208
|
+
type: 'jwt',
|
|
209
|
+
mount: 'gha', // matches wherever the JWT method was mounted, e.g. `vault auth enable -path=gha jwt`
|
|
210
|
+
config: { role: 'ci', jwtProvider: () => core.getIDToken('vault') },
|
|
211
|
+
},
|
|
212
|
+
});
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
`role` is optional here too — omit it to use the mount's `default_role`. `mount` and
|
|
216
|
+
`api.namespace` behave exactly as they do for the other four backends.
|
|
217
|
+
|
|
218
|
+
Vault's JWT method also offers an interactive `oidc` login (a browser redirect for a human user).
|
|
219
|
+
This library implements only the non-interactive `jwt` flow: a service client doing headless
|
|
220
|
+
background renewal has no browser to redirect to, so `oidc` is deliberately not supported.
|
|
221
|
+
|
|
222
|
+
### Token renewal
|
|
223
|
+
|
|
224
|
+
Whenever Vault issues a **renewable** token, the client arms a background timer and renews it at
|
|
225
|
+
half its remaining lifetime, for as long as the client lives. That is the default and suits
|
|
226
|
+
long-running services.
|
|
227
|
+
|
|
228
|
+
Set `renewal: false` on the `auth` block (beside `type`, not inside `config`) to turn it off:
|
|
229
|
+
|
|
230
|
+
```javascript
|
|
231
|
+
auth: {
|
|
232
|
+
type: 'appRole',
|
|
233
|
+
renewal: false,
|
|
234
|
+
config: {
|
|
235
|
+
role_id: '637c065f-...',
|
|
236
|
+
secret_id: '...',
|
|
237
|
+
},
|
|
238
|
+
}
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
These keys sit on `auth` rather than in `auth.config` on purpose: `config` is the backend's own
|
|
242
|
+
credential bag and may already carry keys of your own, so reserving names inside it could break an
|
|
243
|
+
existing caller. Same reasoning that put the namespace at `api.namespace`.
|
|
244
|
+
|
|
245
|
+
With renewal off, the client keeps using the token until it expires and then simply logs in again
|
|
246
|
+
on the next call — no background timer at all. Two reasons to want that:
|
|
247
|
+
|
|
248
|
+
* **Short-lived processes.** The renewal timer keeps the Node.js event loop alive, so a script that
|
|
249
|
+
finishes its work does not exit on its own; you have to call
|
|
250
|
+
[`close()`](#VaultClient+close). With `renewal: false` there is no timer to hold it open.
|
|
251
|
+
* **You would rather re-authenticate than renew** — for example where the auth backend can always
|
|
252
|
+
mint a fresh token (`jwtProvider`, Kubernetes, IAM) and you prefer a clean login over extending
|
|
253
|
+
an existing lease.
|
|
254
|
+
|
|
255
|
+
#### Before you turn it off
|
|
256
|
+
|
|
257
|
+
Renewal off means the token is allowed to expire, and what happens next depends on whether your
|
|
258
|
+
backend can obtain a *fresh* credential unaided:
|
|
259
|
+
|
|
260
|
+
| backend | on expiry with `renewal: false` |
|
|
261
|
+
| --- | --- |
|
|
262
|
+
| `kubernetes`, `iam`, `jwt` with `jwtPath`/`jwtProvider` | clean re-login — the JWT or AWS credential is re-acquired, so this is the intended case |
|
|
263
|
+
| `appRole` | re-login **replays the same `secret_id`**. Fine for a reusable one; with Vault's recommended hardening (`secret_id_num_uses=1`, or a short `secret_id_ttl`) the second login is rejected |
|
|
264
|
+
| `jwt` with a literal `jwt` | replays the same JWT, so it works only until the IdP-issued token expires |
|
|
265
|
+
| `token` | **cannot re-authenticate at all.** The client raises `AuthTokenExpiredError` from then on, permanently |
|
|
266
|
+
|
|
267
|
+
Two consequences worth stating plainly:
|
|
268
|
+
|
|
269
|
+
* For `token` auth this **is** a behaviour change, not a no-op. A renewable token handed to `token`
|
|
270
|
+
auth is renewed indefinitely today; with `renewal: false` it expires and every later call rejects
|
|
271
|
+
for the life of the process. `close()` does not reset it — recovery means
|
|
272
|
+
`VaultClient.clear(name)` and booting again.
|
|
273
|
+
* For `appRole` and literal-`jwt`, a login that can no longer succeed is retried on **every**
|
|
274
|
+
subsequent call, since a failed login clears the cached token. That is a failing request per
|
|
275
|
+
`read()`, with no backoff. Prefer `jwtPath`/`jwtProvider`, a reusable `secret_id`, or leaving
|
|
276
|
+
renewal on.
|
|
277
|
+
|
|
278
|
+
**Expiring a token also revokes its leases.** Vault revokes every lease created by a token when
|
|
279
|
+
that token expires, and this client does not renew secret leases — only the auth token. If you read
|
|
280
|
+
dynamic credentials (`database/creds/*`, cloud credentials) whose lease outlives the auth token,
|
|
281
|
+
leaving renewal on is what currently keeps them alive. KV reads are unaffected.
|
|
282
|
+
|
|
283
|
+
#### Tuning renewal instead of disabling it
|
|
284
|
+
|
|
285
|
+
Two further keys, also on `auth`, shape *how* renewal happens. Both are optional, and leaving them
|
|
286
|
+
out reproduces the behaviour the client has always had.
|
|
287
|
+
|
|
288
|
+
| key | default | meaning |
|
|
289
|
+
| --- | --- | --- |
|
|
290
|
+
| `renewalFraction` | `0.5` | How much of the token's **remaining** lifetime to wait before renewing, as a fraction in `(0, 1)`. `0.5` renews at the halfway point. |
|
|
291
|
+
| `renewalIncrement` | *(unset)* | Seconds of extra TTL to ask for, sent as `increment` to `auth/token/renew-self`. Unset means Vault applies the token's own period. |
|
|
292
|
+
|
|
293
|
+
```javascript
|
|
294
|
+
auth: {
|
|
295
|
+
type: 'kubernetes',
|
|
296
|
+
renewalFraction: 0.25, // renew after a quarter of the remaining lifetime
|
|
297
|
+
renewalIncrement: 3600, // ask Vault for another hour each time
|
|
298
|
+
config: { role: 'my-app' },
|
|
299
|
+
}
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
Lower `renewalFraction` values renew earlier and more often, which buys headroom if Vault is briefly
|
|
303
|
+
unreachable. A failed renewal is retried on the same rule against the *same* token, so the waits
|
|
304
|
+
shrink geometrically as the remaining lifetime does — for a 1-hour token that is roughly 12 attempts
|
|
305
|
+
at `0.5` (1800s, 900s, 450s, …) versus 27 at `0.25`, both bottoming out at a 1-second floor just
|
|
306
|
+
before expiry. Higher values renew later and talk to Vault less.
|
|
307
|
+
|
|
308
|
+
`renewalIncrement` is a request, not a guarantee: Vault grants at most the token's max TTL and may
|
|
309
|
+
return less. Both keys are validated at construction — a `renewalFraction` outside `(0, 1)` or a
|
|
310
|
+
non-positive/non-integer `renewalIncrement` raises `InvalidArgumentsError` rather than failing later
|
|
311
|
+
inside a background timer.
|
|
312
|
+
|
|
128
313
|
## API
|
|
129
314
|
|
|
130
315
|
<a name="VaultClient"></a>
|
|
@@ -148,7 +333,7 @@ const vaultClient = VaultClient.boot('main', {
|
|
|
148
333
|
* [.deleteMetadata(path)](#VaultClient+deleteMetadata) ⇒ <code>Promise.<Object></code>
|
|
149
334
|
* [.close()](#VaultClient+close)
|
|
150
335
|
* _static_
|
|
151
|
-
* [.boot(name,
|
|
336
|
+
* [.boot(name, options)](#VaultClient.boot) ⇒ <code>VaultClient</code>
|
|
152
337
|
* [.get(name)](#VaultClient.get) ⇒ <code>VaultClient</code>
|
|
153
338
|
* [.clear([name])](#VaultClient.clear)
|
|
154
339
|
* [Lease](#Lease)
|
|
@@ -175,11 +360,14 @@ Client constructor function.
|
|
|
175
360
|
| [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)) |
|
|
176
361
|
| [options.api.engines] | <code>Object</code> | `{}` | static mount-to-version map, e.g. `{ secret: 2, legacy: 1 }` (see [KV v2 & generic backends](#kv-v2--generic-backends)) |
|
|
177
362
|
| options.auth | <code>Object</code> | | |
|
|
178
|
-
| options.auth.type | <code>String</code> | |
|
|
179
|
-
| [options.auth.mount] | <code>String</code> | | Vault auth backend mount point; default varies per method (e.g. "aws" for iam, "approle", "token", "kubernetes") |
|
|
363
|
+
| options.auth.type | <code>String</code> | | one of: 'appRole' \| 'token' \| 'iam' \| 'kubernetes' \| 'jwt' |
|
|
364
|
+
| [options.auth.mount] | <code>String</code> | | Vault auth backend mount point; default varies per method (e.g. "aws" for iam, "approle", "token", "kubernetes", "jwt") |
|
|
180
365
|
| options.auth.config | <code>Object</code> | | auth configuration variables |
|
|
181
366
|
| [options.auth.config.namespace] | <code>String</code> | | Optional. Legacy location for the Vault namespace (see `api.namespace`). Sent as the `X-Vault-Namespace` header on **every** request for every auth type. |
|
|
182
|
-
| [options.
|
|
367
|
+
| [options.auth.renewal] | <code>boolean</code> | <code>true</code> | Set `false` to never renew the Vault token in the background; the token is used until it expires and the next call re-authenticates. See [Token renewal](#token-renewal). |
|
|
368
|
+
| [options.auth.renewalFraction] | <code>number</code> | <code>0.5</code> | How much of the token's remaining lifetime to wait before renewing, as a fraction in `(0, 1)`. |
|
|
369
|
+
| [options.auth.renewalIncrement] | <code>number</code> | | Seconds of extra TTL to request on each renewal, sent as `increment` to `auth/token/renew-self`. Vault caps it at the token's max TTL. |
|
|
370
|
+
| [options.logger] | <code>Object</code> \| <code>false</code> | | Logger that must implement **all five** of "error", "warn", "info", "debug" and "trace" — an object missing any one of them is silently ignored and the default logger is used instead. The default logger writes to `console`, except `debug`, which is discarded so that sensitive data is never printed. Pass `false` to disable logging entirely. |
|
|
183
371
|
|
|
184
372
|
##### Custom transport (proxy / self-signed TLS)
|
|
185
373
|
|
|
@@ -214,12 +402,50 @@ env var with no code change. Only disable verification
|
|
|
214
402
|
(`new Agent({ connect: { rejectUnauthorized: false } })`) in throwaway/dev setups — it removes
|
|
215
403
|
MITM protection.
|
|
216
404
|
|
|
405
|
+
The `undici` you install must be interface-compatible with the one Node bundles
|
|
406
|
+
(`process.versions.undici`). A dispatcher from a mismatched major is rejected by the global
|
|
407
|
+
`fetch()` before the request leaves the process — on Node 24, `undici@8` fails immediately with
|
|
408
|
+
`TypeError: fetch failed` and `cause.code` of `invalid onRequestStart method`. Node 22 and 24
|
|
409
|
+
bundle undici 7.x, so pin `undici@^7`.
|
|
410
|
+
|
|
411
|
+
##### Request timeouts
|
|
412
|
+
|
|
413
|
+
There is no default timeout. A Vault that accepts the connection and never answers will leave
|
|
414
|
+
`read()` (and every other call) pending indefinitely. Set one through the dispatcher:
|
|
415
|
+
|
|
416
|
+
```javascript
|
|
417
|
+
const { Agent } = require('undici');
|
|
418
|
+
|
|
419
|
+
const vaultClient = VaultClient.boot('main', {
|
|
420
|
+
api: {
|
|
421
|
+
url: 'https://vault.example.com:8200/',
|
|
422
|
+
requestOptions: { dispatcher: new Agent({ headersTimeout: 5000, bodyTimeout: 5000 }) },
|
|
423
|
+
},
|
|
424
|
+
auth: { type: 'token', config: { token: '...' } },
|
|
425
|
+
});
|
|
426
|
+
```
|
|
427
|
+
|
|
428
|
+
A failed request rejects with `TypeError: fetch failed`, with `cause.code` set to
|
|
429
|
+
`UND_ERR_HEADERS_TIMEOUT` or `UND_ERR_BODY_TIMEOUT`.
|
|
430
|
+
|
|
431
|
+
Do **not** put `signal: AbortSignal.timeout(ms)` in `requestOptions`. It is created once and
|
|
432
|
+
shared by every request the client makes, so it works for the first call and then aborts all
|
|
433
|
+
later ones with `TimeoutError`. Pass a fresh signal per call, or use the dispatcher above.
|
|
434
|
+
|
|
435
|
+
##### Redirects and the Vault token
|
|
436
|
+
|
|
437
|
+
Requests follow HTTP redirects, because Vault HA clusters answer standby nodes with a 307 to the
|
|
438
|
+
active node. Node's `fetch()` strips only `Authorization`, `Cookie` and `Proxy-Authorization` when
|
|
439
|
+
a redirect crosses origins — `X-Vault-Token` is **not** on that list and is forwarded. A host that
|
|
440
|
+
can answer for `api.url` can therefore redirect the client and receive its Vault token, so treat
|
|
441
|
+
`api.url` (and the DNS and any load balancer in front of it) as trusted infrastructure.
|
|
442
|
+
|
|
217
443
|
<a name="VaultClient+fillNodeConfig"></a>
|
|
218
444
|
|
|
219
445
|
#### vaultClient.fillNodeConfig() ⇒ <code>Promise</code>
|
|
220
446
|
Populates Vault's values to NPM "config" module
|
|
221
447
|
|
|
222
|
-
Resolves once the npm `config` module has been populated from Vault.
|
|
448
|
+
Resolves once the npm `config` module has been populated from Vault. Note that setup failures are thrown **synchronously**, not returned as a rejected promise: a missing `config` peer dependency and an unreadable `<NODE_CONFIG_DIR>/custom-vault-variables.js` both throw `VaultError` before the promise is created, so use `await` or wrap the call in `try`/`catch` rather than relying on `.catch()` alone.
|
|
223
449
|
|
|
224
450
|
**Kind**: instance method of [<code>VaultClient</code>](#VaultClient)
|
|
225
451
|
<a name="VaultClient+read"></a>
|
|
@@ -358,7 +584,7 @@ The destroyed version data cannot be recovered. KV v2 only — rejects with
|
|
|
358
584
|
#### vaultClient.readMetadata(path) ⇒ <code>Promise.<Object></code>
|
|
359
585
|
Reads KV v2 metadata for a secret
|
|
360
586
|
|
|
361
|
-
Resolves to the metadata document (`current_version`, the `versions` map, timestamps, etc.).
|
|
587
|
+
Resolves to the raw parsed Vault response body; the metadata document (`current_version`, the `versions` map, timestamps, etc.) is under its `data` property, e.g. `(await client.readMetadata('secret/foo')).data.current_version`.
|
|
362
588
|
KV v2 only — rejects with `UnsupportedOperationError` on non-v2 mounts.
|
|
363
589
|
|
|
364
590
|
**Kind**: instance method of [<code>VaultClient</code>](#VaultClient)
|
|
@@ -402,6 +628,12 @@ vaultClient.close(); // process can now exit
|
|
|
402
628
|
|
|
403
629
|
**Kind**: instance method of [<code>VaultClient</code>](#VaultClient)
|
|
404
630
|
|
|
631
|
+
|
|
632
|
+
`close()` also settles a renewal that is already in flight: a request that lands after the call is
|
|
633
|
+
discarded rather than arming a fresh timer, so the client stops holding the event loop open. The
|
|
634
|
+
same applies when a token is replaced by a new login — a renewal still running against the previous
|
|
635
|
+
token cannot overwrite the newer one or cancel its timer.
|
|
636
|
+
|
|
405
637
|
<a name="Lease"></a>
|
|
406
638
|
|
|
407
639
|
### Lease
|
|
@@ -412,22 +644,31 @@ accessors to extract the secret data:
|
|
|
412
644
|
* `getValue(key)` ⇒ <code>String</code> — value for a single key. Throws `Requested key does not exist` when the key is absent.
|
|
413
645
|
* `getData()` ⇒ <code>Object</code> — a deep-cloned copy of the whole secret data object.
|
|
414
646
|
* `isRenewable()` ⇒ <code>boolean</code> — whether the underlying lease is renewable.
|
|
647
|
+
* `getMetadata()` ⇒ <code>Object</code> | <code>undefined</code> — KV v2 version metadata
|
|
648
|
+
(`version`, `created_time`, `deletion_time`, `destroyed`, `custom_metadata`), or `undefined`
|
|
649
|
+
on KV v1 and non-KV mounts. See [Lease.getMetadata()](#leasegetmetadata).
|
|
415
650
|
|
|
416
651
|
<a name="VaultClient.boot"></a>
|
|
417
652
|
|
|
418
|
-
#### VaultClient.boot(name,
|
|
653
|
+
#### VaultClient.boot(name, options) ⇒ <code>VaultClient</code>
|
|
419
654
|
Boot an instance of Vault
|
|
420
655
|
|
|
421
656
|
The instance will be stored in a local hash. Calling Vault.boot multiple
|
|
422
657
|
times with the same name will return the same instance.
|
|
423
658
|
|
|
659
|
+
`options` are used only when the instance is first created. A later call for a name that already
|
|
660
|
+
exists returns the existing instance and ignores the options passed to it; when those differ from
|
|
661
|
+
the ones it was booted with, the client logs a warning rather than letting the difference pass
|
|
662
|
+
silently. Use [`VaultClient.get(name)`](#VaultClient.get) to fetch an existing instance, or
|
|
663
|
+
[`VaultClient.clear(name)`](#VaultClient.clear) before booting to replace one.
|
|
664
|
+
|
|
424
665
|
**Kind**: static method of [<code>VaultClient</code>](#VaultClient)
|
|
425
666
|
**Returns**: <code>VaultClient</code>
|
|
426
667
|
|
|
427
668
|
| Param | Type | Description |
|
|
428
669
|
| --- | --- | --- |
|
|
429
670
|
| name | <code>String</code> | Vault instance name |
|
|
430
|
-
|
|
|
671
|
+
| options | <code>Object</code> | options for [Vault#constructor](#new_VaultClient_new). Required on every call, including for a name that was already booted — use [VaultClient.get(name)](#VaultClient.get) to fetch an existing instance. |
|
|
431
672
|
|
|
432
673
|
<a name="VaultClient.get"></a>
|
|
433
674
|
|
|
@@ -507,7 +748,7 @@ const client = VaultClient.boot('main', {
|
|
|
507
748
|
|
|
508
749
|
### KV v2-specific methods
|
|
509
750
|
|
|
510
|
-
These methods require a KV v2 mount and throw `UnsupportedOperationError` on v1 / non-KV mounts.
|
|
751
|
+
These methods require a KV v2 mount and throw `UnsupportedOperationError` on v1 / non-KV mounts. They also require the mount to be *resolved* as v2: with neither `api.kv.autoDetect: true` nor an `api.engines` entry covering the mount, every path resolves as v1 passthrough with no detection call, and these methods fail with `UnsupportedOperationError` (`Mount "secret" is not a KV v2 engine.`) even against a genuine KV v2 mount.
|
|
511
752
|
|
|
512
753
|
```javascript
|
|
513
754
|
// Soft-delete specific versions
|
|
@@ -519,7 +760,8 @@ await client.undeleteVersions('secret/foo', [1]);
|
|
|
519
760
|
// Permanently destroy versions
|
|
520
761
|
await client.destroyVersions('secret/foo', [1, 2]);
|
|
521
762
|
|
|
522
|
-
// Read version metadata
|
|
763
|
+
// Read version metadata. The full Vault envelope is returned, so the metadata
|
|
764
|
+
// fields live under `.data` (meta.data.current_version, meta.data.versions, ...)
|
|
523
765
|
const meta = await client.readMetadata('secret/foo');
|
|
524
766
|
|
|
525
767
|
// Delete all metadata and version history (permanent)
|
|
@@ -591,17 +833,38 @@ await client.request('GET', 'secret/data/foo');
|
|
|
591
833
|
|
|
592
834
|
### Mount detection caching
|
|
593
835
|
|
|
594
|
-
- Each canonical mount is detected
|
|
836
|
+
- Each canonical mount is detected once and then cached for the life of the `VaultClient` instance. The cache is a bounded LRU with a fixed cap of 500 mounts (not configurable through client options); a long-lived client that touches more than 500 distinct mounts evicts the least-recently-used entries, and an evicted mount is detected again on its next use.
|
|
595
837
|
- Concurrent first-touch requests for the same mount share a single in-flight detection promise.
|
|
596
838
|
- The detection endpoint used is `GET sys/internal/ui/mounts/<path>` (readable by any authenticated token).
|
|
597
839
|
- When the token lacks permission on that endpoint, set `api.engines` to skip detection.
|
|
598
840
|
|
|
599
841
|
### Error classes
|
|
600
842
|
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
843
|
+
Every error the client raises extends `VaultError`. The package entry point exports the
|
|
844
|
+
`VaultClient` class only, so the classes themselves are imported from `node-vault-client/src/errors`:
|
|
845
|
+
|
|
846
|
+
```javascript
|
|
847
|
+
const errors = require('node-vault-client/src/errors');
|
|
848
|
+
|
|
849
|
+
try {
|
|
850
|
+
await client.readMetadata('secret/app');
|
|
851
|
+
} catch (err) {
|
|
852
|
+
if (err instanceof errors.UnsupportedOperationError) {
|
|
853
|
+
// the mount did not resolve as KV v2 - see "Path requirements" above
|
|
854
|
+
return null;
|
|
855
|
+
}
|
|
856
|
+
throw err;
|
|
857
|
+
}
|
|
858
|
+
```
|
|
859
|
+
|
|
860
|
+
| Class | Extends | When thrown |
|
|
861
|
+
|---|---|---|
|
|
862
|
+
| `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. |
|
|
863
|
+
| `VaultHttpError` | `VaultError` | Vault answered with a non-2xx status. Carries the status as `statusCode` and the parsed body as `error`. |
|
|
864
|
+
| `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 an invalid node-config substitution map. |
|
|
865
|
+
| `InvalidAWSCredentialsError` | `InvalidArgumentsError` | `auth.config.credentials` was supplied but is not a usable `accessKeyId` / `secretAccessKey` pair. |
|
|
866
|
+
| `AuthTokenExpiredError` | `VaultError` | The Vault token expired and the backend cannot obtain a new one. `token` auth can never re-authenticate; other backends reach this only with `renewal: false` and a credential they cannot replay. |
|
|
867
|
+
| `UnsupportedOperationError` | `VaultError` | A v2-only method (`deleteVersions`, `undeleteVersions`, `destroyVersions`, `readMetadata`, `deleteMetadata`) was called against a mount that did not resolve as KV v2. |
|
|
605
868
|
|
|
606
869
|
## Contributing
|
|
607
870
|
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
export = VaultClient;
|
|
2
|
+
|
|
3
|
+
declare class VaultClient {
|
|
4
|
+
constructor(options: VaultClient.VaultOptions);
|
|
5
|
+
|
|
6
|
+
static boot(name: string, options: VaultClient.VaultOptions): VaultClient;
|
|
7
|
+
static get(name: string): VaultClient;
|
|
8
|
+
static clear(name?: string): void;
|
|
9
|
+
|
|
10
|
+
read(path: string): Promise<VaultClient.Lease>;
|
|
11
|
+
list(path: string): Promise<VaultClient.Lease>;
|
|
12
|
+
write(path: string, data: Record<string, any>): Promise<any>;
|
|
13
|
+
delete(path: string): Promise<any>;
|
|
14
|
+
update(path: string, data: Record<string, any>): Promise<any>;
|
|
15
|
+
request(method: string, path: string, data?: Record<string, any>): Promise<any>;
|
|
16
|
+
|
|
17
|
+
deleteVersions(path: string, versions: number[]): Promise<any>;
|
|
18
|
+
undeleteVersions(path: string, versions: number[]): Promise<any>;
|
|
19
|
+
destroyVersions(path: string, versions: number[]): Promise<any>;
|
|
20
|
+
readMetadata(path: string): Promise<any>;
|
|
21
|
+
deleteMetadata(path: string): Promise<any>;
|
|
22
|
+
|
|
23
|
+
fillNodeConfig(): Promise<any>;
|
|
24
|
+
getHeaders(token: VaultClient.AuthToken): Record<string, string>;
|
|
25
|
+
close(): void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
declare namespace VaultClient {
|
|
29
|
+
interface Logger {
|
|
30
|
+
error(...args: any[]): void;
|
|
31
|
+
warn(...args: any[]): void;
|
|
32
|
+
info(...args: any[]): void;
|
|
33
|
+
debug(...args: any[]): void;
|
|
34
|
+
trace(...args: any[]): void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface ApiConfig {
|
|
38
|
+
url: string;
|
|
39
|
+
apiVersion?: string;
|
|
40
|
+
namespace?: string;
|
|
41
|
+
requestOptions?: Record<string, any>;
|
|
42
|
+
kv?: { autoDetect?: boolean };
|
|
43
|
+
engines?: Record<string, 1 | 2>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface AWSCredentials {
|
|
47
|
+
accessKeyId: string;
|
|
48
|
+
secretAccessKey: string;
|
|
49
|
+
sessionToken?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface TokenAuthConfig {
|
|
53
|
+
token: string;
|
|
54
|
+
namespace?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface AppRoleAuthConfig {
|
|
58
|
+
role_id: string;
|
|
59
|
+
secret_id?: string;
|
|
60
|
+
namespace?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface IAMAuthConfig {
|
|
64
|
+
role: string;
|
|
65
|
+
credentials?: AWSCredentials;
|
|
66
|
+
iam_server_id_header_value?: string;
|
|
67
|
+
region?: string;
|
|
68
|
+
namespace?: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface KubernetesAuthConfig {
|
|
72
|
+
role: string;
|
|
73
|
+
tokenPath?: string;
|
|
74
|
+
namespace?: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface JwtAuthConfigCommon {
|
|
78
|
+
role?: string;
|
|
79
|
+
namespace?: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
type JwtAuthConfig = JwtAuthConfigCommon &
|
|
83
|
+
(
|
|
84
|
+
| { jwt: string; jwtPath?: never; jwtProvider?: never }
|
|
85
|
+
| { jwtPath: string; jwt?: never; jwtProvider?: never }
|
|
86
|
+
| { jwtProvider: () => string | Promise<string>; jwt?: never; jwtPath?: never }
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
interface AuthOptionsCommon {
|
|
90
|
+
mount?: string;
|
|
91
|
+
renewal?: boolean;
|
|
92
|
+
renewalFraction?: number;
|
|
93
|
+
renewalIncrement?: number;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
type AuthOptions = AuthOptionsCommon &
|
|
97
|
+
(
|
|
98
|
+
| { type: 'token'; config: TokenAuthConfig }
|
|
99
|
+
| { type: 'appRole'; config: AppRoleAuthConfig }
|
|
100
|
+
| { type: 'iam'; config: IAMAuthConfig }
|
|
101
|
+
| { type: 'kubernetes'; config: KubernetesAuthConfig }
|
|
102
|
+
| { type: 'jwt'; config: JwtAuthConfig }
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
interface VaultOptions {
|
|
106
|
+
api: ApiConfig;
|
|
107
|
+
auth: AuthOptions;
|
|
108
|
+
logger?: Partial<Logger> | false;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
interface Lease {
|
|
112
|
+
getValue<T = any>(key: string): T;
|
|
113
|
+
getData<T = Record<string, any>>(): T;
|
|
114
|
+
isRenewable(): boolean;
|
|
115
|
+
getMetadata(): Record<string, any> | undefined;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
interface AuthToken {
|
|
119
|
+
getId(): string;
|
|
120
|
+
getAccessor(): string;
|
|
121
|
+
isExpired(): boolean;
|
|
122
|
+
isRenewable(): boolean;
|
|
123
|
+
getExpiresAt(): number | null;
|
|
124
|
+
}
|
|
125
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "node-vault-client",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.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"
|
|
@@ -9,17 +9,22 @@
|
|
|
9
9
|
"node": ">=18.0.0"
|
|
10
10
|
},
|
|
11
11
|
"main": "src/VaultClient.js",
|
|
12
|
+
"types": "index.d.ts",
|
|
12
13
|
"files": [
|
|
13
|
-
"src"
|
|
14
|
+
"src",
|
|
15
|
+
"index.d.ts"
|
|
14
16
|
],
|
|
15
17
|
"scripts": {
|
|
16
18
|
"test": "mocha --exit \"test/**/*.test.mjs\"",
|
|
17
19
|
"test:unit": "mocha \"test/*.test.mjs\"",
|
|
20
|
+
"test:types": "tsc -p types/tsconfig.json",
|
|
18
21
|
"test:e2e": "mocha --exit \"test/e2e/e2e.test.mjs\"",
|
|
19
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:example": "node examples/jwt-auth/vault-jwt-demo.mjs",
|
|
20
25
|
"coverage": "c8 npm run test:unit",
|
|
21
|
-
"lint": "eslint src/ test/",
|
|
22
|
-
"version": "git add
|
|
26
|
+
"lint": "eslint src/ test/ examples/",
|
|
27
|
+
"version": "git add CHANGELOG.md package.json package-lock.json"
|
|
23
28
|
},
|
|
24
29
|
"c8": {
|
|
25
30
|
"include": [
|
|
@@ -64,6 +69,7 @@
|
|
|
64
69
|
"lodash": "^4.18.1",
|
|
65
70
|
"mocha": "^11.7.6",
|
|
66
71
|
"sinon": "^22.1.0",
|
|
67
|
-
"sinon-chai": "^4.0.1"
|
|
72
|
+
"sinon-chai": "^4.0.1",
|
|
73
|
+
"typescript": "^5.9.3"
|
|
68
74
|
}
|
|
69
75
|
}
|
package/src/VaultClient.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const Lease = require('./Lease');
|
|
4
|
+
const crypto = require('crypto');
|
|
4
5
|
const errors = require('./errors');
|
|
5
6
|
const VaultApiClient = require('./VaultApiClient');
|
|
6
7
|
const VaultAppRoleAuth = require('./auth/VaultAppRoleAuth');
|
|
@@ -8,12 +9,43 @@ const VaultTokenAuth = require('./auth/VaultTokenAuth');
|
|
|
8
9
|
const VaultIAMAuth = require('./auth/VaultIAMAuth');
|
|
9
10
|
const VaultNodeConfig = require('./VaultNodeConfig');
|
|
10
11
|
const VaultKubernetesAuth = require('./auth/VaultKubernetesAuth');
|
|
12
|
+
const VaultJwtAuth = require('./auth/VaultJwtAuth');
|
|
11
13
|
const MountResolver = require('./MountResolver');
|
|
12
14
|
const { rewritePath, normalizeResponse } = require('./kvTransform');
|
|
13
15
|
|
|
14
16
|
const noop = () => {};
|
|
15
17
|
const vaultInstances = {};
|
|
16
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Order-insensitive canonical form of the boot options, hashed so that no credential is
|
|
21
|
+
* retained just to compare two boot() calls. Functions compare by identity, which is what
|
|
22
|
+
* a caller passing `jwtProvider` expects.
|
|
23
|
+
*/
|
|
24
|
+
function fingerprintOptions(value, seen) {
|
|
25
|
+
if (typeof value === 'function') {
|
|
26
|
+
return 'fn';
|
|
27
|
+
}
|
|
28
|
+
if (value === null || typeof value !== 'object') {
|
|
29
|
+
return JSON.stringify(value) || String(value);
|
|
30
|
+
}
|
|
31
|
+
seen = seen || new Set();
|
|
32
|
+
if (seen.has(value)) {
|
|
33
|
+
return 'circular';
|
|
34
|
+
}
|
|
35
|
+
seen.add(value);
|
|
36
|
+
if (Array.isArray(value)) {
|
|
37
|
+
return `[${value.map((item) => fingerprintOptions(item, seen)).join(',')}]`;
|
|
38
|
+
}
|
|
39
|
+
const body = Object.keys(value).sort()
|
|
40
|
+
.map((key) => `${JSON.stringify(key)}:${fingerprintOptions(value[key], seen)}`)
|
|
41
|
+
.join(',');
|
|
42
|
+
return `{${body}}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function optionsDigest(options) {
|
|
46
|
+
return crypto.createHash('sha256').update(fingerprintOptions(options)).digest('hex');
|
|
47
|
+
}
|
|
48
|
+
|
|
17
49
|
class VaultClient {
|
|
18
50
|
|
|
19
51
|
/**
|
|
@@ -33,8 +65,17 @@ class VaultClient {
|
|
|
33
65
|
* every request across all auth backends. For backward compatibility `options.auth.config.namespace`
|
|
34
66
|
* is also honored when this is not set.
|
|
35
67
|
* @param {Object} options.auth
|
|
36
|
-
* @param {String} options.auth.type
|
|
68
|
+
* @param {String} options.auth.type - one of: 'appRole' | 'token' | 'iam' | 'kubernetes' | 'jwt'
|
|
37
69
|
* @param {Object} options.auth.config - auth configuration variables
|
|
70
|
+
* @param {boolean} [options.auth.renewal=true] - Set `false` to never renew the Vault token in
|
|
71
|
+
* the background. The token is then used until it expires and the next call
|
|
72
|
+
* re-authenticates. Note `token` auth cannot re-authenticate and will raise
|
|
73
|
+
* {@link AuthTokenExpiredError} from that point on.
|
|
74
|
+
* @param {number} [options.auth.renewalFraction=0.5] - How much of the token's remaining
|
|
75
|
+
* lifetime to wait before renewing, as a fraction in `(0, 1)`.
|
|
76
|
+
* @param {number} [options.auth.renewalIncrement] - Seconds of extra TTL to request on each
|
|
77
|
+
* renewal, sent as `increment` to `auth/token/renew-self`. Vault caps it at the token's
|
|
78
|
+
* max TTL. Omitted by default, which lets Vault apply the token's own period.
|
|
38
79
|
* @param {Object|false} options.logger - Logger that supports "error", "info", "warn", "trace", "debug" methods. Uses `console` by default. Pass `false` to disable logging.
|
|
39
80
|
*/
|
|
40
81
|
constructor(options) {
|
|
@@ -63,6 +104,12 @@ class VaultClient {
|
|
|
63
104
|
this.__api
|
|
64
105
|
);
|
|
65
106
|
|
|
107
|
+
// Renewal options live on `auth`, not inside `auth.config`: `auth.config` is the
|
|
108
|
+
// backend's own credential bag and may already carry consumer-specific keys, so
|
|
109
|
+
// reserving names in it could break an existing caller. Same reasoning that moved
|
|
110
|
+
// `namespace` to `api.namespace`.
|
|
111
|
+
this.__auth.configureRenewal(options.auth);
|
|
112
|
+
|
|
66
113
|
// KV v2 support
|
|
67
114
|
const kvOpts = (options.api && options.api.kv) || {};
|
|
68
115
|
const autoDetect = kvOpts.autoDetect === true;
|
|
@@ -89,8 +136,15 @@ class VaultClient {
|
|
|
89
136
|
* The instance will be stored in a local hash. Calling Vault.boot multiple
|
|
90
137
|
* times with the same name will return the same instance.
|
|
91
138
|
*
|
|
139
|
+
* `options` are used only when the instance is first created. A later call for a name
|
|
140
|
+
* that already exists returns the existing instance and ignores the options it was
|
|
141
|
+
* given; when those options differ from the ones the instance was booted with, that is
|
|
142
|
+
* logged as a warning rather than passing silently. Use {@link VaultClient.get} to
|
|
143
|
+
* fetch an existing instance, or {@link VaultClient.clear} to replace one.
|
|
144
|
+
*
|
|
92
145
|
* @param {String} name - Vault instance name
|
|
93
|
-
* @param {Object}
|
|
146
|
+
* @param {Object} options - options for {@link VaultClient#constructor}. Required on
|
|
147
|
+
* every call, including for a name that already exists.
|
|
94
148
|
* @return {VaultClient}
|
|
95
149
|
*/
|
|
96
150
|
static boot(name, options) {
|
|
@@ -101,6 +155,15 @@ class VaultClient {
|
|
|
101
155
|
let instance = vaultInstances[name];
|
|
102
156
|
if (instance === undefined) {
|
|
103
157
|
vaultInstances[name] = instance = new VaultClient(options);
|
|
158
|
+
instance.__bootDigest = optionsDigest(options);
|
|
159
|
+
} else if (instance.__bootDigest !== optionsDigest(options)) {
|
|
160
|
+
instance.__log.warn(
|
|
161
|
+
'VaultClient.boot("%s") was called with different options than the existing ' +
|
|
162
|
+
'instance was booted with; the existing instance is returned and these options ' +
|
|
163
|
+
'are ignored. Use VaultClient.get("%s") to fetch it, or VaultClient.clear("%s") ' +
|
|
164
|
+
'first to replace it.',
|
|
165
|
+
name, name, name
|
|
166
|
+
);
|
|
104
167
|
}
|
|
105
168
|
return instance;
|
|
106
169
|
}
|
|
@@ -208,6 +271,13 @@ class VaultClient {
|
|
|
208
271
|
authConfig.config,
|
|
209
272
|
authConfig.mount
|
|
210
273
|
);
|
|
274
|
+
case 'jwt':
|
|
275
|
+
return new VaultJwtAuth(
|
|
276
|
+
api,
|
|
277
|
+
this.__log,
|
|
278
|
+
authConfig.config,
|
|
279
|
+
authConfig.mount
|
|
280
|
+
);
|
|
211
281
|
}
|
|
212
282
|
|
|
213
283
|
throw new errors.InvalidArgumentsError('Unsupported auth method')
|
|
@@ -6,6 +6,9 @@
|
|
|
6
6
|
// only if a maintained alternative or a Node.js core fix appears.
|
|
7
7
|
const lt = require('long-timeout');
|
|
8
8
|
const AuthToken = require('./AuthToken');
|
|
9
|
+
|
|
10
|
+
/** Renew at the halfway point of the token's remaining lifetime. */
|
|
11
|
+
const DEFAULT_RENEWAL_FRACTION = 0.5;
|
|
9
12
|
const errors = require('../errors');
|
|
10
13
|
|
|
11
14
|
/**
|
|
@@ -37,6 +40,9 @@ class VaultBaseAuth {
|
|
|
37
40
|
/** @protected */
|
|
38
41
|
this._log = logger;
|
|
39
42
|
this._mount = mount;
|
|
43
|
+
this.__renewalEnabled = true;
|
|
44
|
+
this.__renewalFraction = DEFAULT_RENEWAL_FRACTION;
|
|
45
|
+
this.__renewalIncrement = undefined;
|
|
40
46
|
|
|
41
47
|
/**
|
|
42
48
|
* The currently held token, or `null` when none has been obtained yet.
|
|
@@ -50,6 +56,90 @@ class VaultBaseAuth {
|
|
|
50
56
|
*/
|
|
51
57
|
this.__pendingLogin = null;
|
|
52
58
|
this.__refreshTimeout = null;
|
|
59
|
+
this.__renewalEpoch = 0;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Apply the renewal options from `auth`. Separate from the constructor so that the
|
|
64
|
+
* subclass signatures -- and this one -- stay exactly as they were: anything already
|
|
65
|
+
* subclassing VaultBaseAuth keeps working untouched.
|
|
66
|
+
*
|
|
67
|
+
* @param {Object} [authConfig] - the `auth` block from VaultClient's options
|
|
68
|
+
* @param {boolean} [authConfig.renewal=true]
|
|
69
|
+
* @param {number} [authConfig.renewalFraction=0.5]
|
|
70
|
+
* @param {number} [authConfig.renewalIncrement]
|
|
71
|
+
* @returns {void}
|
|
72
|
+
*/
|
|
73
|
+
configureRenewal(authConfig) {
|
|
74
|
+
this.__renewalEnabled = VaultBaseAuth.__validateRenewal(authConfig);
|
|
75
|
+
this.__renewalFraction = VaultBaseAuth.__validateFraction(authConfig);
|
|
76
|
+
this.__renewalIncrement = VaultBaseAuth.__validateIncrement(authConfig);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Strict on purpose: node-config's `custom-environment-variables` yields strings, so a
|
|
81
|
+
* loose check would read `VAULT_RENEWAL=false` as `'false'` and leave renewal on -- the
|
|
82
|
+
* exact hang the flag exists to prevent, silently.
|
|
83
|
+
*
|
|
84
|
+
* @param {Object} [authConfig]
|
|
85
|
+
* @returns {boolean}
|
|
86
|
+
* @private
|
|
87
|
+
*/
|
|
88
|
+
static __validateRenewal(config) {
|
|
89
|
+
if (!config || config.renewal === undefined) {
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (typeof config.renewal !== 'boolean') {
|
|
94
|
+
throw new errors.InvalidArgumentsError(
|
|
95
|
+
`"renewal" should be a boolean, got ${String(config.renewal)}`
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return config.renewal;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* @param {Object} [authConfig]
|
|
104
|
+
* @returns {number}
|
|
105
|
+
* @private
|
|
106
|
+
*/
|
|
107
|
+
static __validateFraction(config) {
|
|
108
|
+
if (!config || config.renewalFraction === undefined) {
|
|
109
|
+
return DEFAULT_RENEWAL_FRACTION;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const fraction = config.renewalFraction;
|
|
113
|
+
// Exclusive upper bound: at exactly 1 the timer fires at the same instant
|
|
114
|
+
// getAuthToken() starts treating the token as expired, racing the client's own
|
|
115
|
+
// re-authentication and leaving no headroom for the renewal request itself.
|
|
116
|
+
if (typeof fraction !== 'number' || !Number.isFinite(fraction) || fraction <= 0 || fraction >= 1) {
|
|
117
|
+
throw new errors.InvalidArgumentsError(
|
|
118
|
+
`"renewalFraction" should be a number in (0, 1), got ${String(fraction)}`
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return fraction;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* @param {Object} [authConfig]
|
|
127
|
+
* @returns {number|undefined}
|
|
128
|
+
* @private
|
|
129
|
+
*/
|
|
130
|
+
static __validateIncrement(config) {
|
|
131
|
+
if (!config || config.renewalIncrement === undefined) {
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const increment = config.renewalIncrement;
|
|
136
|
+
if (!Number.isInteger(increment) || increment <= 0) {
|
|
137
|
+
throw new errors.InvalidArgumentsError(
|
|
138
|
+
`"renewalIncrement" should be a positive integer number of seconds, got ${String(increment)}`
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return increment;
|
|
53
143
|
}
|
|
54
144
|
|
|
55
145
|
/**
|
|
@@ -84,12 +174,24 @@ class VaultBaseAuth {
|
|
|
84
174
|
const pendingLogin = this._authenticate().then(authToken => {
|
|
85
175
|
this.__pendingLogin = null;
|
|
86
176
|
this.__authToken = authToken;
|
|
177
|
+
this.__renewalEpoch++;
|
|
87
178
|
|
|
88
179
|
if (authToken.isRenewable()) {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
180
|
+
// Gate the log on the flag too: the guard lives inside
|
|
181
|
+
// __setupTokenRefreshTimer, so without this the client claims it armed a
|
|
182
|
+
// timer while arming nothing -- the one diagnostic an operator reads when a
|
|
183
|
+
// `renewal: false` process still hangs.
|
|
184
|
+
if (this.__renewalEnabled) {
|
|
185
|
+
this._log.debug(
|
|
186
|
+
'setting refresh timer for token (accessor=%s)',
|
|
187
|
+
authToken.getAccessor()
|
|
188
|
+
);
|
|
189
|
+
} else {
|
|
190
|
+
this._log.debug(
|
|
191
|
+
'renewal disabled by config; not arming a refresh timer (accessor=%s)',
|
|
192
|
+
authToken.getAccessor()
|
|
193
|
+
);
|
|
194
|
+
}
|
|
93
195
|
this.__setupTokenRefreshTimer(authToken);
|
|
94
196
|
}
|
|
95
197
|
|
|
@@ -116,6 +218,7 @@ class VaultBaseAuth {
|
|
|
116
218
|
* @returns {void}
|
|
117
219
|
*/
|
|
118
220
|
cancelTokenRefresh() {
|
|
221
|
+
this.__renewalEpoch++;
|
|
119
222
|
if (this.__refreshTimeout !== null) {
|
|
120
223
|
lt.clearTimeout(this.__refreshTimeout);
|
|
121
224
|
this.__refreshTimeout = null;
|
|
@@ -151,17 +254,35 @@ class VaultBaseAuth {
|
|
|
151
254
|
this.__refreshTimeout = null;
|
|
152
255
|
}
|
|
153
256
|
|
|
154
|
-
if (!authToken.isRenewable() || authToken.isExpired()) {
|
|
257
|
+
if (!this.__renewalEnabled || !authToken.isRenewable() || authToken.isExpired()) {
|
|
155
258
|
return;
|
|
156
259
|
}
|
|
157
260
|
|
|
158
|
-
const
|
|
261
|
+
const remaining = authToken.getExpiresAt() - Math.floor(Date.now() / 1000);
|
|
262
|
+
const timer = Math.max(remaining * this.__renewalFraction, 1) * 1000;
|
|
263
|
+
|
|
264
|
+
// A renewal that is still in flight when the token is replaced or cancelled must not
|
|
265
|
+
// act on what it finds when it lands: it would overwrite the newer token, or clear
|
|
266
|
+
// the newer token's timer and then bail on its own expired one, leaving a live token
|
|
267
|
+
// permanently un-renewed. Anything that supersedes this timer bumps the epoch.
|
|
268
|
+
const epoch = ++this.__renewalEpoch;
|
|
269
|
+
const superseded = () => this.__renewalEpoch !== epoch;
|
|
159
270
|
|
|
160
271
|
this.__refreshTimeout = lt.setTimeout(() => {
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
272
|
+
if (superseded()) {
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
this.__renewToken(authToken).then(renewedToken => {
|
|
277
|
+
if (superseded()) {
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
this.__authToken = renewedToken;
|
|
281
|
+
this.__setupTokenRefreshTimer(renewedToken);
|
|
164
282
|
}).catch(err => {
|
|
283
|
+
if (superseded()) {
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
165
286
|
this.__setupTokenRefreshTimer(authToken);
|
|
166
287
|
|
|
167
288
|
this._log.error(`Cannot refresh auth token with "${authToken.getAccessor()}" accessor. Error: ${err.message}`);
|
|
@@ -183,7 +304,10 @@ class VaultBaseAuth {
|
|
|
183
304
|
__renewToken(authToken) {
|
|
184
305
|
this._log.debug('renewing vault token');
|
|
185
306
|
|
|
186
|
-
|
|
307
|
+
// null, not {}, when unset: that is the body the client has always sent.
|
|
308
|
+
const data = this.__renewalIncrement === undefined ? null : {increment: this.__renewalIncrement};
|
|
309
|
+
|
|
310
|
+
return this.__apiClient.makeRequest('POST', '/auth/token/renew-self', data, {'X-Vault-Token': authToken.getId()})
|
|
187
311
|
.then(() => {
|
|
188
312
|
this._log.info('successfully renewed token');
|
|
189
313
|
return this._getTokenEntity(authToken.getId());
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const VaultBaseAuth = require('./VaultBaseAuth');
|
|
5
|
+
const errors = require('../errors');
|
|
6
|
+
|
|
7
|
+
const JWT_SOURCE_KEYS = ['jwt', 'jwtPath', 'jwtProvider'];
|
|
8
|
+
|
|
9
|
+
class VaultJwtAuth extends VaultBaseAuth {
|
|
10
|
+
/**
|
|
11
|
+
* @param {VaultApiClient} apiClient - see {@link VaultBaseAuth#constructor}
|
|
12
|
+
* @param {Object} logger
|
|
13
|
+
* @param {Object} config
|
|
14
|
+
* @param {String} [config.role] - Role configured in Vault's JWT auth backend. Optional;
|
|
15
|
+
* when omitted, Vault falls back to the mount's configured `default_role`.
|
|
16
|
+
* @param {String} [config.jwt] - A literal JWT to present at login. Exactly one of
|
|
17
|
+
* `jwt` / `jwtPath` / `jwtProvider` must be provided.
|
|
18
|
+
* @param {String} [config.jwtPath] - Path to a file containing the JWT. Re-read on every
|
|
19
|
+
* login (like {@link VaultKubernetesAuth}'s `tokenPath`), so a rotated token is picked up.
|
|
20
|
+
* @param {Function} [config.jwtProvider] - (optionally async) function invoked at login time,
|
|
21
|
+
* returning `string | Promise<string>`. Called fresh on every login (never at construction
|
|
22
|
+
* or cached across logins) so it can mint a short-lived token -- the shape GitHub Actions'
|
|
23
|
+
* `core.getIDToken()`, cloud metadata endpoints and SPIFFE workloads need.
|
|
24
|
+
* @param {String} [config.namespace] - Optional. Vault namespace. Applied as the X-Vault-Namespace
|
|
25
|
+
* header to every request by {@link VaultApiClient}; see {@link VaultClient#constructor}.
|
|
26
|
+
* @param {String} mount - Vault's mount point ("jwt" by default)
|
|
27
|
+
*/
|
|
28
|
+
constructor(apiClient, logger, config, mount) {
|
|
29
|
+
super(apiClient, logger, mount || 'jwt');
|
|
30
|
+
|
|
31
|
+
const providedSources = JWT_SOURCE_KEYS.filter((key) => config && config[key] !== undefined);
|
|
32
|
+
if (providedSources.length !== 1) {
|
|
33
|
+
throw new errors.InvalidArgumentsError(
|
|
34
|
+
'Exactly one of "jwt", "jwtPath" or "jwtProvider" should be provided for VaultJwtAuth'
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
if (config.jwtProvider !== undefined && typeof config.jwtProvider !== 'function') {
|
|
38
|
+
throw new errors.InvalidArgumentsError('"jwtProvider" should be a function for VaultJwtAuth');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
this.__role = config.role;
|
|
42
|
+
this.__jwt = config.jwt;
|
|
43
|
+
this.__jwtPath = config.jwtPath;
|
|
44
|
+
this.__jwtProvider = config.jwtProvider;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
_authenticate() {
|
|
48
|
+
return Promise.resolve()
|
|
49
|
+
.then(() => this.__acquireJwt())
|
|
50
|
+
.then(({ jwt, source }) => {
|
|
51
|
+
this._log.info(
|
|
52
|
+
'making authentication request: Vault role: "%s"; JWT source: %s (%d bytes)',
|
|
53
|
+
this.__role !== undefined ? this.__role : '(default_role)', source, jwt.length
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
const body = { jwt };
|
|
57
|
+
if (this.__role !== undefined) {
|
|
58
|
+
body.role = this.__role;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return this.__apiClient.makeRequest('POST', `/auth/${this._mount}/login`, body)
|
|
62
|
+
.then((res) => {
|
|
63
|
+
this._log.debug('received Vault client token from JWT login');
|
|
64
|
+
|
|
65
|
+
return this._getTokenEntity(res.auth.client_token);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @returns {{jwt: String, source: String}|Promise<{jwt: String, source: String}>}
|
|
72
|
+
* @private
|
|
73
|
+
*/
|
|
74
|
+
__acquireJwt() {
|
|
75
|
+
if (this.__jwt !== undefined) {
|
|
76
|
+
return { jwt: this.__jwt, source: 'literal' };
|
|
77
|
+
}
|
|
78
|
+
if (this.__jwtPath !== undefined) {
|
|
79
|
+
return { jwt: fs.readFileSync(this.__jwtPath).toString(), source: 'file' };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Wrapping in Promise.resolve().then() normalizes both a sync provider (plain return)
|
|
83
|
+
// and a synchronous throw into the same rejection path as an async one.
|
|
84
|
+
return Promise.resolve().then(() => this.__jwtProvider()).then((jwt) => {
|
|
85
|
+
if (typeof jwt !== 'string' || jwt.length === 0) {
|
|
86
|
+
throw new errors.InvalidArgumentsError(
|
|
87
|
+
'"jwtProvider" must resolve to a non-empty JWT string'
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
return { jwt, source: 'provider' };
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
module.exports = VaultJwtAuth;
|
package/src/errors.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export declare class VaultError extends Error {
|
|
2
|
+
constructor(message: string, options?: { cause?: unknown });
|
|
3
|
+
name: string;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export declare class InvalidArgumentsError extends VaultError {}
|
|
7
|
+
|
|
8
|
+
export declare class InvalidAWSCredentialsError extends InvalidArgumentsError {}
|
|
9
|
+
|
|
10
|
+
export declare class AuthTokenExpiredError extends VaultError {}
|
|
11
|
+
|
|
12
|
+
export declare class UnsupportedOperationError extends VaultError {}
|
|
13
|
+
|
|
14
|
+
export declare class VaultHttpError extends VaultError {
|
|
15
|
+
constructor(statusCode: number, text: string, body?: unknown);
|
|
16
|
+
statusCode: number;
|
|
17
|
+
error: unknown;
|
|
18
|
+
}
|