Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
70 changes: 67 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ const password = lease.getValue<string>('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`
Expand Down Expand Up @@ -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`
},
},
});
Expand Down Expand Up @@ -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<string>`. 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/<tenant-id>/v2.0 \
provider_config='{"provider":"azure","fetch_groups":true}'

vault write auth/jwt/role/my-app \
role_type=jwt user_claim=sub bound_audiences=<application-client-id> \
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
Expand Down
8 changes: 7 additions & 1 deletion index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,12 +79,18 @@ declare namespace VaultClient {
namespace?: string;
}

type JwtDistributedClaimConfig =
| { distributedClaimAccessToken?: never; distributedClaimAccessTokenProvider?: never }
| { distributedClaimAccessToken: string; distributedClaimAccessTokenProvider?: never }
| { distributedClaimAccessTokenProvider: () => string | Promise<string>; distributedClaimAccessToken?: never };

type JwtAuthConfig = JwtAuthConfigCommon &
(
| { jwt: string; jwtPath?: never; jwtProvider?: never }
| { jwtPath: string; jwt?: never; jwtProvider?: never }
| { jwtProvider: () => string | Promise<string>; jwt?: never; jwtPath?: never }
);
) &
JwtDistributedClaimConfig;

interface AuthOptionsCommon {
mount?: string;
Expand Down
89 changes: 74 additions & 15 deletions src/auth/VaultJwtAuth.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,15 @@ class VaultJwtAuth extends VaultBaseAuth {
* returning `string | Promise<string>`. 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<string>`. 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)
Expand All @@ -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);
});
}));
}

/**
Expand All @@ -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;
Loading