Files
unkinben ec12dfb84f
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
Add app-token JWT secrets engine
Implement a generic Vault/OpenBao secrets engine that issues short-lived
signed JWTs for self-made services, replacing per-app static bearer
Secrets. Per-app roles set the audience, TTLs, subject allowlist and
custom claims; creds/<role> mints an EdDSA (or RS256) token. Apps
validate offline against the unauthenticated JWKS + OIDC-metadata paths,
so there is no Vault round-trip per request. Signing keys are seal-wrapped
in the barrier and rotate with a configurable JWKS grace window.

Mirrors the other vault-plugin-secrets-* engines: cmd ServeMultiplex,
Makefile with patch|minor|major tags, .woodpecker CI (k8s resources + SA),
dual-flavour nfpm RPM to artifactapi rpm-internal. Tests (-race) cover
issuance+JWKS validation for both algorithms, rotation grace/trim, role
isolation, subject allowlist and the unauthenticated/seal-wrap wiring.

Claude-Session: https://claude.ai/code/session_01JUoARVdmhxKQHyyyp1pxeT
2026-08-02 23:42:42 +10:00

131 lines
5.9 KiB
Markdown

# vault-plugin-secrets-apptoken
A HashiCorp Vault / OpenBao secrets engine that issues **short-lived, signed
JWT "app tokens"** for self-made services (keaapi, encapi, artifactapi,
tomswallapi, bootapi, ...). It replaces per-app static bearer `Secret`s with one
mechanism: define a per-app role, read `creds/<app>` to mint a token, and let
the app validate it **offline** against the engine's public JWKS — no Vault
round-trip per request.
Any principal allowed to read `creds/<role>` (for example the agents approle)
can mint a token for that app. Signing keys are generated inside Vault's barrier
(seal-wrapped) and never leave it; only the public JWKS is exposed.
```
issuer (this engine) consumer app (keaapi)
├── creds/keaapi ──▶ signed JWT ──────▶ Authorization: Bearer <jwt>
│ (aud=keaapi, exp, sub=caller) │
└── .well-known/jwks.json ◀── fetch ───────┘ verify signature + aud, offline
```
## Signing
Tokens are signed with **EdDSA (Ed25519)** by default — compact signatures,
fast verification, first-class support in Go's stdlib and `go-jose`, which the
whole ecosystem (and Vault itself) uses. `RS256` is available via
`config algorithm=RS256` for consumers that need RSA interop. The `kid` header
is the RFC 7638 JWK thumbprint of the signing key, so it is stable and matches
the JWKS entry.
## Paths
| Path | Op | Purpose |
|------|----|---------|
| `config` | R/W | `issuer` (external mount URL, becomes `iss` + base of `jwks_uri`), `algorithm` (`EdDSA`\|`RS256`), `retained_keys` |
| `config/keys` | R | current signing `kid` + all retained kids (keys are generated lazily on first use) |
| `config/keys/rotate` | W | generate a new current signing key; up to `retained_keys` previous keys stay in the JWKS |
| `roles/<name>` | R/W/D, list `roles/` | per-app role: `audience` (defaults to role name), `ttl`, `max_ttl`, `allowed_subjects`, `claims` |
| `creds/<name>` | R | mint a signed JWT for the role |
| `.well-known/jwks.json` | R, **unauthenticated** | public JSON Web Key Set for offline validation |
| `.well-known/openid-configuration` | R, **unauthenticated** | OIDC issuer metadata (`issuer`, `jwks_uri`) |
Token claims: `iss` (issuer), `aud` (role audience), `sub` (requesting Vault
entity id or token display name), `exp`/`nbf`/`iat`, `jti`, plus any role
`claims`. Registered claims cannot be overridden by a role.
`allowed_subjects`, when set, restricts which callers (matched on Vault entity
id or token display name) may mint from a role. `retained_keys` keeps N previous
signing keys published after a rotation so tokens signed just before it keep
validating during the grace window.
## Usage
```sh
# register + enable (the plugin_directory must contain the binary)
sha=$(sha256sum /opt/vault-plugins/vault-plugin-secrets-apptoken | cut -d' ' -f1)
vault plugin register -sha256=$sha secret vault-plugin-secrets-apptoken
vault secrets enable -path=apptoken vault-plugin-secrets-apptoken
# configure the issuer to this mount's external URL
vault write apptoken/config issuer="https://vault.k8s.syd1.au.unkin.net/v1/apptoken"
# define a per-app role and mint a token
vault write apptoken/roles/keaapi ttl=15m max_ttl=1h claims="scope=leases"
vault read apptoken/creds/keaapi # -> token, aud=keaapi, exp, ...
# public validation material (no token required)
curl -s "$VAULT_ADDR/v1/apptoken/.well-known/jwks.json"
curl -s "$VAULT_ADDR/v1/apptoken/.well-known/openid-configuration"
# rotate signing keys; tokens signed before rotation still validate during grace
vault write -f apptoken/config/keys/rotate
```
## Validating tokens in a service (Go)
Consumers only need the JWKS URL and their expected audience. Fetch and cache
the JWKS (honouring cache headers / periodic refresh), then verify offline:
```go
// keaapi middleware sketch — do NOT hit Vault per request.
import (
"github.com/coreos/go-oidc/v3/oidc" // or MicahParks/keyfunc + golang-jwt
)
// jwks_uri = "https://vault.../v1/apptoken/.well-known/jwks.json"
keySet := oidc.NewRemoteKeySet(ctx, jwksURL)
verifier := func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
raw := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
payload, err := keySet.VerifySignature(r.Context(), raw)
// then decode payload, check: iss == expected, aud contains "keaapi",
// exp in the future. Reject otherwise.
...
})
}
```
`kea-operator`'s `internal/keaapi` is the first intended consumer — see
Follow-ups.
## Why not the built-in OIDC identity-token provider?
Vault ships an identity-token provider (`identity/oidc`) that also issues signed
JWTs with a JWKS. This engine is deliberately parallel to it because:
- **Roles are issuable by other principals.** `creds/<role>` is a normal secret
path, so the agents approle (or CI) can mint an app's token without being the
token's own identity. The identity provider ties issuance to the caller's
entity and its role/client model is heavier.
- **Simpler audience model.** One role = one app = one audience, no separate
OIDC client/assignment objects to wire up.
- **OpenBao parity + house pattern.** Same plugin/Makefile/`.woodpecker`/RPM
shape as the other `vault-plugin-secrets-*` engines, and the identical binary
runs on OpenBao.
If you only need tokens whose subject is always the calling entity, the built-in
provider may suffice; for per-app service tokens minted by shared automation,
this engine is the better fit.
## Build
```sh
make build # -> dist/vault-plugin-secrets-apptoken
make test lint # go test -race / go vet
make rpm # vault + openbao RPM flavours
```
CI (Woodpecker) runs pre-commit/build/lint/test on PRs and, on a `v*` tag,
builds the vault + openbao RPM flavours and uploads them to the artifactapi
`rpm-internal` repo. Cut a release with `make patch|minor|major` (tags + pushes).