163 lines
6 KiB
Go
163 lines
6 KiB
Go
|
|
package authclient
|
||
|
|
|
||
|
|
import (
|
||
|
|
"bytes"
|
||
|
|
"context"
|
||
|
|
"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{}
|