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

324 lines
10 KiB
Go

package apptoken
import (
"context"
"testing"
"time"
"github.com/hashicorp/vault/sdk/logical"
)
func TestConfig_Validation(t *testing.T) {
b, s := getTestBackend(t)
// Bad issuer is rejected.
resp, err := doReq(b, s, &logical.Request{
Operation: logical.CreateOperation,
Path: "config",
Data: map[string]interface{}{"issuer": "not-a-url"},
})
if err != nil {
t.Fatal(err)
}
if resp == nil || !resp.IsError() {
t.Fatalf("expected error for bad issuer, got %#v", resp)
}
// Bad algorithm is rejected.
resp, err = doReq(b, s, &logical.Request{
Operation: logical.CreateOperation,
Path: "config",
Data: map[string]interface{}{"issuer": testIssuer, "algorithm": "HS256"},
})
if err != nil {
t.Fatal(err)
}
if resp == nil || !resp.IsError() {
t.Fatalf("expected error for bad algorithm, got %#v", resp)
}
// Good config round-trips, trailing slash trimmed.
do(t, b, s, logical.CreateOperation, "config", map[string]interface{}{
"issuer": testIssuer + "/",
"algorithm": algRS256,
"retained_keys": 3,
})
read := do(t, b, s, logical.ReadOperation, "config", nil)
if read.Data["issuer"] != testIssuer {
t.Fatalf("issuer not trimmed/stored: %v", read.Data["issuer"])
}
if read.Data["algorithm"] != algRS256 {
t.Fatalf("algorithm not stored: %v", read.Data["algorithm"])
}
if read.Data["retained_keys"] != 3 {
t.Fatalf("retained_keys not stored: %v", read.Data["retained_keys"])
}
}
func TestRole_CRUDAndReservedClaims(t *testing.T) {
b, s := getTestBackend(t)
do(t, b, s, logical.CreateOperation, "roles/keaapi", map[string]interface{}{
"ttl": "5m",
"max_ttl": "30m",
"claims": []string{"team=dns", "tier=prod"},
})
read := do(t, b, s, logical.ReadOperation, "roles/keaapi", nil)
if read.Data["ttl"].(int64) != 300 {
t.Fatalf("ttl = %v, want 300", read.Data["ttl"])
}
if read.Data["audience"] != "" {
t.Fatalf("audience should default to empty (role name at issue time)")
}
claims := read.Data["claims"].(map[string]string)
if claims["team"] != "dns" || claims["tier"] != "prod" {
t.Fatalf("claims not stored: %#v", claims)
}
// List.
list := do(t, b, s, logical.ListOperation, "roles/", nil)
if got := list.Data["keys"].([]string); len(got) != 1 || got[0] != "keaapi" {
t.Fatalf("list = %#v", got)
}
// Reserved claim rejected.
resp, err := doReq(b, s, &logical.Request{
Operation: logical.CreateOperation,
Path: "roles/bad",
Data: map[string]interface{}{"claims": "iss=evil"},
})
if err != nil {
t.Fatal(err)
}
if resp == nil || !resp.IsError() {
t.Fatalf("expected reserved-claim error, got %#v", resp)
}
// ttl > max_ttl rejected.
resp, err = doReq(b, s, &logical.Request{
Operation: logical.CreateOperation,
Path: "roles/bad2",
Data: map[string]interface{}{"ttl": "1h", "max_ttl": "1m"},
})
if err != nil {
t.Fatal(err)
}
if resp == nil || !resp.IsError() {
t.Fatalf("expected ttl>max_ttl error, got %#v", resp)
}
// Delete.
do(t, b, s, logical.DeleteOperation, "roles/keaapi", nil)
if read := do(t, b, s, logical.ReadOperation, "roles/keaapi", nil); read != nil {
t.Fatalf("role should be gone, got %#v", read)
}
}
func TestCreds_IssueAndValidateAgainstJWKS(t *testing.T) {
for _, alg := range []string{algEdDSA, algRS256} {
t.Run(alg, func(t *testing.T) {
b, s := getTestBackend(t)
configureIssuer(t, b, s, alg)
do(t, b, s, logical.CreateOperation, "roles/keaapi", map[string]interface{}{
"ttl": "10m",
"claims": "scope=leases",
})
resp := mintToken(t, b, s, "keaapi")
token := resp.Data["token"].(string)
if resp.Data["audience"] != "keaapi" {
t.Fatalf("audience = %v, want keaapi", resp.Data["audience"])
}
if resp.Data["algorithm"] != alg {
t.Fatalf("algorithm = %v, want %v", resp.Data["algorithm"], alg)
}
jwks := readJWKS(t, b, s)
claims := validateToken(t, jwks, token, testIssuer, "keaapi")
if claims.Subject != "test-caller" {
t.Fatalf("subject = %q, want test-caller", claims.Subject)
}
if claims.ID == "" {
t.Fatalf("jti should be set")
}
if claims.Expiry == nil || claims.IssuedAt == nil {
t.Fatalf("exp/iat must be set")
}
if d := claims.Expiry.Time().Sub(claims.IssuedAt.Time()); d != 10*time.Minute {
t.Fatalf("ttl = %v, want 10m", d)
}
// Wrong audience must fail validation.
if err := verifyWrongAudienceFails(jwks, token); err == nil {
t.Fatalf("validation should fail for wrong audience")
}
})
}
}
func TestCreds_RequiresIssuer(t *testing.T) {
b, s := getTestBackend(t)
do(t, b, s, logical.CreateOperation, "roles/keaapi", nil)
resp, err := doReq(b, s, &logical.Request{Operation: logical.ReadOperation, Path: "creds/keaapi", DisplayName: "x"})
if err != nil {
t.Fatal(err)
}
if resp == nil || !resp.IsError() {
t.Fatalf("expected issuer-not-configured error, got %#v", resp)
}
}
func TestCreds_UnknownRole(t *testing.T) {
b, s := getTestBackend(t)
configureIssuer(t, b, s, "")
resp, err := doReq(b, s, &logical.Request{Operation: logical.ReadOperation, Path: "creds/nope", DisplayName: "x"})
if err != nil {
t.Fatal(err)
}
if resp == nil || !resp.IsError() {
t.Fatalf("expected unknown-role error, got %#v", resp)
}
}
// TestRoles_Isolation proves a token minted for one role cannot masquerade as
// another: the audience is bound to the issuing role.
func TestRoles_Isolation(t *testing.T) {
b, s := getTestBackend(t)
configureIssuer(t, b, s, "")
do(t, b, s, logical.CreateOperation, "roles/keaapi", nil)
do(t, b, s, logical.CreateOperation, "roles/encapi", map[string]interface{}{"audience": "encapi.internal"})
kea := mintToken(t, b, s, "keaapi").Data["token"].(string)
enc := mintToken(t, b, s, "encapi").Data["token"].(string)
// JWKS is read after issuance so the lazily-generated key is present.
jwks := readJWKS(t, b, s)
validateToken(t, jwks, kea, testIssuer, "keaapi")
validateToken(t, jwks, enc, testIssuer, "encapi.internal")
// A keaapi token must not validate for the encapi audience.
if err := verifyAudience(jwks, kea, "encapi.internal"); err == nil {
t.Fatalf("keaapi token validated for encapi audience")
}
}
func TestRole_SubjectAllowlist(t *testing.T) {
b, s := getTestBackend(t)
configureIssuer(t, b, s, "")
do(t, b, s, logical.CreateOperation, "roles/keaapi", map[string]interface{}{
"allowed_subjects": "agents,ci",
})
// Caller display name not in allowlist -> denied (returned as a
// permission-denied error and/or an error response).
resp, err := doReq(b, s, &logical.Request{Operation: logical.ReadOperation, Path: "creds/keaapi", DisplayName: "intruder"})
if err == nil && (resp == nil || !resp.IsError()) {
t.Fatalf("expected permission denied, got resp=%#v err=%v", resp, err)
}
// Allowed caller -> issued, subject is the matched identity.
resp, err = doReq(b, s, &logical.Request{Operation: logical.ReadOperation, Path: "creds/keaapi", DisplayName: "agents"})
if err != nil {
t.Fatal(err)
}
if resp.IsError() {
t.Fatalf("allowed caller denied: %v", resp.Error())
}
if resp.Data["subject"] != "agents" {
t.Fatalf("subject = %v, want agents", resp.Data["subject"])
}
}
// TestRotation_GraceAndTrim proves that after rotation an older token still
// validates (its key stays in the JWKS) up to retained_keys, and that keys
// beyond the retention window are dropped so their tokens no longer validate.
func TestRotation_GraceAndTrim(t *testing.T) {
b, s := getTestBackend(t)
// retained_keys=1: keep current + 1 previous.
do(t, b, s, logical.CreateOperation, "config", map[string]interface{}{
"issuer": testIssuer,
"retained_keys": 1,
})
do(t, b, s, logical.CreateOperation, "roles/keaapi", nil)
tokenV1 := mintToken(t, b, s, "keaapi").Data["token"].(string)
kidV1 := currentKID(t, b, s)
// Rotate once: v1 key retained, v1 token still validates.
do(t, b, s, logical.UpdateOperation, "config/keys/rotate", nil)
kidV2 := currentKID(t, b, s)
if kidV1 == kidV2 {
t.Fatalf("rotation did not change current kid")
}
tokenV2 := mintToken(t, b, s, "keaapi").Data["token"].(string)
jwks := readJWKS(t, b, s)
if len(jwks.Keys) != 2 {
t.Fatalf("expected 2 keys in JWKS after 1 rotation, got %d", len(jwks.Keys))
}
validateToken(t, jwks, tokenV1, testIssuer, "keaapi") // grace: still valid
validateToken(t, jwks, tokenV2, testIssuer, "keaapi")
// Rotate again: v1 key now beyond retention and dropped.
do(t, b, s, logical.UpdateOperation, "config/keys/rotate", nil)
jwks = readJWKS(t, b, s)
if len(jwks.Keys) != 2 {
t.Fatalf("expected 2 keys after 2nd rotation (trim), got %d", len(jwks.Keys))
}
if len(jwks.Key(kidV1)) != 0 {
t.Fatalf("v1 kid should have been trimmed from JWKS")
}
// v1 token can no longer be validated (its key is gone).
if err := findAndVerify(jwks, tokenV1); err == nil {
t.Fatalf("v1 token should no longer validate after key trimmed")
}
}
func TestOpenIDConfiguration(t *testing.T) {
b, s := getTestBackend(t)
configureIssuer(t, b, s, "")
resp := do(t, b, s, logical.ReadOperation, openidConfigPath, nil)
raw := resp.Data[logical.HTTPRawBody].([]byte)
meta := decodeJSON(t, raw)
if meta["issuer"] != testIssuer {
t.Fatalf("issuer = %v", meta["issuer"])
}
if meta["jwks_uri"] != testIssuer+"/"+jwksPath {
t.Fatalf("jwks_uri = %v", meta["jwks_uri"])
}
}
func TestUnauthenticatedPaths(t *testing.T) {
b := backend()
special := b.SpecialPaths()
want := map[string]bool{jwksPath: false, openidConfigPath: false}
for _, p := range special.Unauthenticated {
if _, ok := want[p]; ok {
want[p] = true
}
}
for p, found := range want {
if !found {
t.Fatalf("path %q not marked unauthenticated", p)
}
}
// The keyset must be seal-wrapped.
if len(special.SealWrapStorage) == 0 || special.SealWrapStorage[0] != keysetStoragePath {
t.Fatalf("keyset not seal-wrapped: %#v", special.SealWrapStorage)
}
}
// currentKID reads config/keys and returns the current key id.
func currentKID(t *testing.T, b *appTokenBackend, s logical.Storage) string {
t.Helper()
resp := do(t, b, s, logical.ReadOperation, "config/keys", nil)
return resp.Data["current_key_id"].(string)
}
var _ = context.Background