diff --git a/CHANGELOG.md b/CHANGELOG.md index d8300a3..de737c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,17 @@ +# Unreleased + +- 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 + as the role has some other bound constraint it is accepted, and the failure only appears at login + as `audience claim found in JWT but no audiences bound to the role`. The JWT section now gives + that error verbatim, the wrong-audience variant, and a `vault write .../role/...` example that + binds the audience. The GitHub Actions section additionally spells out that + `core.getIDToken('vault')` mints `aud: vault`, so the role must bind `vault` — following that + snippet without it was a guaranteed failed login. Also recorded that Vault does not require an + `exp` claim, so a `jwtProvider` that omits one produces a credential that never expires. Every + quoted error string was taken from a live Vault and is identical on 1.21 and 2.0. No code change. + # 2.2.0 Release notes (2026-09-01) - Documented three things about the HTTP transport that were true but unwritten, and scoped the diff --git a/README.md b/README.md index 5453b83..3fd5ca8 100644 --- a/README.md +++ b/README.md @@ -191,6 +191,34 @@ Exactly one of three mutually exclusive `config` keys supplies the JWT: be minted per login — GitHub Actions' `core.getIDToken()`, a cloud metadata endpoint, a SPIFFE/SPIRE workload API. +##### The Vault role needs `bound_audiences` when your JWT carries an `aud` claim + +This is server-side configuration rather than a client option, but it is the most common reason a +first login fails. Vault requires a `jwt` role to bind the audience your token carries, and it does +**not** catch the omission when the role is created — as long as the role has some other bound +constraint (`bound_subject`, `bound_claims`, ...) it is accepted, and the problem only surfaces at +login: + +``` +VaultHttpError: 400 - {"errors":["audience claim found in JWT but no audiences bound to the role"]} +``` + +A wrong (rather than missing) audience fails with `error validating token: invalid audience (aud) +claim: audience claim does not match any expected audience`. So bind the audience the token +actually has: + +```shell +vault write auth/jwt/role/my-app \ + role_type=jwt user_claim=sub bound_audiences=my-audience token_policies=my-policy +``` + +A role with *no* bound constraint at all is rejected when you create it (`must have at least one +bound constraint when creating/updating a role`), so that case is self-correcting. An `aud` array +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`. + #### Authenticating from GitHub Actions ```yaml @@ -212,6 +240,18 @@ const vaultClient = VaultClient.boot('ci', { }); ``` +`core.getIDToken('vault')` mints a token whose `aud` is `vault`, so the role has to bind that +audience or the login fails with `audience claim found in JWT but no audiences bound to the role`: + +```shell +vault write auth/gha/role/ci \ + role_type=jwt user_claim=sub bound_audiences=vault \ + bound_claims='{"repository":"my-org/my-repo"}' token_policies=ci +``` + +Pass the same string to `core.getIDToken()` and to `bound_audiences`. Calling `getIDToken()` with +no argument uses GitHub's default audience instead, which then will not match. + `role` is optional here too — omit it to use the mount's `default_role`. `mount` and `api.namespace` behave exactly as they do for the other four backends.