node-vault-client 2.2.0 → 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 +113 -3
- package/index.d.ts +7 -1
- package/package.json +3 -3
- package/src/VaultClient.js +3 -0
- package/src/auth/VaultJwtAuth.js +74 -15
- package/src/checkNodeVersion.js +27 -0
package/README.md
CHANGED
|
@@ -18,6 +18,12 @@ 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
|
+
|
|
21
27
|
### TypeScript
|
|
22
28
|
|
|
23
29
|
Type declarations ship with the package, so no separate install is needed:
|
|
@@ -36,7 +42,8 @@ const password = lease.getValue<string>('password');
|
|
|
36
42
|
|
|
37
43
|
`auth` is a discriminated union on `type`, so each backend only accepts its own
|
|
38
44
|
configuration, and the three mutually-exclusive JWT sources (`jwt`, `jwtPath`,
|
|
39
|
-
`jwtProvider`) are enforced at compile time
|
|
45
|
+
`jwtProvider`) are enforced at compile time — as is the mutually-exclusive pair
|
|
46
|
+
`distributedClaimAccessToken` / `distributedClaimAccessTokenProvider`.
|
|
40
47
|
|
|
41
48
|
Types that appear in signatures are exported in type space under the `VaultClient`
|
|
42
49
|
namespace — `VaultClient.Lease`, `VaultClient.VaultOptions`, `VaultClient.AuthToken`
|
|
@@ -167,8 +174,9 @@ const vaultClient = VaultClient.boot('main', {
|
|
|
167
174
|
type: 'jwt',
|
|
168
175
|
mount: 'jwt', // Optional. Vault JWT auth mount point ("jwt" by default)
|
|
169
176
|
config: {
|
|
170
|
-
role: 'my-app',
|
|
171
|
-
jwt: process.env.CI_JOB_JWT,
|
|
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`
|
|
172
180
|
},
|
|
173
181
|
},
|
|
174
182
|
});
|
|
@@ -191,6 +199,96 @@ Exactly one of three mutually exclusive `config` keys supplies the JWT:
|
|
|
191
199
|
be minted per login — GitHub Actions' `core.getIDToken()`, a cloud metadata endpoint, a
|
|
192
200
|
SPIFFE/SPIRE workload API.
|
|
193
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
|
+
|
|
194
292
|
#### Authenticating from GitHub Actions
|
|
195
293
|
|
|
196
294
|
```yaml
|
|
@@ -212,6 +310,18 @@ const vaultClient = VaultClient.boot('ci', {
|
|
|
212
310
|
});
|
|
213
311
|
```
|
|
214
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
|
+
|
|
215
325
|
`role` is optional here too — omit it to use the mount's `default_role`. `mount` and
|
|
216
326
|
`api.namespace` behave exactly as they do for the other four backends.
|
|
217
327
|
|
package/index.d.ts
CHANGED
|
@@ -79,12 +79,18 @@ declare namespace VaultClient {
|
|
|
79
79
|
namespace?: string;
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
type JwtDistributedClaimConfig =
|
|
83
|
+
| { distributedClaimAccessToken?: never; distributedClaimAccessTokenProvider?: never }
|
|
84
|
+
| { distributedClaimAccessToken: string; distributedClaimAccessTokenProvider?: never }
|
|
85
|
+
| { distributedClaimAccessTokenProvider: () => string | Promise<string>; distributedClaimAccessToken?: never };
|
|
86
|
+
|
|
82
87
|
type JwtAuthConfig = JwtAuthConfigCommon &
|
|
83
88
|
(
|
|
84
89
|
| { jwt: string; jwtPath?: never; jwtProvider?: never }
|
|
85
90
|
| { jwtPath: string; jwt?: never; jwtProvider?: never }
|
|
86
91
|
| { jwtProvider: () => string | Promise<string>; jwt?: never; jwtPath?: never }
|
|
87
|
-
)
|
|
92
|
+
) &
|
|
93
|
+
JwtDistributedClaimConfig;
|
|
88
94
|
|
|
89
95
|
interface AuthOptionsCommon {
|
|
90
96
|
mount?: string;
|
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"
|
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
"glob": "^13.0.0",
|
|
58
58
|
"diff": "^8.0.3",
|
|
59
59
|
"brace-expansion": "^5.0.9",
|
|
60
|
-
"js-yaml": "^4.3.
|
|
60
|
+
"js-yaml": "^4.3.2"
|
|
61
61
|
},
|
|
62
62
|
"devDependencies": {
|
|
63
63
|
"@eslint/js": "^9.39.5",
|
|
@@ -70,6 +70,6 @@
|
|
|
70
70
|
"mocha": "^11.7.6",
|
|
71
71
|
"sinon": "^22.1.0",
|
|
72
72
|
"sinon-chai": "^4.0.1",
|
|
73
|
-
"typescript": "^
|
|
73
|
+
"typescript": "^7.0.2"
|
|
74
74
|
}
|
|
75
75
|
}
|
package/src/VaultClient.js
CHANGED
|
@@ -12,6 +12,9 @@ const VaultKubernetesAuth = require('./auth/VaultKubernetesAuth');
|
|
|
12
12
|
const VaultJwtAuth = require('./auth/VaultJwtAuth');
|
|
13
13
|
const MountResolver = require('./MountResolver');
|
|
14
14
|
const { rewritePath, normalizeResponse } = require('./kvTransform');
|
|
15
|
+
const { warnIfNode18 } = require('./checkNodeVersion');
|
|
16
|
+
|
|
17
|
+
warnIfNode18();
|
|
15
18
|
|
|
16
19
|
const noop = () => {};
|
|
17
20
|
const vaultInstances = {};
|
package/src/auth/VaultJwtAuth.js
CHANGED
|
@@ -21,6 +21,15 @@ class VaultJwtAuth extends VaultBaseAuth {
|
|
|
21
21
|
* returning `string | Promise<string>`. Called fresh on every login (never at construction
|
|
22
22
|
* or cached across logins) so it can mint a short-lived token -- the shape GitHub Actions'
|
|
23
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`.
|
|
24
33
|
* @param {String} [config.namespace] - Optional. Vault namespace. Applied as the X-Vault-Namespace
|
|
25
34
|
* header to every request by {@link VaultApiClient}; see {@link VaultClient#constructor}.
|
|
26
35
|
* @param {String} mount - Vault's mount point ("jwt" by default)
|
|
@@ -37,34 +46,57 @@ class VaultJwtAuth extends VaultBaseAuth {
|
|
|
37
46
|
if (config.jwtProvider !== undefined && typeof config.jwtProvider !== 'function') {
|
|
38
47
|
throw new errors.InvalidArgumentsError('"jwtProvider" should be a function for VaultJwtAuth');
|
|
39
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
|
+
}
|
|
40
62
|
|
|
41
63
|
this.__role = config.role;
|
|
42
64
|
this.__jwt = config.jwt;
|
|
43
65
|
this.__jwtPath = config.jwtPath;
|
|
44
66
|
this.__jwtProvider = config.jwtProvider;
|
|
67
|
+
this.__distributedClaimAccessToken = config.distributedClaimAccessToken;
|
|
68
|
+
this.__distributedClaimAccessTokenProvider = config.distributedClaimAccessTokenProvider;
|
|
45
69
|
}
|
|
46
70
|
|
|
47
71
|
_authenticate() {
|
|
48
72
|
return Promise.resolve()
|
|
49
73
|
.then(() => this.__acquireJwt())
|
|
50
|
-
.then(({ jwt, source }) =>
|
|
51
|
-
this.
|
|
52
|
-
|
|
53
|
-
this.
|
|
54
|
-
|
|
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
|
+
);
|
|
55
84
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
+
}
|
|
60
92
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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');
|
|
64
96
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
97
|
+
return this._getTokenEntity(res.auth.client_token);
|
|
98
|
+
});
|
|
99
|
+
}));
|
|
68
100
|
}
|
|
69
101
|
|
|
70
102
|
/**
|
|
@@ -90,6 +122,33 @@ class VaultJwtAuth extends VaultBaseAuth {
|
|
|
90
122
|
return { jwt, source: 'provider' };
|
|
91
123
|
});
|
|
92
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
|
+
}
|
|
93
152
|
}
|
|
94
153
|
|
|
95
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 };
|