key-cape/src/internal/authclient/verify_test.go

226 lines
8.6 KiB
Go
Raw Normal View History

Ship the live-registration check both blocked tasks depend on KEY-WP-0013-T02 and KEY-WP-0014-T04 stay blocked on custody and on ops-warden, but each contains a KeyCape-owned piece that had been left as prose. T02 requires proving "live JWKS verification and denied excess scopes without logging values"; T04 step 4 requires verifying a rotated secret, refusing its predecessor and refusing excess scope. Both were describable and neither was runnable, so the proof would have been improvised by hand against production at the moment custody lands -- the worst possible time for it. keycape verify-client does it in one command. Per registration it checks discovery origin, the client_credentials exchange and its RS256 signature against the deployed JWKS, exact sub/tenant/roles/principal_type, that every -deny-scope is refused, and that the token carries no scope that was not requested. That last check is a real gap: the caller commands prove every requested scope was granted, never that nothing extra came back. -previous-secret-env additionally requires the predecessor to be refused, and treats an unchanged secret as a rotation that did not happen. Nothing is written to disk and no value is printed. Failures name the claim, not the observed value, so running this against production cannot turn a verification into a disclosure. Every check runs before it reports, so one failure does not hide the rest. The exact invocation for each approval client is recorded in the verification block of the provisioning packet, so custody admission hands back a command rather than a description. Tests cover the passing case, an over-broad registration, a live predecessor, an identical rotation and output non-disclosure; neutering mustFail makes the suite fail, so the checks have teeth. Also recorded in KEY-WP-0014: read from ops-warden's catalog rather than waiting for a reply, key-cape-oidc-login is asked-and-waiting on us since 2026-08-28 and is a pointer lane with no programmatic consumers, and the rapp-qonto-keycape-client blocker citing an absent native exchange command went stale when service-token shipped on 2026-09-05. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016uV8zoCKpA1WRAxsKRYbdH Assistant: claude-code Assistant-Model: opus Assistant-Process: 1182213@bnt-lap001 Assistant-Session: 966597b9-ae61-46a4-8b9e-1594ab3ec4ad
2026-09-08 14:30:43 +02:00
package authclient
import (
"bytes"
"context"
"errors"
"io"
"net/http"
"net/url"
Ship the live-registration check both blocked tasks depend on KEY-WP-0013-T02 and KEY-WP-0014-T04 stay blocked on custody and on ops-warden, but each contains a KeyCape-owned piece that had been left as prose. T02 requires proving "live JWKS verification and denied excess scopes without logging values"; T04 step 4 requires verifying a rotated secret, refusing its predecessor and refusing excess scope. Both were describable and neither was runnable, so the proof would have been improvised by hand against production at the moment custody lands -- the worst possible time for it. keycape verify-client does it in one command. Per registration it checks discovery origin, the client_credentials exchange and its RS256 signature against the deployed JWKS, exact sub/tenant/roles/principal_type, that every -deny-scope is refused, and that the token carries no scope that was not requested. That last check is a real gap: the caller commands prove every requested scope was granted, never that nothing extra came back. -previous-secret-env additionally requires the predecessor to be refused, and treats an unchanged secret as a rotation that did not happen. Nothing is written to disk and no value is printed. Failures name the claim, not the observed value, so running this against production cannot turn a verification into a disclosure. Every check runs before it reports, so one failure does not hide the rest. The exact invocation for each approval client is recorded in the verification block of the provisioning packet, so custody admission hands back a command rather than a description. Tests cover the passing case, an over-broad registration, a live predecessor, an identical rotation and output non-disclosure; neutering mustFail makes the suite fail, so the checks have teeth. Also recorded in KEY-WP-0014: read from ops-warden's catalog rather than waiting for a reply, key-cape-oidc-login is asked-and-waiting on us since 2026-08-28 and is a pointer lane with no programmatic consumers, and the rapp-qonto-keycape-client blocker citing an absent native exchange command went stale when service-token shipped on 2026-09-05. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016uV8zoCKpA1WRAxsKRYbdH Assistant: claude-code Assistant-Model: opus Assistant-Process: 1182213@bnt-lap001 Assistant-Session: 966597b9-ae61-46a4-8b9e-1594ab3ec4ad
2026-09-08 14:30:43 +02:00
"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")
}
}
})
}
}
}