From 5063a5607a1d556bbf836e1bf7bcb8575eac90f2 Mon Sep 17 00:00:00 2001 From: kurok <22548029+kurok@users.noreply.github.com> Date: Wed, 2 Sep 2026 08:52:22 +0100 Subject: [PATCH] feat(auth): support distributed_claim_access_token on JWT login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vault's JWT login API accepts an optional `distributed_claim_access_token` body parameter, which VaultJwtAuth never sent. It only applies to the Azure (Entra ID) provider: past 200 groups Azure stops putting group membership in the token and emits OIDC distributed claims instead, and a mount whose `provider_config` sets `fetch_groups` skips the claim unconditionally. Either way Vault resolves the groups against the Microsoft Graph API itself, and the JWT being logged in with is not a credential for that call — so an Azure role relying on group lookups could not be used through this client. Two optional, mutually exclusive `config` keys now supply it: * `distributedClaimAccessToken` — a literal Graph access token. * `distributedClaimAccessTokenProvider` — an (optionally async) function invoked fresh at login time, never at construction and never cached, mirroring `jwtProvider`. Graph access tokens last about an hour, so the literal form inherits the literal-`jwt` staleness caveat and is wrong for anything longer-lived than a one-shot script. Purely additive. With neither key set the login body is byte-for-byte what it was before — the key is absent rather than undefined — so no existing configuration changes behaviour. The access token is treated as a credential and never logged; only its source is. Signed-off-by: kurok <22548029+kurok@users.noreply.github.com> --- CHANGELOG.md | 23 ++++ README.md | 70 ++++++++++- index.d.ts | 8 +- src/auth/VaultJwtAuth.js | 89 ++++++++++--- test/auth.jwt.test.mjs | 262 +++++++++++++++++++++++++++++++++++++++ types/index.test-d.ts | 15 +++ 6 files changed, 448 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index de737c2..f28da5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,28 @@ # Unreleased +- Added optional `distributedClaimAccessToken` and `distributedClaimAccessTokenProvider` keys to + the `jwt` backend's `config` (#175), which supply Vault's optional + `distributed_claim_access_token` login parameter. It matters only for Microsoft Entra ID (Azure + AD): past 200 groups Azure stops putting group membership in the token and sends OIDC distributed + claims instead — `_claim_names`/`_claim_sources` pointing at the Microsoft Graph API — and a mount + whose `provider_config` sets `fetch_groups` skips the claim unconditionally and always asks Graph. + Either way Vault has to call Graph itself, and the JWT being logged in with is not a credential + for that call, so without this parameter such a login fails at the group-fetch step — after the + JWT has already validated, so the failure names the group lookup rather than the token, which was + fine. `distributedClaimAccessToken` takes a literal Graph access token; it is fixed at + construction and re-sent on every re-login, so it inherits the literal-`jwt` staleness caveat — + more sharply, since an Entra access token lasts about an hour. + `distributedClaimAccessTokenProvider` takes an (optionally async) function invoked fresh at login + time — never at construction, never cached — exactly mirroring `jwtProvider`, and is the option + for anything longer-lived than the Graph token. Providing both raises `InvalidArgumentsError` at + construction, as does a non-function provider; a provider resolving to a non-string or empty + string raises it at login, without a request being sent. Purely additive: both keys are optional and + absent by default, and with neither set the login request body is byte-for-byte what it was + before, so no existing configuration changes behaviour. The README's JWT section gains a + subsection on when this is needed, and records that `fetch_groups` is a `provider_config` option + on the auth mount's config rather than on the role, while `groups_claim` — without which Vault + resolves no groups and ignores any Graph token passed — is on the role. + - Documented the `bound_audiences` requirement for JWT auth, which is the most common reason a first login fails and was previously absent from the README. Vault requires a `jwt` role to bind the audience the token carries, but does not catch the omission when the role is created: as long diff --git a/README.md b/README.md index 3fd5ca8..29bb5e2 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,8 @@ const password = lease.getValue('password'); `auth` is a discriminated union on `type`, so each backend only accepts its own configuration, and the three mutually-exclusive JWT sources (`jwt`, `jwtPath`, -`jwtProvider`) are enforced at compile time. +`jwtProvider`) are enforced at compile time — as is the mutually-exclusive pair +`distributedClaimAccessToken` / `distributedClaimAccessTokenProvider`. Types that appear in signatures are exported in type space under the `VaultClient` namespace — `VaultClient.Lease`, `VaultClient.VaultOptions`, `VaultClient.AuthToken` @@ -167,8 +168,9 @@ const vaultClient = VaultClient.boot('main', { type: 'jwt', mount: 'jwt', // Optional. Vault JWT auth mount point ("jwt" by default) config: { - role: 'my-app', // Optional. Role configured in Vault's JWT auth backend; omitted uses the mount's `default_role` - jwt: process.env.CI_JOB_JWT, // Exactly one of `jwt` / `jwtPath` / `jwtProvider` is required (see below) + role: 'my-app', // Optional. Role configured in Vault's JWT auth backend; omitted uses the mount's `default_role` + jwt: process.env.CI_JOB_JWT, // Exactly one of `jwt` / `jwtPath` / `jwtProvider` is required (see below) + distributedClaimAccessToken: process.env.GRAPH_TOKEN, // Optional. Azure/Entra ID group lookups only (see below); or `distributedClaimAccessTokenProvider` }, }, }); @@ -219,6 +221,68 @@ is fine — Vault matches `bound_audiences` against any entry. Vault also does not require an `exp` claim: a token minted without one is accepted and never expires. If you write your own `jwtProvider`, give the tokens it mints a short `exp`. +##### Azure / Entra ID group lookups need `distributedClaimAccessToken` + +Skip this unless you log in with Microsoft Entra ID (Azure AD) tokens *and* the Vault role sets +`groups_claim`. Vault's `distributed_claim_access_token` parameter +["only applies to the Azure (Entra ID) provider"](https://developer.hashicorp.com/vault/api-docs/auth/jwt#distributed_claim_access_token); +on every other IdP these two keys do nothing at all. + +Azure does not always put group membership in the token. Past 200 groups it sends OIDC +[distributed claims](https://openid.net/specs/openid-connect-core-1_0.html#AggregatedDistributedClaims) +instead — `_claim_names`/`_claim_sources` pointing at the Microsoft Graph API rather than the group +names themselves — and a mount configured with `fetch_groups` skips the claim entirely and always +asks Graph. Either way Vault has to call Graph itself, and the JWT you logged in with is not a +credential for that call: it needs a separate Graph access token, sent on the login request as the +optional `distributed_claim_access_token`. Leave it out on a mount/role set up this way and the +login fails at the group-fetch step — *after* the JWT has already validated, so the error names the +group lookup rather than anything about your token. + +Two optional, mutually exclusive `config` keys supply it. Passing both raises +`InvalidArgumentsError` at construction; passing neither leaves the login request byte-for-byte +what it was before these keys existed. + +* **`distributedClaimAccessToken`** — a literal Graph access token. It carries exactly the caveat + the literal `jwt` above does, for the same reason: the value is fixed at construction and every + re-login re-sends it, so it works until that token expires. Entra access tokens last about an + hour, which is shorter than most processes — fine for a one-shot script, wrong for a service. +* **`distributedClaimAccessTokenProvider`** — an (optionally async) function, called fresh at login + time (never at construction, never cached), returning `string | Promise`. Acquire the + Graph token inside it — an MSAL client-credentials call, the Azure IMDS managed-identity endpoint + — and every login gets a current one. Resolving to a non-string or an empty string raises + `InvalidArgumentsError` without sending a login request. + +```javascript +const vaultClient = VaultClient.boot('main', { + api: { url: 'https://vault.example.com:8200/' }, + auth: { + type: 'jwt', + config: { + role: 'my-app', + jwtProvider: () => getEntraIdToken(), + distributedClaimAccessTokenProvider: () => getGraphAccessToken(), + }, + }, +}); +``` + +On the Vault side, `fetch_groups` lives in `provider_config` on the **mount's config, not on the +role** — which is where people tend to look for it: + +```shell +vault write auth/jwt/config \ + oidc_discovery_url=https://login.microsoftonline.com//v2.0 \ + provider_config='{"provider":"azure","fetch_groups":true}' + +vault write auth/jwt/role/my-app \ + role_type=jwt user_claim=sub bound_audiences= \ + groups_claim=groups token_policies=my-policy +``` + +`fetch_groups` is optional — the distributed-claim path is taken without it whenever Azure omits +the groups claim. `groups_claim` on the role is not: with it unset Vault never resolves groups at +all, so a Graph token you pass is accepted and never used. + #### Authenticating from GitHub Actions ```yaml diff --git a/index.d.ts b/index.d.ts index d33e3e4..8d8ec2c 100644 --- a/index.d.ts +++ b/index.d.ts @@ -79,12 +79,18 @@ declare namespace VaultClient { namespace?: string; } + type JwtDistributedClaimConfig = + | { distributedClaimAccessToken?: never; distributedClaimAccessTokenProvider?: never } + | { distributedClaimAccessToken: string; distributedClaimAccessTokenProvider?: never } + | { distributedClaimAccessTokenProvider: () => string | Promise; distributedClaimAccessToken?: never }; + type JwtAuthConfig = JwtAuthConfigCommon & ( | { jwt: string; jwtPath?: never; jwtProvider?: never } | { jwtPath: string; jwt?: never; jwtProvider?: never } | { jwtProvider: () => string | Promise; jwt?: never; jwtPath?: never } - ); + ) & + JwtDistributedClaimConfig; interface AuthOptionsCommon { mount?: string; diff --git a/src/auth/VaultJwtAuth.js b/src/auth/VaultJwtAuth.js index bb687e0..dc8a5f8 100644 --- a/src/auth/VaultJwtAuth.js +++ b/src/auth/VaultJwtAuth.js @@ -21,6 +21,15 @@ class VaultJwtAuth extends VaultBaseAuth { * returning `string | Promise`. Called fresh on every login (never at construction * or cached across logins) so it can mint a short-lived token -- the shape GitHub Actions' * `core.getIDToken()`, cloud metadata endpoints and SPIFFE workloads need. + * @param {String} [config.distributedClaimAccessToken] - A literal OAuth access token forwarded + * to Vault as `distributed_claim_access_token`. Azure/Entra roles with `fetch_groups` enabled + * need it so Vault can resolve the distributed group-membership claim against the Microsoft + * Graph API. Optional, and mutually exclusive with `distributedClaimAccessTokenProvider`. + * @param {Function} [config.distributedClaimAccessTokenProvider] - (optionally async) function + * invoked at login time, returning `string | Promise`. Called fresh on every login + * (never at construction or cached across logins) because Graph access tokens are short-lived + * and are usually acquired next to the JWT itself. Mutually exclusive with + * `distributedClaimAccessToken`. * @param {String} [config.namespace] - Optional. Vault namespace. Applied as the X-Vault-Namespace * header to every request by {@link VaultApiClient}; see {@link VaultClient#constructor}. * @param {String} mount - Vault's mount point ("jwt" by default) @@ -37,34 +46,57 @@ class VaultJwtAuth extends VaultBaseAuth { if (config.jwtProvider !== undefined && typeof config.jwtProvider !== 'function') { throw new errors.InvalidArgumentsError('"jwtProvider" should be a function for VaultJwtAuth'); } + if (config.distributedClaimAccessToken !== undefined + && config.distributedClaimAccessTokenProvider !== undefined) { + throw new errors.InvalidArgumentsError( + 'Only one of "distributedClaimAccessToken" or "distributedClaimAccessTokenProvider"' + + ' should be provided for VaultJwtAuth' + ); + } + if (config.distributedClaimAccessTokenProvider !== undefined + && typeof config.distributedClaimAccessTokenProvider !== 'function') { + throw new errors.InvalidArgumentsError( + '"distributedClaimAccessTokenProvider" should be a function for VaultJwtAuth' + ); + } this.__role = config.role; this.__jwt = config.jwt; this.__jwtPath = config.jwtPath; this.__jwtProvider = config.jwtProvider; + this.__distributedClaimAccessToken = config.distributedClaimAccessToken; + this.__distributedClaimAccessTokenProvider = config.distributedClaimAccessTokenProvider; } _authenticate() { return Promise.resolve() .then(() => this.__acquireJwt()) - .then(({ jwt, source }) => { - this._log.info( - 'making authentication request: Vault role: "%s"; JWT source: %s (%d bytes)', - this.__role !== undefined ? this.__role : '(default_role)', source, jwt.length - ); + .then(({ jwt, source }) => Promise.resolve() + .then(() => this.__acquireDistributedClaimAccessToken()) + .then((distributedClaim) => { + this._log.info( + 'making authentication request: Vault role: "%s"; JWT source: %s (%d bytes)%s', + this.__role !== undefined ? this.__role : '(default_role)', source, jwt.length, + distributedClaim === undefined + ? '' + : `; distributed claim access token: ${distributedClaim.source}` + ); - const body = { jwt }; - if (this.__role !== undefined) { - body.role = this.__role; - } + const body = { jwt }; + if (this.__role !== undefined) { + body.role = this.__role; + } + if (distributedClaim !== undefined) { + body.distributed_claim_access_token = distributedClaim.accessToken; + } - return this.__apiClient.makeRequest('POST', `/auth/${this._mount}/login`, body) - .then((res) => { - this._log.debug('received Vault client token from JWT login'); + return this.__apiClient.makeRequest('POST', `/auth/${this._mount}/login`, body) + .then((res) => { + this._log.debug('received Vault client token from JWT login'); - return this._getTokenEntity(res.auth.client_token); - }); - }); + return this._getTokenEntity(res.auth.client_token); + }); + })); } /** @@ -90,6 +122,33 @@ class VaultJwtAuth extends VaultBaseAuth { return { jwt, source: 'provider' }; }); } + + /** + * Resolves to `undefined` when neither option is configured, so that the login body stays + * byte-identical to what it was before this option existed. + * + * @returns {undefined|{accessToken: String, source: String}|Promise<{accessToken: String, source: String}>} + * @private + */ + __acquireDistributedClaimAccessToken() { + if (this.__distributedClaimAccessToken !== undefined) { + return { accessToken: this.__distributedClaimAccessToken, source: 'literal' }; + } + if (this.__distributedClaimAccessTokenProvider === undefined) { + return undefined; + } + + // Wrapping in Promise.resolve().then() normalizes both a sync provider (plain return) + // and a synchronous throw into the same rejection path as an async one. + return Promise.resolve().then(() => this.__distributedClaimAccessTokenProvider()).then((accessToken) => { + if (typeof accessToken !== 'string' || accessToken.length === 0) { + throw new errors.InvalidArgumentsError( + '"distributedClaimAccessTokenProvider" must resolve to a non-empty access token string' + ); + } + return { accessToken, source: 'provider' }; + }); + } } module.exports = VaultJwtAuth; diff --git a/test/auth.jwt.test.mjs b/test/auth.jwt.test.mjs index de65e23..c27ecfd 100644 --- a/test/auth.jwt.test.mjs +++ b/test/auth.jwt.test.mjs @@ -27,6 +27,7 @@ const logger = createNoopLogger(); // Distinctive values so the log-hygiene assertions cannot pass by accident. const THE_JWT = 'eyJhbGciOiJSUzI1NiJ9.TDD-SECRET-JWT-VALUE.sig'; const THE_CLIENT_TOKEN = 'hvs.THE-VAULT-CLIENT-TOKEN-NOBODY-MAY-LOG'; +const THE_ACCESS_TOKEN = 'ya29.THE-MS-GRAPH-DISTRIBUTED-CLAIM-ACCESS-TOKEN'; /** * Canned Vault responses. `tokenTtl`/`tokenCreation` control whether the token @@ -223,6 +224,246 @@ describe('VaultJwtAuth (TDD spec for #130)', function () { }); }); + describe('distributed claim access token [#175]', function () { + describe('constructor validation', function () { + it('rejects both the literal and the provider together', function () { + expect(() => new VaultJwtAuth(api(), logger, { + jwt: THE_JWT, + distributedClaimAccessToken: THE_ACCESS_TOKEN, + distributedClaimAccessTokenProvider: () => THE_ACCESS_TOKEN, + })).to.throw( + errors.InvalidArgumentsError, + 'Only one of "distributedClaimAccessToken" or "distributedClaimAccessTokenProvider" should be provided for VaultJwtAuth' + ); + }); + + it('rejects a non-function distributedClaimAccessTokenProvider', function () { + expect(() => new VaultJwtAuth(api(), logger, { + jwt: THE_JWT, + distributedClaimAccessTokenProvider: THE_ACCESS_TOKEN, + })).to.throw( + errors.InvalidArgumentsError, + '"distributedClaimAccessTokenProvider" should be a function for VaultJwtAuth' + ); + }); + + it('accepts either option on its own, and neither at all', function () { + expect(() => new VaultJwtAuth(api(), logger, { jwt: THE_JWT })).to.not.throw(); + expect(() => new VaultJwtAuth(api(), logger, { + jwt: THE_JWT, distributedClaimAccessToken: THE_ACCESS_TOKEN, + })).to.not.throw(); + expect(() => new VaultJwtAuth(api(), logger, { + jwt: THE_JWT, distributedClaimAccessTokenProvider: () => THE_ACCESS_TOKEN, + })).to.not.throw(); + }); + + it('does not call the provider at construction time', function () { + const provider = sinon.stub().resolves(THE_ACCESS_TOKEN); + new VaultJwtAuth(api(), logger, { role: 'r', jwt: THE_JWT, distributedClaimAccessTokenProvider: provider }); + expect(provider).to.not.have.been.called; + }); + }); + + describe('login body', function () { + it('omits the distributed_claim_access_token key entirely when neither option is configured', async function () { + const auth = new VaultJwtAuth(api(), logger, { role: 'my-app', jwt: THE_JWT }); + await auth.getAuthToken(); + const body = loginCalls(fetchStub)[0].body; + // The backward-compatibility guarantee: not "sent as undefined", but absent. + expect(Object.prototype.hasOwnProperty.call(body, 'distributed_claim_access_token'), + 'pre-#175 login bodies must stay byte-identical').to.be.false; + expect(body).to.deep.equal({ role: 'my-app', jwt: THE_JWT }); + }); + + it('omits the key with no role configured either', async function () { + const auth = new VaultJwtAuth(api(), logger, { jwt: THE_JWT }); + await auth.getAuthToken(); + expect(loginCalls(fetchStub)[0].body).to.deep.equal({ jwt: THE_JWT }); + }); + + it('sends the literal distributedClaimAccessToken', async function () { + const auth = new VaultJwtAuth(api(), logger, { + role: 'my-app', jwt: THE_JWT, distributedClaimAccessToken: THE_ACCESS_TOKEN, + }); + await auth.getAuthToken(); + expect(loginCalls(fetchStub)[0].body).to.deep.equal({ + role: 'my-app', jwt: THE_JWT, distributed_claim_access_token: THE_ACCESS_TOKEN, + }); + }); + + it('sends the value resolved by distributedClaimAccessTokenProvider', async function () { + const auth = new VaultJwtAuth(api(), logger, { + role: 'my-app', jwt: THE_JWT, distributedClaimAccessTokenProvider: async () => THE_ACCESS_TOKEN, + }); + await auth.getAuthToken(); + expect(loginCalls(fetchStub)[0].body).to.deep.equal({ + role: 'my-app', jwt: THE_JWT, distributed_claim_access_token: THE_ACCESS_TOKEN, + }); + }); + + it('accepts a synchronous provider', async function () { + const auth = new VaultJwtAuth(api(), logger, { + role: 'r', jwt: THE_JWT, distributedClaimAccessTokenProvider: () => THE_ACCESS_TOKEN, + }); + await auth.getAuthToken(); + expect(loginCalls(fetchStub)[0].body.distributed_claim_access_token).to.equal(THE_ACCESS_TOKEN); + }); + + it('omits the role key while still sending the access token', async function () { + const auth = new VaultJwtAuth(api(), logger, { + jwt: THE_JWT, distributedClaimAccessToken: THE_ACCESS_TOKEN, + }); + await auth.getAuthToken(); + expect(loginCalls(fetchStub)[0].body).to.deep.equal({ + jwt: THE_JWT, distributed_claim_access_token: THE_ACCESS_TOKEN, + }); + }); + }); + + describe('provider lifecycle', function () { + it('is not called at construction, once per login afterwards', async function () { + const provider = sinon.stub().resolves(THE_ACCESS_TOKEN); + const auth = new VaultJwtAuth(api(), logger, { + role: 'r', jwt: THE_JWT, distributedClaimAccessTokenProvider: provider, + }); + expect(provider).to.not.have.been.called; + await auth.getAuthToken(); + expect(provider).to.have.been.calledOnce; + expect(loginCalls(fetchStub)[0].body.distributed_claim_access_token).to.equal(THE_ACCESS_TOKEN); + }); + + it('is called again for a re-login, and the fresh access token is sent (never cached)', async function () { + fetchStub.restore(); + fetchStub = stubFetch({ tokenTtl: 1, tokenCreation: 1600000000 }); + const provider = sinon.stub(); + provider.onFirstCall().resolves('access-first'); + provider.onSecondCall().resolves('access-second'); + const auth = new VaultJwtAuth(api(), logger, { + role: 'r', jwt: THE_JWT, distributedClaimAccessTokenProvider: provider, + }); + await auth.getAuthToken(); + await auth.getAuthToken(); + expect(provider).to.have.been.calledTwice; + expect(loginCalls(fetchStub).map((l) => l.body.distributed_claim_access_token)) + .to.deep.equal(['access-first', 'access-second']); + }); + + it('a rejecting provider fails the login without wedging single-flight', async function () { + const boom = new Error('Graph token endpoint unavailable'); + const provider = sinon.stub(); + provider.onFirstCall().rejects(boom); + provider.onSecondCall().resolves(THE_ACCESS_TOKEN); + const auth = new VaultJwtAuth(api(), logger, { + role: 'r', jwt: THE_JWT, distributedClaimAccessTokenProvider: provider, + }); + let thrown; + try { await auth.getAuthToken(); } catch (err) { thrown = err; } + expect(thrown).to.equal(boom); + expect(loginCalls(fetchStub)).to.have.length(0); + // The failed attempt must not leave a pending login behind: + const token = await auth.getAuthToken(); + expect(token.getId()).to.equal(THE_CLIENT_TOKEN); + expect(loginCalls(fetchStub)[0].body.distributed_claim_access_token).to.equal(THE_ACCESS_TOKEN); + }); + + it('surfaces a synchronously throwing provider as a rejection, not a sync throw', async function () { + const boom = new Error('no Graph credentials in this process'); + const auth = new VaultJwtAuth(api(), logger, { + role: 'r', jwt: THE_JWT, distributedClaimAccessTokenProvider: () => { throw boom; }, + }); + let pending; + expect(() => { pending = auth.getAuthToken(); }, 'must not throw out of _authenticate').to.not.throw(); + let thrown; + try { await pending; } catch (err) { thrown = err; } + expect(thrown).to.equal(boom); + expect(loginCalls(fetchStub)).to.have.length(0); + }); + + it('rejects with InvalidArgumentsError when the provider resolves a non-string', async function () { + const auth = new VaultJwtAuth(api(), logger, { + role: 'r', jwt: THE_JWT, distributedClaimAccessTokenProvider: () => 42, + }); + let thrown; + try { await auth.getAuthToken(); } catch (err) { thrown = err; } + expect(thrown).to.be.instanceOf(errors.InvalidArgumentsError); + expect(thrown.message).to.equal( + '"distributedClaimAccessTokenProvider" must resolve to a non-empty access token string' + ); + expect(loginCalls(fetchStub)).to.have.length(0); // no garbage login sent + }); + + it('rejects with InvalidArgumentsError when the provider resolves an empty string', async function () { + const auth = new VaultJwtAuth(api(), logger, { + role: 'r', jwt: THE_JWT, distributedClaimAccessTokenProvider: async () => '', + }); + let thrown; + try { await auth.getAuthToken(); } catch (err) { thrown = err; } + expect(thrown).to.be.instanceOf(errors.InvalidArgumentsError); + expect(thrown.message).to.equal( + '"distributedClaimAccessTokenProvider" must resolve to a non-empty access token string' + ); + expect(loginCalls(fetchStub)).to.have.length(0); + }); + }); + + describe('combined with each JWT source', function () { + let jwtFile; + + beforeEach(function () { + jwtFile = path.join(os.tmpdir(), `nvc-jwt-dcat-${process.pid}.jwt`); + fs.writeFileSync(jwtFile, THE_JWT); + }); + + afterEach(function () { + try { fs.unlinkSync(jwtFile); } catch { /* ignore */ } + }); + + it('works alongside jwtPath', async function () { + const auth = new VaultJwtAuth(api(), logger, { + role: 'r', jwtPath: jwtFile, distributedClaimAccessToken: THE_ACCESS_TOKEN, + }); + await auth.getAuthToken(); + expect(loginCalls(fetchStub)[0].body).to.deep.equal({ + role: 'r', jwt: THE_JWT, distributed_claim_access_token: THE_ACCESS_TOKEN, + }); + }); + + it('works alongside jwtProvider, with both providers called per login', async function () { + const jwtProvider = sinon.stub().resolves(THE_JWT); + const accessTokenProvider = sinon.stub().resolves(THE_ACCESS_TOKEN); + const auth = new VaultJwtAuth(api(), logger, { + jwtProvider, distributedClaimAccessTokenProvider: accessTokenProvider, + }); + await auth.getAuthToken(); + expect(jwtProvider).to.have.been.calledOnce; + expect(accessTokenProvider).to.have.been.calledOnce; + expect(loginCalls(fetchStub)[0].body).to.deep.equal({ + jwt: THE_JWT, distributed_claim_access_token: THE_ACCESS_TOKEN, + }); + }); + + it('does not send the access token when the JWT source itself fails', async function () { + const auth = new VaultJwtAuth(api(), logger, { + role: 'r', jwtProvider: () => 42, distributedClaimAccessToken: THE_ACCESS_TOKEN, + }); + let thrown; + try { await auth.getAuthToken(); } catch (err) { thrown = err; } + expect(thrown).to.be.instanceOf(errors.InvalidArgumentsError); + expect(loginCalls(fetchStub)).to.have.length(0); + }); + + it('logs in against a custom mount with the access token attached', async function () { + const auth = new VaultJwtAuth(api(), logger, { + role: 'r', jwt: THE_JWT, distributedClaimAccessToken: THE_ACCESS_TOKEN, + }, 'entra'); + await auth.getAuthToken(); + const login = loginCalls(fetchStub)[0]; + expect(login.path).to.equal('/v1/auth/entra/login'); + expect(login.body.distributed_claim_access_token).to.equal(THE_ACCESS_TOKEN); + }); + }); + }); + describe('log hygiene (#104 rule) [#131]', function () { it('never passes the JWT or the client token to any log level', async function () { const log = createSpyLogger(); @@ -232,6 +473,15 @@ describe('VaultJwtAuth (TDD spec for #130)', function () { expect(text, 'raw JWT must never reach the logger').to.not.contain(THE_JWT); expect(text, 'client token must never reach the logger').to.not.contain(THE_CLIENT_TOKEN); }); + + it('never passes the distributed claim access token to any log level [#175]', async function () { + const log = createSpyLogger(); + const auth = new VaultJwtAuth(new VaultApiClient({ url: 'https://vault.example' }, log), log, { + role: 'r', jwt: THE_JWT, distributedClaimAccessToken: THE_ACCESS_TOKEN, + }); + await auth.getAuthToken(); + expect(loggedText(log), 'access token must never reach the logger').to.not.contain(THE_ACCESS_TOKEN); + }); }); describe('VaultClient dispatch [#131]', function () { @@ -246,6 +496,18 @@ describe('VaultJwtAuth (TDD spec for #130)', function () { expect(loginCalls(fetchStub)[0].path).to.equal('/v1/auth/jwt/login'); }); + it('forwards auth.config.distributedClaimAccessToken to the login body [#175]', async function () { + const client = new VaultClient({ + api: { url: 'https://vault.example/' }, + logger: false, + auth: { type: 'jwt', config: { role: 'r', jwt: THE_JWT, distributedClaimAccessToken: THE_ACCESS_TOKEN } }, + }); + await client.read('secret/anything'); + expect(loginCalls(fetchStub)[0].body).to.deep.equal({ + role: 'r', jwt: THE_JWT, distributed_claim_access_token: THE_ACCESS_TOKEN, + }); + }); + it('honours the legacy auth.config.namespace location [#133]', async function () { const client = new VaultClient({ api: { url: 'https://vault.example/' }, diff --git a/types/index.test-d.ts b/types/index.test-d.ts index 89ac6c6..0afd71e 100644 --- a/types/index.test-d.ts +++ b/types/index.test-d.ts @@ -35,6 +35,9 @@ new VaultClient({ api: { url: 'u' }, auth: { type: 'jwt', config: { jwt: 'e.y.z' new VaultClient({ api: { url: 'u' }, auth: { type: 'jwt', config: { role: 'r', jwtPath: '/tmp/jwt' } } }); new VaultClient({ api: { url: 'u' }, auth: { type: 'jwt', config: { jwtProvider: async () => 'e.y.z' } } }); new VaultClient({ api: { url: 'u' }, auth: { type: 'jwt', config: { jwtProvider: () => 'e.y.z' } } }); +new VaultClient({ api: { url: 'u' }, auth: { type: 'jwt', config: { jwt: 'e.y.z', distributedClaimAccessToken: 'graph-token' } } }); +new VaultClient({ api: { url: 'u' }, auth: { type: 'jwt', config: { jwt: 'e.y.z', distributedClaimAccessTokenProvider: () => 'graph-token' } } }); +new VaultClient({ api: { url: 'u' }, auth: { type: 'jwt', config: { jwtProvider: async () => 'e.y.z', distributedClaimAccessTokenProvider: async () => 'graph-token' } } }); new VaultClient({ api: { url: 'u' }, @@ -113,6 +116,18 @@ new VaultClient({ api: { url: 'u' }, auth: { type: 'jwt', config: { jwt: 'a', jw // @ts-expect-error a JWT source is required new VaultClient({ api: { url: 'u' }, auth: { type: 'jwt', config: { role: 'r' } } }); +// @ts-expect-error only one distributed claim access token source may be supplied +new VaultClient({ api: { url: 'u' }, auth: { type: 'jwt', config: { jwt: 'a', distributedClaimAccessToken: 't', distributedClaimAccessTokenProvider: () => 't' } } }); + +// @ts-expect-error distributedClaimAccessTokenProvider must be a function +new VaultClient({ api: { url: 'u' }, auth: { type: 'jwt', config: { jwt: 'a', distributedClaimAccessTokenProvider: 't' } } }); + +// @ts-expect-error distributedClaimAccessTokenProvider must resolve to a string +new VaultClient({ api: { url: 'u' }, auth: { type: 'jwt', config: { jwt: 'a', distributedClaimAccessTokenProvider: () => 42 } } }); + +// @ts-expect-error distributedClaimAccessToken is a string +new VaultClient({ api: { url: 'u' }, auth: { type: 'jwt', config: { jwt: 'a', distributedClaimAccessToken: 42 } } }); + // @ts-expect-error renewalFraction is a number new VaultClient({ api: { url: 'u' }, auth: { type: 'token', config: { token: 't' }, renewalFraction: '0.5' } });