node-vault-client 3.1.0 → 3.2.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 +100 -13
- package/index.d.ts +1 -1
- package/package.json +6 -5
- package/src/Lease.js +10 -4
- package/src/MountResolver.js +30 -8
- package/src/VaultApiClient.js +170 -2
- package/src/VaultClient.js +139 -60
- package/src/auth/AuthToken.js +13 -4
- package/src/auth/VaultBaseAuth.js +142 -9
- package/src/auth/VaultIAMAuth.js +54 -0
- package/src/auth/VaultKubernetesAuth.js +21 -14
- package/src/warnOnce.js +44 -0
package/README.md
CHANGED
|
@@ -116,6 +116,21 @@ when Vault's `sts_region` / `sts_endpoint` is configured for a non-`us-east-1` r
|
|
|
116
116
|
`SignatureDoesNotMatch — Credential should be scoped to a valid region`. Omitting `region`
|
|
117
117
|
preserves the previous (global-endpoint) behavior.
|
|
118
118
|
|
|
119
|
+
#### `iam_server_id_header_value`
|
|
120
|
+
|
|
121
|
+
Set it to the same value as `iam_server_id_header_value` in the mount's `auth/<mount>/config/client`.
|
|
122
|
+
The client then sends it in the signed request as `X-Vault-AWS-IAM-Server-ID`, so the signature
|
|
123
|
+
names your Vault server. A Vault server that enforces its own `iam_server_id_header_value` then
|
|
124
|
+
rejects a request captured from, or meant for, a different server; one that does not set the value
|
|
125
|
+
checks nothing. Vault rejects a login with a missing or wrong value only when the mount sets the
|
|
126
|
+
value. The header does not stop a replay to the same server while the signature is valid (about 15
|
|
127
|
+
minutes).
|
|
128
|
+
|
|
129
|
+
When `config.iam_server_id_header_value` is missing, empty or only whitespace, the first login
|
|
130
|
+
logs one warning and logs in as before, sending the value it has. Each logger gets the warning once
|
|
131
|
+
per Vault server, namespace and mount: clients on the default console logger share it, so the
|
|
132
|
+
process logs it once, and a client with `logger: false` logs nothing and leaves it to the others.
|
|
133
|
+
|
|
119
134
|
### AppRole auth
|
|
120
135
|
|
|
121
136
|
```javascript
|
|
@@ -423,6 +438,27 @@ that token expires, and this client does not renew secret leases — only the au
|
|
|
423
438
|
dynamic credentials (`database/creds/*`, cloud credentials) whose lease outlives the auth token,
|
|
424
439
|
leaving renewal on is what currently keeps them alive. KV reads are unaffected.
|
|
425
440
|
|
|
441
|
+
#### Tokens revoked on the Vault side
|
|
442
|
+
|
|
443
|
+
A token can stop working before its expiry, for example when an operator revokes it
|
|
444
|
+
(`vault token revoke`, `sys/leases/revoke-prefix`). Vault then answers 403 to every request that
|
|
445
|
+
carries it. When a secret request or a mount detection gets a 403, the client asks
|
|
446
|
+
`auth/token/lookup-self`, which every login already calls, whether Vault still accepts the token:
|
|
447
|
+
|
|
448
|
+
* If it does, the 403 was a policy denial. The call rejects with it as before, and no login is made.
|
|
449
|
+
* If it does not, the client drops the token and its renewal timer, logs `Vault no longer accepts
|
|
450
|
+
the token (accessor=…); logging in again` at `warn`, logs in, and sends the request once more.
|
|
451
|
+
The call then returns what that attempt got: its result, or its error (a failed login, a 404, a
|
|
452
|
+
timeout). Like any re-login, it replays the configured credential (see the table above).
|
|
453
|
+
|
|
454
|
+
Concurrent requests rejected with the same token share one lookup and one login. A 403 on the login
|
|
455
|
+
itself is returned as before. Under `token` auth there is no credential to log in with, so the 403
|
|
456
|
+
is returned as before, without a lookup.
|
|
457
|
+
|
|
458
|
+
So revoking a client's token no longer locks it out: it logs straight back in with the same
|
|
459
|
+
credential. To cut a client off, also revoke or rotate what it logs in with (the AppRole
|
|
460
|
+
`secret_id` or role, the Kubernetes or JWT role binding, the IAM role).
|
|
461
|
+
|
|
426
462
|
#### Tuning renewal instead of disabling it
|
|
427
463
|
|
|
428
464
|
Two further keys, also on `auth`, shape *how* renewal happens. Both are optional, and leaving them
|
|
@@ -502,9 +538,9 @@ Client constructor function.
|
|
|
502
538
|
| --- | --- | --- | --- |
|
|
503
539
|
| options | `Object` | | |
|
|
504
540
|
| options.api | <code>Object</code> | | |
|
|
505
|
-
| options.api.url | <code>String</code> | | the url of the vault server |
|
|
541
|
+
| options.api.url | <code>String</code> | | the url of the vault server. Plain `http://` to a host other than this machine logs a warning; see "Plain `http://` api.url" |
|
|
506
542
|
| [options.api.apiVersion] | <code>String</code> | `v1` | |
|
|
507
|
-
| [options.api.requestOptions] | <code>Object</code> | | extra options merged into every HTTP request (see [Custom transport](#custom-transport-proxy--self-signed-tls)) |
|
|
543
|
+
| [options.api.requestOptions] | <code>Object</code> | | extra options merged into every HTTP request (see [Custom transport](#custom-transport-proxy--self-signed-tls)); a `redirect` there replaces the same-origin redirect policy (see [Redirects and the Vault token](#redirects-and-the-vault-token)) |
|
|
508
544
|
| [options.api.timeout] | <code>number</code> | `120000` | milliseconds a request may take, response headers, body and redirects included, before it rejects with `RequestTimeoutError`; `0` turns the limit off, `null` gives the default (see [Request timeouts](#request-timeouts)) |
|
|
509
545
|
| [options.api.namespace] | <code>String</code> | | Optional. Vault namespace, sent as the `X-Vault-Namespace` header on **every** request — login, token lookup/renewal, and all secret operations — for every auth type. This is the canonical location; `auth.config.namespace` is still honored for backward compatibility. |
|
|
510
546
|
| [options.api.kv.autoDetect] | <code>boolean</code> | `false` | auto-detect the KV version of each mount on first use (see [KV v2 & generic backends](#kv-v2--generic-backends)) |
|
|
@@ -608,17 +644,48 @@ on shutdown. `fetch()` then receives the client's own per-request signal, which
|
|
|
608
644
|
shared by every request the client makes, so it works for the first calls and then aborts all
|
|
609
645
|
later ones. Use `api.timeout` instead.
|
|
610
646
|
|
|
647
|
+
##### Plain `http://` api.url
|
|
648
|
+
|
|
649
|
+
When `api.url` is `http://` and its host is not this machine, the client logs one warning on its
|
|
650
|
+
first request: `"api.url" uses plain http:// for <host:port>, which is not a loopback address; the
|
|
651
|
+
Vault token and secrets travel unencrypted. …` (on port 80 the message names the bare host).
|
|
652
|
+
Nothing else changes: the client connects and works as before. `localhost`, `127.0.0.0/8` (also
|
|
653
|
+
as `::ffff:127.x.y.z`), `::1`, and the unspecified addresses `0.0.0.0` and `::` count as this
|
|
654
|
+
machine; subdomains of `localhost` do not, since the system resolver can send them anywhere. The
|
|
655
|
+
warning names only the host and port, never credentials or a path from the URL. Each logger gets
|
|
656
|
+
it once per host and port: clients on the default console logger share it, so the process logs it
|
|
657
|
+
once, and a client with `logger: false` logs nothing and leaves the warning to the others. If the
|
|
658
|
+
logger throws, the client tries again on its next request. Only `api.url` is checked, not where a
|
|
659
|
+
proxy `dispatcher` or `requestOptions.redirect: 'follow'` sends the request.
|
|
660
|
+
|
|
661
|
+
If the connection is protected another way (a service mesh with mTLS, a TLS-terminating sidecar on
|
|
662
|
+
another host), filter that message in your `logger`, or switch to `https://`.
|
|
663
|
+
|
|
611
664
|
##### Redirects and the Vault token
|
|
612
665
|
|
|
613
|
-
Requests follow HTTP redirects
|
|
614
|
-
|
|
615
|
-
a
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
`
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
666
|
+
Requests follow HTTP redirects only within the origin (scheme, host and port) of `api.url`, the
|
|
667
|
+
way `fetch()` follows them: up to 20 hops, a 307 or 308 re-sends the method and body, and a 303,
|
|
668
|
+
or a 301 or 302 after a `POST`, becomes a `GET` without a body. A redirect to any other origin
|
|
669
|
+
rejects with a `VaultError` ("Refusing to follow a redirect from … to …") and nothing is sent
|
|
670
|
+
there. That includes a change of scheme on the same host: if a load balancer redirects an
|
|
671
|
+
`http://` `api.url` to `https://`, use the `https://` URL. Where `fetch()` itself gives up on a
|
|
672
|
+
redirect (more than 20 hops, or a `Location` that does not parse, is not http(s) or carries
|
|
673
|
+
credentials), the request rejects as it always has, with `TypeError: fetch failed` and the reason
|
|
674
|
+
on `cause`, so catch both if you handle redirect failures.
|
|
675
|
+
|
|
676
|
+
Earlier versions followed every redirect. Node's `fetch()` strips only `Authorization`,
|
|
677
|
+
`Cookie` and `Proxy-Authorization` when a redirect crosses origins, so `X-Vault-Token` went to the
|
|
678
|
+
new origin, a 307 or 308 replayed a login body with its credentials (the AppRole `secret_id`, the
|
|
679
|
+
Kubernetes or JWT token, the signed IAM request, a `distributedClaimAccessToken` Graph token), and
|
|
680
|
+
that origin's answer was returned as Vault's.
|
|
681
|
+
|
|
682
|
+
A Vault HA standby forwards requests to the active node. It answers with a 307 to the active
|
|
683
|
+
node's `api_addr` only when forwarding fails or the client sends `X-Vault-No-Request-Forwarding`.
|
|
684
|
+
If that address is another origin, point `api.url` at a load balancer that routes to the active
|
|
685
|
+
node. A `redirect` in `api.requestOptions` (other than `null`, which reads as not set) is passed
|
|
686
|
+
to `fetch()` as it is: `'follow'` restores
|
|
687
|
+
the old behaviour (then treat `api.url`, its DNS and anything in front of it as trusted with the
|
|
688
|
+
token), `'manual'` returns a redirect as a `VaultHttpError`, and `'error'` fails it.
|
|
622
689
|
|
|
623
690
|
<a name="VaultClient+fillNodeConfig"></a>
|
|
624
691
|
|
|
@@ -815,6 +882,14 @@ discarded rather than arming a fresh timer, so the client stops holding the even
|
|
|
815
882
|
same applies when a token is replaced by a new login — a renewal still running against the previous
|
|
816
883
|
token cannot overwrite the newer one or cancel its timer.
|
|
817
884
|
|
|
885
|
+
The same goes for a login that is in flight when `close()` runs, such as the first `read` of a
|
|
886
|
+
client closed before it settles: the read still resolves and the token is cached for later calls,
|
|
887
|
+
but no refresh timer is armed for it. A call made after `close()` that joins that same login
|
|
888
|
+
shares its token and gets no timer either, just as if `close()` had run right after the login. A
|
|
889
|
+
login that starts after `close()` arms one as before. As after any `close()`, the token is then
|
|
890
|
+
used until it expires; under `token` auth, which cannot log in again, later calls reject with
|
|
891
|
+
`AuthTokenExpiredError`, so close a client only once nothing will use it any more.
|
|
892
|
+
|
|
818
893
|
<a name="Lease"></a>
|
|
819
894
|
|
|
820
895
|
### Lease
|
|
@@ -822,7 +897,7 @@ token cannot overwrite the newer one or cancel its timer.
|
|
|
822
897
|
The object returned by `read()` and `list()` (they resolve to `Promise<Lease>`). Use its
|
|
823
898
|
accessors to extract the secret data:
|
|
824
899
|
|
|
825
|
-
* `getValue(key)` ⇒ <code
|
|
900
|
+
* `getValue(key)` ⇒ <code>\*</code> — value for a single key, as Vault returned it: usually a string, but `null`, `false`, `0`, objects and arrays come back as they are. Throws `Requested key does not exist` when the key is not an own property of the response data (for an array, such as a `list()` result, that means an index or `length`). A name such as `constructor` or `toString` counts only when Vault sent a field by that name, never as an inherited member.
|
|
826
901
|
* `getData()` ⇒ <code>Object</code> — a deep-cloned copy of the whole secret data object.
|
|
827
902
|
* `isRenewable()` ⇒ <code>boolean</code> — whether the underlying lease is renewable.
|
|
828
903
|
* `getMetadata()` ⇒ <code>Object</code> | <code>undefined</code> — KV v2 version metadata
|
|
@@ -881,6 +956,18 @@ Clear named Vault instance
|
|
|
881
956
|
|
|
882
957
|
If no name passed all named instances will be cleared.
|
|
883
958
|
|
|
959
|
+
Only a string names one instance. Any other argument (a number, `null`, an object, a symbol)
|
|
960
|
+
clears every instance not named by a symbol, as it always has. That is rarely what was meant:
|
|
961
|
+
`clear(42)`, or `clear` passed as a callback that receives a code or a resolved value
|
|
962
|
+
(`promise.then(VaultClient.clear)`). So the first such call in a process also emits a
|
|
963
|
+
`DeprecationWarning` with code `NVC_DEP_CLEAR_NON_STRING`, naming the argument's type but not its
|
|
964
|
+
value. Call `clear()` with no argument to clear every instance.
|
|
965
|
+
|
|
966
|
+
Node delivers warnings on the next tick, so the warning is not shown when `clear` runs inside a
|
|
967
|
+
`process.on('exit')` listener, which still clears every instance. `--no-deprecation` silences the
|
|
968
|
+
warning. Under `--throw-deprecation` it becomes an uncaught exception on the next tick, not an
|
|
969
|
+
error thrown by `clear()`.
|
|
970
|
+
|
|
884
971
|
**Kind**: static method of [<code>VaultClient</code>](#VaultClient)
|
|
885
972
|
|
|
886
973
|
| Param | Type | Description |
|
|
@@ -1094,7 +1181,7 @@ try {
|
|
|
1094
1181
|
|
|
1095
1182
|
| Class | Extends | When thrown |
|
|
1096
1183
|
|---|---|---|
|
|
1097
|
-
| `VaultError` | `Error` | Base class for every error below. Raised directly when mount detection fails (e.g. permission denied) and no `api.engines` override was provided. |
|
|
1184
|
+
| `VaultError` | `Error` | Base class for every error below. Raised directly when mount detection fails (e.g. permission denied) and no `api.engines` override was provided; the failure is on `cause`, so Vault's HTTP status is `err.cause.statusCode`. Vault answers 403 both for a mount the token may not use and for one that does not exist. |
|
|
1098
1185
|
| `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. |
|
|
1099
1186
|
| `InvalidArgumentsError` | `VaultError` | Bad arguments or configuration: `boot()` called without options, an unknown instance name passed to `get()`, an unsupported `auth.type`, invalid renewal options or `api.timeout`, an invalid node-config substitution map, or a KV method path that fails [path validation](#path-validation). |
|
|
1100
1187
|
| `InvalidAWSCredentialsError` | `InvalidArgumentsError` | `auth.config.credentials` was supplied but is not a usable `accessKeyId` / `secretAccessKey` pair. |
|
package/index.d.ts
CHANGED
|
@@ -121,7 +121,7 @@ declare namespace VaultClient {
|
|
|
121
121
|
}
|
|
122
122
|
|
|
123
123
|
interface Lease {
|
|
124
|
-
getValue<T = any>(key: string): T;
|
|
124
|
+
getValue<T = any>(key: string | number): T;
|
|
125
125
|
getData<T = Record<string, any>>(): T;
|
|
126
126
|
isRenewable(): boolean;
|
|
127
127
|
getMetadata(): Record<string, any> | undefined;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "node-vault-client",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.2.0",
|
|
4
4
|
"description": "A Vault Client implemented in pure javascript for HashiCorp Vault. It supports variety of Auth Backends and performs lease renewal for issued auth token.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"url": "git+https://github.com/namecheap/node-vault-client.git"
|
|
@@ -18,10 +18,10 @@
|
|
|
18
18
|
"test": "mocha --exit \"test/**/*.test.mjs\"",
|
|
19
19
|
"test:unit": "mocha \"test/*.test.mjs\"",
|
|
20
20
|
"test:types": "tsc -p types/tsconfig.json",
|
|
21
|
-
"test:e2e": "mocha --exit \"test/e2e/e2e.test.mjs\"",
|
|
22
|
-
"test:e2e:kv2": "mocha --exit \"test/e2e/e2e.kv2.test.mjs\"",
|
|
23
|
-
"test:e2e:jwt": "mocha --exit \"test/e2e/e2e.jwt.test.mjs\"",
|
|
24
|
-
"test:e2e:generic": "mocha --exit \"test/e2e/e2e.generic.test.mjs\"",
|
|
21
|
+
"test:e2e": "mocha --exit --require test/e2e/renewal-timer-guard.mjs \"test/e2e/e2e.test.mjs\"",
|
|
22
|
+
"test:e2e:kv2": "mocha --exit --require test/e2e/renewal-timer-guard.mjs \"test/e2e/e2e.kv2.test.mjs\"",
|
|
23
|
+
"test:e2e:jwt": "mocha --exit --require test/e2e/renewal-timer-guard.mjs \"test/e2e/e2e.jwt.test.mjs\"",
|
|
24
|
+
"test:e2e:generic": "mocha --exit --require test/e2e/renewal-timer-guard.mjs \"test/e2e/e2e.generic.test.mjs\"",
|
|
25
25
|
"test:example": "node examples/jwt-auth/vault-jwt-demo.mjs",
|
|
26
26
|
"coverage": "c8 npm run test:unit",
|
|
27
27
|
"lint": "eslint src/ test/ examples/",
|
|
@@ -65,6 +65,7 @@
|
|
|
65
65
|
"@eslint/js": "^10.0.1",
|
|
66
66
|
"c8": "^12.0.0",
|
|
67
67
|
"chai": "^6.2.2",
|
|
68
|
+
"config": "^4.4.2",
|
|
68
69
|
"deep-freeze": "^0.0.1",
|
|
69
70
|
"eslint": "^10.10.0",
|
|
70
71
|
"globals": "^17.8.0",
|
package/src/Lease.js
CHANGED
|
@@ -29,15 +29,21 @@ class Lease {
|
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
/**
|
|
32
|
-
* @param {String} key
|
|
33
|
-
* @returns {
|
|
32
|
+
* @param {String|number} key - a field name, or an index when the data is an array (a list)
|
|
33
|
+
* @returns {*} the field's value as Vault returned it: a string for most secrets, but
|
|
34
|
+
* `null`, `false`, `0`, an object or an array are returned as they are
|
|
35
|
+
* @throws {Error} 'Requested key does not exist' unless `key` is an own property of the
|
|
36
|
+
* response data whose value is not `undefined`
|
|
34
37
|
*/
|
|
35
38
|
getValue(key) {
|
|
36
|
-
|
|
39
|
+
const data = this.__data;
|
|
40
|
+
// Own fields only: an inherited member such as `constructor` or `__proto__` is not
|
|
41
|
+
// something Vault returned.
|
|
42
|
+
if (data === null || typeof data !== 'object' || !Object.hasOwn(data, key) || data[key] === undefined) {
|
|
37
43
|
throw new Error('Requested key does not exist');
|
|
38
44
|
}
|
|
39
45
|
|
|
40
|
-
return
|
|
46
|
+
return data[key];
|
|
41
47
|
}
|
|
42
48
|
|
|
43
49
|
/**
|
package/src/MountResolver.js
CHANGED
|
@@ -36,6 +36,26 @@ function decodePath(path) {
|
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
/**
|
|
40
|
+
* The error a caller gets when the detection it started or joined failed. It is built for each
|
|
41
|
+
* caller, so its message names that caller's own path rather than the first caller's. The
|
|
42
|
+
* failure stays on `cause`.
|
|
43
|
+
* safeMessage: this message is logged by the operation's catch handler, so it must not embed a
|
|
44
|
+
* raw response body.
|
|
45
|
+
*
|
|
46
|
+
* @param {Error} err - why the detection failed
|
|
47
|
+
* @param {string} interimKey - the mount the detection was keyed on
|
|
48
|
+
* @param {string} path - the caller's path
|
|
49
|
+
* @returns {VaultError}
|
|
50
|
+
*/
|
|
51
|
+
function detectionError(err, interimKey, path) {
|
|
52
|
+
return new VaultError(
|
|
53
|
+
`Failed to detect KV engine version for mount "${interimKey}" (path: ${path}): ${safeMessage(err)}. ` +
|
|
54
|
+
'Set api.engines or disable autoDetect to bypass detection.',
|
|
55
|
+
{ cause: err }
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
39
59
|
/**
|
|
40
60
|
* Resolves the KV engine version for a given secret path.
|
|
41
61
|
*
|
|
@@ -215,6 +235,11 @@ class MountResolver {
|
|
|
215
235
|
// The shared detection resolved to a different mount — start our own.
|
|
216
236
|
// Use the full path as key so it won't collide with the first-segment key.
|
|
217
237
|
return this.__detectWithCache(path, path);
|
|
238
|
+
}, (err) => {
|
|
239
|
+
// A failed detection is shared, as it always was: one request, whatever it
|
|
240
|
+
// answered, so a rate limit or a failed login is not repeated per caller. Only
|
|
241
|
+
// the error is built per caller, so it names that caller's own path.
|
|
242
|
+
throw detectionError(err, interimKey, path);
|
|
218
243
|
});
|
|
219
244
|
}
|
|
220
245
|
|
|
@@ -255,17 +280,14 @@ class MountResolver {
|
|
|
255
280
|
.catch((err) => {
|
|
256
281
|
// Clear in-flight so callers can retry
|
|
257
282
|
this.__inflight.delete(interimKey);
|
|
258
|
-
|
|
259
|
-
// must not embed a raw response body; the original error stays on `cause`.
|
|
260
|
-
throw new VaultError(
|
|
261
|
-
`Failed to detect KV engine version for mount "${interimKey}" (path: ${path}): ${safeMessage(err)}. ` +
|
|
262
|
-
'Set api.engines or disable autoDetect to bypass detection.',
|
|
263
|
-
{ cause: err }
|
|
264
|
-
);
|
|
283
|
+
throw err;
|
|
265
284
|
});
|
|
266
285
|
|
|
286
|
+
// Callers that join get the raw failure and build their own error (see above).
|
|
267
287
|
this.__inflight.set(interimKey, promise);
|
|
268
|
-
return promise
|
|
288
|
+
return promise.catch((err) => {
|
|
289
|
+
throw detectionError(err, interimKey, path);
|
|
290
|
+
});
|
|
269
291
|
}
|
|
270
292
|
|
|
271
293
|
/**
|
package/src/VaultApiClient.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
const { defaultMaxListeners, getMaxListeners, setMaxListeners } = require('events');
|
|
4
4
|
const errors = require('./errors');
|
|
5
5
|
const { trimTrailingSlashes } = require('./slashes');
|
|
6
|
+
const warnOnce = require('./warnOnce');
|
|
6
7
|
|
|
7
8
|
// Above Vault's own default max_request_duration (90 s), so no request a default-configured
|
|
8
9
|
// Vault would still answer is cut short, and below the 300 s headers/body timeouts of the
|
|
@@ -12,6 +13,11 @@ const DEFAULT_TIMEOUT_MS = 120000;
|
|
|
12
13
|
const MAX_TIMEOUT_MS = 2147483647;
|
|
13
14
|
// The listener limit fetch() gives a signal it is passed (see deadline()).
|
|
14
15
|
const FETCH_MAX_LISTENERS = 1500;
|
|
16
|
+
// The statuses fetch() follows, its limit on hops, and the headers it drops when a redirect
|
|
17
|
+
// turns the request into a GET without a body (Fetch Standard, "HTTP-redirect fetch").
|
|
18
|
+
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
19
|
+
const MAX_REDIRECTS = 20;
|
|
20
|
+
const BODY_HEADERS = new Set(['content-encoding', 'content-language', 'content-location', 'content-type']);
|
|
15
21
|
|
|
16
22
|
function validateTimeout(timeout) {
|
|
17
23
|
// null reads as "not set", as it does in a config file.
|
|
@@ -81,6 +87,87 @@ function deadline(ms, signal) {
|
|
|
81
87
|
};
|
|
82
88
|
}
|
|
83
89
|
|
|
90
|
+
function isPrintableAscii(value) {
|
|
91
|
+
for (let i = 0; i < value.length; i++) {
|
|
92
|
+
const code = value.charCodeAt(i);
|
|
93
|
+
if (code < 0x20 || code > 0x7e) {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Follows the redirects of a request sent with `redirect: 'manual'` as fetch()'s 'follow'
|
|
102
|
+
* would, but only within the origin of the URL it was sent to. 'follow' sends every header
|
|
103
|
+
* but Authorization, Cookie and Proxy-Authorization to any origin a Location names, so
|
|
104
|
+
* X-Vault-Token, and on a 307/308 a login body, would reach that origin, and its answer would
|
|
105
|
+
* be read as Vault's.
|
|
106
|
+
*
|
|
107
|
+
* @param {Response} response - the answer to the request
|
|
108
|
+
* @param {string} url - the URL the request was sent to
|
|
109
|
+
* @param {Object} options - the fetch() options it was sent with
|
|
110
|
+
* @param {number} [hops=0] - redirects followed so far
|
|
111
|
+
* @returns {Response|Promise<Response>} the first answer that is not a redirect
|
|
112
|
+
*/
|
|
113
|
+
function followSameOrigin(response, url, options, hops = 0) {
|
|
114
|
+
let location = REDIRECT_STATUSES.has(response.status) ? response.headers.get('location') : null;
|
|
115
|
+
if (location === null) {
|
|
116
|
+
return response;
|
|
117
|
+
}
|
|
118
|
+
// A redirect's own body is never read.
|
|
119
|
+
if (response.body) {
|
|
120
|
+
response.body.cancel().catch(() => {});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Where fetch() gives up on a redirect, it rejects with this; so does this, except for the
|
|
124
|
+
// one thing it adds: refusing another origin.
|
|
125
|
+
const fetchFailed = (cause) => new TypeError('fetch failed', { cause });
|
|
126
|
+
// As fetch() does: a Location sent as raw UTF-8 bytes instead of percent-encoded.
|
|
127
|
+
if (!isPrintableAscii(location)) {
|
|
128
|
+
location = Buffer.from(location, 'latin1').toString('utf8');
|
|
129
|
+
}
|
|
130
|
+
let next;
|
|
131
|
+
try {
|
|
132
|
+
next = new URL(location, url);
|
|
133
|
+
} catch (err) {
|
|
134
|
+
throw fetchFailed(err);
|
|
135
|
+
}
|
|
136
|
+
if (next.protocol !== 'http:' && next.protocol !== 'https:') {
|
|
137
|
+
throw fetchFailed(new TypeError('URL scheme must be a HTTP(S) scheme'));
|
|
138
|
+
}
|
|
139
|
+
if (hops === MAX_REDIRECTS) {
|
|
140
|
+
throw fetchFailed(new TypeError('redirect count exceeded'));
|
|
141
|
+
}
|
|
142
|
+
const from = new URL(url);
|
|
143
|
+
if (next.origin !== from.origin) {
|
|
144
|
+
throw new errors.VaultError(
|
|
145
|
+
`Refusing to follow a redirect from ${from.origin} to ${next.origin}: ` +
|
|
146
|
+
'only redirects within the origin of "api.url" are followed'
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
if (next.username !== '' || next.password !== '') {
|
|
150
|
+
throw fetchFailed(new TypeError('redirect location includes credentials'));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
let nextOptions = options;
|
|
154
|
+
const method = options.method.toUpperCase();
|
|
155
|
+
if ((response.status === 303 && method !== 'GET' && method !== 'HEAD')
|
|
156
|
+
|| ((response.status === 301 || response.status === 302) && method === 'POST')) {
|
|
157
|
+
const headers = {};
|
|
158
|
+
for (const [name, value] of Object.entries(options.headers)) {
|
|
159
|
+
if (!BODY_HEADERS.has(name.toLowerCase())) {
|
|
160
|
+
headers[name] = value;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
nextOptions = Object.assign({}, options, { method: 'GET', headers });
|
|
164
|
+
delete nextOptions.body;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return fetch(next.href, nextOptions)
|
|
168
|
+
.then((nextResponse) => followSameOrigin(nextResponse, next.href, nextOptions, hops + 1));
|
|
169
|
+
}
|
|
170
|
+
|
|
84
171
|
/**
|
|
85
172
|
* Joins URL segments with single slashes while preserving the leading
|
|
86
173
|
* protocol (e.g. "https://"). Replaces the former `url-join` dependency.
|
|
@@ -101,6 +188,66 @@ function joinUrl(...parts) {
|
|
|
101
188
|
.join('/');
|
|
102
189
|
}
|
|
103
190
|
|
|
191
|
+
/** `::ffff:127.x.y.z` as WHATWG URL prints it: `[::ffff:7fXX:XXXX]`. */
|
|
192
|
+
const IPV4_MAPPED_LOOPBACK = /^\[::ffff:7f[0-9a-f]{2}:[0-9a-f]{1,4}\]$/;
|
|
193
|
+
const IPV4_LOOPBACK = /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/;
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* True for a host that never leaves the machine: `localhost`, 127.0.0.0/8, its IPv4-mapped IPv6
|
|
197
|
+
* form `::ffff:127.0.0.0/104`, `::1`, and the unspecified addresses `0.0.0.0` and `::`, which
|
|
198
|
+
* connect to this machine. Subdomains of `localhost` are not included: Node resolves them through
|
|
199
|
+
* the system resolver, which can send them anywhere. `hostname` is WHATWG-normalised, so IPv4
|
|
200
|
+
* shorthands such as `127.1` already read `127.0.0.1` and IPv6 is bracketed and compressed.
|
|
201
|
+
* @param {string} hostname - `URL#hostname`
|
|
202
|
+
* @returns {boolean}
|
|
203
|
+
* @private
|
|
204
|
+
*/
|
|
205
|
+
function isLoopbackHost(hostname) {
|
|
206
|
+
const host = hostname.endsWith('.') ? hostname.slice(0, -1) : hostname;
|
|
207
|
+
return host === 'localhost'
|
|
208
|
+
|| host === '[::1]'
|
|
209
|
+
|| host === '[::]'
|
|
210
|
+
|| host === '0.0.0.0'
|
|
211
|
+
|| IPV4_MAPPED_LOOPBACK.test(host)
|
|
212
|
+
|| IPV4_LOOPBACK.test(host);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* The host:port to warn about when `api.url` is plain http:// to a host off this machine (the
|
|
217
|
+
* token and every secret would cross the network unencrypted), or null. An unparsable URL gives
|
|
218
|
+
* null and is left to fail where it is used, as before.
|
|
219
|
+
* @param {*} url - `api.url`
|
|
220
|
+
* @returns {string|null}
|
|
221
|
+
* @private
|
|
222
|
+
*/
|
|
223
|
+
function plainHttpHost(url) {
|
|
224
|
+
try {
|
|
225
|
+
const parsed = new URL(url);
|
|
226
|
+
return parsed.protocol === 'http:' && !isLoopbackHost(parsed.hostname) ? parsed.host : null;
|
|
227
|
+
} catch {
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Log the plain http:// warning for `host` unless this `logger.warn` has logged it already.
|
|
234
|
+
* Only the host and port are named, never credentials or a path from the URL.
|
|
235
|
+
* @param {string} host
|
|
236
|
+
* @param {Object} logger
|
|
237
|
+
* @returns {boolean} true once the warning is logged or was logged before; false when the
|
|
238
|
+
* logger threw, so the caller can try again on its next request
|
|
239
|
+
* @private
|
|
240
|
+
*/
|
|
241
|
+
function warnOnPlainHttp(host, logger) {
|
|
242
|
+
return warnOnce(
|
|
243
|
+
logger,
|
|
244
|
+
host,
|
|
245
|
+
'"api.url" uses plain http:// for %s, which is not a loopback address; the Vault token and '
|
|
246
|
+
+ 'secrets travel unencrypted. Use https:// unless the network path is protected otherwise',
|
|
247
|
+
host
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
104
251
|
class VaultApiClient {
|
|
105
252
|
|
|
106
253
|
/**
|
|
@@ -112,7 +259,10 @@ class VaultApiClient {
|
|
|
112
259
|
* or for custom TLS trust (self-signed / internal CA). Request-specific fields
|
|
113
260
|
* (`method`, `body`) always take precedence; `headers` are merged with the
|
|
114
261
|
* per-request headers winning. Stored by reference (not deep-cloned) so live
|
|
115
|
-
* objects such as a Dispatcher keep their prototype and remain usable.
|
|
262
|
+
* objects such as a Dispatcher keep their prototype and remain usable. Without a
|
|
263
|
+
* `redirect` there, redirects are followed only within the origin of `config.url`;
|
|
264
|
+
* a `redirect` there other than `null` is passed to `fetch()` as it is (`'follow'`
|
|
265
|
+
* follows them to any origin).
|
|
116
266
|
* @param {number} [config.timeout=120000] - milliseconds a request may take, response
|
|
117
267
|
* headers, body and redirects included, before it rejects with
|
|
118
268
|
* {@link RequestTimeoutError}. `0` turns the limit off; `null` gives the default.
|
|
@@ -142,11 +292,18 @@ class VaultApiClient {
|
|
|
142
292
|
logger.warn('"api.timeout" is %d ms; it is in milliseconds, not seconds', this.__timeout);
|
|
143
293
|
}
|
|
144
294
|
|
|
295
|
+
// Logged on the first request rather than here, so a VaultClient whose construction
|
|
296
|
+
// fails after building this client logs nothing.
|
|
297
|
+
this.__plainHttpHost = plainHttpHost(this.__config.url);
|
|
298
|
+
|
|
145
299
|
this.__namespace = namespace !== undefined ? namespace : this.__config.namespace;
|
|
146
300
|
this._logger = logger;
|
|
147
301
|
}
|
|
148
302
|
|
|
149
303
|
makeRequest(method, path, data, headers) {
|
|
304
|
+
if (this.__plainHttpHost !== null && warnOnPlainHttp(this.__plainHttpHost, this._logger)) {
|
|
305
|
+
this.__plainHttpHost = null;
|
|
306
|
+
}
|
|
150
307
|
data = data === undefined ? null : data;
|
|
151
308
|
headers = headers === undefined ? {} : headers;
|
|
152
309
|
|
|
@@ -160,7 +317,7 @@ class VaultApiClient {
|
|
|
160
317
|
const namespaceHeader = this.__namespace ? { 'X-Vault-Namespace': this.__namespace } : {};
|
|
161
318
|
|
|
162
319
|
const options = Object.assign(
|
|
163
|
-
{
|
|
320
|
+
{},
|
|
164
321
|
requestOptions,
|
|
165
322
|
{
|
|
166
323
|
method: method,
|
|
@@ -172,6 +329,13 @@ class VaultApiClient {
|
|
|
172
329
|
),
|
|
173
330
|
}
|
|
174
331
|
);
|
|
332
|
+
// Read after the merge, which copies only own enumerable keys: a `redirect` it leaves
|
|
333
|
+
// out, or an explicit `redirect: undefined`, must not turn fetch()'s 'follow' back on.
|
|
334
|
+
// null reads as "not set", as it does for api.timeout (fetch() rejected it).
|
|
335
|
+
const sameOrigin = options.redirect === undefined || options.redirect === null;
|
|
336
|
+
if (sameOrigin) {
|
|
337
|
+
options.redirect = 'manual';
|
|
338
|
+
}
|
|
175
339
|
if (data !== null) {
|
|
176
340
|
options.body = JSON.stringify(data);
|
|
177
341
|
// Only set the default content-type if the caller did not already supply one.
|
|
@@ -210,6 +374,10 @@ class VaultApiClient {
|
|
|
210
374
|
}
|
|
211
375
|
}
|
|
212
376
|
|
|
377
|
+
if (sameOrigin) {
|
|
378
|
+
pending = pending.then((response) => followSameOrigin(response, uri, options));
|
|
379
|
+
}
|
|
380
|
+
|
|
213
381
|
const request = pending.then((response) => {
|
|
214
382
|
return response.text().then((text) => {
|
|
215
383
|
let body;
|
package/src/VaultClient.js
CHANGED
|
@@ -16,7 +16,13 @@ const safeMessage = require('./safeMessage');
|
|
|
16
16
|
const { trimTrailingSlashes } = require('./slashes');
|
|
17
17
|
|
|
18
18
|
const noop = () => {};
|
|
19
|
-
|
|
19
|
+
// Named instances. A Map, so a name such as "toString" or "constructor" is not found on
|
|
20
|
+
// Object.prototype; keyed exactly as an object property would be, so boot(1) and get('1') still
|
|
21
|
+
// meet and an object that converts to a symbol is keyed by that symbol.
|
|
22
|
+
const vaultInstances = new Map();
|
|
23
|
+
const instanceKey = (name) => Reflect.ownKeys({ [name]: undefined })[0];
|
|
24
|
+
// clear(name) with a non-string name warns once per process (#239); see VaultClient.clear().
|
|
25
|
+
let warnedClearNonString = false;
|
|
20
26
|
|
|
21
27
|
// Functions, and objects that are not plain data (an undici dispatcher, a logger class
|
|
22
28
|
// instance), compare by identity. Their own enumerable keys say nothing about what they do:
|
|
@@ -187,6 +193,32 @@ function assertSafeKvPath(path, allowTrailingSlash) {
|
|
|
187
193
|
return trimmed;
|
|
188
194
|
}
|
|
189
195
|
|
|
196
|
+
/**
|
|
197
|
+
* clear() with a name that is not a string closes every instance, which is rarely what the
|
|
198
|
+
* caller meant (#239): `clear(42)`, or `clear` passed as a callback that receives an exit code
|
|
199
|
+
* or a resolved value. The behaviour stays as it was; this makes it visible, once per process.
|
|
200
|
+
* It names only the argument's type, never its value, which could be anything (#239).
|
|
201
|
+
* Node delivers warnings on the next tick, so one emitted from an 'exit' listener is never shown;
|
|
202
|
+
* --no-deprecation silences it, as for any DeprecationWarning.
|
|
203
|
+
* @param {*} name
|
|
204
|
+
* @returns {void}
|
|
205
|
+
* @private
|
|
206
|
+
*/
|
|
207
|
+
function warnClearNonString(name) {
|
|
208
|
+
if (warnedClearNonString) {
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
warnedClearNonString = true;
|
|
212
|
+
const type = typeof name;
|
|
213
|
+
const what = name === null ? 'null' : (type === 'object' ? 'an object' : `a ${type}`);
|
|
214
|
+
process.emitWarning(
|
|
215
|
+
`VaultClient.clear() was called with ${what} ` +
|
|
216
|
+
'instead of a string name, which clears every instance not named by a symbol, as it always ' +
|
|
217
|
+
'has. Call clear() with no argument to clear them all, or pass the string name of one instance.',
|
|
218
|
+
{ type: 'DeprecationWarning', code: 'NVC_DEP_CLEAR_NON_STRING' }
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
190
222
|
class VaultClient {
|
|
191
223
|
|
|
192
224
|
/**
|
|
@@ -194,10 +226,12 @@ class VaultClient {
|
|
|
194
226
|
*
|
|
195
227
|
* @param {Object} options
|
|
196
228
|
* @param {Object} options.api
|
|
197
|
-
* @param {String} options.api.url - the url of the vault server
|
|
229
|
+
* @param {String} options.api.url - the url of the vault server. Plain `http://` to a host
|
|
230
|
+
* other than this machine logs a warning; see "Plain `http://` api.url" in the README
|
|
198
231
|
* @param {String} [options.api.apiVersion='v1']
|
|
199
232
|
* @param {Object} [options.api.requestOptions] - extra options merged into every HTTP
|
|
200
233
|
* request (e.g. an undici `dispatcher` for a proxy/SOCKS agent or custom TLS/CA trust).
|
|
234
|
+
* Redirects are followed only within the origin of `api.url` unless it sets `redirect`.
|
|
201
235
|
* See {@link VaultApiClient#constructor}.
|
|
202
236
|
* @param {number} [options.api.timeout=120000] - milliseconds a request may take, response
|
|
203
237
|
* headers, body and redirects included, before it rejects with RequestTimeoutError.
|
|
@@ -304,9 +338,10 @@ class VaultClient {
|
|
|
304
338
|
throw new errors.InvalidArgumentsError('Options should be provided');
|
|
305
339
|
}
|
|
306
340
|
|
|
307
|
-
let instance = vaultInstances
|
|
341
|
+
let instance = vaultInstances.get(instanceKey(name));
|
|
308
342
|
if (instance === undefined) {
|
|
309
|
-
|
|
343
|
+
instance = new VaultClient(options);
|
|
344
|
+
vaultInstances.set(instanceKey(name), instance);
|
|
310
345
|
instance.__bootDigests = optionsDigests(options);
|
|
311
346
|
instance.__bootWarned = new Set();
|
|
312
347
|
return instance;
|
|
@@ -338,7 +373,7 @@ class VaultClient {
|
|
|
338
373
|
* @return {VaultClient}
|
|
339
374
|
*/
|
|
340
375
|
static get(name) {
|
|
341
|
-
let instance = vaultInstances
|
|
376
|
+
let instance = vaultInstances.get(instanceKey(name));
|
|
342
377
|
|
|
343
378
|
if (instance === undefined) {
|
|
344
379
|
throw new errors.InvalidArgumentsError('Invalid instance name');
|
|
@@ -352,22 +387,33 @@ class VaultClient {
|
|
|
352
387
|
*
|
|
353
388
|
* If no name passed all named instances will be cleared.
|
|
354
389
|
*
|
|
390
|
+
* Only a string names one instance. Any other argument (a number, `null`, an object) clears
|
|
391
|
+
* every instance, as it always has, and emits a one-time `DeprecationWarning`
|
|
392
|
+
* (`NVC_DEP_CLEAR_NON_STRING`): call `clear()` with no argument to clear them all.
|
|
393
|
+
*
|
|
355
394
|
* @param {String} [name] - Vault instance name, all instances will be cleared if no name were passed
|
|
356
395
|
*/
|
|
357
396
|
static clear(name) {
|
|
358
397
|
if (typeof name === 'string') {
|
|
359
|
-
const instance = vaultInstances
|
|
398
|
+
const instance = vaultInstances.get(name);
|
|
360
399
|
if (instance !== undefined) {
|
|
361
400
|
instance.close();
|
|
362
|
-
delete
|
|
401
|
+
vaultInstances.delete(name);
|
|
363
402
|
}
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
if (name !== undefined) {
|
|
406
|
+
warnClearNonString(name);
|
|
407
|
+
}
|
|
408
|
+
for (const [key, instance] of vaultInstances) {
|
|
409
|
+
// Kept for compatibility: the object this replaced was walked with for...in,
|
|
410
|
+
// which skips symbol keys, so a symbol-named instance has always stayed
|
|
411
|
+
// registered, with its renewal timer, through clear(), and callers may rely on it.
|
|
412
|
+
if (typeof key === 'symbol') {
|
|
413
|
+
continue;
|
|
370
414
|
}
|
|
415
|
+
instance.close();
|
|
416
|
+
vaultInstances.delete(key);
|
|
371
417
|
}
|
|
372
418
|
}
|
|
373
419
|
|
|
@@ -378,11 +424,17 @@ class VaultClient {
|
|
|
378
424
|
* alive for renewable tokens. Call this once you are done with the client so a
|
|
379
425
|
* short-lived script can exit on its own. Safe to call when no timer is armed and
|
|
380
426
|
* safe to call multiple times. After calling `close()` the client may still be used;
|
|
381
|
-
* the next operation that fetches a renewable token will arm a new refresh timer.
|
|
427
|
+
* the next operation that fetches a renewable token will arm a new refresh timer. A
|
|
428
|
+
* login already in flight when `close()` runs still resolves, but arms no timer, and
|
|
429
|
+
* neither does a call that joins that login after `close()`.
|
|
382
430
|
*
|
|
383
431
|
* @returns {void}
|
|
384
432
|
*/
|
|
385
433
|
close() {
|
|
434
|
+
if (this.__auth && typeof this.__auth.__markClosed === 'function') {
|
|
435
|
+
// Directly as well: a subclass may override cancelTokenRefresh() without super.
|
|
436
|
+
this.__auth.__markClosed();
|
|
437
|
+
}
|
|
386
438
|
if (this.__auth && typeof this.__auth.cancelTokenRefresh === 'function') {
|
|
387
439
|
this.__auth.cancelTokenRefresh();
|
|
388
440
|
}
|
|
@@ -468,11 +520,39 @@ class VaultClient {
|
|
|
468
520
|
* @private
|
|
469
521
|
*/
|
|
470
522
|
__detectMount(path) {
|
|
471
|
-
return this.
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
523
|
+
return this.__withAuthToken((token) => {
|
|
524
|
+
const detectPath = `sys/internal/ui/mounts/${path}`;
|
|
525
|
+
return this.__api.makeRequest('GET', detectPath, null, this.getHeaders(token));
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Calls `send` with the current auth token. When Vault answers 403 and the token turns out
|
|
531
|
+
* to be one Vault no longer accepts (revoked server-side), logs in again and calls `send`
|
|
532
|
+
* once more; see {@link VaultBaseAuth#checkRejectedToken}. A policy denial and a 403 under
|
|
533
|
+
* `token` auth surface that 403; otherwise the retry's own result or error is returned.
|
|
534
|
+
*
|
|
535
|
+
* @param {function(AuthToken): Promise<*>} send
|
|
536
|
+
* @returns {Promise<*>}
|
|
537
|
+
* @private
|
|
538
|
+
*/
|
|
539
|
+
__withAuthToken(send) {
|
|
540
|
+
return this.__auth.getAuthToken().then((token) => send(token).catch((err) => {
|
|
541
|
+
if (!(err instanceof errors.VaultHttpError) || err.statusCode !== 403
|
|
542
|
+
|| typeof this.__auth.checkRejectedToken !== 'function') {
|
|
543
|
+
throw err;
|
|
544
|
+
}
|
|
545
|
+
return this.__auth.checkRejectedToken(token).then((retry) => {
|
|
546
|
+
if (!retry) {
|
|
547
|
+
throw err;
|
|
548
|
+
}
|
|
549
|
+
// What the retry got, success or failure, is the request's outcome now: a login
|
|
550
|
+
// that fails, a 404 or a timeout says more than the 403 of a token already gone.
|
|
551
|
+
return this.__auth.getAuthToken().then(send);
|
|
552
|
+
}, () => {
|
|
553
|
+
throw err;
|
|
475
554
|
});
|
|
555
|
+
}));
|
|
476
556
|
}
|
|
477
557
|
|
|
478
558
|
// -------------------------------------------------------------------------
|
|
@@ -517,50 +597,49 @@ class VaultClient {
|
|
|
517
597
|
}
|
|
518
598
|
}
|
|
519
599
|
|
|
520
|
-
return this.
|
|
521
|
-
.
|
|
522
|
-
const headers = Object.assign({}, this.getHeaders(token), extraHeaders || {});
|
|
600
|
+
return this.__withAuthToken((token) => {
|
|
601
|
+
const headers = Object.assign({}, this.getHeaders(token), extraHeaders || {});
|
|
523
602
|
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
603
|
+
if (raw) {
|
|
604
|
+
return this.__api.makeRequest(method, path, data, headers)
|
|
605
|
+
.then((body) => ({ body, version: undefined, mount: undefined, apiPath: path }));
|
|
606
|
+
}
|
|
528
607
|
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
608
|
+
return this.__resolver.resolve(path)
|
|
609
|
+
.then(({ mount, version }) => {
|
|
610
|
+
if (v2Only && version !== 2) {
|
|
611
|
+
throw new errors.UnsupportedOperationError(
|
|
612
|
+
`Operation "${op}" is only supported on KV v2 mounts. ` +
|
|
613
|
+
`Mount "${mount}" is not a KV v2 engine.`
|
|
614
|
+
);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
let apiPath;
|
|
618
|
+
if (version === 2) {
|
|
619
|
+
// Split the path into mount + logical sub-path and rewrite for KV v2
|
|
620
|
+
const logicalPath = this.__logicalPath(path, mount);
|
|
621
|
+
apiPath = rewritePath(version, op, mount, logicalPath);
|
|
622
|
+
} else {
|
|
623
|
+
// v1 / disabled: preserve the caller's literal path byte-for-byte,
|
|
624
|
+
// including any trailing slash, to maintain the old wire behavior.
|
|
625
|
+
apiPath = path;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// Data envelope: wrapData forces { data } on every engine version
|
|
629
|
+
// (merge-patch update() semantics — v1 mounts reject PATCH anyway);
|
|
630
|
+
// otherwise only v2 write/update wrap non-null data.
|
|
631
|
+
let requestData = data;
|
|
632
|
+
if (wrapData) {
|
|
633
|
+
requestData = { data };
|
|
634
|
+
} else if (version === 2 && (op === 'write' || op === 'update')
|
|
635
|
+
&& data !== null && data !== undefined) {
|
|
636
|
+
requestData = { data };
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
return this.__api.makeRequest(method, apiPath, requestData, headers)
|
|
640
|
+
.then((body) => ({ body, version, mount, apiPath }));
|
|
641
|
+
});
|
|
642
|
+
});
|
|
564
643
|
}
|
|
565
644
|
|
|
566
645
|
/**
|
package/src/auth/AuthToken.js
CHANGED
|
@@ -56,19 +56,25 @@ class AuthToken {
|
|
|
56
56
|
const hasPreciseExpiry = unadjustedExpiresAtMilliseconds !== null;
|
|
57
57
|
const expiresAtMilliseconds = AuthToken.__resolveExpiresAtMilliseconds(data, unadjustedExpiresAtMilliseconds);
|
|
58
58
|
|
|
59
|
-
|
|
59
|
+
const renewable = data.renewable !== undefined ? data.renewable : false;
|
|
60
|
+
const authToken = new AuthToken(
|
|
60
61
|
data.id,
|
|
61
62
|
data.accessor,
|
|
62
63
|
data.creation_time,
|
|
63
64
|
null,
|
|
64
65
|
data.explicit_max_ttl,
|
|
65
66
|
data.num_uses,
|
|
66
|
-
|
|
67
|
+
renewable,
|
|
67
68
|
expiresAtMilliseconds,
|
|
68
69
|
data.ttl,
|
|
69
70
|
hasPreciseExpiry,
|
|
70
71
|
unadjustedExpiresAtMilliseconds
|
|
71
72
|
);
|
|
73
|
+
// Without an expiry the token is not renewable. Remember that Vault said it was, so the
|
|
74
|
+
// renewal timer can say why it stops (VaultBaseAuth); a ttl of 0 means it never expires.
|
|
75
|
+
authToken.__renewableWithoutExpiry = Boolean(renewable) && data.ttl !== 0
|
|
76
|
+
&& expiresAtMilliseconds === null;
|
|
77
|
+
return authToken;
|
|
72
78
|
}
|
|
73
79
|
|
|
74
80
|
/**
|
|
@@ -85,7 +91,8 @@ class AuthToken {
|
|
|
85
91
|
* @private
|
|
86
92
|
* @param {Object} data - the `data` object of a token lookup response
|
|
87
93
|
* @param {number|null} expireTime - parsed Vault expiry in milliseconds
|
|
88
|
-
* @returns {int|null} client expiry in milliseconds, or null when
|
|
94
|
+
* @returns {int|null} client expiry in milliseconds, or null when there is none to use: ttl 0
|
|
95
|
+
* without expire_time, or neither expire_time nor a numeric ttl and creation time
|
|
89
96
|
*/
|
|
90
97
|
static __resolveExpiresAtMilliseconds(data, expireTime) {
|
|
91
98
|
const networkLatency = 60;
|
|
@@ -106,7 +113,9 @@ class AuthToken {
|
|
|
106
113
|
// Fallback for responses without a usable `expire_time`.
|
|
107
114
|
const creationTime = parseInt(data.last_renewal_time !== undefined ? data.last_renewal_time : data.creation_time, 10);
|
|
108
115
|
const ttl = data.ttl > networkLatency ? data.ttl - networkLatency : data.ttl;
|
|
109
|
-
|
|
116
|
+
const expiresAt = (creationTime + parseInt(ttl, 10)) * 1000;
|
|
117
|
+
// A missing ttl or creation time made this NaN, a deadline that never passes.
|
|
118
|
+
return Number.isFinite(expiresAt) ? expiresAt : null;
|
|
110
119
|
}
|
|
111
120
|
|
|
112
121
|
/**
|
|
@@ -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,19 @@ 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;
|
|
141
165
|
}
|
|
142
166
|
|
|
143
167
|
/**
|
|
@@ -252,12 +276,20 @@ class VaultBaseAuth {
|
|
|
252
276
|
|
|
253
277
|
this._log.info('getting auth token (mount=%s)', this._mount);
|
|
254
278
|
|
|
255
|
-
|
|
279
|
+
// Read before _authenticate() runs: a close() from then on, even from inside it, means
|
|
280
|
+
// this login arms no refresh timer.
|
|
281
|
+
const cancelCountAtStart = this.__cancelCount;
|
|
282
|
+
// A backend that throws instead of rejecting, or returns something that is not a
|
|
283
|
+
// promise, must not throw out of every operation. The executor still calls
|
|
284
|
+
// _authenticate() synchronously, as before.
|
|
285
|
+
const authentication = new Promise((resolve) => resolve(this._authenticate()));
|
|
286
|
+
|
|
287
|
+
const pendingLogin = authentication.then(authToken => {
|
|
256
288
|
this.__pendingLogin = null;
|
|
257
289
|
this.__authToken = authToken;
|
|
258
290
|
this.__renewalEpoch++;
|
|
259
291
|
|
|
260
|
-
if (authToken.isRenewable()) {
|
|
292
|
+
if (authToken.isRenewable() || renewableWithoutExpiry(authToken)) {
|
|
261
293
|
// __setupTokenRefreshTimer logs once it has actually armed a timer, and warns
|
|
262
294
|
// when it cannot; this covers the remaining case, the one diagnostic an operator
|
|
263
295
|
// reads when a `renewal: false` process still hangs.
|
|
@@ -267,7 +299,9 @@ class VaultBaseAuth {
|
|
|
267
299
|
authToken.getAccessor()
|
|
268
300
|
);
|
|
269
301
|
}
|
|
270
|
-
|
|
302
|
+
// close() ran while this login was in flight: every check and warning runs as
|
|
303
|
+
// before, but no timer is armed to hold the event loop.
|
|
304
|
+
this.__setupTokenRefreshTimer(authToken, this.__cancelCount !== cancelCountAtStart);
|
|
271
305
|
}
|
|
272
306
|
|
|
273
307
|
return authToken;
|
|
@@ -293,6 +327,7 @@ class VaultBaseAuth {
|
|
|
293
327
|
* @returns {void}
|
|
294
328
|
*/
|
|
295
329
|
cancelTokenRefresh() {
|
|
330
|
+
this.__markClosed();
|
|
296
331
|
this.__renewalEpoch++;
|
|
297
332
|
if (this.__refreshTimeout !== null) {
|
|
298
333
|
lt.clearTimeout(this.__refreshTimeout);
|
|
@@ -300,6 +335,87 @@ class VaultBaseAuth {
|
|
|
300
335
|
}
|
|
301
336
|
}
|
|
302
337
|
|
|
338
|
+
/**
|
|
339
|
+
* Record a close(), so a login in flight arms no refresh timer. Called by
|
|
340
|
+
* {@link VaultBaseAuth#cancelTokenRefresh} and directly by `VaultClient#close()`, so a
|
|
341
|
+
* subclass that overrides cancelTokenRefresh() without calling super keeps the behaviour.
|
|
342
|
+
* @returns {void}
|
|
343
|
+
* @private
|
|
344
|
+
*/
|
|
345
|
+
__markClosed() {
|
|
346
|
+
this.__cancelCount++;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Called by VaultClient after Vault answered 403 to a request made with `authToken`.
|
|
351
|
+
* Vault answers 403 both to a token it no longer accepts (revoked, or expired before the
|
|
352
|
+
* local clock says so) and to a policy that denies the request. `lookup-self`, which every
|
|
353
|
+
* login here already makes, tells the two apart, so a policy denial costs no login. A dead
|
|
354
|
+
* token is dropped, and its renewal timer cancelled, so the next getAuthToken() logs in.
|
|
355
|
+
*
|
|
356
|
+
* @param {AuthToken} authToken - the token the rejected request carried
|
|
357
|
+
* @returns {Promise<boolean>} true when the request may succeed if sent once more with
|
|
358
|
+
* the token getAuthToken() now gives: the token was dead and has been dropped, or it
|
|
359
|
+
* had been replaced already
|
|
360
|
+
*/
|
|
361
|
+
checkRejectedToken(authToken) {
|
|
362
|
+
if (!this._reauthenticationAllowed()) {
|
|
363
|
+
return Promise.resolve(false);
|
|
364
|
+
}
|
|
365
|
+
const id = authToken.getId();
|
|
366
|
+
if (this.__authToken === null || this.__authToken.getId() !== id) {
|
|
367
|
+
return Promise.resolve(true);
|
|
368
|
+
}
|
|
369
|
+
if (this.__rejectionCheck !== null && this.__rejectionCheck.id === id) {
|
|
370
|
+
return this.__rejectionCheck.promise;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const check = {
|
|
374
|
+
id,
|
|
375
|
+
promise: this.__apiClient.makeRequest('GET', '/auth/token/lookup-self', null, {'X-Vault-Token': id})
|
|
376
|
+
.then(() => false, err => {
|
|
377
|
+
if (err instanceof errors.VaultHttpError && err.statusCode === 403) {
|
|
378
|
+
return true;
|
|
379
|
+
}
|
|
380
|
+
// Neither answer: the 403 is returned as it is, so say why no login follows.
|
|
381
|
+
try {
|
|
382
|
+
this._log.debug(
|
|
383
|
+
'token rejection check inconclusive (accessor=%s): %s; returning the 403',
|
|
384
|
+
authToken.getAccessor(), safeMessage(err)
|
|
385
|
+
);
|
|
386
|
+
} catch {
|
|
387
|
+
// a throwing logger changes nothing here
|
|
388
|
+
}
|
|
389
|
+
return false;
|
|
390
|
+
})
|
|
391
|
+
.then(dead => {
|
|
392
|
+
if (this.__rejectionCheck === check) {
|
|
393
|
+
this.__rejectionCheck = null;
|
|
394
|
+
}
|
|
395
|
+
if (dead && this.__authToken !== null && this.__authToken.getId() === id) {
|
|
396
|
+
this.__authToken = null;
|
|
397
|
+
// As before, through the public method, so an override still hears of it.
|
|
398
|
+
// Dropping a dead token is not a close(): the next login's token must be
|
|
399
|
+
// renewed, so the close count is put back.
|
|
400
|
+
const cancelCount = this.__cancelCount;
|
|
401
|
+
this.cancelTokenRefresh();
|
|
402
|
+
this.__cancelCount = cancelCount;
|
|
403
|
+
try {
|
|
404
|
+
this._log.warn(
|
|
405
|
+
'Vault no longer accepts the token (accessor=%s); logging in again',
|
|
406
|
+
authToken.getAccessor()
|
|
407
|
+
);
|
|
408
|
+
} catch {
|
|
409
|
+
// a throwing logger must not keep the dead token in use
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
return dead;
|
|
413
|
+
}),
|
|
414
|
+
};
|
|
415
|
+
this.__rejectionCheck = check;
|
|
416
|
+
return check.promise;
|
|
417
|
+
}
|
|
418
|
+
|
|
303
419
|
/**
|
|
304
420
|
* @protected
|
|
305
421
|
* @returns {Promise<AuthToken>}
|
|
@@ -321,9 +437,11 @@ class VaultBaseAuth {
|
|
|
321
437
|
|
|
322
438
|
/**
|
|
323
439
|
* @param {AuthToken} authToken
|
|
440
|
+
* @param {boolean} [closedDuringLogin=false] - close() ran while the login that brought
|
|
441
|
+
* `authToken` was in flight: check and warn as usual, but arm no timer
|
|
324
442
|
* @private
|
|
325
443
|
*/
|
|
326
|
-
__setupTokenRefreshTimer(authToken) {
|
|
444
|
+
__setupTokenRefreshTimer(authToken, closedDuringLogin = false) {
|
|
327
445
|
if (this.__refreshTimeout !== null) {
|
|
328
446
|
lt.clearTimeout(this.__refreshTimeout);
|
|
329
447
|
this.__refreshTimeout = null;
|
|
@@ -333,11 +451,13 @@ class VaultBaseAuth {
|
|
|
333
451
|
return;
|
|
334
452
|
}
|
|
335
453
|
if (!authToken.isRenewable() || authToken.isExpired()) {
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
454
|
+
let reason = 'token is not renewable';
|
|
455
|
+
if (authToken.isExpired()) {
|
|
456
|
+
reason = 'token already expired';
|
|
457
|
+
} else if (renewableWithoutExpiry(authToken)) {
|
|
458
|
+
reason = 'token has no usable expiry';
|
|
459
|
+
}
|
|
460
|
+
warnRenewalStopped(this, authToken, reason);
|
|
341
461
|
return;
|
|
342
462
|
}
|
|
343
463
|
|
|
@@ -362,6 +482,19 @@ class VaultBaseAuth {
|
|
|
362
482
|
return;
|
|
363
483
|
}
|
|
364
484
|
|
|
485
|
+
if (closedDuringLogin) {
|
|
486
|
+
// The token is cached and returned as after any close(); nothing holds the loop.
|
|
487
|
+
try {
|
|
488
|
+
this._log.debug(
|
|
489
|
+
'client closed during login; not arming a refresh timer (accessor=%s)',
|
|
490
|
+
authToken.getAccessor()
|
|
491
|
+
);
|
|
492
|
+
} catch {
|
|
493
|
+
// a throwing logger must not fail a login that succeeded
|
|
494
|
+
}
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
|
|
365
498
|
// A renewal that is still in flight when the token is replaced or cancelled must not
|
|
366
499
|
// act on what it finds when it lands: it would overwrite the newer token, or clear
|
|
367
500
|
// 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/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;
|