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

92 lines
2.7 KiB
Go

package apptoken
import (
"context"
"time"
"github.com/hashicorp/vault/sdk/framework"
"github.com/hashicorp/vault/sdk/logical"
)
func pathConfigKeys(b *appTokenBackend) *framework.Path {
return &framework.Path{
Pattern: "config/keys$",
DisplayAttrs: &framework.DisplayAttributes{
OperationPrefix: "apptoken",
OperationSuffix: "keys",
},
Operations: map[logical.Operation]framework.OperationHandler{
logical.ReadOperation: &framework.PathOperation{Callback: b.pathConfigKeysRead},
},
HelpSynopsis: "Inspect the signing keyset.",
HelpDescription: "Read the current signing key id plus every key id retained in the JWKS. Signing keys are generated lazily on first use.",
}
}
func pathConfigKeysRotate(b *appTokenBackend) *framework.Path {
return &framework.Path{
Pattern: "config/keys/rotate$",
DisplayAttrs: &framework.DisplayAttributes{
OperationPrefix: "apptoken",
OperationSuffix: "keys-rotate",
},
Operations: map[logical.Operation]framework.OperationHandler{
logical.UpdateOperation: &framework.PathOperation{Callback: b.pathConfigKeysRotate},
},
HelpSynopsis: "Rotate the signing key.",
HelpDescription: "Generate a new current signing key. Up to retained_keys previous keys stay published in the JWKS so tokens signed before the rotation keep validating.",
}
}
func (b *appTokenBackend) pathConfigKeysRead(ctx context.Context, req *logical.Request, _ *framework.FieldData) (*logical.Response, error) {
cfg, err := b.getConfigOrDefault(ctx, req.Storage)
if err != nil {
return nil, err
}
b.keyLock.Lock()
defer b.keyLock.Unlock()
ks, err := b.getOrCreateKeyset(ctx, req.Storage, cfg)
if err != nil {
return nil, err
}
keys := make([]map[string]interface{}, 0, len(ks.Keys))
for i := range ks.Keys {
keys = append(keys, map[string]interface{}{
"key_id": ks.Keys[i].KeyID,
"algorithm": ks.Keys[i].Algorithm,
"created": ks.Keys[i].Created.Format(time.RFC3339),
})
}
return &logical.Response{Data: map[string]interface{}{
"current_key_id": ks.current().KeyID,
"keys": keys,
}}, nil
}
func (b *appTokenBackend) pathConfigKeysRotate(ctx context.Context, req *logical.Request, _ *framework.FieldData) (*logical.Response, error) {
cfg, err := b.getConfigOrDefault(ctx, req.Storage)
if err != nil {
return nil, err
}
b.keyLock.Lock()
defer b.keyLock.Unlock()
ks, err := b.getOrCreateKeyset(ctx, req.Storage, cfg)
if err != nil {
return nil, err
}
if err := ks.rotate(cfg.Algorithm, cfg.RetainedKeys, time.Now()); err != nil {
return logical.ErrorResponse(err.Error()), nil
}
if err := b.putKeyset(ctx, req.Storage, ks); err != nil {
return nil, err
}
return &logical.Response{Data: map[string]interface{}{
"current_key_id": ks.current().KeyID,
}}, nil
}