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
324 lines
10 KiB
Go
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
|