node-vault-client 3.0.0 → 3.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +66 -21
- package/index.d.ts +8 -3
- package/package.json +1 -1
- package/src/VaultClient.js +22 -5
- package/src/auth/AuthToken.js +88 -23
- package/src/auth/VaultBaseAuth.js +138 -19
- 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', {
|
|
@@ -365,19 +376,43 @@ on the next call — no background timer at all. Two reasons to want that:
|
|
|
365
376
|
Renewal off means the token is allowed to expire, and what happens next depends on whether your
|
|
366
377
|
backend can obtain a *fresh* credential unaided:
|
|
367
378
|
|
|
368
|
-
| backend | on expiry with `renewal: false` |
|
|
369
|
-
| --- | --- |
|
|
370
|
-
| `kubernetes`, `iam`, `jwt` with `jwtPath`/`jwtProvider` | clean re-login — the JWT or AWS credential is re-acquired, so this is the intended case |
|
|
371
|
-
| `
|
|
372
|
-
| `
|
|
373
|
-
| `
|
|
379
|
+
| backend | on expiry with `renewal: false` | at max TTL with renewal enabled |
|
|
380
|
+
| --- | --- | --- |
|
|
381
|
+
| `kubernetes`, `iam`, `jwt` with `jwtPath`/`jwtProvider` | clean re-login — the JWT or AWS credential is re-acquired, so this is the intended case | clean re-login on the next call after the client-side expiry deadline |
|
|
382
|
+
| `jwt` with `jwtPath`/`jwtProvider` **and a literal `distributedClaimAccessToken`** | the JWT is fresh but the Graph access token is **replayed**; once it expires (about an hour for Entra ID) Vault's group lookup fails every re-login. Use `distributedClaimAccessTokenProvider` | same: re-login replays the expired Graph token and is rejected |
|
|
383
|
+
| `appRole` | re-login **replays the same `secret_id`**. Fine for a reusable one; with Vault's recommended hardening (`secret_id_num_uses=1`, or a short `secret_id_ttl`) the second login is rejected | re-login replays the same `secret_id`; a single-use or expired secret is rejected |
|
|
384
|
+
| `jwt` with a literal `jwt` | replays the same JWT, so it works only until the IdP-issued token expires | replays the same JWT; once it expires, login is rejected |
|
|
385
|
+
| `token` | **cannot re-authenticate at all.** The client raises `AuthTokenExpiredError` from then on, permanently | **cannot re-authenticate at all.** The client raises `AuthTokenExpiredError` after the max-TTL expiry |
|
|
386
|
+
|
|
387
|
+
#### Tokens that reach their maximum TTL
|
|
388
|
+
|
|
389
|
+
Vault stops extending a renewable token when it reaches its maximum TTL: the token's explicit max
|
|
390
|
+
TTL, the role's `token_max_ttl`, or the auth mount's max lease TTL. When a renewal is capped, Vault
|
|
391
|
+
says so in the `renew-self` response, with a warning such as `TTL of "1h" exceeded the effective
|
|
392
|
+
max_ttl of "20m"; TTL value is capped accordingly`. The client stops background renewal on that
|
|
393
|
+
warning and logs `token renewal stopped (accessor=…): Vault capped the renewal at the token's
|
|
394
|
+
maximum TTL`. A renewal without the warning is never treated as capped, however little it moved
|
|
395
|
+
the deadline. That includes short periodic tokens renewed less than a second apart, and a
|
|
396
|
+
`renewalIncrement` that leaves the deadline where it was or brings it closer. Vault's other cap
|
|
397
|
+
warning, `period of "…" exceeded the effective max_ttl of "…"; period value is capped accordingly`,
|
|
398
|
+
does not stop renewal either: it comes with every renewal of a periodic token whose period is above
|
|
399
|
+
the role's `token_max_ttl` or the mount's max lease TTL, and that token stays renewable at the
|
|
400
|
+
shorter period. The next renewal is always scheduled from the new deadline.
|
|
401
|
+
|
|
402
|
+
The client uses Vault's absolute `expire_time` when available and treats `ttl: 0` as expired
|
|
403
|
+
immediately. Because Vault truncates `ttl` to whole seconds, a token with a period of **1 second**
|
|
404
|
+
reports `ttl: 0` right after each renewal and is treated as expired. Use a period of at least
|
|
405
|
+
2 seconds. For positive TTLs above 60 seconds, the client keeps a one-minute safety margin for
|
|
406
|
+
requests in flight; TTLs of 60 seconds or less have no margin. The next call after the client-side
|
|
407
|
+
expiry deadline re-authenticates, as shown in the table above.
|
|
374
408
|
|
|
375
409
|
Two consequences worth stating plainly:
|
|
376
410
|
|
|
377
|
-
* For `token` auth
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
`
|
|
411
|
+
* **For `token` auth, there is no fresh credential for re-authentication.** With `renewal: false`,
|
|
412
|
+
an otherwise renewable token is allowed to expire without a background renewal. With renewal
|
|
413
|
+
enabled, the client stops the background loop once Vault reports that a renewal was capped.
|
|
414
|
+
After expiry every later call rejects for the life of the process. `close()` does not reset it —
|
|
415
|
+
recovery means `VaultClient.clear(name)` and booting again.
|
|
381
416
|
* For `appRole` and literal-`jwt`, a login that can no longer succeed is retried on **every**
|
|
382
417
|
subsequent call, since a failed login clears the cached token. That is a failing request per
|
|
383
418
|
`read()`, with no backoff. Prefer `jwtPath`/`jwtProvider`, a reusable `secret_id`, or leaving
|
|
@@ -410,11 +445,17 @@ auth: {
|
|
|
410
445
|
Lower `renewalFraction` values renew earlier and more often, which buys headroom if Vault is briefly
|
|
411
446
|
unreachable. A failed renewal is retried on the same rule against the *same* token, so the waits
|
|
412
447
|
shrink geometrically as the remaining lifetime does — for a 1-hour token that is roughly 12 attempts
|
|
413
|
-
at `0.5` (1800s, 900s, 450s, …) versus 27 at `0.25`, both bottoming out at a
|
|
414
|
-
before expiry. Higher values renew later and talk to Vault less.
|
|
448
|
+
at `0.5` (1800s, 900s, 450s, …) versus 27 at `0.25`, both bottoming out at a 250 ms minimum delay
|
|
449
|
+
when it still fits before expiry. Higher values renew later and talk to Vault less. A low fraction on a
|
|
450
|
+
short TTL is safe: renewals under a second apart keep working. The 250 ms minimum delay only matters
|
|
451
|
+
once less than 250 ms of lifetime is left, and the client then stops with `too little lifetime left
|
|
452
|
+
to renew`.
|
|
415
453
|
|
|
416
454
|
`renewalIncrement` is a request, not a guarantee: Vault grants at most the token's max TTL and may
|
|
417
|
-
return less.
|
|
455
|
+
return less. For a non-periodic token the increment *replaces* the remaining lifetime, counted from
|
|
456
|
+
the renewal, so a value below the remaining TTL shortens the token. `renewalIncrement: 1` leaves a
|
|
457
|
+
1-second token that lookup reports as `ttl: 0`, so the client treats it as expired after the first
|
|
458
|
+
renewal; use at least `2`. Periodic tokens ignore the increment and renew to their period. Both keys are validated at construction — a `renewalFraction` outside `(0, 1)` or a
|
|
418
459
|
non-positive/non-integer `renewalIncrement` raises `InvalidArgumentsError` rather than failing later
|
|
419
460
|
inside a background timer.
|
|
420
461
|
|
|
@@ -544,9 +585,13 @@ later ones with `TimeoutError`. Pass a fresh signal per call, or use the dispatc
|
|
|
544
585
|
|
|
545
586
|
Requests follow HTTP redirects, because Vault HA clusters answer standby nodes with a 307 to the
|
|
546
587
|
active node. Node's `fetch()` strips only `Authorization`, `Cookie` and `Proxy-Authorization` when
|
|
547
|
-
a redirect crosses origins — `X-Vault-Token` is **not** on that list and is forwarded. A
|
|
548
|
-
|
|
549
|
-
`
|
|
588
|
+
a redirect crosses origins — `X-Vault-Token` is **not** on that list and is forwarded. A 307 or 308
|
|
589
|
+
also re-sends the request body, so a login is replayed to the redirect target with its credentials:
|
|
590
|
+
the AppRole `secret_id`, the Kubernetes or JWT token, the signed IAM request and, with
|
|
591
|
+
`distributedClaimAccessToken`, a Microsoft Graph access token that is a credential for systems other
|
|
592
|
+
than Vault. A host that can answer for `api.url` can therefore redirect the client and receive its
|
|
593
|
+
Vault token and its login credentials, so treat `api.url` (and the DNS and any load balancer in
|
|
594
|
+
front of it) as trusted infrastructure.
|
|
550
595
|
|
|
551
596
|
<a name="VaultClient+fillNodeConfig"></a>
|
|
552
597
|
|
|
@@ -1018,7 +1063,7 @@ try {
|
|
|
1018
1063
|
| `VaultHttpError` | `VaultError` | Vault answered with a non-2xx status. Carries the status as `statusCode` and the parsed body as `error`. `message` (`"<status> - <raw body text>"`) and `error` hold the full response body, which can include secret material (e.g. a partially failed transit batch), so log `statusCode` and `error.errors` rather than the error object itself. |
|
|
1019
1064
|
| `InvalidArgumentsError` | `VaultError` | Bad arguments or configuration: `boot()` called without options, an unknown instance name passed to `get()`, an unsupported `auth.type`, invalid renewal options, an invalid node-config substitution map, or a KV method path that fails [path validation](#path-validation). |
|
|
1020
1065
|
| `InvalidAWSCredentialsError` | `InvalidArgumentsError` | `auth.config.credentials` was supplied but is not a usable `accessKeyId` / `secretAccessKey` pair. |
|
|
1021
|
-
| `AuthTokenExpiredError` | `VaultError` | The Vault token expired and the backend cannot obtain a new one. `token` auth can never re-authenticate; other backends reach
|
|
1066
|
+
| `AuthTokenExpiredError` | `VaultError` | The Vault token expired and the backend cannot obtain a new one. `token` auth can never re-authenticate and reaches this error at max TTL even with renewal enabled; other backends can also reach it when re-authentication is disabled or their credential can no longer be replayed. |
|
|
1022
1067
|
| `UnsupportedOperationError` | `VaultError` | A v2-only method (`deleteVersions`, `undeleteVersions`, `destroyVersions`, `readMetadata`, `deleteMetadata`) was called against a mount that did not resolve as KV v2. |
|
|
1023
1068
|
|
|
1024
1069
|
## Contributing
|
package/index.d.ts
CHANGED
|
@@ -79,10 +79,14 @@ declare namespace VaultClient {
|
|
|
79
79
|
namespace?: string;
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
// Either key may be `undefined` (it is then not sent), so a conditionally-set value such as
|
|
83
|
+
// `process.env.GRAPH_TOKEN` type-checks; setting both is still rejected.
|
|
82
84
|
type JwtDistributedClaimConfig =
|
|
83
|
-
| { distributedClaimAccessToken?:
|
|
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
|
(
|
|
@@ -127,5 +131,6 @@ declare namespace VaultClient {
|
|
|
127
131
|
isExpired(): boolean;
|
|
128
132
|
isRenewable(): boolean;
|
|
129
133
|
getExpiresAt(): number | null;
|
|
134
|
+
getTtlSeconds?(): number | null;
|
|
130
135
|
}
|
|
131
136
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "node-vault-client",
|
|
3
|
-
"version": "3.0.
|
|
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
|
}
|
package/src/auth/AuthToken.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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 (
|
|
50
|
-
* as expired
|
|
51
|
-
*
|
|
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
|
-
* @
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
166
|
+
return this.__isRenewable;
|
|
131
167
|
}
|
|
132
168
|
|
|
133
169
|
/**
|
|
@@ -136,6 +172,35 @@ class AuthToken {
|
|
|
136
172
|
getExpiresAt() {
|
|
137
173
|
return this.__expiresAt;
|
|
138
174
|
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* @returns {int|null} precise UNIX timestamp in milliseconds, when available
|
|
178
|
+
*/
|
|
179
|
+
getExpiresAtMilliseconds() {
|
|
180
|
+
return this.__expiresAtMilliseconds;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* @returns {int|null} expiry before the safety adjustment, when available
|
|
185
|
+
*/
|
|
186
|
+
getUnadjustedExpiresAtMilliseconds() {
|
|
187
|
+
return this.__unadjustedExpiresAtMilliseconds;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Return Vault's whole-second TTL when this token came from a lookup response.
|
|
192
|
+
* @returns {number|null}
|
|
193
|
+
*/
|
|
194
|
+
getTtlSeconds() {
|
|
195
|
+
return this.__ttlSeconds;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* @returns {boolean}
|
|
200
|
+
*/
|
|
201
|
+
hasPreciseExpiry() {
|
|
202
|
+
return this.__hasPreciseExpiry;
|
|
203
|
+
}
|
|
139
204
|
}
|
|
140
205
|
|
|
141
206
|
module.exports = AuthToken;
|
|
@@ -29,6 +29,86 @@ function humanizeMs(ms) {
|
|
|
29
29
|
return `${hours}h ${totalMinutes % 60}m`;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Read precise expiry when available, preserving the public AuthToken interface fallback.
|
|
34
|
+
* @param {AuthToken} authToken
|
|
35
|
+
* @returns {number|null}
|
|
36
|
+
* @private
|
|
37
|
+
*/
|
|
38
|
+
function tokenExpiryMilliseconds(authToken) {
|
|
39
|
+
if (typeof authToken.getExpiresAtMilliseconds === 'function') {
|
|
40
|
+
const expiry = authToken.getExpiresAtMilliseconds();
|
|
41
|
+
if (Number.isFinite(expiry)) {
|
|
42
|
+
return expiry;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const expiry = authToken.getExpiresAt();
|
|
47
|
+
return typeof expiry === 'number' && Number.isFinite(expiry) ? expiry * 1000 : null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* True when Vault says it granted less than the requested lifetime because the token reached a
|
|
52
|
+
* maximum TTL (its explicit max TTL, the role's `token_max_ttl` or the mount's max lease TTL).
|
|
53
|
+
* Vault reports this on every capped renew-self as a warning such as
|
|
54
|
+
* `TTL of "10s" exceeded the effective max_ttl of "9s"; TTL value is capped accordingly`
|
|
55
|
+
* (measured on 1.21 and 2.2) and never on an uncapped one. A capped token's deadline cannot move
|
|
56
|
+
* again, so further renewals are pointless; the next one would fail with "past the max TTL".
|
|
57
|
+
*
|
|
58
|
+
* Vault's other cap warning, `period of "4s" exceeded the effective max_ttl of "2s"; period value
|
|
59
|
+
* is capped accordingly`, must NOT match: it is sent on every renewal of a periodic token whose
|
|
60
|
+
* period exceeds the role's `token_max_ttl` or the mount's max lease TTL, and that token stays
|
|
61
|
+
* renewable indefinitely at the shorter period (measured on 2.2).
|
|
62
|
+
*
|
|
63
|
+
* `lease_duration < increment` is not a usable substitute: periodic tokens ignore the increment.
|
|
64
|
+
*
|
|
65
|
+
* @param {Object|null|undefined} renewResponse - renew-self response body
|
|
66
|
+
* @returns {boolean}
|
|
67
|
+
* @private
|
|
68
|
+
*/
|
|
69
|
+
function renewalCappedByMaxTtl(renewResponse) {
|
|
70
|
+
const warnings = renewResponse && Array.isArray(renewResponse.warnings) ? renewResponse.warnings : [];
|
|
71
|
+
return warnings.some((warning) => typeof warning === 'string'
|
|
72
|
+
&& /max_?ttl/i.test(warning) && /\bTTL value is capped\b/i.test(warning));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* ISO form of a millisecond timestamp, or its plain string form when Date cannot represent it,
|
|
77
|
+
* so that a log line about a duck-typed token never throws a RangeError.
|
|
78
|
+
* @param {number} ms
|
|
79
|
+
* @returns {string}
|
|
80
|
+
* @private
|
|
81
|
+
*/
|
|
82
|
+
function formatTimestamp(ms) {
|
|
83
|
+
const date = new Date(ms);
|
|
84
|
+
return Number.isNaN(date.getTime()) ? String(ms) : date.toISOString();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Explain why background renewal ended without exposing the token ID.
|
|
89
|
+
* @param {VaultBaseAuth} auth
|
|
90
|
+
* @param {AuthToken} authToken
|
|
91
|
+
* @param {string} reason
|
|
92
|
+
* @returns {void}
|
|
93
|
+
* @private
|
|
94
|
+
*/
|
|
95
|
+
function warnRenewalStopped(auth, authToken, reason) {
|
|
96
|
+
const expiry = tokenExpiryMilliseconds(authToken);
|
|
97
|
+
const nextAction = expiry === null
|
|
98
|
+
? 'expiry is unknown; the cached token remains in use until it is replaced'
|
|
99
|
+
: auth._reauthenticationAllowed()
|
|
100
|
+
? 'the next call after expiry will re-authenticate'
|
|
101
|
+
: 'the next call after expiry will fail with AuthTokenExpiredError';
|
|
102
|
+
|
|
103
|
+
auth._log.warn(
|
|
104
|
+
'token renewal stopped (accessor=%s): %s; client-side expiry is %s; %s',
|
|
105
|
+
authToken.getAccessor(),
|
|
106
|
+
reason,
|
|
107
|
+
expiry === null ? 'unknown' : formatTimestamp(expiry),
|
|
108
|
+
nextAction
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
32
112
|
class VaultBaseAuth {
|
|
33
113
|
|
|
34
114
|
/**
|
|
@@ -178,16 +258,10 @@ class VaultBaseAuth {
|
|
|
178
258
|
this.__renewalEpoch++;
|
|
179
259
|
|
|
180
260
|
if (authToken.isRenewable()) {
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
|
|
185
|
-
if (this.__renewalEnabled) {
|
|
186
|
-
this._log.debug(
|
|
187
|
-
'setting refresh timer for token (accessor=%s)',
|
|
188
|
-
authToken.getAccessor()
|
|
189
|
-
);
|
|
190
|
-
} else {
|
|
261
|
+
// __setupTokenRefreshTimer logs once it has actually armed a timer, and warns
|
|
262
|
+
// when it cannot; this covers the remaining case, the one diagnostic an operator
|
|
263
|
+
// reads when a `renewal: false` process still hangs.
|
|
264
|
+
if (!this.__renewalEnabled) {
|
|
191
265
|
this._log.debug(
|
|
192
266
|
'renewal disabled by config; not arming a refresh timer (accessor=%s)',
|
|
193
267
|
authToken.getAccessor()
|
|
@@ -255,12 +329,38 @@ class VaultBaseAuth {
|
|
|
255
329
|
this.__refreshTimeout = null;
|
|
256
330
|
}
|
|
257
331
|
|
|
258
|
-
if (!this.__renewalEnabled
|
|
332
|
+
if (!this.__renewalEnabled) {
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
if (!authToken.isRenewable() || authToken.isExpired()) {
|
|
336
|
+
warnRenewalStopped(
|
|
337
|
+
this,
|
|
338
|
+
authToken,
|
|
339
|
+
authToken.isExpired() ? 'token already expired' : 'token is not renewable'
|
|
340
|
+
);
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const expiry = tokenExpiryMilliseconds(authToken);
|
|
345
|
+
if (expiry === null) {
|
|
346
|
+
warnRenewalStopped(this, authToken, 'token has no usable expiry');
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const remaining = expiry - Date.now();
|
|
351
|
+
if (remaining <= 0) {
|
|
352
|
+
warnRenewalStopped(this, authToken, 'token already expired');
|
|
259
353
|
return;
|
|
260
354
|
}
|
|
261
355
|
|
|
262
|
-
const
|
|
263
|
-
|
|
356
|
+
const timer = Math.max(
|
|
357
|
+
Math.floor(remaining * this.__renewalFraction),
|
|
358
|
+
250
|
|
359
|
+
);
|
|
360
|
+
if (timer >= remaining) {
|
|
361
|
+
warnRenewalStopped(this, authToken, 'too little lifetime left to renew');
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
264
364
|
|
|
265
365
|
// A renewal that is still in flight when the token is replaced or cancelled must not
|
|
266
366
|
// act on what it finds when it lands: it would overwrite the newer token, or clear
|
|
@@ -273,14 +373,22 @@ class VaultBaseAuth {
|
|
|
273
373
|
if (superseded()) {
|
|
274
374
|
return;
|
|
275
375
|
}
|
|
376
|
+
this.__refreshTimeout = null;
|
|
276
377
|
|
|
277
|
-
|
|
378
|
+
// Two handlers, not .then().catch(): once the renewed token is adopted, a failure
|
|
379
|
+
// in the bookkeeping below must not be mistaken for a failed renewal and re-arm a
|
|
380
|
+
// timer against the token this client no longer holds.
|
|
381
|
+
this.__renewToken(authToken).then(({ renewedToken, cappedByMaxTtl }) => {
|
|
278
382
|
if (superseded()) {
|
|
279
383
|
return;
|
|
280
384
|
}
|
|
281
385
|
this.__authToken = renewedToken;
|
|
386
|
+
if (cappedByMaxTtl) {
|
|
387
|
+
warnRenewalStopped(this, renewedToken, 'Vault capped the renewal at the token\'s maximum TTL');
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
282
390
|
this.__setupTokenRefreshTimer(renewedToken);
|
|
283
|
-
}
|
|
391
|
+
}, err => {
|
|
284
392
|
if (superseded()) {
|
|
285
393
|
return;
|
|
286
394
|
}
|
|
@@ -294,18 +402,27 @@ class VaultBaseAuth {
|
|
|
294
402
|
} else {
|
|
295
403
|
this._log.error(message, err);
|
|
296
404
|
}
|
|
405
|
+
}).catch(err => {
|
|
406
|
+
// Only a throwing logger (or a broken duck-typed token) gets here. Report it if
|
|
407
|
+
// the logger allows, and never let it surface as an unhandled rejection.
|
|
408
|
+
try {
|
|
409
|
+
this._log.error('token renewal bookkeeping failed: %s', safeMessage(err));
|
|
410
|
+
} catch {
|
|
411
|
+
// nothing left to report to
|
|
412
|
+
}
|
|
297
413
|
});
|
|
298
414
|
}, timer);
|
|
299
415
|
|
|
300
416
|
this._log.debug(
|
|
301
|
-
'
|
|
417
|
+
'renewal timer armed (accessor=%s); renewing in %s',
|
|
418
|
+
authToken.getAccessor(),
|
|
302
419
|
humanizeMs(timer)
|
|
303
420
|
);
|
|
304
421
|
}
|
|
305
422
|
|
|
306
423
|
/**
|
|
307
424
|
* @param {AuthToken} authToken
|
|
308
|
-
* @returns {Promise.<AuthToken>}
|
|
425
|
+
* @returns {Promise.<{renewedToken: AuthToken, cappedByMaxTtl: boolean}>}
|
|
309
426
|
* @private
|
|
310
427
|
*/
|
|
311
428
|
__renewToken(authToken) {
|
|
@@ -315,9 +432,11 @@ class VaultBaseAuth {
|
|
|
315
432
|
const data = this.__renewalIncrement === undefined ? null : {increment: this.__renewalIncrement};
|
|
316
433
|
|
|
317
434
|
return this.__apiClient.makeRequest('POST', '/auth/token/renew-self', data, {'X-Vault-Token': authToken.getId()})
|
|
318
|
-
.then(() => {
|
|
435
|
+
.then((renewResponse) => {
|
|
319
436
|
this._log.info('successfully renewed token');
|
|
320
|
-
|
|
437
|
+
const cappedByMaxTtl = renewalCappedByMaxTtl(renewResponse);
|
|
438
|
+
return this._getTokenEntity(authToken.getId())
|
|
439
|
+
.then((renewedToken) => ({ renewedToken, cappedByMaxTtl }));
|
|
321
440
|
})
|
|
322
441
|
.catch((reason) => {
|
|
323
442
|
this._log.error('token renew failed: %s', safeMessage(reason));
|
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;
|