Make the replacement harness runnable and target a live issuer
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 36s

Closes the runnable half of gap G04. The Scenario B and C scripts could not
execute: absent compose files, binaries sought at src/bin where the Makefile
builds to bin/, --base-dn passed to a generator whose flag is --basedn, and a
hardcoded workstation Go path invoked from outside the module.

Repairing the shell alone would have proved nothing. Both scripts set
KEYCAPE_TEST_ISSUER while the profile suite built its own httptest server and
never read it, so they passed identically whether or not a provider was running.
A harness that cannot fail for the reason it exists is worse than a missing one.

src/tests/conformance targets the issuer named by KEYCAPE_TEST_ISSUER over HTTP:
discovery, the profile authorization surface, published keys parsed under the
runtime's own rules, excluded grants, and -- with credentials -- a real token
exchange verified against those keys. It skips when the variable is unset, so
make test is unchanged.

Run against Keycloak 26.0 rather than asserted to work. Discovery, authorization
surface and key checks passed, and a client_credentials exchange produced a token
that verified against Keycloak's published JWKS through internal/jose.

It also failed, correctly: stock Keycloak advertises the excluded implicit and
password grants, and in Keycloak those are server capabilities rather than
per-client toggles, so no emitted realm removes them. A migrated Keycloak has a
wider grant surface than KeyCape, which substantiates with evidence what SCOPE
previously asserted without it. Scenario B legitimately reports failure today.

Directory migration, credential and MFA preservation and relying-party behaviour
remain unexercised, and Scenario C has never been run end to end, so G04 does not
fully close.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NV9oijZukGyGbRQGGKnK4P

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 713576@bnt-lap001
Assistant-Session: 384c511d-9bce-4cb8-a676-2aef6c0c8df6
This commit is contained in:
tegwick 2026-09-07 23:32:11 +02:00
parent ebc48e31ae
commit 7534552754
8 changed files with 511 additions and 31 deletions

View file

@ -0,0 +1,229 @@
// Package conformance_test exercises a running OIDC issuer over HTTP.
//
// Every other suite in this repository builds its own httptest server, which is
// the right shape for testing handlers but cannot say anything about a
// replacement provider. This suite exists for the opposite case: point it at a
// deployed KeyCape, or at the Keycloak a migration produced, and it checks the
// profile surface that a relying party actually depends on (KEY-WP-0022).
//
// It skips when KEYCAPE_TEST_ISSUER is unset, so `make test` is unaffected.
//
// KEYCAPE_TEST_ISSUER=http://localhost:8080/realms/netkingdom go test ./tests/conformance/...
//
// Optional, enabling the token-exchange checks:
//
// KEYCAPE_TEST_CLIENT_ID, KEYCAPE_TEST_CLIENT_SECRET, KEYCAPE_TEST_SCOPE
package conformance_test
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"testing"
"time"
"keycape/internal/jose"
)
const requestTimeout = 15 * time.Second
func issuer(t *testing.T) string {
t.Helper()
value := strings.TrimRight(os.Getenv("KEYCAPE_TEST_ISSUER"), "/")
if value == "" {
t.Skip("KEYCAPE_TEST_ISSUER is not set; this suite targets a running issuer")
}
return value
}
type discovery struct {
Issuer string `json:"issuer"`
AuthorizationEndpoint string `json:"authorization_endpoint"`
TokenEndpoint string `json:"token_endpoint"`
JWKSURI string `json:"jwks_uri"`
ResponseTypesSupported []string `json:"response_types_supported"`
GrantTypesSupported []string `json:"grant_types_supported"`
CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"`
IDTokenSigningAlgValues []string `json:"id_token_signing_alg_values_supported"`
}
func get(t *testing.T, target string) []byte {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), requestTimeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
if err != nil {
t.Fatalf("build request for %s: %v", target, err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("GET %s: %v", target, err)
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
t.Fatalf("read %s: %v", target, err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("GET %s: status %d", target, resp.StatusCode)
}
return body
}
func fetchDiscovery(t *testing.T) discovery {
t.Helper()
var doc discovery
raw := get(t, issuer(t)+"/.well-known/openid-configuration")
if err := json.Unmarshal(raw, &doc); err != nil {
t.Fatalf("parse discovery document: %v", err)
}
return doc
}
func contains(values []string, want string) bool {
for _, value := range values {
if value == want {
return true
}
}
return false
}
// The issuer claim binds every token this provider mints, so a discovery
// document advertising a different issuer than the one we asked breaks every
// downstream audience and issuer check.
func TestIssuerAdvertisesItself(t *testing.T) {
doc := fetchDiscovery(t)
if doc.Issuer != issuer(t) {
t.Errorf("issuer is %q, expected %q", doc.Issuer, issuer(t))
}
}
// The profile's non-negotiable authorization surface: code flow with S256 PKCE
// and RS256 signing. A replacement provider missing any of these is not a
// drop-in, whatever else it supports.
func TestProfileAuthorizationSurface(t *testing.T) {
doc := fetchDiscovery(t)
for _, required := range []struct {
what string
values []string
want string
}{
{"response_types_supported", doc.ResponseTypesSupported, "code"},
{"grant_types_supported", doc.GrantTypesSupported, "authorization_code"},
{"code_challenge_methods_supported", doc.CodeChallengeMethodsSupported, "S256"},
{"id_token_signing_alg_values_supported", doc.IDTokenSigningAlgValues, "RS256"},
} {
if !contains(required.values, required.want) {
t.Errorf("%s does not advertise %q: %v", required.what, required.want, required.values)
}
}
for _, endpoint := range []struct{ what, value string }{
{"authorization_endpoint", doc.AuthorizationEndpoint},
{"token_endpoint", doc.TokenEndpoint},
{"jwks_uri", doc.JWKSURI},
} {
if endpoint.value == "" {
t.Errorf("%s is absent", endpoint.what)
continue
}
if _, err := url.Parse(endpoint.value); err != nil {
t.Errorf("%s is not a URL: %v", endpoint.what, err)
}
}
}
// The published keys must satisfy the same rules the runtime applies when
// verifying a token, so a provider publishing keys KeyCape would refuse is
// caught here rather than at the first login.
func TestPublishedKeysAreUsable(t *testing.T) {
doc := fetchDiscovery(t)
if doc.JWKSURI == "" {
t.Skip("no jwks_uri advertised")
}
keys, err := jose.ParseJWKS(get(t, doc.JWKSURI))
if err != nil {
t.Fatalf("published key set is not usable: %v", err)
}
if len(keys) == 0 {
t.Fatal("no usable RS256 signing keys published")
}
}
// Implicit and password grants are excluded by the profile. A provider that
// still offers them is a wider attack surface than KeyCape presents, which
// matters when it is standing in for KeyCape.
func TestExcludedGrantsAreNotAdvertised(t *testing.T) {
doc := fetchDiscovery(t)
for _, excluded := range []string{"implicit", "password"} {
if contains(doc.GrantTypesSupported, excluded) {
t.Errorf("issuer advertises the excluded %q grant", excluded)
}
}
}
// With credentials supplied, exchange them and verify the resulting token
// against the issuer's own published keys — the end-to-end property a relying
// party depends on.
func TestServiceTokenVerifiesAgainstPublishedKeys(t *testing.T) {
clientID := os.Getenv("KEYCAPE_TEST_CLIENT_ID")
clientSecret := os.Getenv("KEYCAPE_TEST_CLIENT_SECRET")
if clientID == "" || clientSecret == "" {
t.Skip("KEYCAPE_TEST_CLIENT_ID/SECRET not set; skipping token exchange")
}
doc := fetchDiscovery(t)
form := url.Values{"grant_type": {"client_credentials"}}
if scope := os.Getenv("KEYCAPE_TEST_SCOPE"); scope != "" {
form.Set("scope", scope)
}
ctx, cancel := context.WithTimeout(context.Background(), requestTimeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, doc.TokenEndpoint, strings.NewReader(form.Encode()))
if err != nil {
t.Fatal(err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.SetBasicAuth(url.QueryEscape(clientID), url.QueryEscape(clientSecret))
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("token exchange: %v", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode != http.StatusOK {
// The body can carry the secret back in an error echo; report status only.
t.Fatalf("token exchange: status %d", resp.StatusCode)
}
var tokens struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
}
if err := json.Unmarshal(body, &tokens); err != nil {
t.Fatalf("parse token response: %v", err)
}
if !strings.EqualFold(tokens.TokenType, "Bearer") {
t.Errorf("token_type is %q, expected Bearer", tokens.TokenType)
}
keys, err := jose.ParseJWKS(get(t, doc.JWKSURI))
if err != nil {
t.Fatalf("published key set is not usable: %v", err)
}
claims, err := jose.Verify(tokens.AccessToken, keys)
if err != nil {
t.Fatalf("issued token does not verify against the published keys: %v", err)
}
if got, _ := claims["iss"].(string); got != doc.Issuer {
t.Errorf("token issuer %q does not match discovery issuer %q", got, doc.Issuer)
}
if _, ok := claims["exp"].(float64); !ok {
t.Error("issued token has no exp claim")
}
fmt.Fprintf(os.Stderr, "verified a token from %s against its published keys\n", doc.Issuer)
}