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