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

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
}