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 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', // Optional. Role configured in Vault's JWT auth backend; omitted uses the mount's `default_role`
171
- jwt: process.env.CI_JOB_JWT, // Exactly one of `jwt` / `jwtPath` / `jwtProvider` is required (see below)
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.2.0",
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.0"
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": "^5.9.3"
73
+ "typescript": "^7.0.2"
74
74
  }
75
75
  }
@@ -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 = {};
@@ -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._log.info(
52
- 'making authentication request: Vault role: "%s"; JWT source: %s (%d bytes)',
53
- this.__role !== undefined ? this.__role : '(default_role)', source, jwt.length
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
- const body = { jwt };
57
- if (this.__role !== undefined) {
58
- body.role = this.__role;
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
- return this.__apiClient.makeRequest('POST', `/auth/${this._mount}/login`, body)
62
- .then((res) => {
63
- this._log.debug('received Vault client token from JWT login');
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
- return this._getTokenEntity(res.auth.client_token);
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 };