Files
vault-plugin-secrets-apptoken/path_config.go
T
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

161 lines
4.7 KiB
Go

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)
}