node-vault-client 2.1.2 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -18,6 +18,45 @@ npm install --save node-vault-client
18
18
 
19
19
  Node.js >= 18 — the client uses the native `fetch` API.
20
20
 
21
+ > **Node.js 18 sunset notice:** this is the last release of node-vault-client to support Node 18.
22
+ > Node 18 reached end-of-life on 2025-04-30 and no longer receives security patches from the
23
+ > Node.js project. Running this version under Node 18 emits a one-time process warning
24
+ > (`NodeVaultClientNode18Deprecation`); the next major release raises the minimum to
25
+ > Node.js >= 20.19.0. See [CHANGELOG.md](CHANGELOG.md) for details.
26
+
27
+ ### TypeScript
28
+
29
+ Type declarations ship with the package, so no separate install is needed:
30
+
31
+ ```typescript
32
+ import VaultClient = require('node-vault-client');
33
+
34
+ const client = new VaultClient({
35
+ api: { url: 'http://127.0.0.1:8200' },
36
+ auth: { type: 'appRole', config: { role_id: 'roleId', secret_id: 'secretId' } },
37
+ });
38
+
39
+ const lease = await client.read('secret/app');
40
+ const password = lease.getValue<string>('password');
41
+ ```
42
+
43
+ `auth` is a discriminated union on `type`, so each backend only accepts its own
44
+ configuration, and the three mutually-exclusive JWT sources (`jwt`, `jwtPath`,
45
+ `jwtProvider`) are enforced at compile time — as is the mutually-exclusive pair
46
+ `distributedClaimAccessToken` / `distributedClaimAccessTokenProvider`.
47
+
48
+ Types that appear in signatures are exported in type space under the `VaultClient`
49
+ namespace — `VaultClient.Lease`, `VaultClient.VaultOptions`, `VaultClient.AuthToken`
50
+ and the per-backend config interfaces. They are types only; the classes behind them
51
+ are not exported at runtime.
52
+
53
+ If you previously installed the third-party `@types/node-vault-client`, remove it —
54
+ it stops at the 1.x API and its declarations take precedence in some setups:
55
+
56
+ ```
57
+ npm uninstall @types/node-vault-client
58
+ ```
59
+
21
60
  ## Example
22
61
 
23
62
  ```javascript
@@ -26,7 +65,7 @@ const VaultClient = require('node-vault-client');
26
65
  const vaultClient = VaultClient.boot('main', {
27
66
  api: { url: 'https://vault.example.com:8200/' },
28
67
  auth: {
29
- type: 'appRole', // one of: 'appRole' | 'token' | 'iam' | 'kubernetes'
68
+ type: 'appRole', // one of: 'appRole' | 'token' | 'iam' | 'kubernetes' | 'jwt'
30
69
  config: { role_id: '637c065f-c644-5e12-d3d1-e9fa4363af61' }
31
70
  },
32
71
  });
@@ -42,6 +81,7 @@ vaultClient.read('secret/tst').then(lease => {
42
81
  * [AppRole](https://developer.hashicorp.com/vault/docs/auth/approle)
43
82
  * [Token](https://developer.hashicorp.com/vault/docs/auth/token)
44
83
  * [Kubernetes](https://developer.hashicorp.com/vault/docs/auth/kubernetes)
84
+ * [JWT](https://developer.hashicorp.com/vault/docs/auth/jwt)
45
85
 
46
86
  ### AWS IAM auth
47
87
 
@@ -125,6 +165,261 @@ const vaultClient = VaultClient.boot('main', {
125
165
  });
126
166
  ```
127
167
 
168
+ ### JWT auth
169
+
170
+ ```javascript
171
+ const vaultClient = VaultClient.boot('main', {
172
+ api: { url: 'https://vault.example.com:8200/' },
173
+ auth: {
174
+ type: 'jwt',
175
+ mount: 'jwt', // Optional. Vault JWT auth mount point ("jwt" by default)
176
+ config: {
177
+ role: 'my-app', // Optional. Role configured in Vault's JWT auth backend; omitted uses the mount's `default_role`
178
+ jwt: process.env.CI_JOB_JWT, // Exactly one of `jwt` / `jwtPath` / `jwtProvider` is required (see below)
179
+ distributedClaimAccessToken: process.env.GRAPH_TOKEN, // Optional. Azure/Entra ID group lookups only (see below); or `distributedClaimAccessTokenProvider`
180
+ },
181
+ },
182
+ });
183
+ ```
184
+
185
+ Exactly one of three mutually exclusive `config` keys supplies the JWT:
186
+
187
+ * **`jwt`** — a literal token string. Use it for CI jobs and other processes that are guaranteed
188
+ to finish before the token expires. Caveat: the value is fixed at construction, so if the
189
+ client re-authenticates (its Vault-issued token TTL runs out while the process is still up) it
190
+ re-sends that exact same JWT — which works only until the IdP-issued token itself expires,
191
+ after which Vault rejects every further login attempt. Do not use `jwt` in a long-running
192
+ process.
193
+ * **`jwtPath`** — path to a file containing the JWT, re-read on every login (mirrors
194
+ [Kubernetes auth](#kubernetes-auth)'s `tokenPath`). Use it for rotated *projected* tokens, such
195
+ as a Kubernetes projected service-account token the kubelet refreshes on disk, so each login
196
+ picks up whatever is currently on disk instead of a token captured once at startup.
197
+ * **`jwtProvider`** — an (optionally async) function, called fresh at login time (never at
198
+ construction, never cached), returning `string | Promise<string>`. Use it when the JWT has to
199
+ be minted per login — GitHub Actions' `core.getIDToken()`, a cloud metadata endpoint, a
200
+ SPIFFE/SPIRE workload API.
201
+
202
+ ##### The Vault role needs `bound_audiences` when your JWT carries an `aud` claim
203
+
204
+ This is server-side configuration rather than a client option, but it is the most common reason a
205
+ first login fails. Vault requires a `jwt` role to bind the audience your token carries, and it does
206
+ **not** catch the omission when the role is created — as long as the role has some other bound
207
+ constraint (`bound_subject`, `bound_claims`, ...) it is accepted, and the problem only surfaces at
208
+ login:
209
+
210
+ ```
211
+ VaultHttpError: 400 - {"errors":["audience claim found in JWT but no audiences bound to the role"]}
212
+ ```
213
+
214
+ A wrong (rather than missing) audience fails with `error validating token: invalid audience (aud)
215
+ claim: audience claim does not match any expected audience`. So bind the audience the token
216
+ actually has:
217
+
218
+ ```shell
219
+ vault write auth/jwt/role/my-app \
220
+ role_type=jwt user_claim=sub bound_audiences=my-audience token_policies=my-policy
221
+ ```
222
+
223
+ A role with *no* bound constraint at all is rejected when you create it (`must have at least one
224
+ bound constraint when creating/updating a role`), so that case is self-correcting. An `aud` array
225
+ is fine — Vault matches `bound_audiences` against any entry.
226
+
227
+ Vault also does not require an `exp` claim: a token minted without one is accepted and never
228
+ expires. If you write your own `jwtProvider`, give the tokens it mints a short `exp`.
229
+
230
+ ##### Azure / Entra ID group lookups need `distributedClaimAccessToken`
231
+
232
+ Skip this unless you log in with Microsoft Entra ID (Azure AD) tokens *and* the Vault role sets
233
+ `groups_claim`. Vault's `distributed_claim_access_token` parameter
234
+ ["only applies to the Azure (Entra ID) provider"](https://developer.hashicorp.com/vault/api-docs/auth/jwt#distributed_claim_access_token);
235
+ on every other IdP these two keys do nothing at all.
236
+
237
+ Azure does not always put group membership in the token. Past 200 groups it sends OIDC
238
+ [distributed claims](https://openid.net/specs/openid-connect-core-1_0.html#AggregatedDistributedClaims)
239
+ instead — `_claim_names`/`_claim_sources` pointing at the Microsoft Graph API rather than the group
240
+ names themselves — and a mount configured with `fetch_groups` skips the claim entirely and always
241
+ asks Graph. Either way Vault has to call Graph itself, and the JWT you logged in with is not a
242
+ credential for that call: it needs a separate Graph access token, sent on the login request as the
243
+ optional `distributed_claim_access_token`. Leave it out on a mount/role set up this way and the
244
+ login fails at the group-fetch step — *after* the JWT has already validated, so the error names the
245
+ group lookup rather than anything about your token.
246
+
247
+ Two optional, mutually exclusive `config` keys supply it. Passing both raises
248
+ `InvalidArgumentsError` at construction; passing neither leaves the login request byte-for-byte
249
+ what it was before these keys existed.
250
+
251
+ * **`distributedClaimAccessToken`** — a literal Graph access token. It carries exactly the caveat
252
+ the literal `jwt` above does, for the same reason: the value is fixed at construction and every
253
+ re-login re-sends it, so it works until that token expires. Entra access tokens last about an
254
+ hour, which is shorter than most processes — fine for a one-shot script, wrong for a service.
255
+ * **`distributedClaimAccessTokenProvider`** — an (optionally async) function, called fresh at login
256
+ time (never at construction, never cached), returning `string | Promise<string>`. Acquire the
257
+ Graph token inside it — an MSAL client-credentials call, the Azure IMDS managed-identity endpoint
258
+ — and every login gets a current one. Resolving to a non-string or an empty string raises
259
+ `InvalidArgumentsError` without sending a login request.
260
+
261
+ ```javascript
262
+ const vaultClient = VaultClient.boot('main', {
263
+ api: { url: 'https://vault.example.com:8200/' },
264
+ auth: {
265
+ type: 'jwt',
266
+ config: {
267
+ role: 'my-app',
268
+ jwtProvider: () => getEntraIdToken(),
269
+ distributedClaimAccessTokenProvider: () => getGraphAccessToken(),
270
+ },
271
+ },
272
+ });
273
+ ```
274
+
275
+ On the Vault side, `fetch_groups` lives in `provider_config` on the **mount's config, not on the
276
+ role** — which is where people tend to look for it:
277
+
278
+ ```shell
279
+ vault write auth/jwt/config \
280
+ oidc_discovery_url=https://login.microsoftonline.com/<tenant-id>/v2.0 \
281
+ provider_config='{"provider":"azure","fetch_groups":true}'
282
+
283
+ vault write auth/jwt/role/my-app \
284
+ role_type=jwt user_claim=sub bound_audiences=<application-client-id> \
285
+ groups_claim=groups token_policies=my-policy
286
+ ```
287
+
288
+ `fetch_groups` is optional — the distributed-claim path is taken without it whenever Azure omits
289
+ the groups claim. `groups_claim` on the role is not: with it unset Vault never resolves groups at
290
+ all, so a Graph token you pass is accepted and never used.
291
+
292
+ #### Authenticating from GitHub Actions
293
+
294
+ ```yaml
295
+ permissions:
296
+ id-token: write
297
+ ```
298
+
299
+ ```javascript
300
+ const core = require('@actions/core');
301
+ const VaultClient = require('node-vault-client');
302
+
303
+ const vaultClient = VaultClient.boot('ci', {
304
+ api: { url: process.env.VAULT_ADDR },
305
+ auth: {
306
+ type: 'jwt',
307
+ mount: 'gha', // matches wherever the JWT method was mounted, e.g. `vault auth enable -path=gha jwt`
308
+ config: { role: 'ci', jwtProvider: () => core.getIDToken('vault') },
309
+ },
310
+ });
311
+ ```
312
+
313
+ `core.getIDToken('vault')` mints a token whose `aud` is `vault`, so the role has to bind that
314
+ audience or the login fails with `audience claim found in JWT but no audiences bound to the role`:
315
+
316
+ ```shell
317
+ vault write auth/gha/role/ci \
318
+ role_type=jwt user_claim=sub bound_audiences=vault \
319
+ bound_claims='{"repository":"my-org/my-repo"}' token_policies=ci
320
+ ```
321
+
322
+ Pass the same string to `core.getIDToken()` and to `bound_audiences`. Calling `getIDToken()` with
323
+ no argument uses GitHub's default audience instead, which then will not match.
324
+
325
+ `role` is optional here too — omit it to use the mount's `default_role`. `mount` and
326
+ `api.namespace` behave exactly as they do for the other four backends.
327
+
328
+ Vault's JWT method also offers an interactive `oidc` login (a browser redirect for a human user).
329
+ This library implements only the non-interactive `jwt` flow: a service client doing headless
330
+ background renewal has no browser to redirect to, so `oidc` is deliberately not supported.
331
+
332
+ ### Token renewal
333
+
334
+ Whenever Vault issues a **renewable** token, the client arms a background timer and renews it at
335
+ half its remaining lifetime, for as long as the client lives. That is the default and suits
336
+ long-running services.
337
+
338
+ Set `renewal: false` on the `auth` block (beside `type`, not inside `config`) to turn it off:
339
+
340
+ ```javascript
341
+ auth: {
342
+ type: 'appRole',
343
+ renewal: false,
344
+ config: {
345
+ role_id: '637c065f-...',
346
+ secret_id: '...',
347
+ },
348
+ }
349
+ ```
350
+
351
+ These keys sit on `auth` rather than in `auth.config` on purpose: `config` is the backend's own
352
+ credential bag and may already carry keys of your own, so reserving names inside it could break an
353
+ existing caller. Same reasoning that put the namespace at `api.namespace`.
354
+
355
+ With renewal off, the client keeps using the token until it expires and then simply logs in again
356
+ on the next call — no background timer at all. Two reasons to want that:
357
+
358
+ * **Short-lived processes.** The renewal timer keeps the Node.js event loop alive, so a script that
359
+ finishes its work does not exit on its own; you have to call
360
+ [`close()`](#VaultClient+close). With `renewal: false` there is no timer to hold it open.
361
+ * **You would rather re-authenticate than renew** — for example where the auth backend can always
362
+ mint a fresh token (`jwtProvider`, Kubernetes, IAM) and you prefer a clean login over extending
363
+ an existing lease.
364
+
365
+ #### Before you turn it off
366
+
367
+ Renewal off means the token is allowed to expire, and what happens next depends on whether your
368
+ backend can obtain a *fresh* credential unaided:
369
+
370
+ | backend | on expiry with `renewal: false` |
371
+ | --- | --- |
372
+ | `kubernetes`, `iam`, `jwt` with `jwtPath`/`jwtProvider` | clean re-login — the JWT or AWS credential is re-acquired, so this is the intended case |
373
+ | `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 |
374
+ | `jwt` with a literal `jwt` | replays the same JWT, so it works only until the IdP-issued token expires |
375
+ | `token` | **cannot re-authenticate at all.** The client raises `AuthTokenExpiredError` from then on, permanently |
376
+
377
+ Two consequences worth stating plainly:
378
+
379
+ * For `token` auth this **is** a behaviour change, not a no-op. A renewable token handed to `token`
380
+ auth is renewed indefinitely today; with `renewal: false` it expires and every later call rejects
381
+ for the life of the process. `close()` does not reset it — recovery means
382
+ `VaultClient.clear(name)` and booting again.
383
+ * For `appRole` and literal-`jwt`, a login that can no longer succeed is retried on **every**
384
+ subsequent call, since a failed login clears the cached token. That is a failing request per
385
+ `read()`, with no backoff. Prefer `jwtPath`/`jwtProvider`, a reusable `secret_id`, or leaving
386
+ renewal on.
387
+
388
+ **Expiring a token also revokes its leases.** Vault revokes every lease created by a token when
389
+ that token expires, and this client does not renew secret leases — only the auth token. If you read
390
+ dynamic credentials (`database/creds/*`, cloud credentials) whose lease outlives the auth token,
391
+ leaving renewal on is what currently keeps them alive. KV reads are unaffected.
392
+
393
+ #### Tuning renewal instead of disabling it
394
+
395
+ Two further keys, also on `auth`, shape *how* renewal happens. Both are optional, and leaving them
396
+ out reproduces the behaviour the client has always had.
397
+
398
+ | key | default | meaning |
399
+ | --- | --- | --- |
400
+ | `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. |
401
+ | `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. |
402
+
403
+ ```javascript
404
+ auth: {
405
+ type: 'kubernetes',
406
+ renewalFraction: 0.25, // renew after a quarter of the remaining lifetime
407
+ renewalIncrement: 3600, // ask Vault for another hour each time
408
+ config: { role: 'my-app' },
409
+ }
410
+ ```
411
+
412
+ Lower `renewalFraction` values renew earlier and more often, which buys headroom if Vault is briefly
413
+ unreachable. A failed renewal is retried on the same rule against the *same* token, so the waits
414
+ shrink geometrically as the remaining lifetime does — for a 1-hour token that is roughly 12 attempts
415
+ at `0.5` (1800s, 900s, 450s, …) versus 27 at `0.25`, both bottoming out at a 1-second floor just
416
+ before expiry. Higher values renew later and talk to Vault less.
417
+
418
+ `renewalIncrement` is a request, not a guarantee: Vault grants at most the token's max TTL and may
419
+ return less. Both keys are validated at construction — a `renewalFraction` outside `(0, 1)` or a
420
+ non-positive/non-integer `renewalIncrement` raises `InvalidArgumentsError` rather than failing later
421
+ inside a background timer.
422
+
128
423
  ## API
129
424
 
130
425
  <a name="VaultClient"></a>
@@ -148,7 +443,7 @@ const vaultClient = VaultClient.boot('main', {
148
443
  * [.deleteMetadata(path)](#VaultClient+deleteMetadata) ⇒ <code>Promise.&lt;Object&gt;</code>
149
444
  * [.close()](#VaultClient+close)
150
445
  * _static_
151
- * [.boot(name, [options])](#VaultClient.boot) ⇒ <code>VaultClient</code>
446
+ * [.boot(name, options)](#VaultClient.boot) ⇒ <code>VaultClient</code>
152
447
  * [.get(name)](#VaultClient.get) ⇒ <code>VaultClient</code>
153
448
  * [.clear([name])](#VaultClient.clear)
154
449
  * [Lease](#Lease)
@@ -175,11 +470,14 @@ Client constructor function.
175
470
  | [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
471
  | [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
472
  | 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") |
473
+ | options.auth.type | <code>String</code> | | one of: 'appRole' \| 'token' \| 'iam' \| 'kubernetes' \| 'jwt' |
474
+ | [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
475
  | options.auth.config | <code>Object</code> | | auth configuration variables |
181
476
  | [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.logger] | <code>Object</code> \| <code>false</code> | | Logger that supports "error", "info", "warn", "trace", "debug" methods. Uses `console` by default. Pass `false` to disable logging. |
477
+ | [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). |
478
+ | [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)`. |
479
+ | [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. |
480
+ | [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
481
 
184
482
  ##### Custom transport (proxy / self-signed TLS)
185
483
 
@@ -214,12 +512,50 @@ env var with no code change. Only disable verification
214
512
  (`new Agent({ connect: { rejectUnauthorized: false } })`) in throwaway/dev setups — it removes
215
513
  MITM protection.
216
514
 
515
+ The `undici` you install must be interface-compatible with the one Node bundles
516
+ (`process.versions.undici`). A dispatcher from a mismatched major is rejected by the global
517
+ `fetch()` before the request leaves the process — on Node 24, `undici@8` fails immediately with
518
+ `TypeError: fetch failed` and `cause.code` of `invalid onRequestStart method`. Node 22 and 24
519
+ bundle undici 7.x, so pin `undici@^7`.
520
+
521
+ ##### Request timeouts
522
+
523
+ There is no default timeout. A Vault that accepts the connection and never answers will leave
524
+ `read()` (and every other call) pending indefinitely. Set one through the dispatcher:
525
+
526
+ ```javascript
527
+ const { Agent } = require('undici');
528
+
529
+ const vaultClient = VaultClient.boot('main', {
530
+ api: {
531
+ url: 'https://vault.example.com:8200/',
532
+ requestOptions: { dispatcher: new Agent({ headersTimeout: 5000, bodyTimeout: 5000 }) },
533
+ },
534
+ auth: { type: 'token', config: { token: '...' } },
535
+ });
536
+ ```
537
+
538
+ A failed request rejects with `TypeError: fetch failed`, with `cause.code` set to
539
+ `UND_ERR_HEADERS_TIMEOUT` or `UND_ERR_BODY_TIMEOUT`.
540
+
541
+ Do **not** put `signal: AbortSignal.timeout(ms)` in `requestOptions`. It is created once and
542
+ shared by every request the client makes, so it works for the first call and then aborts all
543
+ later ones with `TimeoutError`. Pass a fresh signal per call, or use the dispatcher above.
544
+
545
+ ##### Redirects and the Vault token
546
+
547
+ Requests follow HTTP redirects, because Vault HA clusters answer standby nodes with a 307 to the
548
+ active node. Node's `fetch()` strips only `Authorization`, `Cookie` and `Proxy-Authorization` when
549
+ a redirect crosses origins — `X-Vault-Token` is **not** on that list and is forwarded. A host that
550
+ can answer for `api.url` can therefore redirect the client and receive its Vault token, so treat
551
+ `api.url` (and the DNS and any load balancer in front of it) as trusted infrastructure.
552
+
217
553
  <a name="VaultClient+fillNodeConfig"></a>
218
554
 
219
555
  #### vaultClient.fillNodeConfig() ⇒ <code>Promise</code>
220
556
  Populates Vault's values to NPM "config" module
221
557
 
222
- Resolves once the npm `config` module has been populated from Vault.
558
+ 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
559
 
224
560
  **Kind**: instance method of [<code>VaultClient</code>](#VaultClient)
225
561
  <a name="VaultClient+read"></a>
@@ -358,7 +694,7 @@ The destroyed version data cannot be recovered. KV v2 only — rejects with
358
694
  #### vaultClient.readMetadata(path) ⇒ <code>Promise.&lt;Object&gt;</code>
359
695
  Reads KV v2 metadata for a secret
360
696
 
361
- Resolves to the metadata document (`current_version`, the `versions` map, timestamps, etc.).
697
+ 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
698
  KV v2 only — rejects with `UnsupportedOperationError` on non-v2 mounts.
363
699
 
364
700
  **Kind**: instance method of [<code>VaultClient</code>](#VaultClient)
@@ -402,6 +738,12 @@ vaultClient.close(); // process can now exit
402
738
 
403
739
  **Kind**: instance method of [<code>VaultClient</code>](#VaultClient)
404
740
 
741
+
742
+ `close()` also settles a renewal that is already in flight: a request that lands after the call is
743
+ discarded rather than arming a fresh timer, so the client stops holding the event loop open. The
744
+ same applies when a token is replaced by a new login — a renewal still running against the previous
745
+ token cannot overwrite the newer one or cancel its timer.
746
+
405
747
  <a name="Lease"></a>
406
748
 
407
749
  ### Lease
@@ -412,22 +754,31 @@ accessors to extract the secret data:
412
754
  * `getValue(key)` ⇒ <code>String</code> — value for a single key. Throws `Requested key does not exist` when the key is absent.
413
755
  * `getData()` ⇒ <code>Object</code> — a deep-cloned copy of the whole secret data object.
414
756
  * `isRenewable()` ⇒ <code>boolean</code> — whether the underlying lease is renewable.
757
+ * `getMetadata()` ⇒ <code>Object</code> | <code>undefined</code> — KV v2 version metadata
758
+ (`version`, `created_time`, `deletion_time`, `destroyed`, `custom_metadata`), or `undefined`
759
+ on KV v1 and non-KV mounts. See [Lease.getMetadata()](#leasegetmetadata).
415
760
 
416
761
  <a name="VaultClient.boot"></a>
417
762
 
418
- #### VaultClient.boot(name, [options]) ⇒ <code>VaultClient</code>
763
+ #### VaultClient.boot(name, options) ⇒ <code>VaultClient</code>
419
764
  Boot an instance of Vault
420
765
 
421
766
  The instance will be stored in a local hash. Calling Vault.boot multiple
422
767
  times with the same name will return the same instance.
423
768
 
769
+ `options` are used only when the instance is first created. A later call for a name that already
770
+ exists returns the existing instance and ignores the options passed to it; when those differ from
771
+ the ones it was booted with, the client logs a warning rather than letting the difference pass
772
+ silently. Use [`VaultClient.get(name)`](#VaultClient.get) to fetch an existing instance, or
773
+ [`VaultClient.clear(name)`](#VaultClient.clear) before booting to replace one.
774
+
424
775
  **Kind**: static method of [<code>VaultClient</code>](#VaultClient)
425
776
  **Returns**: <code>VaultClient</code>
426
777
 
427
778
  | Param | Type | Description |
428
779
  | --- | --- | --- |
429
780
  | name | <code>String</code> | Vault instance name |
430
- | [options] | <code>Object</code> | options for [Vault#constructor](#new_VaultClient_new). |
781
+ | 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
782
 
432
783
  <a name="VaultClient.get"></a>
433
784
 
@@ -507,7 +858,7 @@ const client = VaultClient.boot('main', {
507
858
 
508
859
  ### KV v2-specific methods
509
860
 
510
- These methods require a KV v2 mount and throw `UnsupportedOperationError` on v1 / non-KV mounts.
861
+ 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
862
 
512
863
  ```javascript
513
864
  // Soft-delete specific versions
@@ -519,7 +870,8 @@ await client.undeleteVersions('secret/foo', [1]);
519
870
  // Permanently destroy versions
520
871
  await client.destroyVersions('secret/foo', [1, 2]);
521
872
 
522
- // Read version metadata (current_version, versions map, etc.)
873
+ // Read version metadata. The full Vault envelope is returned, so the metadata
874
+ // fields live under `.data` (meta.data.current_version, meta.data.versions, ...)
523
875
  const meta = await client.readMetadata('secret/foo');
524
876
 
525
877
  // Delete all metadata and version history (permanent)
@@ -591,17 +943,38 @@ await client.request('GET', 'secret/data/foo');
591
943
 
592
944
  ### Mount detection caching
593
945
 
594
- - Each canonical mount is detected at most once per `VaultClient` instance.
946
+ - 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
947
  - Concurrent first-touch requests for the same mount share a single in-flight detection promise.
596
948
  - The detection endpoint used is `GET sys/internal/ui/mounts/<path>` (readable by any authenticated token).
597
949
  - When the token lacks permission on that endpoint, set `api.engines` to skip detection.
598
950
 
599
951
  ### Error classes
600
952
 
601
- | Class | When thrown |
602
- |---|---|
603
- | `UnsupportedOperationError` | A v2-only method (`deleteVersions`, `undeleteVersions`, `destroyVersions`, `readMetadata`, `deleteMetadata`) is called against a non-v2 mount. |
604
- | `VaultError` | Mount detection fails (e.g. permission denied) and no `api.engines` override was provided. |
953
+ Every error the client raises extends `VaultError`. The package entry point exports the
954
+ `VaultClient` class only, so the classes themselves are imported from `node-vault-client/src/errors`:
955
+
956
+ ```javascript
957
+ const errors = require('node-vault-client/src/errors');
958
+
959
+ try {
960
+ await client.readMetadata('secret/app');
961
+ } catch (err) {
962
+ if (err instanceof errors.UnsupportedOperationError) {
963
+ // the mount did not resolve as KV v2 - see "Path requirements" above
964
+ return null;
965
+ }
966
+ throw err;
967
+ }
968
+ ```
969
+
970
+ | Class | Extends | When thrown |
971
+ |---|---|---|
972
+ | `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. |
973
+ | `VaultHttpError` | `VaultError` | Vault answered with a non-2xx status. Carries the status as `statusCode` and the parsed body as `error`. |
974
+ | `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. |
975
+ | `InvalidAWSCredentialsError` | `InvalidArgumentsError` | `auth.config.credentials` was supplied but is not a usable `accessKeyId` / `secretAccessKey` pair. |
976
+ | `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. |
977
+ | `UnsupportedOperationError` | `VaultError` | A v2-only method (`deleteVersions`, `undeleteVersions`, `destroyVersions`, `readMetadata`, `deleteMetadata`) was called against a mount that did not resolve as KV v2. |
605
978
 
606
979
  ## Contributing
607
980
 
package/index.d.ts ADDED
@@ -0,0 +1,131 @@
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 JwtDistributedClaimConfig =
83
+ | { distributedClaimAccessToken?: never; distributedClaimAccessTokenProvider?: never }
84
+ | { distributedClaimAccessToken: string; distributedClaimAccessTokenProvider?: never }
85
+ | { distributedClaimAccessTokenProvider: () => string | Promise<string>; distributedClaimAccessToken?: never };
86
+
87
+ type JwtAuthConfig = JwtAuthConfigCommon &
88
+ (
89
+ | { jwt: string; jwtPath?: never; jwtProvider?: never }
90
+ | { jwtPath: string; jwt?: never; jwtProvider?: never }
91
+ | { jwtProvider: () => string | Promise<string>; jwt?: never; jwtPath?: never }
92
+ ) &
93
+ JwtDistributedClaimConfig;
94
+
95
+ interface AuthOptionsCommon {
96
+ mount?: string;
97
+ renewal?: boolean;
98
+ renewalFraction?: number;
99
+ renewalIncrement?: number;
100
+ }
101
+
102
+ type AuthOptions = AuthOptionsCommon &
103
+ (
104
+ | { type: 'token'; config: TokenAuthConfig }
105
+ | { type: 'appRole'; config: AppRoleAuthConfig }
106
+ | { type: 'iam'; config: IAMAuthConfig }
107
+ | { type: 'kubernetes'; config: KubernetesAuthConfig }
108
+ | { type: 'jwt'; config: JwtAuthConfig }
109
+ );
110
+
111
+ interface VaultOptions {
112
+ api: ApiConfig;
113
+ auth: AuthOptions;
114
+ logger?: Partial<Logger> | false;
115
+ }
116
+
117
+ interface Lease {
118
+ getValue<T = any>(key: string): T;
119
+ getData<T = Record<string, any>>(): T;
120
+ isRenewable(): boolean;
121
+ getMetadata(): Record<string, any> | undefined;
122
+ }
123
+
124
+ interface AuthToken {
125
+ getId(): string;
126
+ getAccessor(): string;
127
+ isExpired(): boolean;
128
+ isRenewable(): boolean;
129
+ getExpiresAt(): number | null;
130
+ }
131
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-vault-client",
3
- "version": "2.1.2",
3
+ "version": "2.3.0",
4
4
  "description": "A Vault Client implemented in pure javascript for HashiCorp Vault. It supports variety of Auth Backends and performs lease renewal for issued auth token.",
5
5
  "repository": {
6
6
  "url": "git+https://github.com/namecheap/node-vault-client.git"
@@ -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 -A ."
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": [
@@ -52,7 +57,7 @@
52
57
  "glob": "^13.0.0",
53
58
  "diff": "^8.0.3",
54
59
  "brace-expansion": "^5.0.9",
55
- "js-yaml": "^4.3.0"
60
+ "js-yaml": "^4.3.2"
56
61
  },
57
62
  "devDependencies": {
58
63
  "@eslint/js": "^9.39.5",
@@ -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": "^7.0.2"
68
74
  }
69
75
  }
@@ -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,46 @@ 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');
15
+ const { warnIfNode18 } = require('./checkNodeVersion');
16
+
17
+ warnIfNode18();
13
18
 
14
19
  const noop = () => {};
15
20
  const vaultInstances = {};
16
21
 
22
+ /**
23
+ * Order-insensitive canonical form of the boot options, hashed so that no credential is
24
+ * retained just to compare two boot() calls. Functions compare by identity, which is what
25
+ * a caller passing `jwtProvider` expects.
26
+ */
27
+ function fingerprintOptions(value, seen) {
28
+ if (typeof value === 'function') {
29
+ return 'fn';
30
+ }
31
+ if (value === null || typeof value !== 'object') {
32
+ return JSON.stringify(value) || String(value);
33
+ }
34
+ seen = seen || new Set();
35
+ if (seen.has(value)) {
36
+ return 'circular';
37
+ }
38
+ seen.add(value);
39
+ if (Array.isArray(value)) {
40
+ return `[${value.map((item) => fingerprintOptions(item, seen)).join(',')}]`;
41
+ }
42
+ const body = Object.keys(value).sort()
43
+ .map((key) => `${JSON.stringify(key)}:${fingerprintOptions(value[key], seen)}`)
44
+ .join(',');
45
+ return `{${body}}`;
46
+ }
47
+
48
+ function optionsDigest(options) {
49
+ return crypto.createHash('sha256').update(fingerprintOptions(options)).digest('hex');
50
+ }
51
+
17
52
  class VaultClient {
18
53
 
19
54
  /**
@@ -33,8 +68,17 @@ class VaultClient {
33
68
  * every request across all auth backends. For backward compatibility `options.auth.config.namespace`
34
69
  * is also honored when this is not set.
35
70
  * @param {Object} options.auth
36
- * @param {String} options.auth.type
71
+ * @param {String} options.auth.type - one of: 'appRole' | 'token' | 'iam' | 'kubernetes' | 'jwt'
37
72
  * @param {Object} options.auth.config - auth configuration variables
73
+ * @param {boolean} [options.auth.renewal=true] - Set `false` to never renew the Vault token in
74
+ * the background. The token is then used until it expires and the next call
75
+ * re-authenticates. Note `token` auth cannot re-authenticate and will raise
76
+ * {@link AuthTokenExpiredError} from that point on.
77
+ * @param {number} [options.auth.renewalFraction=0.5] - How much of the token's remaining
78
+ * lifetime to wait before renewing, as a fraction in `(0, 1)`.
79
+ * @param {number} [options.auth.renewalIncrement] - Seconds of extra TTL to request on each
80
+ * renewal, sent as `increment` to `auth/token/renew-self`. Vault caps it at the token's
81
+ * max TTL. Omitted by default, which lets Vault apply the token's own period.
38
82
  * @param {Object|false} options.logger - Logger that supports "error", "info", "warn", "trace", "debug" methods. Uses `console` by default. Pass `false` to disable logging.
39
83
  */
40
84
  constructor(options) {
@@ -63,6 +107,12 @@ class VaultClient {
63
107
  this.__api
64
108
  );
65
109
 
110
+ // Renewal options live on `auth`, not inside `auth.config`: `auth.config` is the
111
+ // backend's own credential bag and may already carry consumer-specific keys, so
112
+ // reserving names in it could break an existing caller. Same reasoning that moved
113
+ // `namespace` to `api.namespace`.
114
+ this.__auth.configureRenewal(options.auth);
115
+
66
116
  // KV v2 support
67
117
  const kvOpts = (options.api && options.api.kv) || {};
68
118
  const autoDetect = kvOpts.autoDetect === true;
@@ -89,8 +139,15 @@ class VaultClient {
89
139
  * The instance will be stored in a local hash. Calling Vault.boot multiple
90
140
  * times with the same name will return the same instance.
91
141
  *
142
+ * `options` are used only when the instance is first created. A later call for a name
143
+ * that already exists returns the existing instance and ignores the options it was
144
+ * given; when those options differ from the ones the instance was booted with, that is
145
+ * logged as a warning rather than passing silently. Use {@link VaultClient.get} to
146
+ * fetch an existing instance, or {@link VaultClient.clear} to replace one.
147
+ *
92
148
  * @param {String} name - Vault instance name
93
- * @param {Object} [options] - options for {@link VaultClient#constructor}.
149
+ * @param {Object} options - options for {@link VaultClient#constructor}. Required on
150
+ * every call, including for a name that already exists.
94
151
  * @return {VaultClient}
95
152
  */
96
153
  static boot(name, options) {
@@ -101,6 +158,15 @@ class VaultClient {
101
158
  let instance = vaultInstances[name];
102
159
  if (instance === undefined) {
103
160
  vaultInstances[name] = instance = new VaultClient(options);
161
+ instance.__bootDigest = optionsDigest(options);
162
+ } else if (instance.__bootDigest !== optionsDigest(options)) {
163
+ instance.__log.warn(
164
+ 'VaultClient.boot("%s") was called with different options than the existing ' +
165
+ 'instance was booted with; the existing instance is returned and these options ' +
166
+ 'are ignored. Use VaultClient.get("%s") to fetch it, or VaultClient.clear("%s") ' +
167
+ 'first to replace it.',
168
+ name, name, name
169
+ );
104
170
  }
105
171
  return instance;
106
172
  }
@@ -208,6 +274,13 @@ class VaultClient {
208
274
  authConfig.config,
209
275
  authConfig.mount
210
276
  );
277
+ case 'jwt':
278
+ return new VaultJwtAuth(
279
+ api,
280
+ this.__log,
281
+ authConfig.config,
282
+ authConfig.mount
283
+ );
211
284
  }
212
285
 
213
286
  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
- this._log.debug(
90
- 'setting refresh timer for token (accessor=%s)',
91
- authToken.getAccessor()
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 timer = Math.max((authToken.getExpiresAt() - Math.floor(Date.now() / 1000)) / 2, 1) * 1000;
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
- this.__renewToken(authToken).then(authToken => {
162
- this.__authToken = authToken;
163
- this.__setupTokenRefreshTimer(authToken);
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
- return this.__apiClient.makeRequest('POST', '/auth/token/renew-self', null, {'X-Vault-Token': authToken.getId()})
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,154 @@
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.distributedClaimAccessToken] - A literal OAuth access token forwarded
25
+ * to Vault as `distributed_claim_access_token`. Azure/Entra roles with `fetch_groups` enabled
26
+ * need it so Vault can resolve the distributed group-membership claim against the Microsoft
27
+ * Graph API. Optional, and mutually exclusive with `distributedClaimAccessTokenProvider`.
28
+ * @param {Function} [config.distributedClaimAccessTokenProvider] - (optionally async) function
29
+ * invoked at login time, returning `string | Promise<string>`. Called fresh on every login
30
+ * (never at construction or cached across logins) because Graph access tokens are short-lived
31
+ * and are usually acquired next to the JWT itself. Mutually exclusive with
32
+ * `distributedClaimAccessToken`.
33
+ * @param {String} [config.namespace] - Optional. Vault namespace. Applied as the X-Vault-Namespace
34
+ * header to every request by {@link VaultApiClient}; see {@link VaultClient#constructor}.
35
+ * @param {String} mount - Vault's mount point ("jwt" by default)
36
+ */
37
+ constructor(apiClient, logger, config, mount) {
38
+ super(apiClient, logger, mount || 'jwt');
39
+
40
+ const providedSources = JWT_SOURCE_KEYS.filter((key) => config && config[key] !== undefined);
41
+ if (providedSources.length !== 1) {
42
+ throw new errors.InvalidArgumentsError(
43
+ 'Exactly one of "jwt", "jwtPath" or "jwtProvider" should be provided for VaultJwtAuth'
44
+ );
45
+ }
46
+ if (config.jwtProvider !== undefined && typeof config.jwtProvider !== 'function') {
47
+ throw new errors.InvalidArgumentsError('"jwtProvider" should be a function for VaultJwtAuth');
48
+ }
49
+ if (config.distributedClaimAccessToken !== undefined
50
+ && config.distributedClaimAccessTokenProvider !== undefined) {
51
+ throw new errors.InvalidArgumentsError(
52
+ 'Only one of "distributedClaimAccessToken" or "distributedClaimAccessTokenProvider"'
53
+ + ' should be provided for VaultJwtAuth'
54
+ );
55
+ }
56
+ if (config.distributedClaimAccessTokenProvider !== undefined
57
+ && typeof config.distributedClaimAccessTokenProvider !== 'function') {
58
+ throw new errors.InvalidArgumentsError(
59
+ '"distributedClaimAccessTokenProvider" should be a function for VaultJwtAuth'
60
+ );
61
+ }
62
+
63
+ this.__role = config.role;
64
+ this.__jwt = config.jwt;
65
+ this.__jwtPath = config.jwtPath;
66
+ this.__jwtProvider = config.jwtProvider;
67
+ this.__distributedClaimAccessToken = config.distributedClaimAccessToken;
68
+ this.__distributedClaimAccessTokenProvider = config.distributedClaimAccessTokenProvider;
69
+ }
70
+
71
+ _authenticate() {
72
+ return Promise.resolve()
73
+ .then(() => this.__acquireJwt())
74
+ .then(({ jwt, source }) => Promise.resolve()
75
+ .then(() => this.__acquireDistributedClaimAccessToken())
76
+ .then((distributedClaim) => {
77
+ this._log.info(
78
+ 'making authentication request: Vault role: "%s"; JWT source: %s (%d bytes)%s',
79
+ this.__role !== undefined ? this.__role : '(default_role)', source, jwt.length,
80
+ distributedClaim === undefined
81
+ ? ''
82
+ : `; distributed claim access token: ${distributedClaim.source}`
83
+ );
84
+
85
+ const body = { jwt };
86
+ if (this.__role !== undefined) {
87
+ body.role = this.__role;
88
+ }
89
+ if (distributedClaim !== undefined) {
90
+ body.distributed_claim_access_token = distributedClaim.accessToken;
91
+ }
92
+
93
+ return this.__apiClient.makeRequest('POST', `/auth/${this._mount}/login`, body)
94
+ .then((res) => {
95
+ this._log.debug('received Vault client token from JWT login');
96
+
97
+ return this._getTokenEntity(res.auth.client_token);
98
+ });
99
+ }));
100
+ }
101
+
102
+ /**
103
+ * @returns {{jwt: String, source: String}|Promise<{jwt: String, source: String}>}
104
+ * @private
105
+ */
106
+ __acquireJwt() {
107
+ if (this.__jwt !== undefined) {
108
+ return { jwt: this.__jwt, source: 'literal' };
109
+ }
110
+ if (this.__jwtPath !== undefined) {
111
+ return { jwt: fs.readFileSync(this.__jwtPath).toString(), source: 'file' };
112
+ }
113
+
114
+ // Wrapping in Promise.resolve().then() normalizes both a sync provider (plain return)
115
+ // and a synchronous throw into the same rejection path as an async one.
116
+ return Promise.resolve().then(() => this.__jwtProvider()).then((jwt) => {
117
+ if (typeof jwt !== 'string' || jwt.length === 0) {
118
+ throw new errors.InvalidArgumentsError(
119
+ '"jwtProvider" must resolve to a non-empty JWT string'
120
+ );
121
+ }
122
+ return { jwt, source: 'provider' };
123
+ });
124
+ }
125
+
126
+ /**
127
+ * Resolves to `undefined` when neither option is configured, so that the login body stays
128
+ * byte-identical to what it was before this option existed.
129
+ *
130
+ * @returns {undefined|{accessToken: String, source: String}|Promise<{accessToken: String, source: String}>}
131
+ * @private
132
+ */
133
+ __acquireDistributedClaimAccessToken() {
134
+ if (this.__distributedClaimAccessToken !== undefined) {
135
+ return { accessToken: this.__distributedClaimAccessToken, source: 'literal' };
136
+ }
137
+ if (this.__distributedClaimAccessTokenProvider === undefined) {
138
+ return undefined;
139
+ }
140
+
141
+ // Wrapping in Promise.resolve().then() normalizes both a sync provider (plain return)
142
+ // and a synchronous throw into the same rejection path as an async one.
143
+ return Promise.resolve().then(() => this.__distributedClaimAccessTokenProvider()).then((accessToken) => {
144
+ if (typeof accessToken !== 'string' || accessToken.length === 0) {
145
+ throw new errors.InvalidArgumentsError(
146
+ '"distributedClaimAccessTokenProvider" must resolve to a non-empty access token string'
147
+ );
148
+ }
149
+ return { accessToken, source: 'provider' };
150
+ });
151
+ }
152
+ }
153
+
154
+ module.exports = VaultJwtAuth;
@@ -0,0 +1,27 @@
1
+ 'use strict';
2
+
3
+ const NODE_18_DEPRECATION_MESSAGE =
4
+ 'node-vault-client: this is the last release of this package to support Node.js 18. ' +
5
+ 'Node 18 reached end-of-life on 2025-04-30 and no longer receives security patches from ' +
6
+ 'the Node.js project. The next major release of node-vault-client will require ' +
7
+ 'Node.js >= 20.19.0 — see CHANGELOG.md for details.';
8
+ const NODE_18_DEPRECATION_CODE = 'NodeVaultClientNode18Deprecation';
9
+
10
+ /**
11
+ * Emits a one-time process warning when running on Node.js 18. Called once, at module load,
12
+ * from VaultClient.js — require()'s own module cache is what keeps it to once per process,
13
+ * not any dedup logic here.
14
+ *
15
+ * Takes a `process`-like object (defaulting to the real `process`) purely so tests can assert
16
+ * the behaviour on a stubbed Node version instead of on whatever Node this suite happens to run
17
+ * under.
18
+ */
19
+ function warnIfNode18(proc) {
20
+ proc = proc || process;
21
+ const nodeVersion = proc.versions && proc.versions.node;
22
+ if (typeof nodeVersion === 'string' && /^18\./.test(nodeVersion)) {
23
+ proc.emitWarning(NODE_18_DEPRECATION_MESSAGE, { code: NODE_18_DEPRECATION_CODE });
24
+ }
25
+ }
26
+
27
+ module.exports = { warnIfNode18, NODE_18_DEPRECATION_MESSAGE, NODE_18_DEPRECATION_CODE };
@@ -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
+ }