node-vault-client 3.0.0 → 3.0.1

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
@@ -365,19 +365,31 @@ on the next call — no background timer at all. Two reasons to want that:
365
365
  Renewal off means the token is allowed to expire, and what happens next depends on whether your
366
366
  backend can obtain a *fresh* credential unaided:
367
367
 
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 |
368
+ | backend | on expiry with `renewal: false` | at max TTL with renewal enabled |
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 | clean re-login on the next call after the client-side expiry deadline |
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 | re-login replays the same `secret_id`; a single-use or expired secret is rejected |
372
+ | `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
+ | `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
+
375
+ #### Tokens that reach their maximum TTL
376
+
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.
374
385
 
375
386
  Two consequences worth stating plainly:
376
387
 
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.
388
+ * **For `token` auth, there is no fresh credential for re-authentication.** With `renewal: false`,
389
+ 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.
391
+ After expiry every later call rejects for the life of the process. `close()` does not reset it —
392
+ recovery means `VaultClient.clear(name)` and booting again.
381
393
  * For `appRole` and literal-`jwt`, a login that can no longer succeed is retried on **every**
382
394
  subsequent call, since a failed login clears the cached token. That is a failing request per
383
395
  `read()`, with no backoff. Prefer `jwtPath`/`jwtProvider`, a reusable `secret_id`, or leaving
@@ -410,8 +422,8 @@ auth: {
410
422
  Lower `renewalFraction` values renew earlier and more often, which buys headroom if Vault is briefly
411
423
  unreachable. A failed renewal is retried on the same rule against the *same* token, so the waits
412
424
  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.
425
+ 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.
415
427
 
416
428
  `renewalIncrement` is a request, not a guarantee: Vault grants at most the token's max TTL and may
417
429
  return less. Both keys are validated at construction — a `renewalFraction` outside `(0, 1)` or a
@@ -1018,7 +1030,7 @@ try {
1018
1030
  | `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
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). |
1020
1032
  | `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. |
1033
+ | `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
1034
  | `UnsupportedOperationError` | `VaultError` | A v2-only method (`deleteVersions`, `undeleteVersions`, `destroyVersions`, `readMetadata`, `deleteMetadata`) was called against a mount that did not resolve as KV v2. |
1023
1035
 
1024
1036
  ## Contributing
package/index.d.ts CHANGED
@@ -127,5 +127,6 @@ declare namespace VaultClient {
127
127
  isExpired(): boolean;
128
128
  isRenewable(): boolean;
129
129
  getExpiresAt(): number | null;
130
+ getTtlSeconds?(): number | null;
130
131
  }
131
132
  }
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.1",
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"
@@ -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;
@@ -9,6 +9,8 @@ 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;
12
14
  const errors = require('../errors');
13
15
  const safeMessage = require('../safeMessage');
14
16
 
@@ -29,6 +31,81 @@ function humanizeMs(ms) {
29
31
  return `${hours}h ${totalMinutes % 60}m`;
30
32
  }
31
33
 
34
+ /**
35
+ * Read precise expiry when available, preserving the public AuthToken interface fallback.
36
+ * @param {AuthToken} authToken
37
+ * @returns {number|null}
38
+ * @private
39
+ */
40
+ function tokenExpiryMilliseconds(authToken) {
41
+ if (typeof authToken.getExpiresAtMilliseconds === 'function') {
42
+ const expiry = authToken.getExpiresAtMilliseconds();
43
+ if (Number.isFinite(expiry)) {
44
+ return expiry;
45
+ }
46
+ }
47
+
48
+ const expiry = authToken.getExpiresAt();
49
+ return typeof expiry === 'number' && Number.isFinite(expiry) ? expiry * 1000 : null;
50
+ }
51
+
52
+ /**
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}
57
+ * @private
58
+ */
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);
68
+ }
69
+
70
+ /**
71
+ * Read Vault's whole-second TTL when the token implementation exposes it.
72
+ * @param {AuthToken} authToken
73
+ * @returns {number|null}
74
+ * @private
75
+ */
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
+ }
83
+
84
+ /**
85
+ * Explain why background renewal ended without exposing the token ID.
86
+ * @param {VaultBaseAuth} auth
87
+ * @param {AuthToken} authToken
88
+ * @param {string} reason
89
+ * @returns {void}
90
+ * @private
91
+ */
92
+ function warnRenewalStopped(auth, authToken, reason) {
93
+ const expiry = tokenExpiryMilliseconds(authToken);
94
+ const nextAction = expiry === null
95
+ ? 'expiry is unknown; the cached token remains in use until it is replaced'
96
+ : auth._reauthenticationAllowed()
97
+ ? 'the next call after expiry will re-authenticate'
98
+ : 'the next call after expiry will fail with AuthTokenExpiredError';
99
+
100
+ auth._log.warn(
101
+ 'token renewal stopped (accessor=%s): %s; client-side expiry is %s; %s',
102
+ authToken.getAccessor(),
103
+ reason,
104
+ expiry === null ? 'unknown' : new Date(expiry).toISOString(),
105
+ nextAction
106
+ );
107
+ }
108
+
32
109
  class VaultBaseAuth {
33
110
 
34
111
  /**
@@ -255,12 +332,38 @@ class VaultBaseAuth {
255
332
  this.__refreshTimeout = null;
256
333
  }
257
334
 
258
- if (!this.__renewalEnabled || !authToken.isRenewable() || authToken.isExpired()) {
335
+ if (!this.__renewalEnabled) {
336
+ return;
337
+ }
338
+ if (!authToken.isRenewable() || authToken.isExpired()) {
339
+ warnRenewalStopped(
340
+ this,
341
+ authToken,
342
+ authToken.isExpired() ? 'token already expired' : 'token is not renewable'
343
+ );
259
344
  return;
260
345
  }
261
346
 
262
- const remaining = authToken.getExpiresAt() - Math.floor(Date.now() / 1000);
263
- const timer = Math.max(remaining * this.__renewalFraction, 1) * 1000;
347
+ const expiry = tokenExpiryMilliseconds(authToken);
348
+ if (expiry === null) {
349
+ warnRenewalStopped(this, authToken, 'token has no usable expiry');
350
+ return;
351
+ }
352
+
353
+ const remaining = expiry - Date.now();
354
+ if (remaining <= 0) {
355
+ warnRenewalStopped(this, authToken, 'token already expired');
356
+ return;
357
+ }
358
+
359
+ const timer = Math.max(
360
+ Math.floor(remaining * this.__renewalFraction),
361
+ 250
362
+ );
363
+ if (timer >= remaining) {
364
+ warnRenewalStopped(this, authToken, 'too little lifetime left to renew');
365
+ return;
366
+ }
264
367
 
265
368
  // A renewal that is still in flight when the token is replaced or cancelled must not
266
369
  // act on what it finds when it lands: it would overwrite the newer token, or clear
@@ -268,18 +371,54 @@ class VaultBaseAuth {
268
371
  // permanently un-renewed. Anything that supersedes this timer bumps the epoch.
269
372
  const epoch = ++this.__renewalEpoch;
270
373
  const superseded = () => this.__renewalEpoch !== epoch;
374
+ const scheduledAt = Date.now();
271
375
 
272
376
  this.__refreshTimeout = lt.setTimeout(() => {
273
377
  if (superseded()) {
274
378
  return;
275
379
  }
380
+ this.__refreshTimeout = null;
276
381
 
277
382
  this.__renewToken(authToken).then(renewedToken => {
278
383
  if (superseded()) {
279
384
  return;
280
385
  }
281
386
  this.__authToken = renewedToken;
282
- this.__setupTokenRefreshTimer(renewedToken);
387
+ if (!renewedToken.isRenewable() || renewedToken.isExpired()) {
388
+ this.__setupTokenRefreshTimer(renewedToken);
389
+ return;
390
+ }
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
+ }
283
422
  }).catch(err => {
284
423
  if (superseded()) {
285
424
  return;