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
136 lines
3.7 KiB
Go
136 lines
3.7 KiB
Go
package apptoken
|
|
|
|
import (
|
|
"context"
|
|
"time"
|
|
|
|
"github.com/hashicorp/vault/sdk/framework"
|
|
"github.com/hashicorp/vault/sdk/logical"
|
|
)
|
|
|
|
func pathCredentials(b *appTokenBackend) *framework.Path {
|
|
return &framework.Path{
|
|
Pattern: "creds/" + framework.GenericNameRegex("name"),
|
|
DisplayAttrs: &framework.DisplayAttributes{
|
|
OperationPrefix: "apptoken",
|
|
OperationSuffix: "credentials",
|
|
},
|
|
Fields: map[string]*framework.FieldSchema{
|
|
"name": {
|
|
Type: framework.TypeLowerCaseString,
|
|
Description: "Name of the role to mint a token for.",
|
|
Required: true,
|
|
},
|
|
},
|
|
Operations: map[logical.Operation]framework.OperationHandler{
|
|
logical.ReadOperation: &framework.PathOperation{Callback: b.pathCredentialsRead},
|
|
logical.UpdateOperation: &framework.PathOperation{Callback: b.pathCredentialsRead},
|
|
},
|
|
HelpSynopsis: "Mint a signed JWT app token from a role.",
|
|
HelpDescription: "Reading this path issues a short-lived JWT whose audience is the role's app. Services validate it offline against the engine's JWKS.",
|
|
}
|
|
}
|
|
|
|
func (b *appTokenBackend) pathCredentialsRead(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
|
|
name := data.Get("name").(string)
|
|
|
|
role, err := b.getRole(ctx, req.Storage, name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if role == nil {
|
|
return logical.ErrorResponse("role %q does not exist", name), nil
|
|
}
|
|
|
|
cfg, err := b.getConfigOrDefault(ctx, req.Storage)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if cfg.Issuer == "" {
|
|
return logical.ErrorResponse("issuer is not configured; set it via the config path"), nil
|
|
}
|
|
|
|
subject, ok := b.resolveSubject(req, role)
|
|
if !ok {
|
|
return logical.ErrorResponse("caller is not permitted to mint tokens from role %q", name), logical.ErrPermissionDenied
|
|
}
|
|
|
|
b.keyLock.Lock()
|
|
ks, err := b.getOrCreateKeyset(ctx, req.Storage, cfg)
|
|
b.keyLock.Unlock()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
ttl := b.resolveTTL(role)
|
|
tok, err := signToken(ks.current(), cfg.Issuer, subject, role.audience(name), role.Claims, ttl, time.Now())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &logical.Response{Data: map[string]interface{}{
|
|
"token": tok.Token,
|
|
"token_type": "Bearer",
|
|
"issuer": cfg.Issuer,
|
|
"audience": tok.Audience,
|
|
"subject": tok.Subject,
|
|
"key_id": tok.KeyID,
|
|
"algorithm": tok.Algorithm,
|
|
"jti": tok.JTI,
|
|
"issued_at": tok.IssuedAt.Format(time.RFC3339),
|
|
"expires_at": tok.ExpiresAt.Format(time.RFC3339),
|
|
"ttl_seconds": int64(ttl.Seconds()),
|
|
}}, nil
|
|
}
|
|
|
|
// resolveSubject determines the JWT subject from the caller's Vault identity
|
|
// and enforces the role's subject allowlist. It returns (subject, allowed).
|
|
func (b *appTokenBackend) resolveSubject(req *logical.Request, role *appRole) (string, bool) {
|
|
var candidates []string
|
|
if req.EntityID != "" {
|
|
candidates = append(candidates, req.EntityID)
|
|
}
|
|
if req.DisplayName != "" {
|
|
candidates = append(candidates, req.DisplayName)
|
|
}
|
|
|
|
subject := ""
|
|
if len(candidates) > 0 {
|
|
subject = candidates[0]
|
|
}
|
|
|
|
if len(role.AllowedSubjects) == 0 {
|
|
return subject, true
|
|
}
|
|
allowed := make(map[string]bool, len(role.AllowedSubjects))
|
|
for _, s := range role.AllowedSubjects {
|
|
allowed[s] = true
|
|
}
|
|
for _, c := range candidates {
|
|
if allowed[c] {
|
|
return c, true
|
|
}
|
|
}
|
|
return subject, false
|
|
}
|
|
|
|
// resolveTTL clamps the role TTL against the role max and the mount/system
|
|
// lease limits.
|
|
func (b *appTokenBackend) resolveTTL(role *appRole) time.Duration {
|
|
sysMax := b.System().MaxLeaseTTL()
|
|
|
|
maxTTL := role.MaxTTL
|
|
if maxTTL <= 0 || maxTTL > sysMax {
|
|
maxTTL = sysMax
|
|
}
|
|
|
|
ttl := role.TTL
|
|
if ttl <= 0 {
|
|
ttl = b.System().DefaultLeaseTTL()
|
|
}
|
|
if ttl > maxTTL {
|
|
ttl = maxTTL
|
|
}
|
|
return ttl
|
|
}
|