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:
@@ -0,0 +1,86 @@
|
||||
package apptoken
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
jose "github.com/go-jose/go-jose/v4"
|
||||
"github.com/go-jose/go-jose/v4/jwt"
|
||||
"github.com/hashicorp/go-uuid"
|
||||
)
|
||||
|
||||
func base64URL(b []byte) string {
|
||||
return base64.RawURLEncoding.EncodeToString(b)
|
||||
}
|
||||
|
||||
// issuedToken is the result of minting a token: the compact JWT plus the
|
||||
// claims that let a caller reason about it without decoding.
|
||||
type issuedToken struct {
|
||||
Token string
|
||||
KeyID string
|
||||
Algorithm string
|
||||
Subject string
|
||||
Audience string
|
||||
IssuedAt time.Time
|
||||
ExpiresAt time.Time
|
||||
JTI string
|
||||
}
|
||||
|
||||
// signToken builds and signs a JWT for the given role using the current
|
||||
// signing key. now/ttl are supplied by the caller so tests are deterministic.
|
||||
func signToken(key *signingKey, issuer, subject, audience string, extra map[string]string, ttl time.Duration, now time.Time) (*issuedToken, error) {
|
||||
signer, err := key.signer()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
jti, err := uuid.GenerateUUID()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generating jti: %w", err)
|
||||
}
|
||||
|
||||
joseSigner, err := jose.NewSigner(
|
||||
jose.SigningKey{Algorithm: key.joseAlgorithm(), Key: signer},
|
||||
(&jose.SignerOptions{}).WithType("JWT").WithHeader("kid", key.KeyID),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("building signer: %w", err)
|
||||
}
|
||||
|
||||
expiry := now.Add(ttl)
|
||||
registered := jwt.Claims{
|
||||
Issuer: issuer,
|
||||
Subject: subject,
|
||||
Audience: jwt.Audience{audience},
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
NotBefore: jwt.NewNumericDate(now),
|
||||
Expiry: jwt.NewNumericDate(expiry),
|
||||
ID: jti,
|
||||
}
|
||||
|
||||
builder := jwt.Signed(joseSigner).Claims(registered)
|
||||
if len(extra) > 0 {
|
||||
claims := make(map[string]interface{}, len(extra))
|
||||
for k, v := range extra {
|
||||
claims[k] = v
|
||||
}
|
||||
builder = builder.Claims(claims)
|
||||
}
|
||||
|
||||
token, err := builder.Serialize()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("serializing token: %w", err)
|
||||
}
|
||||
|
||||
return &issuedToken{
|
||||
Token: token,
|
||||
KeyID: key.KeyID,
|
||||
Algorithm: key.Algorithm,
|
||||
Subject: subject,
|
||||
Audience: audience,
|
||||
IssuedAt: now,
|
||||
ExpiresAt: expiry,
|
||||
JTI: jti,
|
||||
}, nil
|
||||
}
|
||||
Reference in New Issue
Block a user