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
87 lines
2.0 KiB
Go
87 lines
2.0 KiB
Go
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
|
|
}
|