node-vault-client 2.1.2 → 2.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 +389 -16
- package/index.d.ts +131 -0
- package/package.json +12 -6
- package/src/VaultClient.js +75 -2
- package/src/auth/VaultBaseAuth.js +134 -10
- package/src/auth/VaultJwtAuth.js +154 -0
- package/src/checkNodeVersion.js +27 -0
- package/src/errors.d.ts +18 -0
package/README.md
CHANGED
|
@@ -18,6 +18,45 @@ npm install --save node-vault-client
|
|
|
18
18
|
|
|
19
19
|
Node.js >= 18 — the client uses the native `fetch` API.
|
|
20
20
|
|
|
21
|
+
> **Node.js 18 sunset notice:** this is the last release of node-vault-client to support Node 18.
|
|
22
|
+
> Node 18 reached end-of-life on 2025-04-30 and no longer receives security patches from the
|
|
23
|
+
> Node.js project. Running this version under Node 18 emits a one-time process warning
|
|
24
|
+
> (`NodeVaultClientNode18Deprecation`); the next major release raises the minimum to
|
|
25
|
+
> Node.js >= 20.19.0. See [CHANGELOG.md](CHANGELOG.md) for details.
|
|
26
|
+
|
|
27
|
+
### TypeScript
|
|
28
|
+
|
|
29
|
+
Type declarations ship with the package, so no separate install is needed:
|
|
30
|
+
|
|
31
|
+
```typescript
|
|
32
|
+
import VaultClient = require('node-vault-client');
|
|
33
|
+
|
|
34
|
+
const client = new VaultClient({
|
|
35
|
+
api: { url: 'http://127.0.0.1:8200' },
|
|
36
|
+
auth: { type: 'appRole', config: { role_id: 'roleId', secret_id: 'secretId' } },
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const lease = await client.read('secret/app');
|
|
40
|
+
const password = lease.getValue<string>('password');
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`auth` is a discriminated union on `type`, so each backend only accepts its own
|
|
44
|
+
configuration, and the three mutually-exclusive JWT sources (`jwt`, `jwtPath`,
|
|
45
|
+
`jwtProvider`) are enforced at compile time — as is the mutually-exclusive pair
|
|
46
|
+
`distributedClaimAccessToken` / `distributedClaimAccessTokenProvider`.
|
|
47
|
+
|
|
48
|
+
Types that appear in signatures are exported in type space under the `VaultClient`
|
|
49
|
+
namespace — `VaultClient.Lease`, `VaultClient.VaultOptions`, `VaultClient.AuthToken`
|
|
50
|
+
and the per-backend config interfaces. They are types only; the classes behind them
|
|
51
|
+
are not exported at runtime.
|
|
52
|
+
|
|
53
|
+
If you previously installed the third-party `@types/node-vault-client`, remove it —
|
|
54
|
+
it stops at the 1.x API and its declarations take precedence in some setups:
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
npm uninstall @types/node-vault-client
|
|
58
|
+
```
|
|
59
|
+
|
|
21
60
|
## Example
|
|
22
61
|
|
|
23
62
|
```javascript
|
|
@@ -26,7 +65,7 @@ const VaultClient = require('node-vault-client');
|
|
|
26
65
|
const vaultClient = VaultClient.boot('main', {
|
|
27
66
|
api: { url: 'https://vault.example.com:8200/' },
|
|
28
67
|
auth: {
|
|
29
|
-
type: 'appRole', // one of: 'appRole' | 'token' | 'iam' | 'kubernetes'
|
|
68
|
+
type: 'appRole', // one of: 'appRole' | 'token' | 'iam' | 'kubernetes' | 'jwt'
|
|
30
69
|
config: { role_id: '637c065f-c644-5e12-d3d1-e9fa4363af61' }
|
|
31
70
|
},
|
|
32
71
|
});
|
|
@@ -42,6 +81,7 @@ vaultClient.read('secret/tst').then(lease => {
|
|
|
42
81
|
* [AppRole](https://developer.hashicorp.com/vault/docs/auth/approle)
|
|
43
82
|
* [Token](https://developer.hashicorp.com/vault/docs/auth/token)
|
|
44
83
|
* [Kubernetes](https://developer.hashicorp.com/vault/docs/auth/kubernetes)
|
|
84
|
+
* [JWT](https://developer.hashicorp.com/vault/docs/auth/jwt)
|
|
45
85
|
|
|
46
86
|
### AWS IAM auth
|
|
47
87
|
|
|
@@ -125,6 +165,261 @@ const vaultClient = VaultClient.boot('main', {
|
|
|
125
165
|
});
|
|
126
166
|
```
|
|
127
167
|
|
|
168
|
+
### JWT auth
|
|
169
|
+
|
|
170
|
+
```javascript
|
|
171
|
+
const vaultClient = VaultClient.boot('main', {
|
|
172
|
+
api: { url: 'https://vault.example.com:8200/' },
|
|
173
|
+
auth: {
|
|
174
|
+
type: 'jwt',
|
|
175
|
+
mount: 'jwt', // Optional. Vault JWT auth mount point ("jwt" by default)
|
|
176
|
+
config: {
|
|
177
|
+
role: 'my-app', // Optional. Role configured in Vault's JWT auth backend; omitted uses the mount's `default_role`
|
|
178
|
+
jwt: process.env.CI_JOB_JWT, // Exactly one of `jwt` / `jwtPath` / `jwtProvider` is required (see below)
|
|
179
|
+
distributedClaimAccessToken: process.env.GRAPH_TOKEN, // Optional. Azure/Entra ID group lookups only (see below); or `distributedClaimAccessTokenProvider`
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Exactly one of three mutually exclusive `config` keys supplies the JWT:
|
|
186
|
+
|
|
187
|
+
* **`jwt`** — a literal token string. Use it for CI jobs and other processes that are guaranteed
|
|
188
|
+
to finish before the token expires. Caveat: the value is fixed at construction, so if the
|
|
189
|
+
client re-authenticates (its Vault-issued token TTL runs out while the process is still up) it
|
|
190
|
+
re-sends that exact same JWT — which works only until the IdP-issued token itself expires,
|
|
191
|
+
after which Vault rejects every further login attempt. Do not use `jwt` in a long-running
|
|
192
|
+
process.
|
|
193
|
+
* **`jwtPath`** — path to a file containing the JWT, re-read on every login (mirrors
|
|
194
|
+
[Kubernetes auth](#kubernetes-auth)'s `tokenPath`). Use it for rotated *projected* tokens, such
|
|
195
|
+
as a Kubernetes projected service-account token the kubelet refreshes on disk, so each login
|
|
196
|
+
picks up whatever is currently on disk instead of a token captured once at startup.
|
|
197
|
+
* **`jwtProvider`** — an (optionally async) function, called fresh at login time (never at
|
|
198
|
+
construction, never cached), returning `string | Promise<string>`. Use it when the JWT has to
|
|
199
|
+
be minted per login — GitHub Actions' `core.getIDToken()`, a cloud metadata endpoint, a
|
|
200
|
+
SPIFFE/SPIRE workload API.
|
|
201
|
+
|
|
202
|
+
##### The Vault role needs `bound_audiences` when your JWT carries an `aud` claim
|
|
203
|
+
|
|
204
|
+
This is server-side configuration rather than a client option, but it is the most common reason a
|
|
205
|
+
first login fails. Vault requires a `jwt` role to bind the audience your token carries, and it does
|
|
206
|
+
**not** catch the omission when the role is created — as long as the role has some other bound
|
|
207
|
+
constraint (`bound_subject`, `bound_claims`, ...) it is accepted, and the problem only surfaces at
|
|
208
|
+
login:
|
|
209
|
+
|
|
210
|
+
```
|
|
211
|
+
VaultHttpError: 400 - {"errors":["audience claim found in JWT but no audiences bound to the role"]}
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
A wrong (rather than missing) audience fails with `error validating token: invalid audience (aud)
|
|
215
|
+
claim: audience claim does not match any expected audience`. So bind the audience the token
|
|
216
|
+
actually has:
|
|
217
|
+
|
|
218
|
+
```shell
|
|
219
|
+
vault write auth/jwt/role/my-app \
|
|
220
|
+
role_type=jwt user_claim=sub bound_audiences=my-audience token_policies=my-policy
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
A role with *no* bound constraint at all is rejected when you create it (`must have at least one
|
|
224
|
+
bound constraint when creating/updating a role`), so that case is self-correcting. An `aud` array
|
|
225
|
+
is fine — Vault matches `bound_audiences` against any entry.
|
|
226
|
+
|
|
227
|
+
Vault also does not require an `exp` claim: a token minted without one is accepted and never
|
|
228
|
+
expires. If you write your own `jwtProvider`, give the tokens it mints a short `exp`.
|
|
229
|
+
|
|
230
|
+
##### Azure / Entra ID group lookups need `distributedClaimAccessToken`
|
|
231
|
+
|
|
232
|
+
Skip this unless you log in with Microsoft Entra ID (Azure AD) tokens *and* the Vault role sets
|
|
233
|
+
`groups_claim`. Vault's `distributed_claim_access_token` parameter
|
|
234
|
+
["only applies to the Azure (Entra ID) provider"](https://developer.hashicorp.com/vault/api-docs/auth/jwt#distributed_claim_access_token);
|
|
235
|
+
on every other IdP these two keys do nothing at all.
|
|
236
|
+
|
|
237
|
+
Azure does not always put group membership in the token. Past 200 groups it sends OIDC
|
|
238
|
+
[distributed claims](https://openid.net/specs/openid-connect-core-1_0.html#AggregatedDistributedClaims)
|
|
239
|
+
instead — `_claim_names`/`_claim_sources` pointing at the Microsoft Graph API rather than the group
|
|
240
|
+
names themselves — and a mount configured with `fetch_groups` skips the claim entirely and always
|
|
241
|
+
asks Graph. Either way Vault has to call Graph itself, and the JWT you logged in with is not a
|
|
242
|
+
credential for that call: it needs a separate Graph access token, sent on the login request as the
|
|
243
|
+
optional `distributed_claim_access_token`. Leave it out on a mount/role set up this way and the
|
|
244
|
+
login fails at the group-fetch step — *after* the JWT has already validated, so the error names the
|
|
245
|
+
group lookup rather than anything about your token.
|
|
246
|
+
|
|
247
|
+
Two optional, mutually exclusive `config` keys supply it. Passing both raises
|
|
248
|
+
`InvalidArgumentsError` at construction; passing neither leaves the login request byte-for-byte
|
|
249
|
+
what it was before these keys existed.
|
|
250
|
+
|
|
251
|
+
* **`distributedClaimAccessToken`** — a literal Graph access token. It carries exactly the caveat
|
|
252
|
+
the literal `jwt` above does, for the same reason: the value is fixed at construction and every
|
|
253
|
+
re-login re-sends it, so it works until that token expires. Entra access tokens last about an
|
|
254
|
+
hour, which is shorter than most processes — fine for a one-shot script, wrong for a service.
|
|
255
|
+
* **`distributedClaimAccessTokenProvider`** — an (optionally async) function, called fresh at login
|
|
256
|
+
time (never at construction, never cached), returning `string | Promise<string>`. Acquire the
|
|
257
|
+
Graph token inside it — an MSAL client-credentials call, the Azure IMDS managed-identity endpoint
|
|
258
|
+
— and every login gets a current one. Resolving to a non-string or an empty string raises
|
|
259
|
+
`InvalidArgumentsError` without sending a login request.
|
|
260
|
+
|
|
261
|
+
```javascript
|
|
262
|
+
const vaultClient = VaultClient.boot('main', {
|
|
263
|
+
api: { url: 'https://vault.example.com:8200/' },
|
|
264
|
+
auth: {
|
|
265
|
+
type: 'jwt',
|
|
266
|
+
config: {
|
|
267
|
+
role: 'my-app',
|
|
268
|
+
jwtProvider: () => getEntraIdToken(),
|
|
269
|
+
distributedClaimAccessTokenProvider: () => getGraphAccessToken(),
|
|
270
|
+
},
|
|
271
|
+
},
|
|
272
|
+
});
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
On the Vault side, `fetch_groups` lives in `provider_config` on the **mount's config, not on the
|
|
276
|
+
role** — which is where people tend to look for it:
|
|
277
|
+
|
|
278
|
+
```shell
|
|
279
|
+
vault write auth/jwt/config \
|
|
280
|
+
oidc_discovery_url=https://login.microsoftonline.com/<tenant-id>/v2.0 \
|
|
281
|
+
provider_config='{"provider":"azure","fetch_groups":true}'
|
|
282
|
+
|
|
283
|
+
vault write auth/jwt/role/my-app \
|
|
284
|
+
role_type=jwt user_claim=sub bound_audiences=<application-client-id> \
|
|
285
|
+
groups_claim=groups token_policies=my-policy
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
`fetch_groups` is optional — the distributed-claim path is taken without it whenever Azure omits
|
|
289
|
+
the groups claim. `groups_claim` on the role is not: with it unset Vault never resolves groups at
|
|
290
|
+
all, so a Graph token you pass is accepted and never used.
|
|
291
|
+
|
|
292
|
+
#### Authenticating from GitHub Actions
|
|
293
|
+
|
|
294
|
+
```yaml
|
|
295
|
+
permissions:
|
|
296
|
+
id-token: write
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
```javascript
|
|
300
|
+
const core = require('@actions/core');
|
|
301
|
+
const VaultClient = require('node-vault-client');
|
|
302
|
+
|
|
303
|
+
const vaultClient = VaultClient.boot('ci', {
|
|
304
|
+
api: { url: process.env.VAULT_ADDR },
|
|
305
|
+
auth: {
|
|
306
|
+
type: 'jwt',
|
|
307
|
+
mount: 'gha', // matches wherever the JWT method was mounted, e.g. `vault auth enable -path=gha jwt`
|
|
308
|
+
config: { role: 'ci', jwtProvider: () => core.getIDToken('vault') },
|
|
309
|
+
},
|
|
310
|
+
});
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
`core.getIDToken('vault')` mints a token whose `aud` is `vault`, so the role has to bind that
|
|
314
|
+
audience or the login fails with `audience claim found in JWT but no audiences bound to the role`:
|
|
315
|
+
|
|
316
|
+
```shell
|
|
317
|
+
vault write auth/gha/role/ci \
|
|
318
|
+
role_type=jwt user_claim=sub bound_audiences=vault \
|
|
319
|
+
bound_claims='{"repository":"my-org/my-repo"}' token_policies=ci
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
Pass the same string to `core.getIDToken()` and to `bound_audiences`. Calling `getIDToken()` with
|
|
323
|
+
no argument uses GitHub's default audience instead, which then will not match.
|
|
324
|
+
|
|
325
|
+
`role` is optional here too — omit it to use the mount's `default_role`. `mount` and
|
|
326
|
+
`api.namespace` behave exactly as they do for the other four backends.
|
|
327
|
+
|
|
328
|
+
Vault's JWT method also offers an interactive `oidc` login (a browser redirect for a human user).
|
|
329
|
+
This library implements only the non-interactive `jwt` flow: a service client doing headless
|
|
330
|
+
background renewal has no browser to redirect to, so `oidc` is deliberately not supported.
|
|
331
|
+
|
|
332
|
+
### Token renewal
|
|
333
|
+
|
|
334
|
+
Whenever Vault issues a **renewable** token, the client arms a background timer and renews it at
|
|
335
|
+
half its remaining lifetime, for as long as the client lives. That is the default and suits
|
|
336
|
+
long-running services.
|
|
337
|
+
|
|
338
|
+
Set `renewal: false` on the `auth` block (beside `type`, not inside `config`) to turn it off:
|
|
339
|
+
|
|
340
|
+
```javascript
|
|
341
|
+
auth: {
|
|
342
|
+
type: 'appRole',
|
|
343
|
+
renewal: false,
|
|
344
|
+
config: {
|
|
345
|
+
role_id: '637c065f-...',
|
|
346
|
+
secret_id: '...',
|
|
347
|
+
},
|
|
348
|
+
}
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
These keys sit on `auth` rather than in `auth.config` on purpose: `config` is the backend's own
|
|
352
|
+
credential bag and may already carry keys of your own, so reserving names inside it could break an
|
|
353
|
+
existing caller. Same reasoning that put the namespace at `api.namespace`.
|
|
354
|
+
|
|
355
|
+
With renewal off, the client keeps using the token until it expires and then simply logs in again
|
|
356
|
+
on the next call — no background timer at all. Two reasons to want that:
|
|
357
|
+
|
|
358
|
+
* **Short-lived processes.** The renewal timer keeps the Node.js event loop alive, so a script that
|
|
359
|
+
finishes its work does not exit on its own; you have to call
|
|
360
|
+
[`close()`](#VaultClient+close). With `renewal: false` there is no timer to hold it open.
|
|
361
|
+
* **You would rather re-authenticate than renew** — for example where the auth backend can always
|
|
362
|
+
mint a fresh token (`jwtProvider`, Kubernetes, IAM) and you prefer a clean login over extending
|
|
363
|
+
an existing lease.
|
|
364
|
+
|
|
365
|
+
#### Before you turn it off
|
|
366
|
+
|
|
367
|
+
Renewal off means the token is allowed to expire, and what happens next depends on whether your
|
|
368
|
+
backend can obtain a *fresh* credential unaided:
|
|
369
|
+
|
|
370
|
+
| backend | on expiry with `renewal: false` |
|
|
371
|
+
| --- | --- |
|
|
372
|
+
| `kubernetes`, `iam`, `jwt` with `jwtPath`/`jwtProvider` | clean re-login — the JWT or AWS credential is re-acquired, so this is the intended case |
|
|
373
|
+
| `appRole` | re-login **replays the same `secret_id`**. Fine for a reusable one; with Vault's recommended hardening (`secret_id_num_uses=1`, or a short `secret_id_ttl`) the second login is rejected |
|
|
374
|
+
| `jwt` with a literal `jwt` | replays the same JWT, so it works only until the IdP-issued token expires |
|
|
375
|
+
| `token` | **cannot re-authenticate at all.** The client raises `AuthTokenExpiredError` from then on, permanently |
|
|
376
|
+
|
|
377
|
+
Two consequences worth stating plainly:
|
|
378
|
+
|
|
379
|
+
* For `token` auth this **is** a behaviour change, not a no-op. A renewable token handed to `token`
|
|
380
|
+
auth is renewed indefinitely today; with `renewal: false` it expires and every later call rejects
|
|
381
|
+
for the life of the process. `close()` does not reset it — recovery means
|
|
382
|
+
`VaultClient.clear(name)` and booting again.
|
|
383
|
+
* For `appRole` and literal-`jwt`, a login that can no longer succeed is retried on **every**
|
|
384
|
+
subsequent call, since a failed login clears the cached token. That is a failing request per
|
|
385
|
+
`read()`, with no backoff. Prefer `jwtPath`/`jwtProvider`, a reusable `secret_id`, or leaving
|
|
386
|
+
renewal on.
|
|
387
|
+
|
|
388
|
+
**Expiring a token also revokes its leases.** Vault revokes every lease created by a token when
|
|
389
|
+
that token expires, and this client does not renew secret leases — only the auth token. If you read
|
|
390
|
+
dynamic credentials (`database/creds/*`, cloud credentials) whose lease outlives the auth token,
|
|
391
|
+
leaving renewal on is what currently keeps them alive. KV reads are unaffected.
|
|
392
|
+
|
|
393
|
+
#### Tuning renewal instead of disabling it
|
|
394
|
+
|
|
395
|
+
Two further keys, also on `auth`, shape *how* renewal happens. Both are optional, and leaving them
|
|
396
|
+
out reproduces the behaviour the client has always had.
|
|
397
|
+
|
|
398
|
+
| key | default | meaning |
|
|
399
|
+
| --- | --- | --- |
|
|
400
|
+
| `renewalFraction` | `0.5` | How much of the token's **remaining** lifetime to wait before renewing, as a fraction in `(0, 1)`. `0.5` renews at the halfway point. |
|
|
401
|
+
| `renewalIncrement` | *(unset)* | Seconds of extra TTL to ask for, sent as `increment` to `auth/token/renew-self`. Unset means Vault applies the token's own period. |
|
|
402
|
+
|
|
403
|
+
```javascript
|
|
404
|
+
auth: {
|
|
405
|
+
type: 'kubernetes',
|
|
406
|
+
renewalFraction: 0.25, // renew after a quarter of the remaining lifetime
|
|
407
|
+
renewalIncrement: 3600, // ask Vault for another hour each time
|
|
408
|
+
config: { role: 'my-app' },
|
|
409
|
+
}
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
Lower `renewalFraction` values renew earlier and more often, which buys headroom if Vault is briefly
|
|
413
|
+
unreachable. A failed renewal is retried on the same rule against the *same* token, so the waits
|
|
414
|
+
shrink geometrically as the remaining lifetime does — for a 1-hour token that is roughly 12 attempts
|
|
415
|
+
at `0.5` (1800s, 900s, 450s, …) versus 27 at `0.25`, both bottoming out at a 1-second floor just
|
|
416
|
+
before expiry. Higher values renew later and talk to Vault less.
|
|
417
|
+
|
|
418
|
+
`renewalIncrement` is a request, not a guarantee: Vault grants at most the token's max TTL and may
|
|
419
|
+
return less. Both keys are validated at construction — a `renewalFraction` outside `(0, 1)` or a
|
|
420
|
+
non-positive/non-integer `renewalIncrement` raises `InvalidArgumentsError` rather than failing later
|
|
421
|
+
inside a background timer.
|
|
422
|
+
|
|
128
423
|
## API
|
|
129
424
|
|
|
130
425
|
<a name="VaultClient"></a>
|
|
@@ -148,7 +443,7 @@ const vaultClient = VaultClient.boot('main', {
|
|
|
148
443
|
* [.deleteMetadata(path)](#VaultClient+deleteMetadata) ⇒ <code>Promise.<Object></code>
|
|
149
444
|
* [.close()](#VaultClient+close)
|
|
150
445
|
* _static_
|
|
151
|
-
* [.boot(name,
|
|
446
|
+
* [.boot(name, options)](#VaultClient.boot) ⇒ <code>VaultClient</code>
|
|
152
447
|
* [.get(name)](#VaultClient.get) ⇒ <code>VaultClient</code>
|
|
153
448
|
* [.clear([name])](#VaultClient.clear)
|
|
154
449
|
* [Lease](#Lease)
|
|
@@ -175,11 +470,14 @@ Client constructor function.
|
|
|
175
470
|
| [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)) |
|
|
176
471
|
| [options.api.engines] | <code>Object</code> | `{}` | static mount-to-version map, e.g. `{ secret: 2, legacy: 1 }` (see [KV v2 & generic backends](#kv-v2--generic-backends)) |
|
|
177
472
|
| options.auth | <code>Object</code> | | |
|
|
178
|
-
| options.auth.type | <code>String</code> | |
|
|
179
|
-
| [options.auth.mount] | <code>String</code> | | Vault auth backend mount point; default varies per method (e.g. "aws" for iam, "approle", "token", "kubernetes") |
|
|
473
|
+
| options.auth.type | <code>String</code> | | one of: 'appRole' \| 'token' \| 'iam' \| 'kubernetes' \| 'jwt' |
|
|
474
|
+
| [options.auth.mount] | <code>String</code> | | Vault auth backend mount point; default varies per method (e.g. "aws" for iam, "approle", "token", "kubernetes", "jwt") |
|
|
180
475
|
| options.auth.config | <code>Object</code> | | auth configuration variables |
|
|
181
476
|
| [options.auth.config.namespace] | <code>String</code> | | Optional. Legacy location for the Vault namespace (see `api.namespace`). Sent as the `X-Vault-Namespace` header on **every** request for every auth type. |
|
|
182
|
-
| [options.
|
|
477
|
+
| [options.auth.renewal] | <code>boolean</code> | <code>true</code> | Set `false` to never renew the Vault token in the background; the token is used until it expires and the next call re-authenticates. See [Token renewal](#token-renewal). |
|
|
478
|
+
| [options.auth.renewalFraction] | <code>number</code> | <code>0.5</code> | How much of the token's remaining lifetime to wait before renewing, as a fraction in `(0, 1)`. |
|
|
479
|
+
| [options.auth.renewalIncrement] | <code>number</code> | | Seconds of extra TTL to request on each renewal, sent as `increment` to `auth/token/renew-self`. Vault caps it at the token's max TTL. |
|
|
480
|
+
| [options.logger] | <code>Object</code> \| <code>false</code> | | Logger that must implement **all five** of "error", "warn", "info", "debug" and "trace" — an object missing any one of them is silently ignored and the default logger is used instead. The default logger writes to `console`, except `debug`, which is discarded so that sensitive data is never printed. Pass `false` to disable logging entirely. |
|
|
183
481
|
|
|
184
482
|
##### Custom transport (proxy / self-signed TLS)
|
|
185
483
|
|
|
@@ -214,12 +512,50 @@ env var with no code change. Only disable verification
|
|
|
214
512
|
(`new Agent({ connect: { rejectUnauthorized: false } })`) in throwaway/dev setups — it removes
|
|
215
513
|
MITM protection.
|
|
216
514
|
|
|
515
|
+
The `undici` you install must be interface-compatible with the one Node bundles
|
|
516
|
+
(`process.versions.undici`). A dispatcher from a mismatched major is rejected by the global
|
|
517
|
+
`fetch()` before the request leaves the process — on Node 24, `undici@8` fails immediately with
|
|
518
|
+
`TypeError: fetch failed` and `cause.code` of `invalid onRequestStart method`. Node 22 and 24
|
|
519
|
+
bundle undici 7.x, so pin `undici@^7`.
|
|
520
|
+
|
|
521
|
+
##### Request timeouts
|
|
522
|
+
|
|
523
|
+
There is no default timeout. A Vault that accepts the connection and never answers will leave
|
|
524
|
+
`read()` (and every other call) pending indefinitely. Set one through the dispatcher:
|
|
525
|
+
|
|
526
|
+
```javascript
|
|
527
|
+
const { Agent } = require('undici');
|
|
528
|
+
|
|
529
|
+
const vaultClient = VaultClient.boot('main', {
|
|
530
|
+
api: {
|
|
531
|
+
url: 'https://vault.example.com:8200/',
|
|
532
|
+
requestOptions: { dispatcher: new Agent({ headersTimeout: 5000, bodyTimeout: 5000 }) },
|
|
533
|
+
},
|
|
534
|
+
auth: { type: 'token', config: { token: '...' } },
|
|
535
|
+
});
|
|
536
|
+
```
|
|
537
|
+
|
|
538
|
+
A failed request rejects with `TypeError: fetch failed`, with `cause.code` set to
|
|
539
|
+
`UND_ERR_HEADERS_TIMEOUT` or `UND_ERR_BODY_TIMEOUT`.
|
|
540
|
+
|
|
541
|
+
Do **not** put `signal: AbortSignal.timeout(ms)` in `requestOptions`. It is created once and
|
|
542
|
+
shared by every request the client makes, so it works for the first call and then aborts all
|
|
543
|
+
later ones with `TimeoutError`. Pass a fresh signal per call, or use the dispatcher above.
|
|
544
|
+
|
|
545
|
+
##### Redirects and the Vault token
|
|
546
|
+
|
|
547
|
+
Requests follow HTTP redirects, because Vault HA clusters answer standby nodes with a 307 to the
|
|
548
|
+
active node. Node's `fetch()` strips only `Authorization`, `Cookie` and `Proxy-Authorization` when
|
|
549
|
+
a redirect crosses origins — `X-Vault-Token` is **not** on that list and is forwarded. A host that
|
|
550
|
+
can answer for `api.url` can therefore redirect the client and receive its Vault token, so treat
|
|
551
|
+
`api.url` (and the DNS and any load balancer in front of it) as trusted infrastructure.
|
|
552
|
+
|
|
217
553
|
<a name="VaultClient+fillNodeConfig"></a>
|
|
218
554
|
|
|
219
555
|
#### vaultClient.fillNodeConfig() ⇒ <code>Promise</code>
|
|
220
556
|
Populates Vault's values to NPM "config" module
|
|
221
557
|
|
|
222
|
-
Resolves once the npm `config` module has been populated from Vault.
|
|
558
|
+
Resolves once the npm `config` module has been populated from Vault. Note that setup failures are thrown **synchronously**, not returned as a rejected promise: a missing `config` peer dependency and an unreadable `<NODE_CONFIG_DIR>/custom-vault-variables.js` both throw `VaultError` before the promise is created, so use `await` or wrap the call in `try`/`catch` rather than relying on `.catch()` alone.
|
|
223
559
|
|
|
224
560
|
**Kind**: instance method of [<code>VaultClient</code>](#VaultClient)
|
|
225
561
|
<a name="VaultClient+read"></a>
|
|
@@ -358,7 +694,7 @@ The destroyed version data cannot be recovered. KV v2 only — rejects with
|
|
|
358
694
|
#### vaultClient.readMetadata(path) ⇒ <code>Promise.<Object></code>
|
|
359
695
|
Reads KV v2 metadata for a secret
|
|
360
696
|
|
|
361
|
-
Resolves to the metadata document (`current_version`, the `versions` map, timestamps, etc.).
|
|
697
|
+
Resolves to the raw parsed Vault response body; the metadata document (`current_version`, the `versions` map, timestamps, etc.) is under its `data` property, e.g. `(await client.readMetadata('secret/foo')).data.current_version`.
|
|
362
698
|
KV v2 only — rejects with `UnsupportedOperationError` on non-v2 mounts.
|
|
363
699
|
|
|
364
700
|
**Kind**: instance method of [<code>VaultClient</code>](#VaultClient)
|
|
@@ -402,6 +738,12 @@ vaultClient.close(); // process can now exit
|
|
|
402
738
|
|
|
403
739
|
**Kind**: instance method of [<code>VaultClient</code>](#VaultClient)
|
|
404
740
|
|
|
741
|
+
|
|
742
|
+
`close()` also settles a renewal that is already in flight: a request that lands after the call is
|
|
743
|
+
discarded rather than arming a fresh timer, so the client stops holding the event loop open. The
|
|
744
|
+
same applies when a token is replaced by a new login — a renewal still running against the previous
|
|
745
|
+
token cannot overwrite the newer one or cancel its timer.
|
|
746
|
+
|
|
405
747
|
<a name="Lease"></a>
|
|
406
748
|
|
|
407
749
|
### Lease
|
|
@@ -412,22 +754,31 @@ accessors to extract the secret data:
|
|
|
412
754
|
* `getValue(key)` ⇒ <code>String</code> — value for a single key. Throws `Requested key does not exist` when the key is absent.
|
|
413
755
|
* `getData()` ⇒ <code>Object</code> — a deep-cloned copy of the whole secret data object.
|
|
414
756
|
* `isRenewable()` ⇒ <code>boolean</code> — whether the underlying lease is renewable.
|
|
757
|
+
* `getMetadata()` ⇒ <code>Object</code> | <code>undefined</code> — KV v2 version metadata
|
|
758
|
+
(`version`, `created_time`, `deletion_time`, `destroyed`, `custom_metadata`), or `undefined`
|
|
759
|
+
on KV v1 and non-KV mounts. See [Lease.getMetadata()](#leasegetmetadata).
|
|
415
760
|
|
|
416
761
|
<a name="VaultClient.boot"></a>
|
|
417
762
|
|
|
418
|
-
#### VaultClient.boot(name,
|
|
763
|
+
#### VaultClient.boot(name, options) ⇒ <code>VaultClient</code>
|
|
419
764
|
Boot an instance of Vault
|
|
420
765
|
|
|
421
766
|
The instance will be stored in a local hash. Calling Vault.boot multiple
|
|
422
767
|
times with the same name will return the same instance.
|
|
423
768
|
|
|
769
|
+
`options` are used only when the instance is first created. A later call for a name that already
|
|
770
|
+
exists returns the existing instance and ignores the options passed to it; when those differ from
|
|
771
|
+
the ones it was booted with, the client logs a warning rather than letting the difference pass
|
|
772
|
+
silently. Use [`VaultClient.get(name)`](#VaultClient.get) to fetch an existing instance, or
|
|
773
|
+
[`VaultClient.clear(name)`](#VaultClient.clear) before booting to replace one.
|
|
774
|
+
|
|
424
775
|
**Kind**: static method of [<code>VaultClient</code>](#VaultClient)
|
|
425
776
|
**Returns**: <code>VaultClient</code>
|
|
426
777
|
|
|
427
778
|
| Param | Type | Description |
|
|
428
779
|
| --- | --- | --- |
|
|
429
780
|
| name | <code>String</code> | Vault instance name |
|
|
430
|
-
|
|
|
781
|
+
| options | <code>Object</code> | options for [Vault#constructor](#new_VaultClient_new). Required on every call, including for a name that was already booted — use [VaultClient.get(name)](#VaultClient.get) to fetch an existing instance. |
|
|
431
782
|
|
|
432
783
|
<a name="VaultClient.get"></a>
|
|
433
784
|
|
|
@@ -507,7 +858,7 @@ const client = VaultClient.boot('main', {
|
|
|
507
858
|
|
|
508
859
|
### KV v2-specific methods
|
|
509
860
|
|
|
510
|
-
These methods require a KV v2 mount and throw `UnsupportedOperationError` on v1 / non-KV mounts.
|
|
861
|
+
These methods require a KV v2 mount and throw `UnsupportedOperationError` on v1 / non-KV mounts. They also require the mount to be *resolved* as v2: with neither `api.kv.autoDetect: true` nor an `api.engines` entry covering the mount, every path resolves as v1 passthrough with no detection call, and these methods fail with `UnsupportedOperationError` (`Mount "secret" is not a KV v2 engine.`) even against a genuine KV v2 mount.
|
|
511
862
|
|
|
512
863
|
```javascript
|
|
513
864
|
// Soft-delete specific versions
|
|
@@ -519,7 +870,8 @@ await client.undeleteVersions('secret/foo', [1]);
|
|
|
519
870
|
// Permanently destroy versions
|
|
520
871
|
await client.destroyVersions('secret/foo', [1, 2]);
|
|
521
872
|
|
|
522
|
-
// Read version metadata
|
|
873
|
+
// Read version metadata. The full Vault envelope is returned, so the metadata
|
|
874
|
+
// fields live under `.data` (meta.data.current_version, meta.data.versions, ...)
|
|
523
875
|
const meta = await client.readMetadata('secret/foo');
|
|
524
876
|
|
|
525
877
|
// Delete all metadata and version history (permanent)
|
|
@@ -591,17 +943,38 @@ await client.request('GET', 'secret/data/foo');
|
|
|
591
943
|
|
|
592
944
|
### Mount detection caching
|
|
593
945
|
|
|
594
|
-
- Each canonical mount is detected
|
|
946
|
+
- Each canonical mount is detected once and then cached for the life of the `VaultClient` instance. The cache is a bounded LRU with a fixed cap of 500 mounts (not configurable through client options); a long-lived client that touches more than 500 distinct mounts evicts the least-recently-used entries, and an evicted mount is detected again on its next use.
|
|
595
947
|
- Concurrent first-touch requests for the same mount share a single in-flight detection promise.
|
|
596
948
|
- The detection endpoint used is `GET sys/internal/ui/mounts/<path>` (readable by any authenticated token).
|
|
597
949
|
- When the token lacks permission on that endpoint, set `api.engines` to skip detection.
|
|
598
950
|
|
|
599
951
|
### Error classes
|
|
600
952
|
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
953
|
+
Every error the client raises extends `VaultError`. The package entry point exports the
|
|
954
|
+
`VaultClient` class only, so the classes themselves are imported from `node-vault-client/src/errors`:
|
|
955
|
+
|
|
956
|
+
```javascript
|
|
957
|
+
const errors = require('node-vault-client/src/errors');
|
|
958
|
+
|
|
959
|
+
try {
|
|
960
|
+
await client.readMetadata('secret/app');
|
|
961
|
+
} catch (err) {
|
|
962
|
+
if (err instanceof errors.UnsupportedOperationError) {
|
|
963
|
+
// the mount did not resolve as KV v2 - see "Path requirements" above
|
|
964
|
+
return null;
|
|
965
|
+
}
|
|
966
|
+
throw err;
|
|
967
|
+
}
|
|
968
|
+
```
|
|
969
|
+
|
|
970
|
+
| Class | Extends | When thrown |
|
|
971
|
+
|---|---|---|
|
|
972
|
+
| `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. |
|
|
973
|
+
| `VaultHttpError` | `VaultError` | Vault answered with a non-2xx status. Carries the status as `statusCode` and the parsed body as `error`. |
|
|
974
|
+
| `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 an invalid node-config substitution map. |
|
|
975
|
+
| `InvalidAWSCredentialsError` | `InvalidArgumentsError` | `auth.config.credentials` was supplied but is not a usable `accessKeyId` / `secretAccessKey` pair. |
|
|
976
|
+
| `AuthTokenExpiredError` | `VaultError` | The Vault token expired and the backend cannot obtain a new one. `token` auth can never re-authenticate; other backends reach this only with `renewal: false` and a credential they cannot replay. |
|
|
977
|
+
| `UnsupportedOperationError` | `VaultError` | A v2-only method (`deleteVersions`, `undeleteVersions`, `destroyVersions`, `readMetadata`, `deleteMetadata`) was called against a mount that did not resolve as KV v2. |
|
|
605
978
|
|
|
606
979
|
## Contributing
|
|
607
980
|
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
export = VaultClient;
|
|
2
|
+
|
|
3
|
+
declare class VaultClient {
|
|
4
|
+
constructor(options: VaultClient.VaultOptions);
|
|
5
|
+
|
|
6
|
+
static boot(name: string, options: VaultClient.VaultOptions): VaultClient;
|
|
7
|
+
static get(name: string): VaultClient;
|
|
8
|
+
static clear(name?: string): void;
|
|
9
|
+
|
|
10
|
+
read(path: string): Promise<VaultClient.Lease>;
|
|
11
|
+
list(path: string): Promise<VaultClient.Lease>;
|
|
12
|
+
write(path: string, data: Record<string, any>): Promise<any>;
|
|
13
|
+
delete(path: string): Promise<any>;
|
|
14
|
+
update(path: string, data: Record<string, any>): Promise<any>;
|
|
15
|
+
request(method: string, path: string, data?: Record<string, any>): Promise<any>;
|
|
16
|
+
|
|
17
|
+
deleteVersions(path: string, versions: number[]): Promise<any>;
|
|
18
|
+
undeleteVersions(path: string, versions: number[]): Promise<any>;
|
|
19
|
+
destroyVersions(path: string, versions: number[]): Promise<any>;
|
|
20
|
+
readMetadata(path: string): Promise<any>;
|
|
21
|
+
deleteMetadata(path: string): Promise<any>;
|
|
22
|
+
|
|
23
|
+
fillNodeConfig(): Promise<any>;
|
|
24
|
+
getHeaders(token: VaultClient.AuthToken): Record<string, string>;
|
|
25
|
+
close(): void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
declare namespace VaultClient {
|
|
29
|
+
interface Logger {
|
|
30
|
+
error(...args: any[]): void;
|
|
31
|
+
warn(...args: any[]): void;
|
|
32
|
+
info(...args: any[]): void;
|
|
33
|
+
debug(...args: any[]): void;
|
|
34
|
+
trace(...args: any[]): void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface ApiConfig {
|
|
38
|
+
url: string;
|
|
39
|
+
apiVersion?: string;
|
|
40
|
+
namespace?: string;
|
|
41
|
+
requestOptions?: Record<string, any>;
|
|
42
|
+
kv?: { autoDetect?: boolean };
|
|
43
|
+
engines?: Record<string, 1 | 2>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface AWSCredentials {
|
|
47
|
+
accessKeyId: string;
|
|
48
|
+
secretAccessKey: string;
|
|
49
|
+
sessionToken?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface TokenAuthConfig {
|
|
53
|
+
token: string;
|
|
54
|
+
namespace?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface AppRoleAuthConfig {
|
|
58
|
+
role_id: string;
|
|
59
|
+
secret_id?: string;
|
|
60
|
+
namespace?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface IAMAuthConfig {
|
|
64
|
+
role: string;
|
|
65
|
+
credentials?: AWSCredentials;
|
|
66
|
+
iam_server_id_header_value?: string;
|
|
67
|
+
region?: string;
|
|
68
|
+
namespace?: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface KubernetesAuthConfig {
|
|
72
|
+
role: string;
|
|
73
|
+
tokenPath?: string;
|
|
74
|
+
namespace?: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface JwtAuthConfigCommon {
|
|
78
|
+
role?: string;
|
|
79
|
+
namespace?: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
type JwtDistributedClaimConfig =
|
|
83
|
+
| { distributedClaimAccessToken?: never; distributedClaimAccessTokenProvider?: never }
|
|
84
|
+
| { distributedClaimAccessToken: string; distributedClaimAccessTokenProvider?: never }
|
|
85
|
+
| { distributedClaimAccessTokenProvider: () => string | Promise<string>; distributedClaimAccessToken?: never };
|
|
86
|
+
|
|
87
|
+
type JwtAuthConfig = JwtAuthConfigCommon &
|
|
88
|
+
(
|
|
89
|
+
| { jwt: string; jwtPath?: never; jwtProvider?: never }
|
|
90
|
+
| { jwtPath: string; jwt?: never; jwtProvider?: never }
|
|
91
|
+
| { jwtProvider: () => string | Promise<string>; jwt?: never; jwtPath?: never }
|
|
92
|
+
) &
|
|
93
|
+
JwtDistributedClaimConfig;
|
|
94
|
+
|
|
95
|
+
interface AuthOptionsCommon {
|
|
96
|
+
mount?: string;
|
|
97
|
+
renewal?: boolean;
|
|
98
|
+
renewalFraction?: number;
|
|
99
|
+
renewalIncrement?: number;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
type AuthOptions = AuthOptionsCommon &
|
|
103
|
+
(
|
|
104
|
+
| { type: 'token'; config: TokenAuthConfig }
|
|
105
|
+
| { type: 'appRole'; config: AppRoleAuthConfig }
|
|
106
|
+
| { type: 'iam'; config: IAMAuthConfig }
|
|
107
|
+
| { type: 'kubernetes'; config: KubernetesAuthConfig }
|
|
108
|
+
| { type: 'jwt'; config: JwtAuthConfig }
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
interface VaultOptions {
|
|
112
|
+
api: ApiConfig;
|
|
113
|
+
auth: AuthOptions;
|
|
114
|
+
logger?: Partial<Logger> | false;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
interface Lease {
|
|
118
|
+
getValue<T = any>(key: string): T;
|
|
119
|
+
getData<T = Record<string, any>>(): T;
|
|
120
|
+
isRenewable(): boolean;
|
|
121
|
+
getMetadata(): Record<string, any> | undefined;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
interface AuthToken {
|
|
125
|
+
getId(): string;
|
|
126
|
+
getAccessor(): string;
|
|
127
|
+
isExpired(): boolean;
|
|
128
|
+
isRenewable(): boolean;
|
|
129
|
+
getExpiresAt(): number | null;
|
|
130
|
+
}
|
|
131
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "node-vault-client",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.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"
|
|
@@ -9,17 +9,22 @@
|
|
|
9
9
|
"node": ">=18.0.0"
|
|
10
10
|
},
|
|
11
11
|
"main": "src/VaultClient.js",
|
|
12
|
+
"types": "index.d.ts",
|
|
12
13
|
"files": [
|
|
13
|
-
"src"
|
|
14
|
+
"src",
|
|
15
|
+
"index.d.ts"
|
|
14
16
|
],
|
|
15
17
|
"scripts": {
|
|
16
18
|
"test": "mocha --exit \"test/**/*.test.mjs\"",
|
|
17
19
|
"test:unit": "mocha \"test/*.test.mjs\"",
|
|
20
|
+
"test:types": "tsc -p types/tsconfig.json",
|
|
18
21
|
"test:e2e": "mocha --exit \"test/e2e/e2e.test.mjs\"",
|
|
19
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:example": "node examples/jwt-auth/vault-jwt-demo.mjs",
|
|
20
25
|
"coverage": "c8 npm run test:unit",
|
|
21
|
-
"lint": "eslint src/ test/",
|
|
22
|
-
"version": "git add
|
|
26
|
+
"lint": "eslint src/ test/ examples/",
|
|
27
|
+
"version": "git add CHANGELOG.md package.json package-lock.json"
|
|
23
28
|
},
|
|
24
29
|
"c8": {
|
|
25
30
|
"include": [
|
|
@@ -52,7 +57,7 @@
|
|
|
52
57
|
"glob": "^13.0.0",
|
|
53
58
|
"diff": "^8.0.3",
|
|
54
59
|
"brace-expansion": "^5.0.9",
|
|
55
|
-
"js-yaml": "^4.3.
|
|
60
|
+
"js-yaml": "^4.3.2"
|
|
56
61
|
},
|
|
57
62
|
"devDependencies": {
|
|
58
63
|
"@eslint/js": "^9.39.5",
|
|
@@ -64,6 +69,7 @@
|
|
|
64
69
|
"lodash": "^4.18.1",
|
|
65
70
|
"mocha": "^11.7.6",
|
|
66
71
|
"sinon": "^22.1.0",
|
|
67
|
-
"sinon-chai": "^4.0.1"
|
|
72
|
+
"sinon-chai": "^4.0.1",
|
|
73
|
+
"typescript": "^7.0.2"
|
|
68
74
|
}
|
|
69
75
|
}
|
package/src/VaultClient.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const Lease = require('./Lease');
|
|
4
|
+
const crypto = require('crypto');
|
|
4
5
|
const errors = require('./errors');
|
|
5
6
|
const VaultApiClient = require('./VaultApiClient');
|
|
6
7
|
const VaultAppRoleAuth = require('./auth/VaultAppRoleAuth');
|
|
@@ -8,12 +9,46 @@ const VaultTokenAuth = require('./auth/VaultTokenAuth');
|
|
|
8
9
|
const VaultIAMAuth = require('./auth/VaultIAMAuth');
|
|
9
10
|
const VaultNodeConfig = require('./VaultNodeConfig');
|
|
10
11
|
const VaultKubernetesAuth = require('./auth/VaultKubernetesAuth');
|
|
12
|
+
const VaultJwtAuth = require('./auth/VaultJwtAuth');
|
|
11
13
|
const MountResolver = require('./MountResolver');
|
|
12
14
|
const { rewritePath, normalizeResponse } = require('./kvTransform');
|
|
15
|
+
const { warnIfNode18 } = require('./checkNodeVersion');
|
|
16
|
+
|
|
17
|
+
warnIfNode18();
|
|
13
18
|
|
|
14
19
|
const noop = () => {};
|
|
15
20
|
const vaultInstances = {};
|
|
16
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Order-insensitive canonical form of the boot options, hashed so that no credential is
|
|
24
|
+
* retained just to compare two boot() calls. Functions compare by identity, which is what
|
|
25
|
+
* a caller passing `jwtProvider` expects.
|
|
26
|
+
*/
|
|
27
|
+
function fingerprintOptions(value, seen) {
|
|
28
|
+
if (typeof value === 'function') {
|
|
29
|
+
return 'fn';
|
|
30
|
+
}
|
|
31
|
+
if (value === null || typeof value !== 'object') {
|
|
32
|
+
return JSON.stringify(value) || String(value);
|
|
33
|
+
}
|
|
34
|
+
seen = seen || new Set();
|
|
35
|
+
if (seen.has(value)) {
|
|
36
|
+
return 'circular';
|
|
37
|
+
}
|
|
38
|
+
seen.add(value);
|
|
39
|
+
if (Array.isArray(value)) {
|
|
40
|
+
return `[${value.map((item) => fingerprintOptions(item, seen)).join(',')}]`;
|
|
41
|
+
}
|
|
42
|
+
const body = Object.keys(value).sort()
|
|
43
|
+
.map((key) => `${JSON.stringify(key)}:${fingerprintOptions(value[key], seen)}`)
|
|
44
|
+
.join(',');
|
|
45
|
+
return `{${body}}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function optionsDigest(options) {
|
|
49
|
+
return crypto.createHash('sha256').update(fingerprintOptions(options)).digest('hex');
|
|
50
|
+
}
|
|
51
|
+
|
|
17
52
|
class VaultClient {
|
|
18
53
|
|
|
19
54
|
/**
|
|
@@ -33,8 +68,17 @@ class VaultClient {
|
|
|
33
68
|
* every request across all auth backends. For backward compatibility `options.auth.config.namespace`
|
|
34
69
|
* is also honored when this is not set.
|
|
35
70
|
* @param {Object} options.auth
|
|
36
|
-
* @param {String} options.auth.type
|
|
71
|
+
* @param {String} options.auth.type - one of: 'appRole' | 'token' | 'iam' | 'kubernetes' | 'jwt'
|
|
37
72
|
* @param {Object} options.auth.config - auth configuration variables
|
|
73
|
+
* @param {boolean} [options.auth.renewal=true] - Set `false` to never renew the Vault token in
|
|
74
|
+
* the background. The token is then used until it expires and the next call
|
|
75
|
+
* re-authenticates. Note `token` auth cannot re-authenticate and will raise
|
|
76
|
+
* {@link AuthTokenExpiredError} from that point on.
|
|
77
|
+
* @param {number} [options.auth.renewalFraction=0.5] - How much of the token's remaining
|
|
78
|
+
* lifetime to wait before renewing, as a fraction in `(0, 1)`.
|
|
79
|
+
* @param {number} [options.auth.renewalIncrement] - Seconds of extra TTL to request on each
|
|
80
|
+
* renewal, sent as `increment` to `auth/token/renew-self`. Vault caps it at the token's
|
|
81
|
+
* max TTL. Omitted by default, which lets Vault apply the token's own period.
|
|
38
82
|
* @param {Object|false} options.logger - Logger that supports "error", "info", "warn", "trace", "debug" methods. Uses `console` by default. Pass `false` to disable logging.
|
|
39
83
|
*/
|
|
40
84
|
constructor(options) {
|
|
@@ -63,6 +107,12 @@ class VaultClient {
|
|
|
63
107
|
this.__api
|
|
64
108
|
);
|
|
65
109
|
|
|
110
|
+
// Renewal options live on `auth`, not inside `auth.config`: `auth.config` is the
|
|
111
|
+
// backend's own credential bag and may already carry consumer-specific keys, so
|
|
112
|
+
// reserving names in it could break an existing caller. Same reasoning that moved
|
|
113
|
+
// `namespace` to `api.namespace`.
|
|
114
|
+
this.__auth.configureRenewal(options.auth);
|
|
115
|
+
|
|
66
116
|
// KV v2 support
|
|
67
117
|
const kvOpts = (options.api && options.api.kv) || {};
|
|
68
118
|
const autoDetect = kvOpts.autoDetect === true;
|
|
@@ -89,8 +139,15 @@ class VaultClient {
|
|
|
89
139
|
* The instance will be stored in a local hash. Calling Vault.boot multiple
|
|
90
140
|
* times with the same name will return the same instance.
|
|
91
141
|
*
|
|
142
|
+
* `options` are used only when the instance is first created. A later call for a name
|
|
143
|
+
* that already exists returns the existing instance and ignores the options it was
|
|
144
|
+
* given; when those options differ from the ones the instance was booted with, that is
|
|
145
|
+
* logged as a warning rather than passing silently. Use {@link VaultClient.get} to
|
|
146
|
+
* fetch an existing instance, or {@link VaultClient.clear} to replace one.
|
|
147
|
+
*
|
|
92
148
|
* @param {String} name - Vault instance name
|
|
93
|
-
* @param {Object}
|
|
149
|
+
* @param {Object} options - options for {@link VaultClient#constructor}. Required on
|
|
150
|
+
* every call, including for a name that already exists.
|
|
94
151
|
* @return {VaultClient}
|
|
95
152
|
*/
|
|
96
153
|
static boot(name, options) {
|
|
@@ -101,6 +158,15 @@ class VaultClient {
|
|
|
101
158
|
let instance = vaultInstances[name];
|
|
102
159
|
if (instance === undefined) {
|
|
103
160
|
vaultInstances[name] = instance = new VaultClient(options);
|
|
161
|
+
instance.__bootDigest = optionsDigest(options);
|
|
162
|
+
} else if (instance.__bootDigest !== optionsDigest(options)) {
|
|
163
|
+
instance.__log.warn(
|
|
164
|
+
'VaultClient.boot("%s") was called with different options than the existing ' +
|
|
165
|
+
'instance was booted with; the existing instance is returned and these options ' +
|
|
166
|
+
'are ignored. Use VaultClient.get("%s") to fetch it, or VaultClient.clear("%s") ' +
|
|
167
|
+
'first to replace it.',
|
|
168
|
+
name, name, name
|
|
169
|
+
);
|
|
104
170
|
}
|
|
105
171
|
return instance;
|
|
106
172
|
}
|
|
@@ -208,6 +274,13 @@ class VaultClient {
|
|
|
208
274
|
authConfig.config,
|
|
209
275
|
authConfig.mount
|
|
210
276
|
);
|
|
277
|
+
case 'jwt':
|
|
278
|
+
return new VaultJwtAuth(
|
|
279
|
+
api,
|
|
280
|
+
this.__log,
|
|
281
|
+
authConfig.config,
|
|
282
|
+
authConfig.mount
|
|
283
|
+
);
|
|
211
284
|
}
|
|
212
285
|
|
|
213
286
|
throw new errors.InvalidArgumentsError('Unsupported auth method')
|
|
@@ -6,6 +6,9 @@
|
|
|
6
6
|
// only if a maintained alternative or a Node.js core fix appears.
|
|
7
7
|
const lt = require('long-timeout');
|
|
8
8
|
const AuthToken = require('./AuthToken');
|
|
9
|
+
|
|
10
|
+
/** Renew at the halfway point of the token's remaining lifetime. */
|
|
11
|
+
const DEFAULT_RENEWAL_FRACTION = 0.5;
|
|
9
12
|
const errors = require('../errors');
|
|
10
13
|
|
|
11
14
|
/**
|
|
@@ -37,6 +40,9 @@ class VaultBaseAuth {
|
|
|
37
40
|
/** @protected */
|
|
38
41
|
this._log = logger;
|
|
39
42
|
this._mount = mount;
|
|
43
|
+
this.__renewalEnabled = true;
|
|
44
|
+
this.__renewalFraction = DEFAULT_RENEWAL_FRACTION;
|
|
45
|
+
this.__renewalIncrement = undefined;
|
|
40
46
|
|
|
41
47
|
/**
|
|
42
48
|
* The currently held token, or `null` when none has been obtained yet.
|
|
@@ -50,6 +56,90 @@ class VaultBaseAuth {
|
|
|
50
56
|
*/
|
|
51
57
|
this.__pendingLogin = null;
|
|
52
58
|
this.__refreshTimeout = null;
|
|
59
|
+
this.__renewalEpoch = 0;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Apply the renewal options from `auth`. Separate from the constructor so that the
|
|
64
|
+
* subclass signatures -- and this one -- stay exactly as they were: anything already
|
|
65
|
+
* subclassing VaultBaseAuth keeps working untouched.
|
|
66
|
+
*
|
|
67
|
+
* @param {Object} [authConfig] - the `auth` block from VaultClient's options
|
|
68
|
+
* @param {boolean} [authConfig.renewal=true]
|
|
69
|
+
* @param {number} [authConfig.renewalFraction=0.5]
|
|
70
|
+
* @param {number} [authConfig.renewalIncrement]
|
|
71
|
+
* @returns {void}
|
|
72
|
+
*/
|
|
73
|
+
configureRenewal(authConfig) {
|
|
74
|
+
this.__renewalEnabled = VaultBaseAuth.__validateRenewal(authConfig);
|
|
75
|
+
this.__renewalFraction = VaultBaseAuth.__validateFraction(authConfig);
|
|
76
|
+
this.__renewalIncrement = VaultBaseAuth.__validateIncrement(authConfig);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Strict on purpose: node-config's `custom-environment-variables` yields strings, so a
|
|
81
|
+
* loose check would read `VAULT_RENEWAL=false` as `'false'` and leave renewal on -- the
|
|
82
|
+
* exact hang the flag exists to prevent, silently.
|
|
83
|
+
*
|
|
84
|
+
* @param {Object} [authConfig]
|
|
85
|
+
* @returns {boolean}
|
|
86
|
+
* @private
|
|
87
|
+
*/
|
|
88
|
+
static __validateRenewal(config) {
|
|
89
|
+
if (!config || config.renewal === undefined) {
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (typeof config.renewal !== 'boolean') {
|
|
94
|
+
throw new errors.InvalidArgumentsError(
|
|
95
|
+
`"renewal" should be a boolean, got ${String(config.renewal)}`
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return config.renewal;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* @param {Object} [authConfig]
|
|
104
|
+
* @returns {number}
|
|
105
|
+
* @private
|
|
106
|
+
*/
|
|
107
|
+
static __validateFraction(config) {
|
|
108
|
+
if (!config || config.renewalFraction === undefined) {
|
|
109
|
+
return DEFAULT_RENEWAL_FRACTION;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const fraction = config.renewalFraction;
|
|
113
|
+
// Exclusive upper bound: at exactly 1 the timer fires at the same instant
|
|
114
|
+
// getAuthToken() starts treating the token as expired, racing the client's own
|
|
115
|
+
// re-authentication and leaving no headroom for the renewal request itself.
|
|
116
|
+
if (typeof fraction !== 'number' || !Number.isFinite(fraction) || fraction <= 0 || fraction >= 1) {
|
|
117
|
+
throw new errors.InvalidArgumentsError(
|
|
118
|
+
`"renewalFraction" should be a number in (0, 1), got ${String(fraction)}`
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return fraction;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* @param {Object} [authConfig]
|
|
127
|
+
* @returns {number|undefined}
|
|
128
|
+
* @private
|
|
129
|
+
*/
|
|
130
|
+
static __validateIncrement(config) {
|
|
131
|
+
if (!config || config.renewalIncrement === undefined) {
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const increment = config.renewalIncrement;
|
|
136
|
+
if (!Number.isInteger(increment) || increment <= 0) {
|
|
137
|
+
throw new errors.InvalidArgumentsError(
|
|
138
|
+
`"renewalIncrement" should be a positive integer number of seconds, got ${String(increment)}`
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return increment;
|
|
53
143
|
}
|
|
54
144
|
|
|
55
145
|
/**
|
|
@@ -84,12 +174,24 @@ class VaultBaseAuth {
|
|
|
84
174
|
const pendingLogin = this._authenticate().then(authToken => {
|
|
85
175
|
this.__pendingLogin = null;
|
|
86
176
|
this.__authToken = authToken;
|
|
177
|
+
this.__renewalEpoch++;
|
|
87
178
|
|
|
88
179
|
if (authToken.isRenewable()) {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
180
|
+
// Gate the log on the flag too: the guard lives inside
|
|
181
|
+
// __setupTokenRefreshTimer, so without this the client claims it armed a
|
|
182
|
+
// timer while arming nothing -- the one diagnostic an operator reads when a
|
|
183
|
+
// `renewal: false` process still hangs.
|
|
184
|
+
if (this.__renewalEnabled) {
|
|
185
|
+
this._log.debug(
|
|
186
|
+
'setting refresh timer for token (accessor=%s)',
|
|
187
|
+
authToken.getAccessor()
|
|
188
|
+
);
|
|
189
|
+
} else {
|
|
190
|
+
this._log.debug(
|
|
191
|
+
'renewal disabled by config; not arming a refresh timer (accessor=%s)',
|
|
192
|
+
authToken.getAccessor()
|
|
193
|
+
);
|
|
194
|
+
}
|
|
93
195
|
this.__setupTokenRefreshTimer(authToken);
|
|
94
196
|
}
|
|
95
197
|
|
|
@@ -116,6 +218,7 @@ class VaultBaseAuth {
|
|
|
116
218
|
* @returns {void}
|
|
117
219
|
*/
|
|
118
220
|
cancelTokenRefresh() {
|
|
221
|
+
this.__renewalEpoch++;
|
|
119
222
|
if (this.__refreshTimeout !== null) {
|
|
120
223
|
lt.clearTimeout(this.__refreshTimeout);
|
|
121
224
|
this.__refreshTimeout = null;
|
|
@@ -151,17 +254,35 @@ class VaultBaseAuth {
|
|
|
151
254
|
this.__refreshTimeout = null;
|
|
152
255
|
}
|
|
153
256
|
|
|
154
|
-
if (!authToken.isRenewable() || authToken.isExpired()) {
|
|
257
|
+
if (!this.__renewalEnabled || !authToken.isRenewable() || authToken.isExpired()) {
|
|
155
258
|
return;
|
|
156
259
|
}
|
|
157
260
|
|
|
158
|
-
const
|
|
261
|
+
const remaining = authToken.getExpiresAt() - Math.floor(Date.now() / 1000);
|
|
262
|
+
const timer = Math.max(remaining * this.__renewalFraction, 1) * 1000;
|
|
263
|
+
|
|
264
|
+
// A renewal that is still in flight when the token is replaced or cancelled must not
|
|
265
|
+
// act on what it finds when it lands: it would overwrite the newer token, or clear
|
|
266
|
+
// the newer token's timer and then bail on its own expired one, leaving a live token
|
|
267
|
+
// permanently un-renewed. Anything that supersedes this timer bumps the epoch.
|
|
268
|
+
const epoch = ++this.__renewalEpoch;
|
|
269
|
+
const superseded = () => this.__renewalEpoch !== epoch;
|
|
159
270
|
|
|
160
271
|
this.__refreshTimeout = lt.setTimeout(() => {
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
272
|
+
if (superseded()) {
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
this.__renewToken(authToken).then(renewedToken => {
|
|
277
|
+
if (superseded()) {
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
this.__authToken = renewedToken;
|
|
281
|
+
this.__setupTokenRefreshTimer(renewedToken);
|
|
164
282
|
}).catch(err => {
|
|
283
|
+
if (superseded()) {
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
165
286
|
this.__setupTokenRefreshTimer(authToken);
|
|
166
287
|
|
|
167
288
|
this._log.error(`Cannot refresh auth token with "${authToken.getAccessor()}" accessor. Error: ${err.message}`);
|
|
@@ -183,7 +304,10 @@ class VaultBaseAuth {
|
|
|
183
304
|
__renewToken(authToken) {
|
|
184
305
|
this._log.debug('renewing vault token');
|
|
185
306
|
|
|
186
|
-
|
|
307
|
+
// null, not {}, when unset: that is the body the client has always sent.
|
|
308
|
+
const data = this.__renewalIncrement === undefined ? null : {increment: this.__renewalIncrement};
|
|
309
|
+
|
|
310
|
+
return this.__apiClient.makeRequest('POST', '/auth/token/renew-self', data, {'X-Vault-Token': authToken.getId()})
|
|
187
311
|
.then(() => {
|
|
188
312
|
this._log.info('successfully renewed token');
|
|
189
313
|
return this._getTokenEntity(authToken.getId());
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const VaultBaseAuth = require('./VaultBaseAuth');
|
|
5
|
+
const errors = require('../errors');
|
|
6
|
+
|
|
7
|
+
const JWT_SOURCE_KEYS = ['jwt', 'jwtPath', 'jwtProvider'];
|
|
8
|
+
|
|
9
|
+
class VaultJwtAuth extends VaultBaseAuth {
|
|
10
|
+
/**
|
|
11
|
+
* @param {VaultApiClient} apiClient - see {@link VaultBaseAuth#constructor}
|
|
12
|
+
* @param {Object} logger
|
|
13
|
+
* @param {Object} config
|
|
14
|
+
* @param {String} [config.role] - Role configured in Vault's JWT auth backend. Optional;
|
|
15
|
+
* when omitted, Vault falls back to the mount's configured `default_role`.
|
|
16
|
+
* @param {String} [config.jwt] - A literal JWT to present at login. Exactly one of
|
|
17
|
+
* `jwt` / `jwtPath` / `jwtProvider` must be provided.
|
|
18
|
+
* @param {String} [config.jwtPath] - Path to a file containing the JWT. Re-read on every
|
|
19
|
+
* login (like {@link VaultKubernetesAuth}'s `tokenPath`), so a rotated token is picked up.
|
|
20
|
+
* @param {Function} [config.jwtProvider] - (optionally async) function invoked at login time,
|
|
21
|
+
* returning `string | Promise<string>`. Called fresh on every login (never at construction
|
|
22
|
+
* or cached across logins) so it can mint a short-lived token -- the shape GitHub Actions'
|
|
23
|
+
* `core.getIDToken()`, cloud metadata endpoints and SPIFFE workloads need.
|
|
24
|
+
* @param {String} [config.distributedClaimAccessToken] - A literal OAuth access token forwarded
|
|
25
|
+
* to Vault as `distributed_claim_access_token`. Azure/Entra roles with `fetch_groups` enabled
|
|
26
|
+
* need it so Vault can resolve the distributed group-membership claim against the Microsoft
|
|
27
|
+
* Graph API. Optional, and mutually exclusive with `distributedClaimAccessTokenProvider`.
|
|
28
|
+
* @param {Function} [config.distributedClaimAccessTokenProvider] - (optionally async) function
|
|
29
|
+
* invoked at login time, returning `string | Promise<string>`. Called fresh on every login
|
|
30
|
+
* (never at construction or cached across logins) because Graph access tokens are short-lived
|
|
31
|
+
* and are usually acquired next to the JWT itself. Mutually exclusive with
|
|
32
|
+
* `distributedClaimAccessToken`.
|
|
33
|
+
* @param {String} [config.namespace] - Optional. Vault namespace. Applied as the X-Vault-Namespace
|
|
34
|
+
* header to every request by {@link VaultApiClient}; see {@link VaultClient#constructor}.
|
|
35
|
+
* @param {String} mount - Vault's mount point ("jwt" by default)
|
|
36
|
+
*/
|
|
37
|
+
constructor(apiClient, logger, config, mount) {
|
|
38
|
+
super(apiClient, logger, mount || 'jwt');
|
|
39
|
+
|
|
40
|
+
const providedSources = JWT_SOURCE_KEYS.filter((key) => config && config[key] !== undefined);
|
|
41
|
+
if (providedSources.length !== 1) {
|
|
42
|
+
throw new errors.InvalidArgumentsError(
|
|
43
|
+
'Exactly one of "jwt", "jwtPath" or "jwtProvider" should be provided for VaultJwtAuth'
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
if (config.jwtProvider !== undefined && typeof config.jwtProvider !== 'function') {
|
|
47
|
+
throw new errors.InvalidArgumentsError('"jwtProvider" should be a function for VaultJwtAuth');
|
|
48
|
+
}
|
|
49
|
+
if (config.distributedClaimAccessToken !== undefined
|
|
50
|
+
&& config.distributedClaimAccessTokenProvider !== undefined) {
|
|
51
|
+
throw new errors.InvalidArgumentsError(
|
|
52
|
+
'Only one of "distributedClaimAccessToken" or "distributedClaimAccessTokenProvider"'
|
|
53
|
+
+ ' should be provided for VaultJwtAuth'
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
if (config.distributedClaimAccessTokenProvider !== undefined
|
|
57
|
+
&& typeof config.distributedClaimAccessTokenProvider !== 'function') {
|
|
58
|
+
throw new errors.InvalidArgumentsError(
|
|
59
|
+
'"distributedClaimAccessTokenProvider" should be a function for VaultJwtAuth'
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
this.__role = config.role;
|
|
64
|
+
this.__jwt = config.jwt;
|
|
65
|
+
this.__jwtPath = config.jwtPath;
|
|
66
|
+
this.__jwtProvider = config.jwtProvider;
|
|
67
|
+
this.__distributedClaimAccessToken = config.distributedClaimAccessToken;
|
|
68
|
+
this.__distributedClaimAccessTokenProvider = config.distributedClaimAccessTokenProvider;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
_authenticate() {
|
|
72
|
+
return Promise.resolve()
|
|
73
|
+
.then(() => this.__acquireJwt())
|
|
74
|
+
.then(({ jwt, source }) => Promise.resolve()
|
|
75
|
+
.then(() => this.__acquireDistributedClaimAccessToken())
|
|
76
|
+
.then((distributedClaim) => {
|
|
77
|
+
this._log.info(
|
|
78
|
+
'making authentication request: Vault role: "%s"; JWT source: %s (%d bytes)%s',
|
|
79
|
+
this.__role !== undefined ? this.__role : '(default_role)', source, jwt.length,
|
|
80
|
+
distributedClaim === undefined
|
|
81
|
+
? ''
|
|
82
|
+
: `; distributed claim access token: ${distributedClaim.source}`
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const body = { jwt };
|
|
86
|
+
if (this.__role !== undefined) {
|
|
87
|
+
body.role = this.__role;
|
|
88
|
+
}
|
|
89
|
+
if (distributedClaim !== undefined) {
|
|
90
|
+
body.distributed_claim_access_token = distributedClaim.accessToken;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return this.__apiClient.makeRequest('POST', `/auth/${this._mount}/login`, body)
|
|
94
|
+
.then((res) => {
|
|
95
|
+
this._log.debug('received Vault client token from JWT login');
|
|
96
|
+
|
|
97
|
+
return this._getTokenEntity(res.auth.client_token);
|
|
98
|
+
});
|
|
99
|
+
}));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* @returns {{jwt: String, source: String}|Promise<{jwt: String, source: String}>}
|
|
104
|
+
* @private
|
|
105
|
+
*/
|
|
106
|
+
__acquireJwt() {
|
|
107
|
+
if (this.__jwt !== undefined) {
|
|
108
|
+
return { jwt: this.__jwt, source: 'literal' };
|
|
109
|
+
}
|
|
110
|
+
if (this.__jwtPath !== undefined) {
|
|
111
|
+
return { jwt: fs.readFileSync(this.__jwtPath).toString(), source: 'file' };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Wrapping in Promise.resolve().then() normalizes both a sync provider (plain return)
|
|
115
|
+
// and a synchronous throw into the same rejection path as an async one.
|
|
116
|
+
return Promise.resolve().then(() => this.__jwtProvider()).then((jwt) => {
|
|
117
|
+
if (typeof jwt !== 'string' || jwt.length === 0) {
|
|
118
|
+
throw new errors.InvalidArgumentsError(
|
|
119
|
+
'"jwtProvider" must resolve to a non-empty JWT string'
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
return { jwt, source: 'provider' };
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Resolves to `undefined` when neither option is configured, so that the login body stays
|
|
128
|
+
* byte-identical to what it was before this option existed.
|
|
129
|
+
*
|
|
130
|
+
* @returns {undefined|{accessToken: String, source: String}|Promise<{accessToken: String, source: String}>}
|
|
131
|
+
* @private
|
|
132
|
+
*/
|
|
133
|
+
__acquireDistributedClaimAccessToken() {
|
|
134
|
+
if (this.__distributedClaimAccessToken !== undefined) {
|
|
135
|
+
return { accessToken: this.__distributedClaimAccessToken, source: 'literal' };
|
|
136
|
+
}
|
|
137
|
+
if (this.__distributedClaimAccessTokenProvider === undefined) {
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Wrapping in Promise.resolve().then() normalizes both a sync provider (plain return)
|
|
142
|
+
// and a synchronous throw into the same rejection path as an async one.
|
|
143
|
+
return Promise.resolve().then(() => this.__distributedClaimAccessTokenProvider()).then((accessToken) => {
|
|
144
|
+
if (typeof accessToken !== 'string' || accessToken.length === 0) {
|
|
145
|
+
throw new errors.InvalidArgumentsError(
|
|
146
|
+
'"distributedClaimAccessTokenProvider" must resolve to a non-empty access token string'
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
return { accessToken, source: 'provider' };
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
module.exports = VaultJwtAuth;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const NODE_18_DEPRECATION_MESSAGE =
|
|
4
|
+
'node-vault-client: this is the last release of this package to support Node.js 18. ' +
|
|
5
|
+
'Node 18 reached end-of-life on 2025-04-30 and no longer receives security patches from ' +
|
|
6
|
+
'the Node.js project. The next major release of node-vault-client will require ' +
|
|
7
|
+
'Node.js >= 20.19.0 — see CHANGELOG.md for details.';
|
|
8
|
+
const NODE_18_DEPRECATION_CODE = 'NodeVaultClientNode18Deprecation';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Emits a one-time process warning when running on Node.js 18. Called once, at module load,
|
|
12
|
+
* from VaultClient.js — require()'s own module cache is what keeps it to once per process,
|
|
13
|
+
* not any dedup logic here.
|
|
14
|
+
*
|
|
15
|
+
* Takes a `process`-like object (defaulting to the real `process`) purely so tests can assert
|
|
16
|
+
* the behaviour on a stubbed Node version instead of on whatever Node this suite happens to run
|
|
17
|
+
* under.
|
|
18
|
+
*/
|
|
19
|
+
function warnIfNode18(proc) {
|
|
20
|
+
proc = proc || process;
|
|
21
|
+
const nodeVersion = proc.versions && proc.versions.node;
|
|
22
|
+
if (typeof nodeVersion === 'string' && /^18\./.test(nodeVersion)) {
|
|
23
|
+
proc.emitWarning(NODE_18_DEPRECATION_MESSAGE, { code: NODE_18_DEPRECATION_CODE });
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
module.exports = { warnIfNode18, NODE_18_DEPRECATION_MESSAGE, NODE_18_DEPRECATION_CODE };
|
package/src/errors.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export declare class VaultError extends Error {
|
|
2
|
+
constructor(message: string, options?: { cause?: unknown });
|
|
3
|
+
name: string;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export declare class InvalidArgumentsError extends VaultError {}
|
|
7
|
+
|
|
8
|
+
export declare class InvalidAWSCredentialsError extends InvalidArgumentsError {}
|
|
9
|
+
|
|
10
|
+
export declare class AuthTokenExpiredError extends VaultError {}
|
|
11
|
+
|
|
12
|
+
export declare class UnsupportedOperationError extends VaultError {}
|
|
13
|
+
|
|
14
|
+
export declare class VaultHttpError extends VaultError {
|
|
15
|
+
constructor(statusCode: number, text: string, body?: unknown);
|
|
16
|
+
statusCode: number;
|
|
17
|
+
error: unknown;
|
|
18
|
+
}
|