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:
+225
@@ -0,0 +1,225 @@
|
||||
package apptoken
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/hashicorp/vault/sdk/framework"
|
||||
"github.com/hashicorp/vault/sdk/logical"
|
||||
)
|
||||
|
||||
// appRole describes the tokens issued from creds/<name>.
|
||||
type appRole struct {
|
||||
// Audience is the JWT `aud` claim; it defaults to the role name and is the
|
||||
// value the consuming app checks. Empty means "use the role name".
|
||||
Audience string `json:"audience"`
|
||||
// TTL / MaxTTL bound the token lifetime.
|
||||
TTL time.Duration `json:"ttl"`
|
||||
MaxTTL time.Duration `json:"max_ttl"`
|
||||
// AllowedSubjects, when non-empty, restricts which requesting identities
|
||||
// (Vault entity id or token display name) may mint a token from this role.
|
||||
AllowedSubjects []string `json:"allowed_subjects"`
|
||||
// Claims are extra string claims merged into every issued token. They may
|
||||
// not override registered JWT claims (iss, sub, aud, exp, ...).
|
||||
Claims map[string]string `json:"claims"`
|
||||
}
|
||||
|
||||
func (r *appRole) audience(name string) string {
|
||||
if r.Audience != "" {
|
||||
return r.Audience
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func (r *appRole) toResponseData() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"audience": r.Audience,
|
||||
"ttl": int64(r.TTL.Seconds()),
|
||||
"max_ttl": int64(r.MaxTTL.Seconds()),
|
||||
"allowed_subjects": r.AllowedSubjects,
|
||||
"claims": r.Claims,
|
||||
}
|
||||
}
|
||||
|
||||
func pathRole(b *appTokenBackend) *framework.Path {
|
||||
return &framework.Path{
|
||||
Pattern: "roles/" + framework.GenericNameRegex("name"),
|
||||
DisplayAttrs: &framework.DisplayAttributes{
|
||||
OperationPrefix: "apptoken",
|
||||
OperationSuffix: "role",
|
||||
},
|
||||
Fields: map[string]*framework.FieldSchema{
|
||||
"name": {
|
||||
Type: framework.TypeLowerCaseString,
|
||||
Description: "Name of the role (typically the app name).",
|
||||
Required: true,
|
||||
},
|
||||
"audience": {
|
||||
Type: framework.TypeString,
|
||||
Description: "JWT audience (aud) claim. Defaults to the role name.",
|
||||
},
|
||||
"ttl": {
|
||||
Type: framework.TypeDurationSecond,
|
||||
Description: "Default token TTL for this role.",
|
||||
},
|
||||
"max_ttl": {
|
||||
Type: framework.TypeDurationSecond,
|
||||
Description: "Maximum token TTL for this role.",
|
||||
},
|
||||
"allowed_subjects": {
|
||||
Type: framework.TypeCommaStringSlice,
|
||||
Description: "Optional allowlist of requesting identities (Vault entity id or token display name) permitted to mint tokens from this role. Empty means any authorized caller.",
|
||||
},
|
||||
"claims": {
|
||||
Type: framework.TypeKVPairs,
|
||||
Description: "Extra key=value claims merged into each issued token. Registered JWT claims cannot be overridden.",
|
||||
},
|
||||
},
|
||||
Operations: map[logical.Operation]framework.OperationHandler{
|
||||
logical.ReadOperation: &framework.PathOperation{Callback: b.pathRoleRead},
|
||||
logical.CreateOperation: &framework.PathOperation{Callback: b.pathRoleWrite},
|
||||
logical.UpdateOperation: &framework.PathOperation{Callback: b.pathRoleWrite},
|
||||
logical.DeleteOperation: &framework.PathOperation{Callback: b.pathRoleDelete},
|
||||
},
|
||||
ExistenceCheck: b.pathRoleExistenceCheck,
|
||||
HelpSynopsis: "Manage per-app token roles.",
|
||||
HelpDescription: "A role defines the audience, TTLs, subject allowlist and custom claims of the JWTs issued from creds/<name>.",
|
||||
}
|
||||
}
|
||||
|
||||
func pathRolesList(b *appTokenBackend) *framework.Path {
|
||||
return &framework.Path{
|
||||
Pattern: "roles/?$",
|
||||
DisplayAttrs: &framework.DisplayAttributes{
|
||||
OperationPrefix: "apptoken",
|
||||
OperationSuffix: "roles",
|
||||
},
|
||||
Operations: map[logical.Operation]framework.OperationHandler{
|
||||
logical.ListOperation: &framework.PathOperation{Callback: b.pathRolesList},
|
||||
},
|
||||
HelpSynopsis: "List the configured roles.",
|
||||
}
|
||||
}
|
||||
|
||||
func (b *appTokenBackend) pathRoleExistenceCheck(ctx context.Context, req *logical.Request, data *framework.FieldData) (bool, error) {
|
||||
role, err := b.getRole(ctx, req.Storage, data.Get("name").(string))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return role != nil, nil
|
||||
}
|
||||
|
||||
func (b *appTokenBackend) pathRolesList(ctx context.Context, req *logical.Request, _ *framework.FieldData) (*logical.Response, error) {
|
||||
entries, err := req.Storage.List(ctx, roleStoragePrefix)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return logical.ListResponse(entries), nil
|
||||
}
|
||||
|
||||
func (b *appTokenBackend) pathRoleRead(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
|
||||
role, err := b.getRole(ctx, req.Storage, data.Get("name").(string))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if role == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return &logical.Response{Data: role.toResponseData()}, nil
|
||||
}
|
||||
|
||||
func (b *appTokenBackend) pathRoleWrite(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
|
||||
name := data.Get("name").(string)
|
||||
if name == "" {
|
||||
return logical.ErrorResponse("role name is required"), nil
|
||||
}
|
||||
|
||||
role, err := b.getRole(ctx, req.Storage, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if role == nil {
|
||||
role = &appRole{}
|
||||
}
|
||||
|
||||
if v, ok := data.GetOk("audience"); ok {
|
||||
role.Audience = v.(string)
|
||||
}
|
||||
if v, ok := data.GetOk("ttl"); ok {
|
||||
role.TTL = time.Duration(v.(int)) * time.Second
|
||||
}
|
||||
if v, ok := data.GetOk("max_ttl"); ok {
|
||||
role.MaxTTL = time.Duration(v.(int)) * time.Second
|
||||
}
|
||||
if v, ok := data.GetOk("allowed_subjects"); ok {
|
||||
role.AllowedSubjects = v.([]string)
|
||||
}
|
||||
if v, ok := data.GetOk("claims"); ok {
|
||||
role.Claims = v.(map[string]string)
|
||||
}
|
||||
|
||||
if role.TTL < 0 || role.MaxTTL < 0 {
|
||||
return logical.ErrorResponse("ttl and max_ttl must not be negative"), nil
|
||||
}
|
||||
if role.MaxTTL != 0 && role.TTL > role.MaxTTL {
|
||||
return logical.ErrorResponse("ttl must not be greater than max_ttl"), nil
|
||||
}
|
||||
if err := validateClaims(role.Claims); err != nil {
|
||||
return logical.ErrorResponse(err.Error()), nil
|
||||
}
|
||||
|
||||
if err := b.setRole(ctx, req.Storage, name, role); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (b *appTokenBackend) pathRoleDelete(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
|
||||
if err := req.Storage.Delete(ctx, roleStoragePrefix+data.Get("name").(string)); err != nil {
|
||||
return nil, fmt.Errorf("deleting role: %w", err)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (b *appTokenBackend) getRole(ctx context.Context, s logical.Storage, name string) (*appRole, error) {
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("missing role name")
|
||||
}
|
||||
entry, err := s.Get(ctx, roleStoragePrefix+name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if entry == nil {
|
||||
return nil, nil
|
||||
}
|
||||
role := &appRole{}
|
||||
if err := entry.DecodeJSON(role); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return role, nil
|
||||
}
|
||||
|
||||
func (b *appTokenBackend) setRole(ctx context.Context, s logical.Storage, name string, role *appRole) error {
|
||||
entry, err := logical.StorageEntryJSON(roleStoragePrefix+name, role)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.Put(ctx, entry)
|
||||
}
|
||||
|
||||
// reservedClaims are registered JWT claims the engine sets itself; roles may
|
||||
// not override them via custom claims.
|
||||
var reservedClaims = map[string]bool{
|
||||
"iss": true, "sub": true, "aud": true, "exp": true,
|
||||
"nbf": true, "iat": true, "jti": true,
|
||||
}
|
||||
|
||||
func validateClaims(claims map[string]string) error {
|
||||
for k := range claims {
|
||||
if reservedClaims[k] {
|
||||
return fmt.Errorf("claim %q is reserved and cannot be set on a role", k)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user