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 +51 -18
- package/index.d.ts +7 -3
- package/package.json +1 -1
- package/src/VaultClient.js +22 -5
- package/src/auth/VaultBaseAuth.js +55 -75
- package/src/auth/VaultJwtAuth.js +121 -59
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
|
-
|
|
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.
|
|
257
|
-
|
|
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
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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
|
|
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.
|
|
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
|
|
560
|
-
|
|
561
|
-
`
|
|
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?:
|
|
84
|
-
| {
|
|
85
|
-
|
|
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.
|
|
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"
|
package/src/VaultClient.js
CHANGED
|
@@ -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.
|
|
24
|
-
*
|
|
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
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
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
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
*
|
|
72
|
-
*
|
|
73
|
-
* @
|
|
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
|
|
77
|
-
|
|
78
|
-
|
|
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' :
|
|
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
|
-
//
|
|
259
|
-
//
|
|
260
|
-
//
|
|
261
|
-
|
|
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
|
-
|
|
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 (
|
|
388
|
-
this
|
|
386
|
+
if (cappedByMaxTtl) {
|
|
387
|
+
warnRenewalStopped(this, renewedToken, 'Vault capped the renewal at the token\'s maximum TTL');
|
|
389
388
|
return;
|
|
390
389
|
}
|
|
391
|
-
|
|
392
|
-
|
|
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
|
-
'
|
|
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
|
-
|
|
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));
|
package/src/auth/VaultJwtAuth.js
CHANGED
|
@@ -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
|
|
25
|
-
* to Vault as `distributed_claim_access_token`. Azure
|
|
26
|
-
*
|
|
27
|
-
* Graph
|
|
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
|
-
*
|
|
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
|
|
96
|
+
this.#jwt = config.jwt;
|
|
65
97
|
this.__jwtPath = config.jwtPath;
|
|
66
98
|
this.__jwtProvider = config.jwtProvider;
|
|
67
|
-
this
|
|
99
|
+
this.#distributedClaimAccessToken = normalizeAccessToken(config.distributedClaimAccessToken);
|
|
68
100
|
this.__distributedClaimAccessTokenProvider = config.distributedClaimAccessTokenProvider;
|
|
69
101
|
}
|
|
70
102
|
|
|
71
103
|
_authenticate() {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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 {
|
|
144
|
+
* @returns {Promise<{jwt: String, source: String}>}
|
|
104
145
|
* @private
|
|
105
146
|
*/
|
|
106
147
|
__acquireJwt() {
|
|
107
|
-
if (this
|
|
108
|
-
return { jwt: this
|
|
148
|
+
if (this.#jwt !== undefined) {
|
|
149
|
+
return Promise.resolve({ jwt: this.#jwt, source: 'literal' });
|
|
109
150
|
}
|
|
110
151
|
if (this.__jwtPath !== undefined) {
|
|
111
|
-
return
|
|
152
|
+
return Promise.resolve()
|
|
153
|
+
.then(() => ({ jwt: fs.readFileSync(this.__jwtPath).toString(), source: 'file' }));
|
|
112
154
|
}
|
|
113
155
|
|
|
114
|
-
|
|
115
|
-
|
|
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}
|
|
164
|
+
* @returns {Promise<undefined|{accessToken: String, source: String}>}
|
|
131
165
|
* @private
|
|
132
166
|
*/
|
|
133
167
|
__acquireDistributedClaimAccessToken() {
|
|
134
|
-
if (this
|
|
135
|
-
return { accessToken: this
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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;
|