node-vault-client 2.1.1 → 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 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.&lt;Object&gt;</code>
149
334
  * [.close()](#VaultClient+close)
150
335
  * _static_
151
- * [.boot(name, [options])](#VaultClient.boot) ⇒ <code>VaultClient</code>
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.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. |
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.&lt;Object&gt;</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, [options]) ⇒ <code>VaultClient</code>
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
- | [options] | <code>Object</code> | options for [Vault#constructor](#new_VaultClient_new). |
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 (current_version, versions map, etc.)
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 at most once per `VaultClient` instance.
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
- | 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. |
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.1.1",
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 -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": [
@@ -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
  }
@@ -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} [options] - options for {@link VaultClient#constructor}.
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,10 +40,106 @@ 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
- /** @type AuthToken */
47
+ /**
48
+ * The currently held token, or `null` when none has been obtained yet.
49
+ * @type {AuthToken|null}
50
+ */
42
51
  this.__authToken = null;
52
+ /**
53
+ * The in-flight login, or `null` when no login is running. Concurrent
54
+ * `getAuthToken()` callers await this same promise (single-flight).
55
+ * @type {Promise<AuthToken>|null}
56
+ */
57
+ this.__pendingLogin = null;
43
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;
44
143
  }
45
144
 
46
145
  /**
@@ -52,7 +151,12 @@ class VaultBaseAuth {
52
151
  }
53
152
 
54
153
  getAuthToken() {
55
- const tokenExpired = this.__authToken instanceof AuthToken && this.__authToken.isExpired();
154
+ if (this.__pendingLogin !== null) {
155
+ this._log.debug('login already in flight');
156
+ return this.__pendingLogin;
157
+ }
158
+
159
+ const tokenExpired = this.__authToken !== null && this.__authToken.isExpired();
56
160
 
57
161
  if (tokenExpired && !this._reauthenticationAllowed()) {
58
162
  return Promise.reject(new errors.AuthTokenExpiredError(
@@ -60,32 +164,46 @@ class VaultBaseAuth {
60
164
  ));
61
165
  }
62
166
 
63
- if (this.__authToken === null || tokenExpired) {
64
- this._log.info('getting auth token (mount=%s)', this._mount);
167
+ if (this.__authToken !== null && !tokenExpired) {
168
+ this._log.debug('token already exist');
169
+ return Promise.resolve(this.__authToken);
170
+ }
171
+
172
+ this._log.info('getting auth token (mount=%s)', this._mount);
65
173
 
66
- const tokenPromise = this._authenticate().then(authToken => {
67
- this.__authToken = authToken;
174
+ const pendingLogin = this._authenticate().then(authToken => {
175
+ this.__pendingLogin = null;
176
+ this.__authToken = authToken;
177
+ this.__renewalEpoch++;
68
178
 
69
- if (this.__authToken.isRenewable()) {
179
+ if (authToken.isRenewable()) {
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) {
70
185
  this._log.debug(
71
186
  'setting refresh timer for token (accessor=%s)',
72
187
  authToken.getAccessor()
73
188
  );
74
- this.__setupTokenRefreshTimer(this.__authToken);
189
+ } else {
190
+ this._log.debug(
191
+ 'renewal disabled by config; not arming a refresh timer (accessor=%s)',
192
+ authToken.getAccessor()
193
+ );
75
194
  }
195
+ this.__setupTokenRefreshTimer(authToken);
196
+ }
76
197
 
77
- return this.__authToken;
78
- }).catch(e => {
79
- this.__authToken = null;
80
- throw e;
81
- });
82
-
83
- this.__authToken = tokenPromise;
84
- return tokenPromise;
85
- }
198
+ return authToken;
199
+ }).catch(e => {
200
+ this.__pendingLogin = null;
201
+ this.__authToken = null;
202
+ throw e;
203
+ });
86
204
 
87
- this._log.debug('token already exist');
88
- return Promise.resolve(this.__authToken);
205
+ this.__pendingLogin = pendingLogin;
206
+ return pendingLogin;
89
207
  }
90
208
 
91
209
  /**
@@ -100,6 +218,7 @@ class VaultBaseAuth {
100
218
  * @returns {void}
101
219
  */
102
220
  cancelTokenRefresh() {
221
+ this.__renewalEpoch++;
103
222
  if (this.__refreshTimeout !== null) {
104
223
  lt.clearTimeout(this.__refreshTimeout);
105
224
  this.__refreshTimeout = null;
@@ -135,17 +254,35 @@ class VaultBaseAuth {
135
254
  this.__refreshTimeout = null;
136
255
  }
137
256
 
138
- if (!authToken.isRenewable() || authToken.isExpired()) {
257
+ if (!this.__renewalEnabled || !authToken.isRenewable() || authToken.isExpired()) {
139
258
  return;
140
259
  }
141
260
 
142
- 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;
143
270
 
144
271
  this.__refreshTimeout = lt.setTimeout(() => {
145
- this.__renewToken(authToken).then(authToken => {
146
- this.__authToken = authToken;
147
- 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);
148
282
  }).catch(err => {
283
+ if (superseded()) {
284
+ return;
285
+ }
149
286
  this.__setupTokenRefreshTimer(authToken);
150
287
 
151
288
  this._log.error(`Cannot refresh auth token with "${authToken.getAccessor()}" accessor. Error: ${err.message}`);
@@ -167,7 +304,10 @@ class VaultBaseAuth {
167
304
  __renewToken(authToken) {
168
305
  this._log.debug('renewing vault token');
169
306
 
170
- 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()})
171
311
  .then(() => {
172
312
  this._log.info('successfully renewed token');
173
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;
@@ -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
+ }