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
179 lines
5.4 KiB
Go
179 lines
5.4 KiB
Go
package apptoken
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"testing"
|
|
"time"
|
|
|
|
jose "github.com/go-jose/go-jose/v4"
|
|
"github.com/go-jose/go-jose/v4/jwt"
|
|
"github.com/hashicorp/vault/sdk/logical"
|
|
)
|
|
|
|
const testIssuer = "https://vault.example/v1/apptoken"
|
|
|
|
// getTestBackend returns a backend backed by in-memory storage with sane
|
|
// default/max lease TTLs so issued tokens have a non-zero lifetime.
|
|
func getTestBackend(t *testing.T) (*appTokenBackend, logical.Storage) {
|
|
t.Helper()
|
|
|
|
sysView := logical.TestSystemView()
|
|
sysView.DefaultLeaseTTLVal = time.Hour
|
|
sysView.MaxLeaseTTLVal = 24 * time.Hour
|
|
|
|
config := logical.TestBackendConfig()
|
|
config.StorageView = &logical.InmemStorage{}
|
|
config.System = sysView
|
|
|
|
b, err := Factory(context.Background(), config)
|
|
if err != nil {
|
|
t.Fatalf("creating backend: %v", err)
|
|
}
|
|
return b.(*appTokenBackend), config.StorageView
|
|
}
|
|
|
|
// do runs a request against the backend and fails on transport or logical error.
|
|
func do(t *testing.T, b *appTokenBackend, s logical.Storage, op logical.Operation, path string, data map[string]interface{}) *logical.Response {
|
|
t.Helper()
|
|
resp, err := b.HandleRequest(context.Background(), &logical.Request{
|
|
Operation: op,
|
|
Path: path,
|
|
Storage: s,
|
|
Data: data,
|
|
DisplayName: "test-caller",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("%s %s: transport error: %v", op, path, err)
|
|
}
|
|
if resp != nil && resp.IsError() {
|
|
t.Fatalf("%s %s: logical error: %v", op, path, resp.Error())
|
|
}
|
|
return resp
|
|
}
|
|
|
|
// doReq runs a request and returns both resp and err without failing, for
|
|
// negative-path assertions.
|
|
func doReq(b *appTokenBackend, s logical.Storage, req *logical.Request) (*logical.Response, error) {
|
|
req.Storage = s
|
|
return b.HandleRequest(context.Background(), req)
|
|
}
|
|
|
|
// configureIssuer writes the engine config with the test issuer.
|
|
func configureIssuer(t *testing.T, b *appTokenBackend, s logical.Storage, algorithm string) {
|
|
t.Helper()
|
|
data := map[string]interface{}{"issuer": testIssuer}
|
|
if algorithm != "" {
|
|
data["algorithm"] = algorithm
|
|
}
|
|
do(t, b, s, logical.CreateOperation, "config", data)
|
|
}
|
|
|
|
// readJWKS fetches the unauthenticated JWKS endpoint and parses it.
|
|
func readJWKS(t *testing.T, b *appTokenBackend, s logical.Storage) jose.JSONWebKeySet {
|
|
t.Helper()
|
|
resp := do(t, b, s, logical.ReadOperation, jwksPath, nil)
|
|
raw, ok := resp.Data[logical.HTTPRawBody].([]byte)
|
|
if !ok {
|
|
t.Fatalf("jwks response missing raw body: %#v", resp.Data)
|
|
}
|
|
var jwks jose.JSONWebKeySet
|
|
if err := json.Unmarshal(raw, &jwks); err != nil {
|
|
t.Fatalf("parsing jwks: %v", err)
|
|
}
|
|
return jwks
|
|
}
|
|
|
|
// validateToken verifies a compact JWT against the given JWKS the way a
|
|
// consuming service would: find the key by kid, check the signature, then the
|
|
// registered claims. It returns the validated claims.
|
|
func validateToken(t *testing.T, jwks jose.JSONWebKeySet, token, expectedIssuer, expectedAudience string) jwt.Claims {
|
|
t.Helper()
|
|
|
|
parsed, err := jwt.ParseSigned(token, []jose.SignatureAlgorithm{jose.EdDSA, jose.RS256})
|
|
if err != nil {
|
|
t.Fatalf("parsing token: %v", err)
|
|
}
|
|
if len(parsed.Headers) == 0 || parsed.Headers[0].KeyID == "" {
|
|
t.Fatalf("token has no kid header")
|
|
}
|
|
keys := jwks.Key(parsed.Headers[0].KeyID)
|
|
if len(keys) == 0 {
|
|
t.Fatalf("kid %q not present in JWKS", parsed.Headers[0].KeyID)
|
|
}
|
|
|
|
var claims jwt.Claims
|
|
if err := parsed.Claims(keys[0].Key, &claims); err != nil {
|
|
t.Fatalf("verifying token signature: %v", err)
|
|
}
|
|
if err := claims.Validate(jwt.Expected{
|
|
Issuer: expectedIssuer,
|
|
AnyAudience: jwt.Audience{expectedAudience},
|
|
Time: time.Now(),
|
|
}); err != nil {
|
|
t.Fatalf("validating claims: %v", err)
|
|
}
|
|
return claims
|
|
}
|
|
|
|
// mintToken creates a role (if data provided) and reads creds, returning the token string.
|
|
func mintToken(t *testing.T, b *appTokenBackend, s logical.Storage, role string) *logical.Response {
|
|
t.Helper()
|
|
return do(t, b, s, logical.ReadOperation, "creds/"+role, nil)
|
|
}
|
|
|
|
// findAndVerify looks a token's kid up in the JWKS and checks its signature
|
|
// only (no claim validation). Returns an error when the kid is absent or the
|
|
// signature does not verify.
|
|
func findAndVerify(jwks jose.JSONWebKeySet, token string) error {
|
|
parsed, err := jwt.ParseSigned(token, []jose.SignatureAlgorithm{jose.EdDSA, jose.RS256})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(parsed.Headers) == 0 {
|
|
return errNoKID
|
|
}
|
|
keys := jwks.Key(parsed.Headers[0].KeyID)
|
|
if len(keys) == 0 {
|
|
return errNoKID
|
|
}
|
|
var claims jwt.Claims
|
|
return parsed.Claims(keys[0].Key, &claims)
|
|
}
|
|
|
|
// verifyAudience verifies signature and requires the given audience.
|
|
func verifyAudience(jwks jose.JSONWebKeySet, token, audience string) error {
|
|
parsed, err := jwt.ParseSigned(token, []jose.SignatureAlgorithm{jose.EdDSA, jose.RS256})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
keys := jwks.Key(parsed.Headers[0].KeyID)
|
|
if len(keys) == 0 {
|
|
return errNoKID
|
|
}
|
|
var claims jwt.Claims
|
|
if err := parsed.Claims(keys[0].Key, &claims); err != nil {
|
|
return err
|
|
}
|
|
return claims.Validate(jwt.Expected{AnyAudience: jwt.Audience{audience}, Time: time.Now()})
|
|
}
|
|
|
|
func verifyWrongAudienceFails(jwks jose.JSONWebKeySet, token string) error {
|
|
return verifyAudience(jwks, token, "some-other-app")
|
|
}
|
|
|
|
func decodeJSON(t *testing.T, raw []byte) map[string]interface{} {
|
|
t.Helper()
|
|
var m map[string]interface{}
|
|
if err := json.Unmarshal(raw, &m); err != nil {
|
|
t.Fatalf("decoding json: %v", err)
|
|
}
|
|
return m
|
|
}
|
|
|
|
var errNoKID = &kidError{}
|
|
|
|
type kidError struct{}
|
|
|
|
func (*kidError) Error() string { return "kid not found in JWKS" }
|