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
92 lines
2.7 KiB
Go
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
|
|
}
|