ec12dfb84f
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
131 lines
5.9 KiB
Markdown
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).
|