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
This commit is contained in:
+160
@@ -0,0 +1,160 @@
|
||||
package apptoken
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/hashicorp/vault/sdk/framework"
|
||||
"github.com/hashicorp/vault/sdk/logical"
|
||||
)
|
||||
|
||||
// config holds engine-wide settings: the JWT issuer URL, the signing algorithm
|
||||
// used for new keys, and how many retired keys stay published in the JWKS.
|
||||
type config struct {
|
||||
Issuer string `json:"issuer"`
|
||||
Algorithm string `json:"algorithm"`
|
||||
RetainedKeys int `json:"retained_keys"`
|
||||
}
|
||||
|
||||
func defaultConfig() *config {
|
||||
return &config{Algorithm: algEdDSA, RetainedKeys: 1}
|
||||
}
|
||||
|
||||
func pathConfig(b *appTokenBackend) *framework.Path {
|
||||
return &framework.Path{
|
||||
Pattern: "config$",
|
||||
DisplayAttrs: &framework.DisplayAttributes{
|
||||
OperationPrefix: "apptoken",
|
||||
OperationSuffix: "config",
|
||||
},
|
||||
Fields: map[string]*framework.FieldSchema{
|
||||
"issuer": {
|
||||
Type: framework.TypeString,
|
||||
Description: "External URL of this mount, used as the JWT `iss` claim and as the " +
|
||||
"base for the published jwks_uri (e.g. https://vault.example/v1/apptoken).",
|
||||
},
|
||||
"algorithm": {
|
||||
Type: framework.TypeString,
|
||||
Description: "Signing algorithm for newly generated keys: EdDSA (default) or RS256.",
|
||||
Default: algEdDSA,
|
||||
},
|
||||
"retained_keys": {
|
||||
Type: framework.TypeInt,
|
||||
Description: "Number of previous signing keys kept in the JWKS after a rotation, so tokens signed before the rotation still validate.",
|
||||
Default: 1,
|
||||
},
|
||||
},
|
||||
Operations: map[logical.Operation]framework.OperationHandler{
|
||||
logical.ReadOperation: &framework.PathOperation{Callback: b.pathConfigRead},
|
||||
logical.CreateOperation: &framework.PathOperation{Callback: b.pathConfigWrite},
|
||||
logical.UpdateOperation: &framework.PathOperation{Callback: b.pathConfigWrite},
|
||||
},
|
||||
ExistenceCheck: b.pathConfigExistenceCheck,
|
||||
HelpSynopsis: "Configure the issuer, signing algorithm and key retention.",
|
||||
HelpDescription: "Engine-wide settings shared by every role: the JWT issuer URL, the algorithm for new signing keys, and how many retired keys remain in the JWKS.",
|
||||
}
|
||||
}
|
||||
|
||||
func (b *appTokenBackend) pathConfigExistenceCheck(ctx context.Context, req *logical.Request, _ *framework.FieldData) (bool, error) {
|
||||
cfg, err := b.getConfig(ctx, req.Storage)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return cfg != nil, nil
|
||||
}
|
||||
|
||||
func (b *appTokenBackend) pathConfigRead(ctx context.Context, req *logical.Request, _ *framework.FieldData) (*logical.Response, error) {
|
||||
cfg, err := b.getConfig(ctx, req.Storage)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cfg == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return &logical.Response{Data: map[string]interface{}{
|
||||
"issuer": cfg.Issuer,
|
||||
"algorithm": cfg.Algorithm,
|
||||
"retained_keys": cfg.RetainedKeys,
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func (b *appTokenBackend) pathConfigWrite(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
|
||||
cfg, err := b.getConfig(ctx, req.Storage)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cfg == nil {
|
||||
cfg = defaultConfig()
|
||||
}
|
||||
|
||||
if v, ok := data.GetOk("issuer"); ok {
|
||||
issuer := strings.TrimRight(v.(string), "/")
|
||||
if issuer != "" {
|
||||
u, err := url.Parse(issuer)
|
||||
if err != nil || u.Scheme == "" || u.Host == "" {
|
||||
return logical.ErrorResponse("issuer must be an absolute URL"), nil
|
||||
}
|
||||
}
|
||||
cfg.Issuer = issuer
|
||||
}
|
||||
if v, ok := data.GetOk("algorithm"); ok {
|
||||
alg := v.(string)
|
||||
if alg != algEdDSA && alg != algRS256 {
|
||||
return logical.ErrorResponse("algorithm must be EdDSA or RS256"), nil
|
||||
}
|
||||
cfg.Algorithm = alg
|
||||
}
|
||||
if v, ok := data.GetOk("retained_keys"); ok {
|
||||
n := v.(int)
|
||||
if n < 0 {
|
||||
return logical.ErrorResponse("retained_keys must not be negative"), nil
|
||||
}
|
||||
cfg.RetainedKeys = n
|
||||
}
|
||||
|
||||
if err := b.putConfig(ctx, req.Storage, cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (b *appTokenBackend) getConfig(ctx context.Context, s logical.Storage) (*config, error) {
|
||||
entry, err := s.Get(ctx, configStoragePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if entry == nil {
|
||||
return nil, nil
|
||||
}
|
||||
cfg := &config{}
|
||||
if err := entry.DecodeJSON(cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cfg.Algorithm == "" {
|
||||
cfg.Algorithm = algEdDSA
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// getConfigOrDefault returns the stored config or a default one, so issuance
|
||||
// and key generation work before "config" is explicitly written.
|
||||
func (b *appTokenBackend) getConfigOrDefault(ctx context.Context, s logical.Storage) (*config, error) {
|
||||
cfg, err := b.getConfig(ctx, s)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cfg == nil {
|
||||
return defaultConfig(), nil
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (b *appTokenBackend) putConfig(ctx context.Context, s logical.Storage, cfg *config) error {
|
||||
entry, err := logical.StorageEntryJSON(configStoragePath, cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encoding config: %w", err)
|
||||
}
|
||||
return s.Put(ctx, entry)
|
||||
}
|
||||
Reference in New Issue
Block a user