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
161 lines
4.7 KiB
Go
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)
|
|
}
|