Files
vault-plugin-secrets-apptoken/path_creds.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

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
}