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
This commit is contained in:
@@ -0,0 +1,323 @@
|
||||
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
|
||||
Reference in New Issue
Block a user