node-vault-client 3.1.0 → 3.3.0
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 +138 -14
- package/index.d.ts +3 -2
- package/package.json +6 -5
- package/src/Lease.js +10 -4
- package/src/MountResolver.js +30 -8
- package/src/VaultApiClient.js +174 -3
- package/src/VaultClient.js +165 -61
- package/src/auth/AuthToken.js +13 -4
- package/src/auth/VaultBaseAuth.js +256 -9
- package/src/auth/VaultIAMAuth.js +54 -0
- package/src/auth/VaultKubernetesAuth.js +21 -14
- package/src/errors.d.ts +8 -1
- package/src/errors.js +39 -1
- package/src/warnOnce.js +44 -0
|
@@ -35,6 +35,17 @@ function humanizeMs(ms) {
|
|
|
35
35
|
* @returns {number|null}
|
|
36
36
|
* @private
|
|
37
37
|
*/
|
|
38
|
+
/**
|
|
39
|
+
* True for a token Vault marked renewable whose lookup gave nothing to schedule a renewal from
|
|
40
|
+
* (see {@link AuthToken.fromResponse}). It is not renewable, but the renewal timer still reports
|
|
41
|
+
* it, as it did while such a lookup resolved to a NaN deadline.
|
|
42
|
+
* @param {AuthToken} authToken
|
|
43
|
+
* @returns {boolean}
|
|
44
|
+
*/
|
|
45
|
+
function renewableWithoutExpiry(authToken) {
|
|
46
|
+
return authToken.__renewableWithoutExpiry === true;
|
|
47
|
+
}
|
|
48
|
+
|
|
38
49
|
function tokenExpiryMilliseconds(authToken) {
|
|
39
50
|
if (typeof authToken.getExpiresAtMilliseconds === 'function') {
|
|
40
51
|
const expiry = authToken.getExpiresAtMilliseconds();
|
|
@@ -138,6 +149,32 @@ class VaultBaseAuth {
|
|
|
138
149
|
this.__pendingLogin = null;
|
|
139
150
|
this.__refreshTimeout = null;
|
|
140
151
|
this.__renewalEpoch = 0;
|
|
152
|
+
/**
|
|
153
|
+
* Bumped by every {@link VaultBaseAuth#cancelTokenRefresh} call (`VaultClient#close()`
|
|
154
|
+
* makes one) and by nothing else. A login compares it before and after, so a login that
|
|
155
|
+
* was in flight when the client was closed does not arm a refresh timer.
|
|
156
|
+
* @type {number}
|
|
157
|
+
*/
|
|
158
|
+
this.__cancelCount = 0;
|
|
159
|
+
/**
|
|
160
|
+
* The in-flight {@link VaultBaseAuth#checkRejectedToken} lookup, shared by every
|
|
161
|
+
* request that was rejected with the same token.
|
|
162
|
+
* @type {{id: string, promise: Promise<boolean>}|null}
|
|
163
|
+
*/
|
|
164
|
+
this.__rejectionCheck = null;
|
|
165
|
+
/**
|
|
166
|
+
* Whether {@link VaultBaseAuth#revokeSelfOnClose} revokes the held token on the Vault
|
|
167
|
+
* server (`auth/token/revoke-self`). Off by default; set `auth.revokeOnClose` (see
|
|
168
|
+
* {@link VaultBaseAuth#configureRenewal}).
|
|
169
|
+
* @type {boolean}
|
|
170
|
+
*/
|
|
171
|
+
this.__revokeOnClose = false;
|
|
172
|
+
/**
|
|
173
|
+
* The id of the token whose revocation on close has already been started, so a
|
|
174
|
+
* repeated close() does not revoke it twice.
|
|
175
|
+
* @type {String|null}
|
|
176
|
+
*/
|
|
177
|
+
this.__revokedOnCloseId = null;
|
|
141
178
|
}
|
|
142
179
|
|
|
143
180
|
/**
|
|
@@ -149,12 +186,15 @@ class VaultBaseAuth {
|
|
|
149
186
|
* @param {boolean} [authConfig.renewal=true]
|
|
150
187
|
* @param {number} [authConfig.renewalFraction=0.5]
|
|
151
188
|
* @param {number} [authConfig.renewalIncrement]
|
|
189
|
+
* @param {boolean} [authConfig.revokeOnClose=false] - revoke the held token
|
|
190
|
+
* (`auth/token/revoke-self`) when the client is closed
|
|
152
191
|
* @returns {void}
|
|
153
192
|
*/
|
|
154
193
|
configureRenewal(authConfig) {
|
|
155
194
|
this.__renewalEnabled = VaultBaseAuth.__validateRenewal(authConfig);
|
|
156
195
|
this.__renewalFraction = VaultBaseAuth.__validateFraction(authConfig);
|
|
157
196
|
this.__renewalIncrement = VaultBaseAuth.__validateIncrement(authConfig);
|
|
197
|
+
this.__revokeOnClose = VaultBaseAuth.__validateRevokeOnClose(authConfig);
|
|
158
198
|
}
|
|
159
199
|
|
|
160
200
|
/**
|
|
@@ -180,6 +220,29 @@ class VaultBaseAuth {
|
|
|
180
220
|
return config.renewal;
|
|
181
221
|
}
|
|
182
222
|
|
|
223
|
+
/**
|
|
224
|
+
* Strict for the same reason as "renewal": node-config's `custom-environment-variables`
|
|
225
|
+
* yields strings, so a loose check would read `VAULT_REVOKE_ON_CLOSE=true` as `'true'`
|
|
226
|
+
* and silently leave revocation off -- the exact behaviour the flag exists to request.
|
|
227
|
+
*
|
|
228
|
+
* @param {Object} [authConfig]
|
|
229
|
+
* @returns {boolean}
|
|
230
|
+
* @private
|
|
231
|
+
*/
|
|
232
|
+
static __validateRevokeOnClose(config) {
|
|
233
|
+
if (!config || config.revokeOnClose === undefined) {
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (typeof config.revokeOnClose !== 'boolean') {
|
|
238
|
+
throw new errors.InvalidArgumentsError(
|
|
239
|
+
`"revokeOnClose" should be a boolean, got ${String(config.revokeOnClose)}`
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return config.revokeOnClose;
|
|
244
|
+
}
|
|
245
|
+
|
|
183
246
|
/**
|
|
184
247
|
* @param {Object} [authConfig]
|
|
185
248
|
* @returns {number}
|
|
@@ -252,12 +315,20 @@ class VaultBaseAuth {
|
|
|
252
315
|
|
|
253
316
|
this._log.info('getting auth token (mount=%s)', this._mount);
|
|
254
317
|
|
|
255
|
-
|
|
318
|
+
// Read before _authenticate() runs: a close() from then on, even from inside it, means
|
|
319
|
+
// this login arms no refresh timer.
|
|
320
|
+
const cancelCountAtStart = this.__cancelCount;
|
|
321
|
+
// A backend that throws instead of rejecting, or returns something that is not a
|
|
322
|
+
// promise, must not throw out of every operation. The executor still calls
|
|
323
|
+
// _authenticate() synchronously, as before.
|
|
324
|
+
const authentication = new Promise((resolve) => resolve(this._authenticate()));
|
|
325
|
+
|
|
326
|
+
const pendingLogin = authentication.then(authToken => {
|
|
256
327
|
this.__pendingLogin = null;
|
|
257
328
|
this.__authToken = authToken;
|
|
258
329
|
this.__renewalEpoch++;
|
|
259
330
|
|
|
260
|
-
if (authToken.isRenewable()) {
|
|
331
|
+
if (authToken.isRenewable() || renewableWithoutExpiry(authToken)) {
|
|
261
332
|
// __setupTokenRefreshTimer logs once it has actually armed a timer, and warns
|
|
262
333
|
// when it cannot; this covers the remaining case, the one diagnostic an operator
|
|
263
334
|
// reads when a `renewal: false` process still hangs.
|
|
@@ -267,7 +338,9 @@ class VaultBaseAuth {
|
|
|
267
338
|
authToken.getAccessor()
|
|
268
339
|
);
|
|
269
340
|
}
|
|
270
|
-
|
|
341
|
+
// close() ran while this login was in flight: every check and warning runs as
|
|
342
|
+
// before, but no timer is armed to hold the event loop.
|
|
343
|
+
this.__setupTokenRefreshTimer(authToken, this.__cancelCount !== cancelCountAtStart);
|
|
271
344
|
}
|
|
272
345
|
|
|
273
346
|
return authToken;
|
|
@@ -293,6 +366,7 @@ class VaultBaseAuth {
|
|
|
293
366
|
* @returns {void}
|
|
294
367
|
*/
|
|
295
368
|
cancelTokenRefresh() {
|
|
369
|
+
this.__markClosed();
|
|
296
370
|
this.__renewalEpoch++;
|
|
297
371
|
if (this.__refreshTimeout !== null) {
|
|
298
372
|
lt.clearTimeout(this.__refreshTimeout);
|
|
@@ -300,6 +374,162 @@ class VaultBaseAuth {
|
|
|
300
374
|
}
|
|
301
375
|
}
|
|
302
376
|
|
|
377
|
+
/**
|
|
378
|
+
* Record a close(), so a login in flight arms no refresh timer. Called by
|
|
379
|
+
* {@link VaultBaseAuth#cancelTokenRefresh} and directly by `VaultClient#close()`, so a
|
|
380
|
+
* subclass that overrides cancelTokenRefresh() without calling super keeps the behaviour.
|
|
381
|
+
* @returns {void}
|
|
382
|
+
* @private
|
|
383
|
+
*/
|
|
384
|
+
__markClosed() {
|
|
385
|
+
this.__cancelCount++;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Revoke the held token on the Vault server (`POST auth/token/revoke-self`), when
|
|
390
|
+
* `auth.revokeOnClose` is enabled. Called by `VaultClient#close()`.
|
|
391
|
+
*
|
|
392
|
+
* The token to revoke is the one held now, or the one an in-flight login is about to
|
|
393
|
+
* produce: that login still resolves after a close(), and its token would otherwise
|
|
394
|
+
* outlive the client by its whole TTL. A login that fails issued no token. A token that
|
|
395
|
+
* was never obtained, and one whose revocation an earlier close() already started, cost
|
|
396
|
+
* nothing to leave alone.
|
|
397
|
+
*
|
|
398
|
+
* @returns {Promise<void>|null} the revocation, so a caller may await it, or `null` when
|
|
399
|
+
* there is nothing to do: the option is off, or no token was ever obtained. The
|
|
400
|
+
* returned promise never rejects: `VaultClient.clear()` fires it without awaiting,
|
|
401
|
+
* and a failed revocation is logged (`revokeSelf on close failed`) with the token
|
|
402
|
+
* left live on the Vault server instead.
|
|
403
|
+
*/
|
|
404
|
+
revokeSelfOnClose() {
|
|
405
|
+
if (!this.__revokeOnClose) {
|
|
406
|
+
return null;
|
|
407
|
+
}
|
|
408
|
+
if (this.__pendingLogin === null && this.__authToken === null) {
|
|
409
|
+
// No token was ever obtained: nothing was issued, so there is nothing to revoke.
|
|
410
|
+
return null;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// The token to revoke: the one held now, or the one the in-flight login resolves
|
|
414
|
+
// with. The login's own bookkeeping (the token cache, timer decisions) has run by
|
|
415
|
+
// the time this `then` sees it.
|
|
416
|
+
const held = this.__pendingLogin !== null
|
|
417
|
+
? this.__pendingLogin.then((authToken) => authToken, () => null)
|
|
418
|
+
: Promise.resolve(this.__authToken);
|
|
419
|
+
|
|
420
|
+
return held.then((authToken) => {
|
|
421
|
+
// An expired token is already dead server-side: revoke-self would answer 403 and log
|
|
422
|
+
// a failure claiming the token was left live.
|
|
423
|
+
if (authToken === null || authToken.isExpired() || authToken.getId() === this.__revokedOnCloseId) {
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
const id = authToken.getId();
|
|
427
|
+
this.__revokedOnCloseId = id;
|
|
428
|
+
try {
|
|
429
|
+
this._log.info('revoking the auth token on close (accessor=%s)', authToken.getAccessor());
|
|
430
|
+
} catch {
|
|
431
|
+
// a throwing logger must not cancel the revocation
|
|
432
|
+
}
|
|
433
|
+
return this.__apiClient
|
|
434
|
+
.makeRequest('POST', '/auth/token/revoke-self', null, { 'X-Vault-Token': authToken.getId() })
|
|
435
|
+
.then(() => {
|
|
436
|
+
// Gone server-side: do not hand it to a call that keeps using the client.
|
|
437
|
+
if (this.__authToken === authToken) {
|
|
438
|
+
this.__authToken = null;
|
|
439
|
+
}
|
|
440
|
+
}, (err) => {
|
|
441
|
+
// Still live: let a later close() try again.
|
|
442
|
+
if (this.__revokedOnCloseId === id) {
|
|
443
|
+
this.__revokedOnCloseId = null;
|
|
444
|
+
}
|
|
445
|
+
try {
|
|
446
|
+
this._log.error(
|
|
447
|
+
'revokeSelf on close failed (accessor=%s): %s',
|
|
448
|
+
authToken.getAccessor(), safeMessage(err)
|
|
449
|
+
);
|
|
450
|
+
} catch {
|
|
451
|
+
// a throwing logger changes nothing: the failure is already lost to the caller
|
|
452
|
+
}
|
|
453
|
+
});
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Called by VaultClient after Vault answered 403 to a request made with `authToken`.
|
|
459
|
+
* Vault answers 403 both to a token it no longer accepts (revoked, or expired before the
|
|
460
|
+
* local clock says so) and to a policy that denies the request. `lookup-self`, which every
|
|
461
|
+
* login here already makes, tells the two apart, so a policy denial costs no login. A dead
|
|
462
|
+
* token is dropped, and its renewal timer cancelled, so the next getAuthToken() logs in.
|
|
463
|
+
*
|
|
464
|
+
* @param {AuthToken} authToken - the token the rejected request carried
|
|
465
|
+
* @returns {Promise<boolean>} true when the request may succeed if sent once more with
|
|
466
|
+
* the token getAuthToken() now gives: the token was dead and has been dropped, or it
|
|
467
|
+
* had been replaced already
|
|
468
|
+
*/
|
|
469
|
+
checkRejectedToken(authToken) {
|
|
470
|
+
if (!this._reauthenticationAllowed()) {
|
|
471
|
+
return Promise.resolve(false);
|
|
472
|
+
}
|
|
473
|
+
const id = authToken.getId();
|
|
474
|
+
if (id === this.__revokedOnCloseId) {
|
|
475
|
+
// close() revoked this token on purpose (auth.revokeOnClose): a request still in
|
|
476
|
+
// flight that it rejects must not log in again after close() and leave a new,
|
|
477
|
+
// unrevoked token behind.
|
|
478
|
+
return Promise.resolve(false);
|
|
479
|
+
}
|
|
480
|
+
if (this.__authToken === null || this.__authToken.getId() !== id) {
|
|
481
|
+
return Promise.resolve(true);
|
|
482
|
+
}
|
|
483
|
+
if (this.__rejectionCheck !== null && this.__rejectionCheck.id === id) {
|
|
484
|
+
return this.__rejectionCheck.promise;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const check = {
|
|
488
|
+
id,
|
|
489
|
+
promise: this.__apiClient.makeRequest('GET', '/auth/token/lookup-self', null, {'X-Vault-Token': id})
|
|
490
|
+
.then(() => false, err => {
|
|
491
|
+
if (err instanceof errors.VaultHttpError && err.statusCode === 403) {
|
|
492
|
+
return true;
|
|
493
|
+
}
|
|
494
|
+
// Neither answer: the 403 is returned as it is, so say why no login follows.
|
|
495
|
+
try {
|
|
496
|
+
this._log.debug(
|
|
497
|
+
'token rejection check inconclusive (accessor=%s): %s; returning the 403',
|
|
498
|
+
authToken.getAccessor(), safeMessage(err)
|
|
499
|
+
);
|
|
500
|
+
} catch {
|
|
501
|
+
// a throwing logger changes nothing here
|
|
502
|
+
}
|
|
503
|
+
return false;
|
|
504
|
+
})
|
|
505
|
+
.then(dead => {
|
|
506
|
+
if (this.__rejectionCheck === check) {
|
|
507
|
+
this.__rejectionCheck = null;
|
|
508
|
+
}
|
|
509
|
+
if (dead && this.__authToken !== null && this.__authToken.getId() === id) {
|
|
510
|
+
this.__authToken = null;
|
|
511
|
+
// As before, through the public method, so an override still hears of it.
|
|
512
|
+
// Dropping a dead token is not a close(): the next login's token must be
|
|
513
|
+
// renewed, so the close count is put back.
|
|
514
|
+
const cancelCount = this.__cancelCount;
|
|
515
|
+
this.cancelTokenRefresh();
|
|
516
|
+
this.__cancelCount = cancelCount;
|
|
517
|
+
try {
|
|
518
|
+
this._log.warn(
|
|
519
|
+
'Vault no longer accepts the token (accessor=%s); logging in again',
|
|
520
|
+
authToken.getAccessor()
|
|
521
|
+
);
|
|
522
|
+
} catch {
|
|
523
|
+
// a throwing logger must not keep the dead token in use
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
return dead;
|
|
527
|
+
}),
|
|
528
|
+
};
|
|
529
|
+
this.__rejectionCheck = check;
|
|
530
|
+
return check.promise;
|
|
531
|
+
}
|
|
532
|
+
|
|
303
533
|
/**
|
|
304
534
|
* @protected
|
|
305
535
|
* @returns {Promise<AuthToken>}
|
|
@@ -321,9 +551,11 @@ class VaultBaseAuth {
|
|
|
321
551
|
|
|
322
552
|
/**
|
|
323
553
|
* @param {AuthToken} authToken
|
|
554
|
+
* @param {boolean} [closedDuringLogin=false] - close() ran while the login that brought
|
|
555
|
+
* `authToken` was in flight: check and warn as usual, but arm no timer
|
|
324
556
|
* @private
|
|
325
557
|
*/
|
|
326
|
-
__setupTokenRefreshTimer(authToken) {
|
|
558
|
+
__setupTokenRefreshTimer(authToken, closedDuringLogin = false) {
|
|
327
559
|
if (this.__refreshTimeout !== null) {
|
|
328
560
|
lt.clearTimeout(this.__refreshTimeout);
|
|
329
561
|
this.__refreshTimeout = null;
|
|
@@ -333,11 +565,13 @@ class VaultBaseAuth {
|
|
|
333
565
|
return;
|
|
334
566
|
}
|
|
335
567
|
if (!authToken.isRenewable() || authToken.isExpired()) {
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
568
|
+
let reason = 'token is not renewable';
|
|
569
|
+
if (authToken.isExpired()) {
|
|
570
|
+
reason = 'token already expired';
|
|
571
|
+
} else if (renewableWithoutExpiry(authToken)) {
|
|
572
|
+
reason = 'token has no usable expiry';
|
|
573
|
+
}
|
|
574
|
+
warnRenewalStopped(this, authToken, reason);
|
|
341
575
|
return;
|
|
342
576
|
}
|
|
343
577
|
|
|
@@ -362,6 +596,19 @@ class VaultBaseAuth {
|
|
|
362
596
|
return;
|
|
363
597
|
}
|
|
364
598
|
|
|
599
|
+
if (closedDuringLogin) {
|
|
600
|
+
// The token is cached and returned as after any close(); nothing holds the loop.
|
|
601
|
+
try {
|
|
602
|
+
this._log.debug(
|
|
603
|
+
'client closed during login; not arming a refresh timer (accessor=%s)',
|
|
604
|
+
authToken.getAccessor()
|
|
605
|
+
);
|
|
606
|
+
} catch {
|
|
607
|
+
// a throwing logger must not fail a login that succeeded
|
|
608
|
+
}
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
|
|
365
612
|
// A renewal that is still in flight when the token is replaced or cancelled must not
|
|
366
613
|
// act on what it finds when it lands: it would overwrite the newer token, or clear
|
|
367
614
|
// the newer token's timer and then bail on its own expired one, leaving a live token
|
package/src/auth/VaultIAMAuth.js
CHANGED
|
@@ -4,6 +4,23 @@ const VaultBaseAuth = require('./VaultBaseAuth');
|
|
|
4
4
|
const aws4 = require('aws4');
|
|
5
5
|
const { fromNodeProviderChain } = require('@aws-sdk/credential-providers');
|
|
6
6
|
const errors = require('../errors');
|
|
7
|
+
const warnOnce = require('../warnOnce');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The Vault server part of the warning key: origin and path of `api.url`, so spellings such as a
|
|
11
|
+
* trailing slash or an upper-case host name the same server. An unparsable value is used as is.
|
|
12
|
+
* @param {*} url
|
|
13
|
+
* @returns {string}
|
|
14
|
+
* @private
|
|
15
|
+
*/
|
|
16
|
+
function serverKey(url) {
|
|
17
|
+
try {
|
|
18
|
+
const parsed = new URL(url);
|
|
19
|
+
return parsed.origin + parsed.pathname.replace(/\/+$/, '');
|
|
20
|
+
} catch {
|
|
21
|
+
return String(url);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
7
24
|
|
|
8
25
|
/**
|
|
9
26
|
* Implementation of AWS Auth Backend :: IAM Authentication Method
|
|
@@ -51,6 +68,8 @@ class VaultIAMAuth extends VaultBaseAuth {
|
|
|
51
68
|
* @property {String} [namespace] - Optional. Vault namespace. Applied as the X-Vault-Namespace
|
|
52
69
|
* header to every request by {@link VaultApiClient}; see {@link VaultClient#constructor}.
|
|
53
70
|
* @property {String} [iam_server_id_header_value] - Optional. Header's value X-Vault-AWS-IAM-Server-ID.
|
|
71
|
+
* Without it the signed request names no Vault server, and the first login logs a warning,
|
|
72
|
+
* once per Vault server, namespace and mount in a process.
|
|
54
73
|
* @property {String} [region] - Optional. AWS region used to sign the STS GetCallerIdentity
|
|
55
74
|
* request. When set, the request is signed against the regional STS endpoint
|
|
56
75
|
* (`sts.<region>.amazonaws.com`) and the SigV4 credential scope is bound to that region.
|
|
@@ -80,10 +99,45 @@ class VaultIAMAuth extends VaultBaseAuth {
|
|
|
80
99
|
this.__credentialChain = credentials ? () => Promise.resolve(credentials) : fromNodeProviderChain();
|
|
81
100
|
}
|
|
82
101
|
|
|
102
|
+
/**
|
|
103
|
+
* Without `X-Vault-AWS-IAM-Server-ID` the signed STS request names no Vault server, so a
|
|
104
|
+
* server that receives it (or anyone who captures it) can replay it to another Vault server
|
|
105
|
+
* that trusts the same AWS principal until the signature expires. Warn once per Vault server,
|
|
106
|
+
* namespace and mount for each logger (once per process on the default logger) when the
|
|
107
|
+
* header will not be sent or holds only whitespace. Called on login, not in the constructor,
|
|
108
|
+
* so a client whose construction fails later (in VaultClient) logs nothing. Never throws: a
|
|
109
|
+
* failure here must not fail the login.
|
|
110
|
+
* @returns {void}
|
|
111
|
+
* @private
|
|
112
|
+
*/
|
|
113
|
+
__warnOnMissingServerId() {
|
|
114
|
+
try {
|
|
115
|
+
const value = this.__iam_server_id_header_value;
|
|
116
|
+
// A value of only whitespace is sent, but names no server either.
|
|
117
|
+
if (typeof value === 'string' ? value.trim() !== '' : value) {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const api = this.__apiClient || {};
|
|
121
|
+
const key = [serverKey(api.__config && api.__config.url), String(api.__namespace), this._mount]
|
|
122
|
+
.join('\u0000');
|
|
123
|
+
warnOnce(
|
|
124
|
+
this._log,
|
|
125
|
+
key,
|
|
126
|
+
'IAM login on mount "%s" is not configured with "iam_server_id_header_value", so the signed '
|
|
127
|
+
+ 'STS request names no Vault server and could be replayed to another one that trusts the same '
|
|
128
|
+
+ 'AWS principal. Set it to the same value as iam_server_id_header_value on auth/%s/config/client',
|
|
129
|
+
this._mount, this._mount
|
|
130
|
+
);
|
|
131
|
+
} catch {
|
|
132
|
+
// an odd api object changes nothing here
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
83
136
|
/**
|
|
84
137
|
* @inheritDoc
|
|
85
138
|
*/
|
|
86
139
|
_authenticate() {
|
|
140
|
+
this.__warnOnMissingServerId();
|
|
87
141
|
this._log.info(
|
|
88
142
|
'making authentication request: role=%s',
|
|
89
143
|
this.__role
|
|
@@ -33,20 +33,27 @@ class VaultKubernetesAuth extends VaultBaseAuth {
|
|
|
33
33
|
this.__role, this.__tokenPath
|
|
34
34
|
);
|
|
35
35
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
36
|
+
// Inside the chain, as in VaultJwtAuth and VaultIAMAuth: a missing or unreadable token
|
|
37
|
+
// file rejects the login instead of throwing. getAuthToken() turns any backend throw
|
|
38
|
+
// into a rejection too; this keeps the backend's own _authenticate() contract async.
|
|
39
|
+
return Promise.resolve()
|
|
40
|
+
.then(() => {
|
|
41
|
+
const k8sJwtToken = fs.readFileSync(this.__tokenPath).toString();
|
|
42
|
+
this._log.debug(
|
|
43
|
+
'read K8s service-account JWT from "%s" (%d bytes)',
|
|
44
|
+
this.__tokenPath, k8sJwtToken.length
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
return this.__apiClient.makeRequest('POST', `/auth/${this._mount}/login`, {
|
|
48
|
+
role: this.__role,
|
|
49
|
+
jwt: k8sJwtToken,
|
|
50
|
+
});
|
|
51
|
+
})
|
|
52
|
+
.then((res) => {
|
|
53
|
+
this._log.debug('received Vault client token from Kubernetes login');
|
|
54
|
+
|
|
55
|
+
return this._getTokenEntity(res.auth.client_token);
|
|
56
|
+
});
|
|
50
57
|
}
|
|
51
58
|
}
|
|
52
59
|
|
package/src/errors.d.ts
CHANGED
|
@@ -12,9 +12,16 @@ export declare class AuthTokenExpiredError extends VaultError {}
|
|
|
12
12
|
export declare class UnsupportedOperationError extends VaultError {}
|
|
13
13
|
|
|
14
14
|
export declare class VaultHttpError extends VaultError {
|
|
15
|
-
constructor(statusCode: number, text: string, body?: unknown);
|
|
15
|
+
constructor(statusCode: number, text: string, body?: unknown, headers?: { retryAfter?: string | null });
|
|
16
16
|
statusCode: number;
|
|
17
17
|
error: unknown;
|
|
18
|
+
/** Raw `Retry-After` response header; absent when the response did not send one or it was empty. */
|
|
19
|
+
retryAfter?: string;
|
|
20
|
+
/**
|
|
21
|
+
* `Retry-After` in milliseconds (delay-seconds or HTTP-date), capped at 2147483647;
|
|
22
|
+
* absent when the header was not sent or cannot be parsed.
|
|
23
|
+
*/
|
|
24
|
+
retryAfterMs?: number;
|
|
18
25
|
}
|
|
19
26
|
|
|
20
27
|
export declare class RequestTimeoutError extends VaultError {
|
package/src/errors.js
CHANGED
|
@@ -19,6 +19,29 @@ class InvalidAWSCredentialsError extends InvalidArgumentsError {}
|
|
|
19
19
|
class AuthTokenExpiredError extends VaultError {}
|
|
20
20
|
class UnsupportedOperationError extends VaultError {}
|
|
21
21
|
|
|
22
|
+
// The longest delay setTimeout() honours; a longer one fires after 1 ms (same limit as api.timeout).
|
|
23
|
+
const MAX_DELAY_MS = 2147483647;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Parses a `Retry-After` header value (RFC 9110 §10.2.3): delay-seconds or an HTTP-date.
|
|
27
|
+
*
|
|
28
|
+
* @param {string} value - the raw header value, trimmed and not empty
|
|
29
|
+
* @param {number} now - current time in milliseconds
|
|
30
|
+
* @returns {number|undefined} milliseconds to wait, at most MAX_DELAY_MS; `undefined` for a
|
|
31
|
+
* value in neither format (e.g. `-5`, or `30, 120` from two headers)
|
|
32
|
+
*/
|
|
33
|
+
function parseRetryAfter(value, now) {
|
|
34
|
+
if (/^\d+$/.test(value)) {
|
|
35
|
+
return Math.min(Number(value) * 1000, MAX_DELAY_MS);
|
|
36
|
+
}
|
|
37
|
+
// An HTTP-date always names the day or the month; Date.parse() alone also accepts e.g. "-5".
|
|
38
|
+
const date = /[a-z]/i.test(value) ? Date.parse(value) : NaN;
|
|
39
|
+
if (Number.isNaN(date)) {
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
return Math.min(Math.max(0, date - now), MAX_DELAY_MS);
|
|
43
|
+
}
|
|
44
|
+
|
|
22
45
|
/**
|
|
23
46
|
* A non-2xx HTTP response from the Vault server.
|
|
24
47
|
*
|
|
@@ -33,11 +56,26 @@ class VaultHttpError extends VaultError {
|
|
|
33
56
|
* @param {string} text - raw response body text, used in the message.
|
|
34
57
|
* @param {*} [body] - parsed JSON body when the response was valid JSON, the raw text
|
|
35
58
|
* otherwise, or `undefined` for an empty body. Exposed as `error.error`.
|
|
59
|
+
* @param {Object} [headers] - response headers the error exposes:
|
|
60
|
+
* @param {string|null} [headers.retryAfter] - the raw `Retry-After` header, `null` or
|
|
61
|
+
* `undefined` when it was not sent. Vault's rate-limit quotas answer 429 with it, in
|
|
62
|
+
* seconds; proxies in front of Vault send it with 429 and 503. Exposed as
|
|
63
|
+
* `error.retryAfter` (raw) and `error.retryAfterMs` (parsed); both are absent when the
|
|
64
|
+
* header was not sent or is empty, and `retryAfterMs` also when it cannot be parsed.
|
|
36
65
|
*/
|
|
37
|
-
constructor(statusCode, text, body) {
|
|
66
|
+
constructor(statusCode, text, body, { retryAfter } = {}) {
|
|
38
67
|
super(`${statusCode} - ${text}`);
|
|
39
68
|
this.statusCode = statusCode;
|
|
40
69
|
this.error = body;
|
|
70
|
+
|
|
71
|
+
const value = typeof retryAfter === 'string' ? retryAfter.trim() : '';
|
|
72
|
+
if (value !== '') {
|
|
73
|
+
this.retryAfter = value;
|
|
74
|
+
const ms = parseRetryAfter(value, Date.now());
|
|
75
|
+
if (ms !== undefined) {
|
|
76
|
+
this.retryAfterMs = ms;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
41
79
|
}
|
|
42
80
|
}
|
|
43
81
|
|
package/src/warnOnce.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Warnings already logged, kept per `logger.warn` function: the default console logger of every
|
|
5
|
+
* client shares `console.warn`, so the process logs a warning once however many clients it
|
|
6
|
+
* creates, while a client with a logger of its own still gets it, and the no-op logger of
|
|
7
|
+
* `logger: false` uses up nobody else's. Each set holds one short string per distinct warning.
|
|
8
|
+
* @type {WeakMap<Function, Set<string>>}
|
|
9
|
+
*/
|
|
10
|
+
const logged = new WeakMap();
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Log `message` with `args` through `logger.warn`, unless that same `logger.warn` has already
|
|
14
|
+
* logged `message` for `key`. It is recorded only once it has been logged, so a throwing logger
|
|
15
|
+
* uses nothing up, and this never throws.
|
|
16
|
+
* @param {Object} logger
|
|
17
|
+
* @param {string} key - what the warning is about, such as a host or a mount
|
|
18
|
+
* @param {string} message - the format string
|
|
19
|
+
* @param {...*} args
|
|
20
|
+
* @returns {boolean} true when the warning is logged now or was logged before; false when the
|
|
21
|
+
* logger threw, so the caller can try again later
|
|
22
|
+
* @private
|
|
23
|
+
*/
|
|
24
|
+
function warnOnce(logger, key, message, ...args) {
|
|
25
|
+
try {
|
|
26
|
+
const warn = logger.warn;
|
|
27
|
+
const id = `${message}\u0000${key}`;
|
|
28
|
+
let seen = logged.get(warn);
|
|
29
|
+
if (seen && seen.has(id)) {
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
logger.warn(message, ...args);
|
|
33
|
+
if (!seen) {
|
|
34
|
+
seen = new Set();
|
|
35
|
+
logged.set(warn, seen);
|
|
36
|
+
}
|
|
37
|
+
seen.add(id);
|
|
38
|
+
return true;
|
|
39
|
+
} catch {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
module.exports = warnOnce;
|