node-vault-client 3.0.1 → 3.1.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
@@ -174,7 +174,7 @@ const vaultClient = VaultClient.boot('main', {
174
174
  config: {
175
175
  role: 'my-app', // Optional. Role configured in Vault's JWT auth backend; omitted uses the mount's `default_role`
176
176
  jwt: process.env.CI_JOB_JWT, // Exactly one of `jwt` / `jwtPath` / `jwtProvider` is required (see below)
177
- distributedClaimAccessToken: process.env.GRAPH_TOKEN, // Optional. Azure/Entra ID group lookups only (see below); or `distributedClaimAccessTokenProvider`
177
+ // distributedClaimAccessTokenProvider: () => getGraphAccessToken(), // Optional. Azure/Entra ID group lookups only (see below)
178
178
  },
179
179
  },
180
180
  });
@@ -244,17 +244,28 @@ group lookup rather than anything about your token.
244
244
 
245
245
  Two optional, mutually exclusive `config` keys supply it. Passing both raises
246
246
  `InvalidArgumentsError` at construction; passing neither leaves the login request byte-for-byte
247
- what it was before these keys existed.
247
+ what it was before these keys existed. A key whose value is `undefined` counts as not passed, so
248
+ `distributedClaimAccessToken: process.env.GRAPH_TOKEN` with the variable unset logs in *without* a
249
+ Graph token — and on a mount/role that needs one, fails at Vault's group lookup. The login log line
250
+ always names the source (`distributed claim access token: literal`, `provider` or `none`), so check
251
+ it first when that happens.
248
252
 
249
253
  * **`distributedClaimAccessToken`** — a literal Graph access token. It carries exactly the caveat
250
254
  the literal `jwt` above does, for the same reason: the value is fixed at construction and every
251
255
  re-login re-sends it, so it works until that token expires. Entra access tokens last about an
252
256
  hour, which is shorter than most processes — fine for a one-shot script, wrong for a service.
257
+ Surrounding whitespace is trimmed; an empty, whitespace-only or non-string value raises
258
+ `InvalidArgumentsError` at construction instead of being sent.
253
259
  * **`distributedClaimAccessTokenProvider`** — an (optionally async) function, called fresh at login
254
260
  time (never at construction, never cached), returning `string | Promise<string>`. Acquire the
255
261
  Graph token inside it — an MSAL client-credentials call, the Azure IMDS managed-identity endpoint
256
- — and every login gets a current one. Resolving to a non-string or an empty string raises
257
- `InvalidArgumentsError` without sending a login request.
262
+ — and every login gets a current one. It runs concurrently with the JWT source, so login takes as
263
+ long as the slower of the two rather than both in turn. Surrounding whitespace is trimmed;
264
+ resolving to a non-string, an empty or a whitespace-only string raises `InvalidArgumentsError`
265
+ without sending a login request. If either provider throws or rejects, the client logs which one
266
+ (`JWT login aborted: "distributedClaimAccessTokenProvider" failed: ...`) and the login rejects with
267
+ your provider's own error, unchanged. A failing JWT source rejects the login at once, without
268
+ waiting for this provider; when both fail, the JWT source's error is the one raised.
258
269
 
259
270
  ```javascript
260
271
  const vaultClient = VaultClient.boot('main', {
@@ -368,26 +379,38 @@ backend can obtain a *fresh* credential unaided:
368
379
  | backend | on expiry with `renewal: false` | at max TTL with renewal enabled |
369
380
  | --- | --- | --- |
370
381
  | `kubernetes`, `iam`, `jwt` with `jwtPath`/`jwtProvider` | clean re-login — the JWT or AWS credential is re-acquired, so this is the intended case | clean re-login on the next call after the client-side expiry deadline |
382
+ | `jwt` with `jwtPath`/`jwtProvider` **and a literal `distributedClaimAccessToken`** | the JWT is fresh but the Graph access token is **replayed**; once it expires (about an hour for Entra ID) Vault's group lookup fails every re-login. Use `distributedClaimAccessTokenProvider` | same: re-login replays the expired Graph token and is rejected |
371
383
  | `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 | re-login replays the same `secret_id`; a single-use or expired secret is rejected |
372
384
  | `jwt` with a literal `jwt` | replays the same JWT, so it works only until the IdP-issued token expires | replays the same JWT; once it expires, login is rejected |
373
385
  | `token` | **cannot re-authenticate at all.** The client raises `AuthTokenExpiredError` from then on, permanently | **cannot re-authenticate at all.** The client raises `AuthTokenExpiredError` after the max-TTL expiry |
374
386
 
375
387
  #### Tokens that reach their maximum TTL
376
388
 
377
- Vault stops extending a renewable token when it reaches its maximum TTL. The client uses Vault's
378
- absolute `expire_time` when available, treats `ttl: 0` as expired immediately, and stops background
379
- renewal when successive absolute expiry deadlines do not materially advance. Because Vault reports
380
- whole-second TTLs, the scheduler also compares the reported TTL with the elapsed renewal interval;
381
- short periodic tokens keep renewing even when each expiry advance is one second or less. A
382
- `renewalIncrement` that meaningfully shortens the expiry still schedules from the new deadline. For positive TTLs above 60 seconds, the client keeps a one-minute
383
- safety margin for requests in flight; TTLs of 60 seconds or less have no margin. The next call after
384
- the client-side expiry deadline re-authenticates, as shown in the table above.
389
+ Vault stops extending a renewable token when it reaches its maximum TTL: the token's explicit max
390
+ TTL, the role's `token_max_ttl`, or the auth mount's max lease TTL. When a renewal is capped, Vault
391
+ says so in the `renew-self` response, with a warning such as `TTL of "1h" exceeded the effective
392
+ max_ttl of "20m"; TTL value is capped accordingly`. The client stops background renewal on that
393
+ warning and logs `token renewal stopped (accessor=…): Vault capped the renewal at the token's
394
+ maximum TTL`. A renewal without the warning is never treated as capped, however little it moved
395
+ the deadline. That includes short periodic tokens renewed less than a second apart, and a
396
+ `renewalIncrement` that leaves the deadline where it was or brings it closer. Vault's other cap
397
+ warning, `period of "…" exceeded the effective max_ttl of "…"; period value is capped accordingly`,
398
+ does not stop renewal either: it comes with every renewal of a periodic token whose period is above
399
+ the role's `token_max_ttl` or the mount's max lease TTL, and that token stays renewable at the
400
+ shorter period. The next renewal is always scheduled from the new deadline.
401
+
402
+ The client uses Vault's absolute `expire_time` when available and treats `ttl: 0` as expired
403
+ immediately. Because Vault truncates `ttl` to whole seconds, a token with a period of **1 second**
404
+ reports `ttl: 0` right after each renewal and is treated as expired. Use a period of at least
405
+ 2 seconds. For positive TTLs above 60 seconds, the client keeps a one-minute safety margin for
406
+ requests in flight; TTLs of 60 seconds or less have no margin. The next call after the client-side
407
+ expiry deadline re-authenticates, as shown in the table above.
385
408
 
386
409
  Two consequences worth stating plainly:
387
410
 
388
411
  * **For `token` auth, there is no fresh credential for re-authentication.** With `renewal: false`,
389
412
  an otherwise renewable token is allowed to expire without a background renewal. With renewal
390
- enabled, the client stops the background loop once Vault no longer extends the expiry materially.
413
+ enabled, the client stops the background loop once Vault reports that a renewal was capped.
391
414
  After expiry every later call rejects for the life of the process. `close()` does not reset it —
392
415
  recovery means `VaultClient.clear(name)` and booting again.
393
416
  * For `appRole` and literal-`jwt`, a login that can no longer succeed is retried on **every**
@@ -423,10 +446,16 @@ Lower `renewalFraction` values renew earlier and more often, which buys headroom
423
446
  unreachable. A failed renewal is retried on the same rule against the *same* token, so the waits
424
447
  shrink geometrically as the remaining lifetime does — for a 1-hour token that is roughly 12 attempts
425
448
  at `0.5` (1800s, 900s, 450s, …) versus 27 at `0.25`, both bottoming out at a 250 ms minimum delay
426
- when it still fits before expiry. Higher values renew later and talk to Vault less.
449
+ when it still fits before expiry. Higher values renew later and talk to Vault less. A low fraction on a
450
+ short TTL is safe: renewals under a second apart keep working. The 250 ms minimum delay only matters
451
+ once less than 250 ms of lifetime is left, and the client then stops with `too little lifetime left
452
+ to renew`.
427
453
 
428
454
  `renewalIncrement` is a request, not a guarantee: Vault grants at most the token's max TTL and may
429
- return less. Both keys are validated at construction — a `renewalFraction` outside `(0, 1)` or a
455
+ return less. For a non-periodic token the increment *replaces* the remaining lifetime, counted from
456
+ the renewal, so a value below the remaining TTL shortens the token. `renewalIncrement: 1` leaves a
457
+ 1-second token that lookup reports as `ttl: 0`, so the client treats it as expired after the first
458
+ renewal; use at least `2`. Periodic tokens ignore the increment and renew to their period. Both keys are validated at construction — a `renewalFraction` outside `(0, 1)` or a
430
459
  non-positive/non-integer `renewalIncrement` raises `InvalidArgumentsError` rather than failing later
431
460
  inside a background timer.
432
461
 
@@ -476,6 +505,7 @@ Client constructor function.
476
505
  | options.api.url | <code>String</code> | | the url of the vault server |
477
506
  | [options.api.apiVersion] | <code>String</code> | `v1` | |
478
507
  | [options.api.requestOptions] | <code>Object</code> | | extra options merged into every HTTP request (see [Custom transport](#custom-transport-proxy--self-signed-tls)) |
508
+ | [options.api.timeout] | <code>number</code> | `120000` | milliseconds a request may take, response headers, body and redirects included, before it rejects with `RequestTimeoutError`; `0` turns the limit off, `null` gives the default (see [Request timeouts](#request-timeouts)) |
479
509
  | [options.api.namespace] | <code>String</code> | | Optional. Vault namespace, sent as the `X-Vault-Namespace` header on **every** request — login, token lookup/renewal, and all secret operations — for every auth type. This is the canonical location; `auth.config.namespace` is still honored for backward compatibility. |
480
510
  | [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)) |
481
511
  | [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)) |
@@ -530,8 +560,35 @@ bundle undici 7.x, so pin `undici@^7`.
530
560
 
531
561
  ##### Request timeouts
532
562
 
533
- There is no default timeout. A Vault that accepts the connection and never answers will leave
534
- `read()` (and every other call) pending indefinitely. Set one through the dispatcher:
563
+ Every request to Vault, including logins, `lookup-self` and background `renew-self`, has to
564
+ finish within `api.timeout` milliseconds: **120 000 (2 minutes) by default**. The limit covers the
565
+ whole request: response headers, the body and any redirects. A request that runs out of time
566
+ rejects with `RequestTimeoutError` (its `timeout` property holds the limit, `cause` the
567
+ `fetch()` rejection). With `api.kv.autoDetect`, a mount detection that times out fails the call
568
+ with the usual detection `VaultError`, with `RequestTimeoutError` on its `cause`. A renewal that
569
+ times out is handled like any other failed renewal.
570
+
571
+ `api.timeout` is in milliseconds; `null` or leaving it out gives the default, and a value under
572
+ 1000 logs a warning in case it was meant as seconds. Anything but an integer from 0 to 2147483647
573
+ raises `InvalidArgumentsError` when the client is created.
574
+
575
+ The default sits above Vault's own default `max_request_duration` (90 s), so it does not cut
576
+ short a request that a Vault with default settings would still answer. Set it lower to fail
577
+ fast, or higher if your Vault allows longer requests:
578
+
579
+ ```javascript
580
+ const vaultClient = VaultClient.boot('main', {
581
+ api: { url: 'https://vault.example.com:8200/', timeout: 10000 },
582
+ auth: { type: 'token', config: { token: '...' } },
583
+ });
584
+ ```
585
+
586
+ `timeout: 0` turns the limit off, as it was before `api.timeout` existed. What remains then
587
+ are the timeouts of the dispatcher `fetch()` uses: Node's default one gives up after 300 s
588
+ without response headers (`TypeError: fetch failed`, `cause.code` `UND_ERR_HEADERS_TIMEOUT`) or
589
+ 300 s without body data (`UND_ERR_BODY_TIMEOUT`). Those limits apply per phase, so a body that
590
+ keeps trickling in is never cut off. Any timeouts set on a custom dispatcher still apply
591
+ alongside `api.timeout`, and whichever fires first rejects the request:
535
592
 
536
593
  ```javascript
537
594
  const { Agent } = require('undici');
@@ -545,20 +602,23 @@ const vaultClient = VaultClient.boot('main', {
545
602
  });
546
603
  ```
547
604
 
548
- A failed request rejects with `TypeError: fetch failed`, with `cause.code` set to
549
- `UND_ERR_HEADERS_TIMEOUT` or `UND_ERR_BODY_TIMEOUT`.
550
-
551
- Do **not** put `signal: AbortSignal.timeout(ms)` in `requestOptions`. It is created once and
552
- shared by every request the client makes, so it works for the first call and then aborts all
553
- later ones with `TimeoutError`. Pass a fresh signal per call, or use the dispatcher above.
605
+ A `signal` in `requestOptions` still aborts every request in flight when it fires (with the
606
+ signal's own reason, not `RequestTimeoutError`), so an `AbortController` can cancel requests
607
+ on shutdown. `fetch()` then receives the client's own per-request signal, which follows it. Do **not** use `signal: AbortSignal.timeout(ms)` there: it is created once and
608
+ shared by every request the client makes, so it works for the first calls and then aborts all
609
+ later ones. Use `api.timeout` instead.
554
610
 
555
611
  ##### Redirects and the Vault token
556
612
 
557
613
  Requests follow HTTP redirects, because Vault HA clusters answer standby nodes with a 307 to the
558
614
  active node. Node's `fetch()` strips only `Authorization`, `Cookie` and `Proxy-Authorization` when
559
- a redirect crosses origins — `X-Vault-Token` is **not** on that list and is forwarded. A host that
560
- can answer for `api.url` can therefore redirect the client and receive its Vault token, so treat
561
- `api.url` (and the DNS and any load balancer in front of it) as trusted infrastructure.
615
+ a redirect crosses origins — `X-Vault-Token` is **not** on that list and is forwarded. A 307 or 308
616
+ also re-sends the request body, so a login is replayed to the redirect target with its credentials:
617
+ the AppRole `secret_id`, the Kubernetes or JWT token, the signed IAM request and, with
618
+ `distributedClaimAccessToken`, a Microsoft Graph access token that is a credential for systems other
619
+ than Vault. A host that can answer for `api.url` can therefore redirect the client and receive its
620
+ Vault token and its login credentials, so treat `api.url` (and the DNS and any load balancer in
621
+ front of it) as trusted infrastructure.
562
622
 
563
623
  <a name="VaultClient+fillNodeConfig"></a>
564
624
 
@@ -783,6 +843,14 @@ the ones it was booted with, the client logs a warning rather than letting the d
783
843
  silently. Use [`VaultClient.get(name)`](#VaultClient.get) to fetch an existing instance, or
784
844
  [`VaultClient.clear(name)`](#VaultClient.clear) before booting to replace one.
785
845
 
846
+ Options are compared as hashes, so no credential is kept for the comparison. Plain objects and
847
+ arrays compare by content, in any key order. Functions and other objects compare by identity, and
848
+ a `Buffer`, `Date`, `URL` or `RegExp` by value. So a logger class instance, an undici `dispatcher`,
849
+ or a logger object built from new functions counts as different even when it would behave the
850
+ same; a plain object that reuses the same functions does not. The warning names the top-level
851
+ options that differ (for example `logger`) and is logged once per instance for each set of them.
852
+ Boot each name once and fetch it with `get()` elsewhere.
853
+
786
854
  **Kind**: static method of [<code>VaultClient</code>](#VaultClient)
787
855
  **Returns**: <code>VaultClient</code>
788
856
 
@@ -1028,10 +1096,11 @@ try {
1028
1096
  |---|---|---|
1029
1097
  | `VaultError` | `Error` | Base class for every error below. Raised directly when mount detection fails (e.g. permission denied) and no `api.engines` override was provided. |
1030
1098
  | `VaultHttpError` | `VaultError` | Vault answered with a non-2xx status. Carries the status as `statusCode` and the parsed body as `error`. `message` (`"<status> - <raw body text>"`) and `error` hold the full response body, which can include secret material (e.g. a partially failed transit batch), so log `statusCode` and `error.errors` rather than the error object itself. |
1031
- | `InvalidArgumentsError` | `VaultError` | Bad arguments or configuration: `boot()` called without options, an unknown instance name passed to `get()`, an unsupported `auth.type`, invalid renewal options, an invalid node-config substitution map, or a KV method path that fails [path validation](#path-validation). |
1099
+ | `InvalidArgumentsError` | `VaultError` | Bad arguments or configuration: `boot()` called without options, an unknown instance name passed to `get()`, an unsupported `auth.type`, invalid renewal options or `api.timeout`, an invalid node-config substitution map, or a KV method path that fails [path validation](#path-validation). |
1032
1100
  | `InvalidAWSCredentialsError` | `InvalidArgumentsError` | `auth.config.credentials` was supplied but is not a usable `accessKeyId` / `secretAccessKey` pair. |
1033
1101
  | `AuthTokenExpiredError` | `VaultError` | The Vault token expired and the backend cannot obtain a new one. `token` auth can never re-authenticate and reaches this error at max TTL even with renewal enabled; other backends can also reach it when re-authentication is disabled or their credential can no longer be replayed. |
1034
1102
  | `UnsupportedOperationError` | `VaultError` | A v2-only method (`deleteVersions`, `undeleteVersions`, `destroyVersions`, `readMetadata`, `deleteMetadata`) was called against a mount that did not resolve as KV v2. |
1103
+ | `RequestTimeoutError` | `VaultError` | A request to Vault did not finish within `api.timeout` milliseconds (see [Request timeouts](#request-timeouts)). Carries the limit as `timeout` and the `fetch()` rejection (a `TimeoutError` DOMException) as `cause`. |
1035
1104
 
1036
1105
  ## Contributing
1037
1106
 
package/index.d.ts CHANGED
@@ -39,6 +39,8 @@ declare namespace VaultClient {
39
39
  apiVersion?: string;
40
40
  namespace?: string;
41
41
  requestOptions?: Record<string, any>;
42
+ /** Milliseconds a request may take before it rejects with RequestTimeoutError; `0` turns it off. Default 120000. */
43
+ timeout?: number | null;
42
44
  kv?: { autoDetect?: boolean };
43
45
  engines?: Record<string, 1 | 2>;
44
46
  }
@@ -79,10 +81,14 @@ declare namespace VaultClient {
79
81
  namespace?: string;
80
82
  }
81
83
 
84
+ // Either key may be `undefined` (it is then not sent), so a conditionally-set value such as
85
+ // `process.env.GRAPH_TOKEN` type-checks; setting both is still rejected.
82
86
  type JwtDistributedClaimConfig =
83
- | { distributedClaimAccessToken?: never; distributedClaimAccessTokenProvider?: never }
84
- | { distributedClaimAccessToken: string; distributedClaimAccessTokenProvider?: never }
85
- | { distributedClaimAccessTokenProvider: () => string | Promise<string>; distributedClaimAccessToken?: never };
87
+ | { distributedClaimAccessToken?: string | undefined; distributedClaimAccessTokenProvider?: undefined }
88
+ | {
89
+ distributedClaimAccessTokenProvider?: (() => string | Promise<string>) | undefined;
90
+ distributedClaimAccessToken?: undefined;
91
+ };
86
92
 
87
93
  type JwtAuthConfig = JwtAuthConfigCommon &
88
94
  (
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-vault-client",
3
- "version": "3.0.1",
3
+ "version": "3.1.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"
@@ -21,6 +21,7 @@
21
21
  "test:e2e": "mocha --exit \"test/e2e/e2e.test.mjs\"",
22
22
  "test:e2e:kv2": "mocha --exit \"test/e2e/e2e.kv2.test.mjs\"",
23
23
  "test:e2e:jwt": "mocha --exit \"test/e2e/e2e.jwt.test.mjs\"",
24
+ "test:e2e:generic": "mocha --exit \"test/e2e/e2e.generic.test.mjs\"",
24
25
  "test:example": "node examples/jwt-auth/vault-jwt-demo.mjs",
25
26
  "coverage": "c8 npm run test:unit",
26
27
  "lint": "eslint src/ test/ examples/",
@@ -54,10 +55,11 @@
54
55
  },
55
56
  "overrides": {
56
57
  "serialize-javascript": "^7.1.4",
57
- "glob": "^13.0.0",
58
+ "glob": "^14.0.3",
58
59
  "diff": "^8.0.3",
59
60
  "brace-expansion": "^5.0.12",
60
- "js-yaml": "^4.3.2"
61
+ "js-yaml": "^4.3.2",
62
+ "minimatch": "^11.0.4"
61
63
  },
62
64
  "devDependencies": {
63
65
  "@eslint/js": "^10.0.1",
@@ -1,8 +1,86 @@
1
1
  'use strict';
2
2
 
3
+ const { defaultMaxListeners, getMaxListeners, setMaxListeners } = require('events');
3
4
  const errors = require('./errors');
4
5
  const { trimTrailingSlashes } = require('./slashes');
5
6
 
7
+ // Above Vault's own default max_request_duration (90 s), so no request a default-configured
8
+ // Vault would still answer is cut short, and below the 300 s headers/body timeouts of the
9
+ // dispatcher Node's fetch() uses by default.
10
+ const DEFAULT_TIMEOUT_MS = 120000;
11
+ // setTimeout() fires after 1 ms for a delay that does not fit in a signed 32-bit integer.
12
+ const MAX_TIMEOUT_MS = 2147483647;
13
+ // The listener limit fetch() gives a signal it is passed (see deadline()).
14
+ const FETCH_MAX_LISTENERS = 1500;
15
+
16
+ function validateTimeout(timeout) {
17
+ // null reads as "not set", as it does in a config file.
18
+ if (timeout === undefined || timeout === null) {
19
+ return DEFAULT_TIMEOUT_MS;
20
+ }
21
+ if (!Number.isInteger(timeout) || timeout < 0 || timeout > MAX_TIMEOUT_MS) {
22
+ throw new errors.InvalidArgumentsError(
23
+ `"api.timeout" should be an integer number of milliseconds from 0 to ${MAX_TIMEOUT_MS}, ` +
24
+ `got ${String(timeout)}`
25
+ );
26
+ }
27
+ return timeout;
28
+ }
29
+
30
+ /**
31
+ * What fetch() accepts as a signal: Node 20 and 22 take any object with a boolean `aborted`
32
+ * and an `addEventListener` (an abort-controller polyfill, a signal from another realm).
33
+ */
34
+ function isSignalLike(value) {
35
+ return value !== null && typeof value === 'object'
36
+ && typeof value.aborted === 'boolean' && typeof value.addEventListener === 'function';
37
+ }
38
+
39
+ /**
40
+ * A per-request deadline. A plain timer, cleared once the request settles, rather than
41
+ * AbortSignal.timeout(), whose timer cannot be cancelled and so outlives every fast request.
42
+ * A `requestOptions.signal` still aborts the request: it is followed through a listener that
43
+ * is removed at the same point, since that signal lives as long as the client.
44
+ *
45
+ * @param {number} ms
46
+ * @param {AbortSignal} [signal] - `requestOptions.signal`
47
+ * @returns {{signal: AbortSignal, isTimeout: function(*): boolean, clear: function(): void}}
48
+ */
49
+ function deadline(ms, signal) {
50
+ const controller = new AbortController();
51
+ const reason = new DOMException('The operation was aborted due to timeout', 'TimeoutError');
52
+ const timer = setTimeout(() => controller.abort(reason), ms);
53
+ const follow = () => controller.abort(signal.reason);
54
+ if (signal) {
55
+ if (signal.aborted) {
56
+ follow();
57
+ } else {
58
+ // fetch() raised a signal's listener limit to 1500 when it was still at the default;
59
+ // it now gets the client's own signal, so do the same here, or more than ten
60
+ // requests in flight on one signal print MaxListenersExceededWarning (Node 20).
61
+ try {
62
+ if (getMaxListeners(signal) === defaultMaxListeners) {
63
+ setMaxListeners(FETCH_MAX_LISTENERS, signal);
64
+ }
65
+ } catch {
66
+ // not an EventTarget (a polyfill): it has no listener limit to raise
67
+ }
68
+ signal.addEventListener('abort', follow, { once: true });
69
+ }
70
+ }
71
+
72
+ return {
73
+ signal: controller.signal,
74
+ isTimeout: (err) => err === reason,
75
+ clear: () => {
76
+ clearTimeout(timer);
77
+ if (signal && typeof signal.removeEventListener === 'function') {
78
+ signal.removeEventListener('abort', follow);
79
+ }
80
+ },
81
+ };
82
+ }
83
+
6
84
  /**
7
85
  * Joins URL segments with single slashes while preserving the leading
8
86
  * protocol (e.g. "https://"). Replaces the former `url-join` dependency.
@@ -35,6 +113,9 @@ class VaultApiClient {
35
113
  * (`method`, `body`) always take precedence; `headers` are merged with the
36
114
  * per-request headers winning. Stored by reference (not deep-cloned) so live
37
115
  * objects such as a Dispatcher keep their prototype and remain usable.
116
+ * @param {number} [config.timeout=120000] - milliseconds a request may take, response
117
+ * headers, body and redirects included, before it rejects with
118
+ * {@link RequestTimeoutError}. `0` turns the limit off; `null` gives the default.
38
119
  * @param {Object} logger
39
120
  * @param {String} [namespace] - Vault namespace. When set, `X-Vault-Namespace` is added to
40
121
  * every request (login, token lookup/renewal, and secret operations) so namespacing is
@@ -55,6 +136,12 @@ class VaultApiClient {
55
136
  this.__config.requestOptions = requestOptions;
56
137
  }
57
138
 
139
+ this.__timeout = validateTimeout(this.__config.timeout);
140
+ // 3.0.x ignored an api.timeout key, so a config may still carry one written in seconds.
141
+ if (this.__timeout > 0 && this.__timeout < 1000) {
142
+ logger.warn('"api.timeout" is %d ms; it is in milliseconds, not seconds', this.__timeout);
143
+ }
144
+
58
145
  this.__namespace = namespace !== undefined ? namespace : this.__config.namespace;
59
146
  this._logger = logger;
60
147
  }
@@ -102,7 +189,28 @@ class VaultApiClient {
102
189
  uri
103
190
  );
104
191
 
105
- return fetch(uri, options).then((response) => {
192
+ // A requestOptions.signal that fetch() would reject is passed on untouched, without the
193
+ // deadline, so fetch() rejects it as it always has. The deadline is armed last, and
194
+ // cleared if fetch() throws synchronously, so no timer outlives a failed call.
195
+ const userSignal = requestOptions.signal;
196
+ const limit = this.__timeout > 0 && (userSignal === undefined || userSignal === null
197
+ || isSignalLike(userSignal))
198
+ ? deadline(this.__timeout, userSignal || undefined)
199
+ : null;
200
+ let pending;
201
+ if (limit === null) {
202
+ pending = fetch(uri, options);
203
+ } else {
204
+ options.signal = limit.signal;
205
+ try {
206
+ pending = fetch(uri, options);
207
+ } catch (err) {
208
+ limit.clear();
209
+ throw err;
210
+ }
211
+ }
212
+
213
+ const request = pending.then((response) => {
106
214
  return response.text().then((text) => {
107
215
  let body;
108
216
  if (text) {
@@ -127,6 +235,18 @@ class VaultApiClient {
127
235
  return body;
128
236
  });
129
237
  });
238
+ if (limit === null) {
239
+ return request;
240
+ }
241
+
242
+ return request
243
+ .catch((err) => {
244
+ if (limit.isTimeout(err)) {
245
+ throw new errors.RequestTimeoutError(this.__timeout, { cause: err });
246
+ }
247
+ throw err;
248
+ })
249
+ .finally(limit.clear);
130
250
  }
131
251
  }
132
252
 
@@ -18,24 +18,84 @@ const { trimTrailingSlashes } = require('./slashes');
18
18
  const noop = () => {};
19
19
  const vaultInstances = {};
20
20
 
21
+ // Functions, and objects that are not plain data (an undici dispatcher, a logger class
22
+ // instance), compare by identity. Their own enumerable keys say nothing about what they do:
23
+ // structurally, any two dispatchers look alike, and so do two loggers built from different
24
+ // functions, so a boot() that passed a different one was ignored without a warning (#224).
25
+ // A Buffer, Date, URL or RegExp compares by value, so an equal one re-created per call still
26
+ // matches. No user code (getters, toJSON, Proxy traps) decides the comparison.
27
+ //
28
+ // Per-process ids, in a WeakMap so that fingerprinting never keeps an option (or the closure
29
+ // state it captures) alive.
30
+ const identityIds = new WeakMap();
31
+ let nextIdentityId = 0;
32
+
33
+ function identityId(value) {
34
+ if (!identityIds.has(value)) {
35
+ identityIds.set(value, ++nextIdentityId);
36
+ }
37
+ return identityIds.get(value);
38
+ }
39
+
40
+ /**
41
+ * How an object takes part in the fingerprint: 'array' and 'plain' by content, 'value' by
42
+ * {@link valueOf}, anything else by identity. Plain includes a null prototype and an
43
+ * Object.prototype from another realm (vm, a test runner's structuredClone). A Proxy whose
44
+ * traps throw compares by identity rather than making boot() throw.
45
+ */
46
+ function kindOf(value) {
47
+ try {
48
+ if (Array.isArray(value)) {
49
+ return 'array';
50
+ }
51
+ if (Buffer.isBuffer(value) || value instanceof Date || value instanceof URL || value instanceof RegExp) {
52
+ return 'value';
53
+ }
54
+ const proto = Object.getPrototypeOf(value);
55
+ return proto === null || Object.getPrototypeOf(proto) === null ? 'plain' : 'identity';
56
+ } catch {
57
+ return 'identity';
58
+ }
59
+ }
60
+
61
+ function valueOf(value) {
62
+ if (Buffer.isBuffer(value)) {
63
+ return `buffer:${value.toString('base64')}`;
64
+ }
65
+ if (value instanceof Date) {
66
+ return `date:${value.getTime()}`;
67
+ }
68
+ return `${value instanceof URL ? 'url' : 'regexp'}:${JSON.stringify(String(value))}`;
69
+ }
70
+
21
71
  /**
22
- * Order-insensitive canonical form of the boot options, hashed so that no credential is
23
- * retained just to compare two boot() calls. Functions compare by identity, which is what
24
- * a caller passing `jwtProvider` expects.
72
+ * Order-insensitive canonical form of a boot option, hashed so that no credential is retained
73
+ * just to compare two boot() calls. Plain objects and arrays compare by content, functions and
74
+ * other objects by identity, and a Buffer, Date, URL or RegExp by value.
25
75
  */
26
76
  function fingerprintOptions(value, seen) {
27
77
  if (typeof value === 'function') {
28
- return 'fn';
78
+ return `fn#${identityId(value)}`;
79
+ }
80
+ if (typeof value === 'bigint') {
81
+ return `${value}n`;
29
82
  }
30
83
  if (value === null || typeof value !== 'object') {
31
84
  return JSON.stringify(value) || String(value);
32
85
  }
86
+ const kind = kindOf(value);
87
+ if (kind === 'value') {
88
+ return valueOf(value);
89
+ }
90
+ if (kind === 'identity') {
91
+ return `obj#${identityId(value)}`;
92
+ }
33
93
  seen = seen || new Set();
34
94
  if (seen.has(value)) {
35
95
  return 'circular';
36
96
  }
37
97
  seen.add(value);
38
- if (Array.isArray(value)) {
98
+ if (kind === 'array') {
39
99
  return `[${value.map((item) => fingerprintOptions(item, seen)).join(',')}]`;
40
100
  }
41
101
  const body = Object.keys(value).sort()
@@ -44,8 +104,19 @@ function fingerprintOptions(value, seen) {
44
104
  return `{${body}}`;
45
105
  }
46
106
 
47
- function optionsDigest(options) {
48
- return crypto.createHash('sha256').update(fingerprintOptions(options)).digest('hex');
107
+ /** One digest per top-level option, so a mismatch can name the options that differ. */
108
+ function optionsDigests(options) {
109
+ const digests = new Map();
110
+ for (const key of Object.keys(options)) {
111
+ digests.set(key, crypto.createHash('sha256').update(fingerprintOptions(options[key])).digest('hex'));
112
+ }
113
+ return digests;
114
+ }
115
+
116
+ /** The top-level options whose digests differ, sorted; empty when the options match. */
117
+ function differingOptions(before, after) {
118
+ const keys = new Set([...before.keys(), ...after.keys()]);
119
+ return [...keys].filter((key) => before.get(key) !== after.get(key)).sort();
49
120
  }
50
121
 
51
122
  // fetch() parses the joined URL per the WHATWG URL Standard: '?' starts the query, '#' the
@@ -128,6 +199,9 @@ class VaultClient {
128
199
  * @param {Object} [options.api.requestOptions] - extra options merged into every HTTP
129
200
  * request (e.g. an undici `dispatcher` for a proxy/SOCKS agent or custom TLS/CA trust).
130
201
  * See {@link VaultApiClient#constructor}.
202
+ * @param {number} [options.api.timeout=120000] - milliseconds a request may take, response
203
+ * headers, body and redirects included, before it rejects with RequestTimeoutError.
204
+ * `0` turns the limit off.
131
205
  * @param {Object} [options.api.kv] - KV engine options
132
206
  * @param {boolean} [options.api.kv.autoDetect=false] - When true, auto-detect KV version per mount
133
207
  * @param {Object} [options.api.engines] - Mount-to-version overrides, e.g. { secret: 2, legacy: 1 }
@@ -210,8 +284,15 @@ class VaultClient {
210
284
  * `options` are used only when the instance is first created. A later call for a name
211
285
  * that already exists returns the existing instance and ignores the options it was
212
286
  * given; when those options differ from the ones the instance was booted with, that is
213
- * logged as a warning rather than passing silently. Use {@link VaultClient.get} to
214
- * fetch an existing instance, or {@link VaultClient.clear} to replace one.
287
+ * logged as a warning, naming the top-level options that differ, rather than passing
288
+ * silently. The warning is logged once per instance for each set of differing options.
289
+ * Use {@link VaultClient.get} to fetch an existing instance, or {@link VaultClient.clear}
290
+ * to replace one.
291
+ *
292
+ * Options are compared as hashes, so no credential is kept for the comparison. Plain
293
+ * objects and arrays compare by content, in any key order. Functions and other objects (a
294
+ * logger class instance, an undici dispatcher) compare by identity, and a Buffer, Date,
295
+ * URL or RegExp by value.
215
296
  *
216
297
  * @param {String} name - Vault instance name
217
298
  * @param {Object} options - options for {@link VaultClient#constructor}. Required on
@@ -226,14 +307,22 @@ class VaultClient {
226
307
  let instance = vaultInstances[name];
227
308
  if (instance === undefined) {
228
309
  vaultInstances[name] = instance = new VaultClient(options);
229
- instance.__bootDigest = optionsDigest(options);
230
- } else if (instance.__bootDigest !== optionsDigest(options)) {
310
+ instance.__bootDigests = optionsDigests(options);
311
+ instance.__bootWarned = new Set();
312
+ return instance;
313
+ }
314
+
315
+ // Once per instance for each set of differing options: code that re-creates its logger
316
+ // for every boot() call would otherwise log this on every call.
317
+ const differing = differingOptions(instance.__bootDigests, optionsDigests(options)).join(', ');
318
+ if (differing !== '' && !instance.__bootWarned.has(differing)) {
319
+ instance.__bootWarned.add(differing);
231
320
  instance.__log.warn(
232
321
  'VaultClient.boot("%s") was called with different options than the existing ' +
233
322
  'instance was booted with; the existing instance is returned and these options ' +
234
323
  'are ignored. Use VaultClient.get("%s") to fetch it, or VaultClient.clear("%s") ' +
235
- 'first to replace it.',
236
- name, name, name
324
+ 'first to replace it. Differing options: %s.',
325
+ name, name, name, differing
237
326
  );
238
327
  }
239
328
  return instance;
@@ -9,8 +9,6 @@ const AuthToken = require('./AuthToken');
9
9
 
10
10
  /** Renew at the halfway point of the token's remaining lifetime. */
11
11
  const DEFAULT_RENEWAL_FRACTION = 0.5;
12
- /** Maximum rounding difference allowed when comparing expiry movement with a renewal interval. */
13
- const MAX_EXPIRY_JITTER_MS = 1000;
14
12
  const errors = require('../errors');
15
13
  const safeMessage = require('../safeMessage');
16
14
 
@@ -50,35 +48,40 @@ function tokenExpiryMilliseconds(authToken) {
50
48
  }
51
49
 
52
50
  /**
53
- * Read Vault's unadjusted absolute expiry when available; duck-typed tokens fall back to their
54
- * public expiry in seconds.
55
- * @param {AuthToken} authToken
56
- * @returns {number|null}
51
+ * True when Vault says it granted less than the requested lifetime because the token reached a
52
+ * maximum TTL (its explicit max TTL, the role's `token_max_ttl` or the mount's max lease TTL).
53
+ * Vault reports this on every capped renew-self as a warning such as
54
+ * `TTL of "10s" exceeded the effective max_ttl of "9s"; TTL value is capped accordingly`
55
+ * (measured on 1.21 and 2.2) and never on an uncapped one. A capped token's deadline cannot move
56
+ * again, so further renewals are pointless; the next one would fail with "past the max TTL".
57
+ *
58
+ * Vault's other cap warning, `period of "4s" exceeded the effective max_ttl of "2s"; period value
59
+ * is capped accordingly`, must NOT match: it is sent on every renewal of a periodic token whose
60
+ * period exceeds the role's `token_max_ttl` or the mount's max lease TTL, and that token stays
61
+ * renewable indefinitely at the shorter period (measured on 2.2).
62
+ *
63
+ * `lease_duration < increment` is not a usable substitute: periodic tokens ignore the increment.
64
+ *
65
+ * @param {Object|null|undefined} renewResponse - renew-self response body
66
+ * @returns {boolean}
57
67
  * @private
58
68
  */
59
- function tokenUnadjustedExpiryMilliseconds(authToken) {
60
- if (typeof authToken.getUnadjustedExpiresAtMilliseconds === 'function') {
61
- const expiry = authToken.getUnadjustedExpiresAtMilliseconds();
62
- if (Number.isFinite(expiry)) {
63
- return expiry;
64
- }
65
- }
66
-
67
- return tokenExpiryMilliseconds(authToken);
69
+ function renewalCappedByMaxTtl(renewResponse) {
70
+ const warnings = renewResponse && Array.isArray(renewResponse.warnings) ? renewResponse.warnings : [];
71
+ return warnings.some((warning) => typeof warning === 'string'
72
+ && /max_?ttl/i.test(warning) && /\bTTL value is capped\b/i.test(warning));
68
73
  }
69
74
 
70
75
  /**
71
- * Read Vault's whole-second TTL when the token implementation exposes it.
72
- * @param {AuthToken} authToken
73
- * @returns {number|null}
76
+ * ISO form of a millisecond timestamp, or its plain string form when Date cannot represent it,
77
+ * so that a log line about a duck-typed token never throws a RangeError.
78
+ * @param {number} ms
79
+ * @returns {string}
74
80
  * @private
75
81
  */
76
- function tokenTtlSeconds(authToken) {
77
- if (typeof authToken.getTtlSeconds !== 'function') {
78
- return null;
79
- }
80
- const ttl = authToken.getTtlSeconds();
81
- return typeof ttl === 'number' && Number.isFinite(ttl) ? ttl : null;
82
+ function formatTimestamp(ms) {
83
+ const date = new Date(ms);
84
+ return Number.isNaN(date.getTime()) ? String(ms) : date.toISOString();
82
85
  }
83
86
 
84
87
  /**
@@ -101,7 +104,7 @@ function warnRenewalStopped(auth, authToken, reason) {
101
104
  'token renewal stopped (accessor=%s): %s; client-side expiry is %s; %s',
102
105
  authToken.getAccessor(),
103
106
  reason,
104
- expiry === null ? 'unknown' : new Date(expiry).toISOString(),
107
+ expiry === null ? 'unknown' : formatTimestamp(expiry),
105
108
  nextAction
106
109
  );
107
110
  }
@@ -255,16 +258,10 @@ class VaultBaseAuth {
255
258
  this.__renewalEpoch++;
256
259
 
257
260
  if (authToken.isRenewable()) {
258
- // Gate the log on the flag too: the guard lives inside
259
- // __setupTokenRefreshTimer, so without this the client claims it armed a
260
- // timer while arming nothing -- the one diagnostic an operator reads when a
261
- // `renewal: false` process still hangs.
262
- if (this.__renewalEnabled) {
263
- this._log.debug(
264
- 'setting refresh timer for token (accessor=%s)',
265
- authToken.getAccessor()
266
- );
267
- } else {
261
+ // __setupTokenRefreshTimer logs once it has actually armed a timer, and warns
262
+ // when it cannot; this covers the remaining case, the one diagnostic an operator
263
+ // reads when a `renewal: false` process still hangs.
264
+ if (!this.__renewalEnabled) {
268
265
  this._log.debug(
269
266
  'renewal disabled by config; not arming a refresh timer (accessor=%s)',
270
267
  authToken.getAccessor()
@@ -371,7 +368,6 @@ class VaultBaseAuth {
371
368
  // permanently un-renewed. Anything that supersedes this timer bumps the epoch.
372
369
  const epoch = ++this.__renewalEpoch;
373
370
  const superseded = () => this.__renewalEpoch !== epoch;
374
- const scheduledAt = Date.now();
375
371
 
376
372
  this.__refreshTimeout = lt.setTimeout(() => {
377
373
  if (superseded()) {
@@ -379,47 +375,20 @@ class VaultBaseAuth {
379
375
  }
380
376
  this.__refreshTimeout = null;
381
377
 
382
- this.__renewToken(authToken).then(renewedToken => {
378
+ // Two handlers, not .then().catch(): once the renewed token is adopted, a failure
379
+ // in the bookkeeping below must not be mistaken for a failed renewal and re-arm a
380
+ // timer against the token this client no longer holds.
381
+ this.__renewToken(authToken).then(({ renewedToken, cappedByMaxTtl }) => {
383
382
  if (superseded()) {
384
383
  return;
385
384
  }
386
385
  this.__authToken = renewedToken;
387
- if (!renewedToken.isRenewable() || renewedToken.isExpired()) {
388
- this.__setupTokenRefreshTimer(renewedToken);
386
+ if (cappedByMaxTtl) {
387
+ warnRenewalStopped(this, renewedToken, 'Vault capped the renewal at the token\'s maximum TTL');
389
388
  return;
390
389
  }
391
-
392
- const previousExpiry = tokenUnadjustedExpiryMilliseconds(authToken);
393
- const renewedExpiry = tokenUnadjustedExpiryMilliseconds(renewedToken);
394
- const expiryMovement = previousExpiry === null || renewedExpiry === null
395
- ? null
396
- : renewedExpiry - previousExpiry;
397
- const previousTtl = tokenTtlSeconds(authToken);
398
- const renewedTtl = tokenTtlSeconds(renewedToken);
399
- const ttlDidNotDecrease = previousTtl === null || renewedTtl === null
400
- || renewedTtl >= previousTtl;
401
- const renewalElapsedMs = Date.now() - scheduledAt;
402
- // A max-TTL cap leaves the absolute expiry roughly fixed, while a periodic
403
- // renewal moves it forward by about the time spent waiting. Compare the
404
- // reported TTL too, so whole-second rounding does not make a short extension
405
- // look like a capped token.
406
- const expiryAdvancedWithRenewal = expiryMovement !== null
407
- && expiryMovement >= 0
408
- && ttlDidNotDecrease
409
- && Math.abs(expiryMovement - renewalElapsedMs) <= MAX_EXPIRY_JITTER_MS;
410
- const expiryMovedMaterially = expiryMovement !== null
411
- && Math.abs(expiryMovement) > MAX_EXPIRY_JITTER_MS;
412
-
413
- if (expiryMovedMaterially || expiryAdvancedWithRenewal) {
414
- this.__setupTokenRefreshTimer(renewedToken);
415
- } else {
416
- warnRenewalStopped(
417
- this,
418
- renewedToken,
419
- 'Vault did not extend the token (maximum TTL likely reached)'
420
- );
421
- }
422
- }).catch(err => {
390
+ this.__setupTokenRefreshTimer(renewedToken);
391
+ }, err => {
423
392
  if (superseded()) {
424
393
  return;
425
394
  }
@@ -433,18 +402,27 @@ class VaultBaseAuth {
433
402
  } else {
434
403
  this._log.error(message, err);
435
404
  }
405
+ }).catch(err => {
406
+ // Only a throwing logger (or a broken duck-typed token) gets here. Report it if
407
+ // the logger allows, and never let it surface as an unhandled rejection.
408
+ try {
409
+ this._log.error('token renewal bookkeeping failed: %s', safeMessage(err));
410
+ } catch {
411
+ // nothing left to report to
412
+ }
436
413
  });
437
414
  }, timer);
438
415
 
439
416
  this._log.debug(
440
- 'sleeping for %s',
417
+ 'renewal timer armed (accessor=%s); renewing in %s',
418
+ authToken.getAccessor(),
441
419
  humanizeMs(timer)
442
420
  );
443
421
  }
444
422
 
445
423
  /**
446
424
  * @param {AuthToken} authToken
447
- * @returns {Promise.<AuthToken>}
425
+ * @returns {Promise.<{renewedToken: AuthToken, cappedByMaxTtl: boolean}>}
448
426
  * @private
449
427
  */
450
428
  __renewToken(authToken) {
@@ -454,9 +432,11 @@ class VaultBaseAuth {
454
432
  const data = this.__renewalIncrement === undefined ? null : {increment: this.__renewalIncrement};
455
433
 
456
434
  return this.__apiClient.makeRequest('POST', '/auth/token/renew-self', data, {'X-Vault-Token': authToken.getId()})
457
- .then(() => {
435
+ .then((renewResponse) => {
458
436
  this._log.info('successfully renewed token');
459
- return this._getTokenEntity(authToken.getId());
437
+ const cappedByMaxTtl = renewalCappedByMaxTtl(renewResponse);
438
+ return this._getTokenEntity(authToken.getId())
439
+ .then((renewedToken) => ({ renewedToken, cappedByMaxTtl }));
460
440
  })
461
441
  .catch((reason) => {
462
442
  this._log.error('token renew failed: %s', safeMessage(reason));
@@ -3,10 +3,31 @@
3
3
  const fs = require('fs');
4
4
  const VaultBaseAuth = require('./VaultBaseAuth');
5
5
  const errors = require('../errors');
6
+ const safeMessage = require('../safeMessage');
6
7
 
7
8
  const JWT_SOURCE_KEYS = ['jwt', 'jwtPath', 'jwtProvider'];
8
9
 
10
+ /**
11
+ * Normalizes an optional access token: surrounding whitespace (a trailing newline from a file or
12
+ * an env var) is dropped, and anything that is not a non-empty string afterwards is `undefined`.
13
+ *
14
+ * @param {*} value
15
+ * @returns {String|undefined}
16
+ */
17
+ function normalizeAccessToken(value) {
18
+ if (typeof value !== 'string') {
19
+ return undefined;
20
+ }
21
+ const trimmed = value.trim();
22
+ return trimmed.length > 0 ? trimmed : undefined;
23
+ }
24
+
9
25
  class VaultJwtAuth extends VaultBaseAuth {
26
+ // Private fields rather than `__` properties: util.inspect / console.log / JSON.stringify of
27
+ // the client never show them, so neither credential leaks through a logged object.
28
+ #jwt;
29
+ #distributedClaimAccessToken;
30
+
10
31
  /**
11
32
  * @param {VaultApiClient} apiClient - see {@link VaultBaseAuth#constructor}
12
33
  * @param {Object} logger
@@ -21,14 +42,17 @@ class VaultJwtAuth extends VaultBaseAuth {
21
42
  * returning `string | Promise<string>`. Called fresh on every login (never at construction
22
43
  * or cached across logins) so it can mint a short-lived token -- the shape GitHub Actions'
23
44
  * `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`.
45
+ * @param {String} [config.distributedClaimAccessToken] - A literal Microsoft Graph access token
46
+ * forwarded to Vault as `distributed_claim_access_token`. Only the Azure (Entra ID) provider
47
+ * uses it: when the role sets `groups_claim` and Vault has to fetch group membership from
48
+ * Graph (Azure sent distributed claims, or the mount's `provider_config` sets
49
+ * `fetch_groups`). Fixed at construction, so every re-login replays it; prefer the provider
50
+ * in a long-running process. `undefined` means "not sent"; any other value must be a
51
+ * non-empty string. Mutually exclusive with `distributedClaimAccessTokenProvider`.
28
52
  * @param {Function} [config.distributedClaimAccessTokenProvider] - (optionally async) function
29
53
  * 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
54
+ * (never at construction or cached across logins) because Graph access tokens are short-lived.
55
+ * It runs concurrently with the JWT source. Mutually exclusive with
32
56
  * `distributedClaimAccessToken`.
33
57
  * @param {String} [config.namespace] - Optional. Vault namespace. Applied as the X-Vault-Namespace
34
58
  * header to every request by {@link VaultApiClient}; see {@link VaultClient#constructor}.
@@ -53,6 +77,14 @@ class VaultJwtAuth extends VaultBaseAuth {
53
77
  + ' should be provided for VaultJwtAuth'
54
78
  );
55
79
  }
80
+ // An empty or non-string literal would be sent as-is and fail later, at Vault's
81
+ // group-fetch step, with an error that does not mention this option.
82
+ if (config.distributedClaimAccessToken !== undefined
83
+ && normalizeAccessToken(config.distributedClaimAccessToken) === undefined) {
84
+ throw new errors.InvalidArgumentsError(
85
+ '"distributedClaimAccessToken" should be a non-empty string for VaultJwtAuth'
86
+ );
87
+ }
56
88
  if (config.distributedClaimAccessTokenProvider !== undefined
57
89
  && typeof config.distributedClaimAccessTokenProvider !== 'function') {
58
90
  throw new errors.InvalidArgumentsError(
@@ -61,94 +93,124 @@ class VaultJwtAuth extends VaultBaseAuth {
61
93
  }
62
94
 
63
95
  this.__role = config.role;
64
- this.__jwt = config.jwt;
96
+ this.#jwt = config.jwt;
65
97
  this.__jwtPath = config.jwtPath;
66
98
  this.__jwtProvider = config.jwtProvider;
67
- this.__distributedClaimAccessToken = config.distributedClaimAccessToken;
99
+ this.#distributedClaimAccessToken = normalizeAccessToken(config.distributedClaimAccessToken);
68
100
  this.__distributedClaimAccessTokenProvider = config.distributedClaimAccessTokenProvider;
69
101
  }
70
102
 
71
103
  _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
- }));
104
+ // Both credentials are independent and usually network-bound (IdP, IMDS, MSAL), so they
105
+ // are acquired concurrently: login latency is the slower of the two rather than their
106
+ // sum, and the JWT does not age while the Graph token is fetched.
107
+ //
108
+ // A failed JWT source rejects the login at once, as it did when the two ran in turn, so a
109
+ // Graph provider that hangs cannot hold up that error. A failed access token instead waits
110
+ // for the JWT source, so the JWT source's error is the one raised whenever both fail. The
111
+ // Graph provider is not cancelled: if it fails after the login already rejected, its
112
+ // failure is still logged, and its rejection is handled here.
113
+ const jwtPromise = this.__acquireJwt();
114
+ const claimPromise = this.__acquireDistributedClaimAccessToken()
115
+ .catch((claimErr) => jwtPromise.then(() => { throw claimErr; }));
116
+
117
+ return Promise.all([jwtPromise, claimPromise])
118
+ .then(([{ jwt, source }, distributedClaim]) => {
119
+ this._log.info(
120
+ 'making authentication request: Vault role: "%s"; JWT source: %s (%d bytes);'
121
+ + ' distributed claim access token: %s',
122
+ this.__role !== undefined ? this.__role : '(default_role)', source, jwt.length,
123
+ distributedClaim === undefined ? 'none' : distributedClaim.source
124
+ );
125
+
126
+ const body = { jwt };
127
+ if (this.__role !== undefined) {
128
+ body.role = this.__role;
129
+ }
130
+ if (distributedClaim !== undefined) {
131
+ body.distributed_claim_access_token = distributedClaim.accessToken;
132
+ }
133
+
134
+ return this.__apiClient.makeRequest('POST', `/auth/${this._mount}/login`, body);
135
+ })
136
+ .then((res) => {
137
+ this._log.debug('received Vault client token from JWT login');
138
+
139
+ return this._getTokenEntity(res.auth.client_token);
140
+ });
100
141
  }
101
142
 
102
143
  /**
103
- * @returns {{jwt: String, source: String}|Promise<{jwt: String, source: String}>}
144
+ * @returns {Promise<{jwt: String, source: String}>}
104
145
  * @private
105
146
  */
106
147
  __acquireJwt() {
107
- if (this.__jwt !== undefined) {
108
- return { jwt: this.__jwt, source: 'literal' };
148
+ if (this.#jwt !== undefined) {
149
+ return Promise.resolve({ jwt: this.#jwt, source: 'literal' });
109
150
  }
110
151
  if (this.__jwtPath !== undefined) {
111
- return { jwt: fs.readFileSync(this.__jwtPath).toString(), source: 'file' };
152
+ return Promise.resolve()
153
+ .then(() => ({ jwt: fs.readFileSync(this.__jwtPath).toString(), source: 'file' }));
112
154
  }
113
155
 
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
- });
156
+ return this.__callStringProvider(this.__jwtProvider, 'jwtProvider', 'JWT')
157
+ .then((jwt) => ({ jwt, source: 'provider' }));
124
158
  }
125
159
 
126
160
  /**
127
161
  * Resolves to `undefined` when neither option is configured, so that the login body stays
128
162
  * byte-identical to what it was before this option existed.
129
163
  *
130
- * @returns {undefined|{accessToken: String, source: String}|Promise<{accessToken: String, source: String}>}
164
+ * @returns {Promise<undefined|{accessToken: String, source: String}>}
131
165
  * @private
132
166
  */
133
167
  __acquireDistributedClaimAccessToken() {
134
- if (this.__distributedClaimAccessToken !== undefined) {
135
- return { accessToken: this.__distributedClaimAccessToken, source: 'literal' };
168
+ if (this.#distributedClaimAccessToken !== undefined) {
169
+ return Promise.resolve({ accessToken: this.#distributedClaimAccessToken, source: 'literal' });
136
170
  }
137
171
  if (this.__distributedClaimAccessTokenProvider === undefined) {
138
- return undefined;
172
+ return Promise.resolve(undefined);
139
173
  }
140
174
 
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) {
175
+ return this.__callStringProvider(
176
+ this.__distributedClaimAccessTokenProvider, 'distributedClaimAccessTokenProvider', 'access token'
177
+ ).then((accessToken) => {
178
+ const normalized = normalizeAccessToken(accessToken);
179
+ if (normalized === undefined) {
145
180
  throw new errors.InvalidArgumentsError(
146
181
  '"distributedClaimAccessTokenProvider" must resolve to a non-empty access token string'
147
182
  );
148
183
  }
149
- return { accessToken, source: 'provider' };
184
+ return { accessToken: normalized, source: 'provider' };
150
185
  });
151
186
  }
187
+
188
+ /**
189
+ * Calls a user-supplied credential provider. Wrapping in Promise.resolve().then() normalizes
190
+ * both a sync provider (plain return) and a synchronous throw into the same rejection path as
191
+ * an async one. A provider failure is rethrown unchanged, after logging which provider it was:
192
+ * both are often backed by the same metadata endpoint, so the bare error does not say.
193
+ *
194
+ * @param {Function} provider
195
+ * @param {String} name - config key, used in messages
196
+ * @param {String} what - what the provider resolves to, used in messages
197
+ * @returns {Promise<String>}
198
+ * @private
199
+ */
200
+ __callStringProvider(provider, name, what) {
201
+ return Promise.resolve().then(() => provider()).then(
202
+ (value) => {
203
+ if (typeof value !== 'string' || value.length === 0) {
204
+ throw new errors.InvalidArgumentsError(`"${name}" must resolve to a non-empty ${what} string`);
205
+ }
206
+ return value;
207
+ },
208
+ (err) => {
209
+ this._log.error('JWT login aborted: "%s" failed: %s', name, safeMessage(err));
210
+ throw err;
211
+ }
212
+ );
213
+ }
152
214
  }
153
215
 
154
216
  module.exports = VaultJwtAuth;
package/src/errors.d.ts CHANGED
@@ -16,3 +16,8 @@ export declare class VaultHttpError extends VaultError {
16
16
  statusCode: number;
17
17
  error: unknown;
18
18
  }
19
+
20
+ export declare class RequestTimeoutError extends VaultError {
21
+ constructor(timeout: number, options?: { cause?: unknown });
22
+ timeout: number;
23
+ }
package/src/errors.js CHANGED
@@ -41,6 +41,23 @@ class VaultHttpError extends VaultError {
41
41
  }
42
42
  }
43
43
 
44
+ /**
45
+ * A request to Vault did not complete within `api.timeout` milliseconds: response headers,
46
+ * body and any redirects included. The `fetch()` rejection (a `TimeoutError` DOMException)
47
+ * is on `cause`.
48
+ */
49
+ class RequestTimeoutError extends VaultError {
50
+ /**
51
+ * @param {number} timeout - the `api.timeout` that elapsed, in milliseconds. Exposed as
52
+ * `error.timeout`.
53
+ * @param {{cause?: *}} [options]
54
+ */
55
+ constructor(timeout, options) {
56
+ super(`Vault request timed out after ${timeout} ms (api.timeout)`, options);
57
+ this.timeout = timeout;
58
+ }
59
+ }
60
+
44
61
  module.exports = {
45
62
  VaultError,
46
63
  InvalidArgumentsError,
@@ -48,4 +65,5 @@ module.exports = {
48
65
  AuthTokenExpiredError,
49
66
  UnsupportedOperationError,
50
67
  VaultHttpError,
68
+ RequestTimeoutError,
51
69
  };