package authclient import ( "bytes" "context" "errors" "io" "net/http" "net/url" "strings" "testing" "keycape/internal/domain" ) // The verification command is the evidence two blocked tasks will rest on // (KEY-WP-0013-T02 rollout proof, KEY-WP-0014-T04 rotation step 4), so the // tests that matter are the ones proving it FAILS when the issuer misbehaves. // A checker that always passes is worse than no checker. func verifyOpts() verifyOptions { return verifyOptions{ ClientID: "service:consumer", Secret: "special+%: secret", Audience: "approval-engine", Scope: "approval:read", DenyScope: "approval:consume approval:approve", Subject: "service:test", Tenant: "tenant:test", } } func TestVerifyClientPassesOnCorrectRegistration(t *testing.T) { c, _, _ := provider(t) var out bytes.Buffer if err := runVerify(context.Background(), c, verifyOpts(), &out); err != nil { t.Fatalf("correct registration failed verification: %v\n%s", err, out.String()) } for _, want := range []string{ "PASS discovery and endpoint origin", "PASS exchange and JWKS signature for granted scopes", "PASS principal_type is service", "PASS sub matches the registration", "PASS tenant matches the registration", "PASS excess scope refused: approval:consume", "PASS excess scope refused: approval:approve", } { if !strings.Contains(out.String(), want) { t.Errorf("missing check %q in:\n%s", want, out.String()) } } } // The whole point of the command: it must not print what it verified. An // operator runs this against production, so a leaked subject, tenant or token // would turn a verification into a disclosure. func TestVerifyClientNeverPrintsSecretsOrTokens(t *testing.T) { c, _, _ := provider(t) o := verifyOpts() o.Tenant = "tenant:wrong" // force a mismatch so the failure path is covered too var out bytes.Buffer if err := runVerify(context.Background(), c, o, &out); err == nil { t.Fatal("wrong tenant accepted") } text := out.String() for _, forbidden := range []string{"special+%: secret", "eyJ", "tenant:test", "service:test"} { if strings.Contains(text, forbidden) { t.Errorf("output disclosed %q:\n%s", forbidden, text) } } if !strings.Contains(text, `FAIL tenant matches the registration`) { t.Errorf("mismatch not reported by claim name:\n%s", text) } } func TestVerifyClientFailsWhenExcessScopeIsGranted(t *testing.T) { c, _, h := provider(t) // A registration that hands out the grant it must refuse. h.ClientConfig["service:consumer"].AllowedScopes = []string{"approval:read", "approval:consume"} var out bytes.Buffer err := runVerify(context.Background(), c, verifyOpts(), &out) if err == nil { t.Fatal("over-broad registration passed verification") } if !strings.Contains(out.String(), "FAIL excess scope refused: approval:consume") { t.Errorf("over-broad grant not reported:\n%s", out.String()) } if !strings.Contains(out.String(), "PASS excess scope refused: approval:approve") { t.Errorf("unrelated denial should still pass:\n%s", out.String()) } } func TestVerifyClientRejectsUnrotatedAndAcceptedPredecessor(t *testing.T) { c, _, h := provider(t) // Rotation that never happened: predecessor equals current. o := verifyOpts() o.PreviousNamed, o.Previous = true, o.Secret var same bytes.Buffer if err := runVerify(context.Background(), c, o, &same); err == nil { t.Fatal("identical predecessor accepted as a rotation") } if !strings.Contains(same.String(), "no rotation occurred") { t.Errorf("identical secret not diagnosed:\n%s", same.String()) } // A real predecessor that the issuer still honours: the dangerous case, an // old secret left valid after rotation. o.Previous = "previous secret" h.ClientConfig["service:consumer"].ClientSecret = "previous secret" var stale bytes.Buffer if err := runVerify(context.Background(), c, o, &stale); err == nil { t.Fatal("issuer honouring the predecessor passed verification") } if !strings.Contains(stale.String(), "FAIL predecessor secret refused") { t.Errorf("live predecessor not reported:\n%s", stale.String()) } // The rotated state: predecessor differs and is refused. h.ClientConfig["service:consumer"].ClientSecret = o.Secret var good bytes.Buffer if err := runVerify(context.Background(), c, o, &good); err != nil { t.Fatalf("rotated registration failed: %v\n%s", err, good.String()) } if !strings.Contains(good.String(), "PASS predecessor secret refused") { t.Errorf("predecessor rejection not confirmed:\n%s", good.String()) } } func TestVerifyClientRequiresCompleteArguments(t *testing.T) { for _, args := range [][]string{ {"-client-id", "x", "-scope", "a"}, // no secret-env {"-secret-env", "X", "-scope", "a"}, // no client-id {"-client-id", "x", "-secret-env", "X"}, // no scope {"-client-id", "x", "-secret-env", "X", "-scope", "a", "extra"}, // positional } { if err := verifyClient(context.Background(), args, &bytes.Buffer{}); err == nil { t.Errorf("incomplete arguments accepted: %v", args) } } } // noExcessScope is the check Exchange does not perform: Exchange proves every // requested scope was granted, never that nothing extra came back. func TestNoExcessScopeCatchesUnrequestedGrants(t *testing.T) { if err := noExcessScope(map[string]any{"scope": "approval:read"}, "approval:read"); err != nil { t.Errorf("exact grant rejected: %v", err) } err := noExcessScope(map[string]any{"scope": "approval:read approval:consume"}, "approval:read") if err == nil || !strings.Contains(err.Error(), "approval:consume") { t.Errorf("unrequested grant not caught: %v", err) } } func TestHasAllRolesNamesWhatIsMissing(t *testing.T) { claims := map[string]any{"roles": []any{"secrets-engine"}} if err := hasAllRoles(claims, "secrets-engine"); err != nil { t.Errorf("present role rejected: %v", err) } err := hasAllRoles(claims, "secrets-engine approval-operator") if err == nil || !strings.Contains(err.Error(), "approval-operator") { t.Errorf("missing role not named: %v", err) } } var _ = domain.Client{} type verifyTransport func(*http.Request) (*http.Response, error) func (f verifyTransport) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } func TestVerifyClientDoesNotConfuseFailuresWithRefusals(t *testing.T) { for _, target := range []string{"scope", "predecessor"} { for _, failure := range []string{"transport", "server", "invalid_token", "unrelated_refusal", "malformed_refusal"} { t.Run(target+"/"+failure, func(t *testing.T) { c, _, _ := provider(t) original := c.HTTP.Transport o := verifyOpts() if target == "predecessor" { o.PreviousNamed, o.Previous = true, "old-secret" } c.HTTP.Transport = verifyTransport(func(r *http.Request) (*http.Response, error) { if r.Method == http.MethodPost && r.URL.Path == "/token" { body, _ := io.ReadAll(r.Body) r.Body = io.NopCloser(bytes.NewReader(body)) form, _ := url.ParseQuery(string(body)) _, password, _ := r.BasicAuth() password, _ = url.QueryUnescape(password) negative := target == "scope" && form.Get("scope") != o.Scope || target == "predecessor" && password == o.Previous if negative { status, payload := 503, `{"error":"unavailable"}` switch failure { case "transport": return nil, errors.New("transport failed") case "invalid_token": status, payload = 200, `{"token_type":"Bearer","access_token":"invalid","expires_in":900}` case "unrelated_refusal": status, payload = 400, `{"error":"invalid_profile_usage","feature":"client_id","description":"must not disclose this"}` case "malformed_refusal": status, payload = 400, `not-json-sensitive-body` } return &http.Response{StatusCode: status, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(payload)), Request: r}, nil } } return original.RoundTrip(r) }) var out bytes.Buffer if err := runVerify(context.Background(), c, o, &out); err == nil { t.Fatal("a failed negative check was accepted as issuer refusal") } if !strings.Contains(out.String(), "PASS exchange and JWKS signature for granted scopes") { t.Fatalf("test did not reach the negative checks: %s", out.String()) } if !strings.Contains(out.String(), "expected token-endpoint refusal was not proved") { t.Fatalf("wrong failure: %s", out.String()) } for _, value := range []string{o.Secret, o.Previous, "must not disclose this", "not-json-sensitive-body", "eyJ"} { if value != "" && strings.Contains(out.String(), value) { t.Fatal("verification disclosed input or provider payload") } } }) } } }