node-vault-client 3.0.1 → 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', {
@@ -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
 
@@ -556,9 +585,13 @@ later ones with `TimeoutError`. Pass a fresh signal per call, or use the dispatc
556
585
 
557
586
  Requests follow HTTP redirects, because Vault HA clusters answer standby nodes with a 307 to the
558
587
  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.
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.
562
595
 
563
596
  <a name="VaultClient+fillNodeConfig"></a>
564
597
 
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
  (
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-vault-client",
3
- "version": "3.0.1",
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
  }
@@ -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;