node-vault-client 3.0.0 → 3.0.2

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', {
@@ -365,19 +376,43 @@ on the next call — no background timer at all. Two reasons to want that:
365
376
  Renewal off means the token is allowed to expire, and what happens next depends on whether your
366
377
  backend can obtain a *fresh* credential unaided:
367
378
 
368
- | backend | on expiry with `renewal: false` |
369
- | --- | --- |
370
- | `kubernetes`, `iam`, `jwt` with `jwtPath`/`jwtProvider` | clean re-login — the JWT or AWS credential is re-acquired, so this is the intended case |
371
- | `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 |
372
- | `jwt` with a literal `jwt` | replays the same JWT, so it works only until the IdP-issued token expires |
373
- | `token` | **cannot re-authenticate at all.** The client raises `AuthTokenExpiredError` from then on, permanently |
379
+ | backend | on expiry with `renewal: false` | at max TTL with renewal enabled |
380
+ | --- | --- | --- |
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 |
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 |
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 |
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 |
386
+
387
+ #### Tokens that reach their maximum TTL
388
+
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.
374
408
 
375
409
  Two consequences worth stating plainly:
376
410
 
377
- * For `token` auth this **is** a behaviour change, not a no-op. A renewable token handed to `token`
378
- auth is renewed indefinitely today; with `renewal: false` it expires and every later call rejects
379
- for the life of the process. `close()` does not reset it — recovery means
380
- `VaultClient.clear(name)` and booting again.
411
+ * **For `token` auth, there is no fresh credential for re-authentication.** With `renewal: false`,
412
+ an otherwise renewable token is allowed to expire without a background renewal. With renewal
413
+ enabled, the client stops the background loop once Vault reports that a renewal was capped.
414
+ After expiry every later call rejects for the life of the process. `close()` does not reset it —
415
+ recovery means `VaultClient.clear(name)` and booting again.
381
416
  * For `appRole` and literal-`jwt`, a login that can no longer succeed is retried on **every**
382
417
  subsequent call, since a failed login clears the cached token. That is a failing request per
383
418
  `read()`, with no backoff. Prefer `jwtPath`/`jwtProvider`, a reusable `secret_id`, or leaving
@@ -410,11 +445,17 @@ auth: {
410
445
  Lower `renewalFraction` values renew earlier and more often, which buys headroom if Vault is briefly
411
446
  unreachable. A failed renewal is retried on the same rule against the *same* token, so the waits
412
447
  shrink geometrically as the remaining lifetime does — for a 1-hour token that is roughly 12 attempts
413
- at `0.5` (1800s, 900s, 450s, …) versus 27 at `0.25`, both bottoming out at a 1-second floor just
414
- before expiry. Higher values renew later and talk to Vault less.
448
+ at `0.5` (1800s, 900s, 450s, …) versus 27 at `0.25`, both bottoming out at a 250 ms minimum delay
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`.
415
453
 
416
454
  `renewalIncrement` is a request, not a guarantee: Vault grants at most the token's max TTL and may
417
- 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
418
459
  non-positive/non-integer `renewalIncrement` raises `InvalidArgumentsError` rather than failing later
419
460
  inside a background timer.
420
461
 
@@ -544,9 +585,13 @@ later ones with `TimeoutError`. Pass a fresh signal per call, or use the dispatc
544
585
 
545
586
  Requests follow HTTP redirects, because Vault HA clusters answer standby nodes with a 307 to the
546
587
  active node. Node's `fetch()` strips only `Authorization`, `Cookie` and `Proxy-Authorization` when
547
- a redirect crosses origins — `X-Vault-Token` is **not** on that list and is forwarded. A host that
548
- can answer for `api.url` can therefore redirect the client and receive its Vault token, so treat
549
- `api.url` (and the DNS and any load balancer in front of it) as trusted infrastructure.
588
+ a redirect crosses origins — `X-Vault-Token` is **not** on that list and is forwarded. A 307 or 308
589
+ also re-sends the request body, so a login is replayed to the redirect target with its credentials:
590
+ the AppRole `secret_id`, the Kubernetes or JWT token, the signed IAM request and, with
591
+ `distributedClaimAccessToken`, a Microsoft Graph access token that is a credential for systems other
592
+ than Vault. A host that can answer for `api.url` can therefore redirect the client and receive its
593
+ Vault token and its login credentials, so treat `api.url` (and the DNS and any load balancer in
594
+ front of it) as trusted infrastructure.
550
595
 
551
596
  <a name="VaultClient+fillNodeConfig"></a>
552
597
 
@@ -1018,7 +1063,7 @@ try {
1018
1063
  | `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. |
1019
1064
  | `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). |
1020
1065
  | `InvalidAWSCredentialsError` | `InvalidArgumentsError` | `auth.config.credentials` was supplied but is not a usable `accessKeyId` / `secretAccessKey` pair. |
1021
- | `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. |
1066
+ | `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. |
1022
1067
  | `UnsupportedOperationError` | `VaultError` | A v2-only method (`deleteVersions`, `undeleteVersions`, `destroyVersions`, `readMetadata`, `deleteMetadata`) was called against a mount that did not resolve as KV v2. |
1023
1068
 
1024
1069
  ## Contributing
package/index.d.ts CHANGED
@@ -79,10 +79,14 @@ declare namespace VaultClient {
79
79
  namespace?: string;
80
80
  }
81
81
 
82
+ // Either key may be `undefined` (it is then not sent), so a conditionally-set value such as
83
+ // `process.env.GRAPH_TOKEN` type-checks; setting both is still rejected.
82
84
  type JwtDistributedClaimConfig =
83
- | { distributedClaimAccessToken?: never; distributedClaimAccessTokenProvider?: never }
84
- | { distributedClaimAccessToken: string; distributedClaimAccessTokenProvider?: never }
85
- | { distributedClaimAccessTokenProvider: () => string | Promise<string>; distributedClaimAccessToken?: never };
85
+ | { distributedClaimAccessToken?: string | undefined; distributedClaimAccessTokenProvider?: undefined }
86
+ | {
87
+ distributedClaimAccessTokenProvider?: (() => string | Promise<string>) | undefined;
88
+ distributedClaimAccessToken?: undefined;
89
+ };
86
90
 
87
91
  type JwtAuthConfig = JwtAuthConfigCommon &
88
92
  (
@@ -127,5 +131,6 @@ declare namespace VaultClient {
127
131
  isExpired(): boolean;
128
132
  isRenewable(): boolean;
129
133
  getExpiresAt(): number | null;
134
+ getTtlSeconds?(): number | null;
130
135
  }
131
136
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-vault-client",
3
- "version": "3.0.0",
3
+ "version": "3.0.2",
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"
@@ -18,14 +18,31 @@ const { trimTrailingSlashes } = require('./slashes');
18
18
  const noop = () => {};
19
19
  const vaultInstances = {};
20
20
 
21
+ // Only credential providers compare by identity: a different provider means the client would
22
+ // authenticate differently. Other functions (logger methods, an IAM `credentials` provider
23
+ // chain) are often re-created per call with the same behaviour, so they all fingerprint alike.
24
+ const IDENTITY_COMPARED_KEYS = new Set(['jwtProvider', 'distributedClaimAccessTokenProvider']);
25
+
26
+ // Per-process ids for those providers. A WeakMap so that fingerprinting never keeps a
27
+ // provider (or the closure state it captures) alive.
28
+ const functionIds = new WeakMap();
29
+ let nextFunctionId = 0;
30
+
31
+ function functionId(fn) {
32
+ if (!functionIds.has(fn)) {
33
+ functionIds.set(fn, ++nextFunctionId);
34
+ }
35
+ return functionIds.get(fn);
36
+ }
37
+
21
38
  /**
22
39
  * 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.
40
+ * retained just to compare two boot() calls. `jwtProvider` and
41
+ * `distributedClaimAccessTokenProvider` compare by identity; every other function is `fn`.
25
42
  */
26
- function fingerprintOptions(value, seen) {
43
+ function fingerprintOptions(value, seen, key) {
27
44
  if (typeof value === 'function') {
28
- return 'fn';
45
+ return IDENTITY_COMPARED_KEYS.has(key) ? `fn#${functionId(value)}` : 'fn';
29
46
  }
30
47
  if (value === null || typeof value !== 'object') {
31
48
  return JSON.stringify(value) || String(value);
@@ -39,7 +56,7 @@ function fingerprintOptions(value, seen) {
39
56
  return `[${value.map((item) => fingerprintOptions(item, seen)).join(',')}]`;
40
57
  }
41
58
  const body = Object.keys(value).sort()
42
- .map((key) => `${JSON.stringify(key)}:${fingerprintOptions(value[key], seen)}`)
59
+ .map((key) => `${JSON.stringify(key)}:${fingerprintOptions(value[key], seen, key)}`)
43
60
  .join(',');
44
61
  return `{${body}}`;
45
62
  }
@@ -12,6 +12,10 @@ class AuthToken {
12
12
  * @param {int} explicitMaxTtl - in seconds
13
13
  * @param {int} numUses
14
14
  * @param {boolean} isRenewable
15
+ * @param {int|null} expiresAtMilliseconds - precise expiry time when available
16
+ * @param {int|null} ttl - remaining lifetime reported by Vault, when available
17
+ * @param {boolean} hasPreciseExpiry - true when expiry came from Vault's expire_time
18
+ * @param {int|null} unadjustedExpiresAtMilliseconds - Vault's expiry before the client safety margin
15
19
  */
16
20
  constructor(
17
21
  id,
@@ -20,35 +24,58 @@ class AuthToken {
20
24
  expiresAt,
21
25
  explicitMaxTtl,
22
26
  numUses,
23
- isRenewable
27
+ isRenewable,
28
+ expiresAtMilliseconds = null,
29
+ ttl = null,
30
+ hasPreciseExpiry = false,
31
+ unadjustedExpiresAtMilliseconds = null
24
32
  ) {
25
33
  this.__id = id;
26
34
  this.__accessor = accessor;
27
35
  this.__createdAt = createdAt;
28
- this.__expiresAt = expiresAt;
36
+ this.__expiresAtMilliseconds = expiresAt === null
37
+ ? expiresAtMilliseconds
38
+ : (expiresAtMilliseconds ?? expiresAt * 1000);
39
+ this.__expiresAt = this.__expiresAtMilliseconds === null
40
+ ? null
41
+ : Math.floor(this.__expiresAtMilliseconds / 1000);
42
+ this.__unadjustedExpiresAtMilliseconds = unadjustedExpiresAtMilliseconds
43
+ ?? this.__expiresAtMilliseconds;
44
+ this.__hasPreciseExpiry = hasPreciseExpiry;
45
+ this.__ttlSeconds = Number.isFinite(ttl) ? ttl : null;
29
46
  this.__explicitMaxTtl = explicitMaxTtl;
30
47
  this.__numUses = numUses;
31
- this.__isRenewable = isRenewable;
48
+ this.__isRenewable = isRenewable
49
+ && ttl !== 0
50
+ && this.__expiresAtMilliseconds !== null;
32
51
  }
33
52
 
34
53
  static fromResponse(response) {
35
54
  const data = response.data;
55
+ const unadjustedExpiresAtMilliseconds = AuthToken.__parseRfc3339Milliseconds(data.expire_time);
56
+ const hasPreciseExpiry = unadjustedExpiresAtMilliseconds !== null;
57
+ const expiresAtMilliseconds = AuthToken.__resolveExpiresAtMilliseconds(data, unadjustedExpiresAtMilliseconds);
36
58
 
37
59
  return new AuthToken(
38
60
  data.id,
39
61
  data.accessor,
40
62
  data.creation_time,
41
- AuthToken.__resolveExpiresAt(data),
63
+ null,
42
64
  data.explicit_max_ttl,
43
65
  data.num_uses,
44
- data.renewable !== undefined ? data.renewable : false
66
+ data.renewable !== undefined ? data.renewable : false,
67
+ expiresAtMilliseconds,
68
+ data.ttl,
69
+ hasPreciseExpiry,
70
+ unadjustedExpiresAtMilliseconds
45
71
  );
46
72
  }
47
73
 
48
74
  /**
49
- * Resolve the UNIX timestamp (seconds) at which the client should treat the token
50
- * as expired, shifted earlier by a network-latency safety margin so renewal happens
51
- * before the real expiry.
75
+ * Resolve the UNIX timestamp (milliseconds) at which the client should treat the token
76
+ * as expired. A zero TTL is expired when received because the reported TTL is truncated
77
+ * and Vault's wall clock may differ from the client's. For longer TTLs, keep the existing
78
+ * one-minute margin only when more than one minute remains.
52
79
  *
53
80
  * Prefers Vault's authoritative `expire_time` (RFC3339), which is the documented
54
81
  * absolute expiry. Falls back to `(last_renewal_time || creation_time) + ttl` for
@@ -57,37 +84,41 @@ class AuthToken {
57
84
  *
58
85
  * @private
59
86
  * @param {Object} data - the `data` object of a token lookup response
60
- * @returns {int|null} null when the token never expires (ttl === 0)
87
+ * @param {number|null} expireTime - parsed Vault expiry in milliseconds
88
+ * @returns {int|null} client expiry in milliseconds, or null when ttl is 0 without expire_time
61
89
  */
62
- static __resolveExpiresAt(data) {
63
- if (data.ttl === 0) {
64
- return null;
65
- }
66
-
90
+ static __resolveExpiresAtMilliseconds(data, expireTime) {
67
91
  const networkLatency = 60;
68
- const margin = data.ttl > networkLatency ? networkLatency : 0;
69
92
 
70
- const expireTime = AuthToken.__parseRfc3339Seconds(data.expire_time);
71
93
  if (expireTime !== null) {
72
- return expireTime - margin;
94
+ if (data.ttl === 0) {
95
+ return Date.now();
96
+ }
97
+
98
+ const margin = data.ttl > networkLatency ? networkLatency : 0;
99
+ return expireTime - margin * 1000;
100
+ }
101
+
102
+ if (data.ttl === 0) {
103
+ return null;
73
104
  }
74
105
 
75
106
  // Fallback for responses without a usable `expire_time`.
76
107
  const creationTime = parseInt(data.last_renewal_time !== undefined ? data.last_renewal_time : data.creation_time, 10);
77
108
  const ttl = data.ttl > networkLatency ? data.ttl - networkLatency : data.ttl;
78
- return creationTime + parseInt(ttl, 10);
109
+ return (creationTime + parseInt(ttl, 10)) * 1000;
79
110
  }
80
111
 
81
112
  /**
82
113
  * Parse an RFC3339 timestamp (as returned by Vault, up to nanosecond precision with
83
- * a timezone offset) into whole UNIX seconds. Returns null for missing, empty,
114
+ * a timezone offset) into UNIX milliseconds. Returns null for missing, empty,
84
115
  * unparseable, or non-positive (e.g. Vault's "0001-01-01T00:00:00Z" zero value) input.
85
116
  *
86
117
  * @private
87
118
  * @param {String} value
88
119
  * @returns {int|null}
89
120
  */
90
- static __parseRfc3339Seconds(value) {
121
+ static __parseRfc3339Milliseconds(value) {
91
122
  if (typeof value !== 'string' || value === '') {
92
123
  return null;
93
124
  }
@@ -97,8 +128,7 @@ class AuthToken {
97
128
  return null;
98
129
  }
99
130
 
100
- const seconds = Math.floor(ms / 1000);
101
- return seconds > 0 ? seconds : null;
131
+ return ms > 0 ? ms : null;
102
132
  }
103
133
 
104
134
  /**
@@ -120,6 +150,12 @@ class AuthToken {
120
150
  return false;
121
151
  }
122
152
 
153
+ if (this.__hasPreciseExpiry) {
154
+ return Date.now() >= this.__expiresAtMilliseconds;
155
+ }
156
+
157
+ // Preserve the previous whole-second boundary for tokens that do not carry
158
+ // Vault's authoritative expire_time.
123
159
  return Math.floor(Date.now() / 1000) > this.__expiresAt;
124
160
  }
125
161
 
@@ -127,7 +163,7 @@ class AuthToken {
127
163
  * @returns {boolean}
128
164
  */
129
165
  isRenewable() {
130
- return this.__isRenewable && this.getExpiresAt() !== null;
166
+ return this.__isRenewable;
131
167
  }
132
168
 
133
169
  /**
@@ -136,6 +172,35 @@ class AuthToken {
136
172
  getExpiresAt() {
137
173
  return this.__expiresAt;
138
174
  }
175
+
176
+ /**
177
+ * @returns {int|null} precise UNIX timestamp in milliseconds, when available
178
+ */
179
+ getExpiresAtMilliseconds() {
180
+ return this.__expiresAtMilliseconds;
181
+ }
182
+
183
+ /**
184
+ * @returns {int|null} expiry before the safety adjustment, when available
185
+ */
186
+ getUnadjustedExpiresAtMilliseconds() {
187
+ return this.__unadjustedExpiresAtMilliseconds;
188
+ }
189
+
190
+ /**
191
+ * Return Vault's whole-second TTL when this token came from a lookup response.
192
+ * @returns {number|null}
193
+ */
194
+ getTtlSeconds() {
195
+ return this.__ttlSeconds;
196
+ }
197
+
198
+ /**
199
+ * @returns {boolean}
200
+ */
201
+ hasPreciseExpiry() {
202
+ return this.__hasPreciseExpiry;
203
+ }
139
204
  }
140
205
 
141
206
  module.exports = AuthToken;
@@ -29,6 +29,86 @@ function humanizeMs(ms) {
29
29
  return `${hours}h ${totalMinutes % 60}m`;
30
30
  }
31
31
 
32
+ /**
33
+ * Read precise expiry when available, preserving the public AuthToken interface fallback.
34
+ * @param {AuthToken} authToken
35
+ * @returns {number|null}
36
+ * @private
37
+ */
38
+ function tokenExpiryMilliseconds(authToken) {
39
+ if (typeof authToken.getExpiresAtMilliseconds === 'function') {
40
+ const expiry = authToken.getExpiresAtMilliseconds();
41
+ if (Number.isFinite(expiry)) {
42
+ return expiry;
43
+ }
44
+ }
45
+
46
+ const expiry = authToken.getExpiresAt();
47
+ return typeof expiry === 'number' && Number.isFinite(expiry) ? expiry * 1000 : null;
48
+ }
49
+
50
+ /**
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}
67
+ * @private
68
+ */
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));
73
+ }
74
+
75
+ /**
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}
80
+ * @private
81
+ */
82
+ function formatTimestamp(ms) {
83
+ const date = new Date(ms);
84
+ return Number.isNaN(date.getTime()) ? String(ms) : date.toISOString();
85
+ }
86
+
87
+ /**
88
+ * Explain why background renewal ended without exposing the token ID.
89
+ * @param {VaultBaseAuth} auth
90
+ * @param {AuthToken} authToken
91
+ * @param {string} reason
92
+ * @returns {void}
93
+ * @private
94
+ */
95
+ function warnRenewalStopped(auth, authToken, reason) {
96
+ const expiry = tokenExpiryMilliseconds(authToken);
97
+ const nextAction = expiry === null
98
+ ? 'expiry is unknown; the cached token remains in use until it is replaced'
99
+ : auth._reauthenticationAllowed()
100
+ ? 'the next call after expiry will re-authenticate'
101
+ : 'the next call after expiry will fail with AuthTokenExpiredError';
102
+
103
+ auth._log.warn(
104
+ 'token renewal stopped (accessor=%s): %s; client-side expiry is %s; %s',
105
+ authToken.getAccessor(),
106
+ reason,
107
+ expiry === null ? 'unknown' : formatTimestamp(expiry),
108
+ nextAction
109
+ );
110
+ }
111
+
32
112
  class VaultBaseAuth {
33
113
 
34
114
  /**
@@ -178,16 +258,10 @@ class VaultBaseAuth {
178
258
  this.__renewalEpoch++;
179
259
 
180
260
  if (authToken.isRenewable()) {
181
- // Gate the log on the flag too: the guard lives inside
182
- // __setupTokenRefreshTimer, so without this the client claims it armed a
183
- // timer while arming nothing -- the one diagnostic an operator reads when a
184
- // `renewal: false` process still hangs.
185
- if (this.__renewalEnabled) {
186
- this._log.debug(
187
- 'setting refresh timer for token (accessor=%s)',
188
- authToken.getAccessor()
189
- );
190
- } 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) {
191
265
  this._log.debug(
192
266
  'renewal disabled by config; not arming a refresh timer (accessor=%s)',
193
267
  authToken.getAccessor()
@@ -255,12 +329,38 @@ class VaultBaseAuth {
255
329
  this.__refreshTimeout = null;
256
330
  }
257
331
 
258
- if (!this.__renewalEnabled || !authToken.isRenewable() || authToken.isExpired()) {
332
+ if (!this.__renewalEnabled) {
333
+ return;
334
+ }
335
+ if (!authToken.isRenewable() || authToken.isExpired()) {
336
+ warnRenewalStopped(
337
+ this,
338
+ authToken,
339
+ authToken.isExpired() ? 'token already expired' : 'token is not renewable'
340
+ );
341
+ return;
342
+ }
343
+
344
+ const expiry = tokenExpiryMilliseconds(authToken);
345
+ if (expiry === null) {
346
+ warnRenewalStopped(this, authToken, 'token has no usable expiry');
347
+ return;
348
+ }
349
+
350
+ const remaining = expiry - Date.now();
351
+ if (remaining <= 0) {
352
+ warnRenewalStopped(this, authToken, 'token already expired');
259
353
  return;
260
354
  }
261
355
 
262
- const remaining = authToken.getExpiresAt() - Math.floor(Date.now() / 1000);
263
- const timer = Math.max(remaining * this.__renewalFraction, 1) * 1000;
356
+ const timer = Math.max(
357
+ Math.floor(remaining * this.__renewalFraction),
358
+ 250
359
+ );
360
+ if (timer >= remaining) {
361
+ warnRenewalStopped(this, authToken, 'too little lifetime left to renew');
362
+ return;
363
+ }
264
364
 
265
365
  // A renewal that is still in flight when the token is replaced or cancelled must not
266
366
  // act on what it finds when it lands: it would overwrite the newer token, or clear
@@ -273,14 +373,22 @@ class VaultBaseAuth {
273
373
  if (superseded()) {
274
374
  return;
275
375
  }
376
+ this.__refreshTimeout = null;
276
377
 
277
- 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 }) => {
278
382
  if (superseded()) {
279
383
  return;
280
384
  }
281
385
  this.__authToken = renewedToken;
386
+ if (cappedByMaxTtl) {
387
+ warnRenewalStopped(this, renewedToken, 'Vault capped the renewal at the token\'s maximum TTL');
388
+ return;
389
+ }
282
390
  this.__setupTokenRefreshTimer(renewedToken);
283
- }).catch(err => {
391
+ }, err => {
284
392
  if (superseded()) {
285
393
  return;
286
394
  }
@@ -294,18 +402,27 @@ class VaultBaseAuth {
294
402
  } else {
295
403
  this._log.error(message, err);
296
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
+ }
297
413
  });
298
414
  }, timer);
299
415
 
300
416
  this._log.debug(
301
- 'sleeping for %s',
417
+ 'renewal timer armed (accessor=%s); renewing in %s',
418
+ authToken.getAccessor(),
302
419
  humanizeMs(timer)
303
420
  );
304
421
  }
305
422
 
306
423
  /**
307
424
  * @param {AuthToken} authToken
308
- * @returns {Promise.<AuthToken>}
425
+ * @returns {Promise.<{renewedToken: AuthToken, cappedByMaxTtl: boolean}>}
309
426
  * @private
310
427
  */
311
428
  __renewToken(authToken) {
@@ -315,9 +432,11 @@ class VaultBaseAuth {
315
432
  const data = this.__renewalIncrement === undefined ? null : {increment: this.__renewalIncrement};
316
433
 
317
434
  return this.__apiClient.makeRequest('POST', '/auth/token/renew-self', data, {'X-Vault-Token': authToken.getId()})
318
- .then(() => {
435
+ .then((renewResponse) => {
319
436
  this._log.info('successfully renewed token');
320
- return this._getTokenEntity(authToken.getId());
437
+ const cappedByMaxTtl = renewalCappedByMaxTtl(renewResponse);
438
+ return this._getTokenEntity(authToken.getId())
439
+ .then((renewedToken) => ({ renewedToken, cappedByMaxTtl }));
321
440
  })
322
441
  .catch((reason) => {
323
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;