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

@ -78,7 +78,14 @@ Keycloak interchangeability are not established.
See the assessment below.
- Tests cover local handlers, adapters, transformations and CLI protocol behavior.
They do not establish complete replacement against a running Keycloak/full-LDAP
stack. The Scenario B/C shell harnesses are incomplete.
stack. The Scenario B/C harnesses now run, and `src/tests/conformance` targets a
live issuer named by `KEYCAPE_TEST_ISSUER`, skipping when it is unset
(KEY-WP-0022). Run against Keycloak 26.0 it verified discovery, the
authorization surface, the published keys and a real token exchange — and
showed that stock Keycloak advertises the excluded `implicit` and `password`
grants, so it is not a drop-in for this profile. Directory migration,
credential and MFA preservation and relying-party behaviour remain unexercised;
Scenario C has not been run end to end.
- The server listens on HTTP; HTTPS termination is deployment-owned.
`/healthz` reports process status without probing dependencies. Development
Compose needs configuration/key material absent from the checkout. Production

View file

@ -0,0 +1,33 @@
# Scenario B: IAM swap — Keycloak stands in for KeyCape over the same directory.
#
# The realm JSON is produced by scripts/test-scenario-b.sh before this stack
# starts; Keycloak imports it at boot. LLDAP is the same directory KeyCape reads,
# so this scenario changes the issuer and nothing else.
services:
lldap:
image: lldap/lldap:stable
environment:
- LLDAP_JWT_SECRET=devjwtsecret
- LLDAP_LDAP_USER_PASS=adminpassword
- LLDAP_LDAP_BASE_DN=dc=netkingdom,dc=local
ports:
- "3890:3890"
- "17170:17170"
keycloak:
image: quay.io/keycloak/keycloak:26.0
command: ["start-dev", "--import-realm"]
environment:
- KC_BOOTSTRAP_ADMIN_USERNAME=admin
- KC_BOOTSTRAP_ADMIN_PASSWORD=admin
- KC_HEALTH_ENABLED=true
volumes:
# Written by the scenario script from the migration output.
- ./build/scenario-b/realm:/opt/keycloak/data/import:ro
ports:
- "8080:8080"
healthcheck:
test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/9000 && echo -e 'GET /health/ready HTTP/1.1\\r\\nHost: localhost\\r\\nConnection: close\\r\\n\\r\\n' >&3 && cat <&3 | grep -q '\"status\": \"UP\"'"]
interval: 5s
timeout: 5s
retries: 30

View file

@ -0,0 +1,31 @@
# Scenario C: directory swap — OpenLDAP replaces LLDAP, Keycloak replaces KeyCape.
#
# The LDIF and realm JSON are produced by scripts/test-scenario-c.sh before this
# stack starts. OpenLDAP imports any LDIF mounted into its bootstrap directory.
services:
openldap:
image: bitnami/openldap:2.6
environment:
- LDAP_ROOT=dc=netkingdom,dc=local
- LDAP_ADMIN_USERNAME=admin
- LDAP_ADMIN_PASSWORD=adminpassword
- LDAP_SKIP_DEFAULT_TREE=yes
- LDAP_CUSTOM_LDIF_DIR=/ldifs
volumes:
- ./build/scenario-c/ldif:/ldifs:ro
ports:
- "1389:1389"
keycloak:
image: quay.io/keycloak/keycloak:26.0
command: ["start-dev", "--import-realm"]
environment:
- KC_BOOTSTRAP_ADMIN_USERNAME=admin
- KC_BOOTSTRAP_ADMIN_PASSWORD=admin
- KC_HEALTH_ENABLED=true
volumes:
- ./build/scenario-c/realm:/opt/keycloak/data/import:ro
ports:
- "8080:8080"
depends_on:
- openldap

View file

@ -217,6 +217,32 @@ profile tests exercise the external Keycloak issuer.
exercise actual replacement providers, directory migration, claims and MFA;
record unchanged relying-party behavior and explicit migration limitations.
**Status 2026-09-07 (KEY-WP-0022): partially closed.** The harness now runs. The
shell prerequisites are fixed (root `bin/`, `--basedn`, module-relative `go -C
src`, prerequisite checks before anything starts), the two missing compose files
exist and pass `docker compose config`, and `src/tests/conformance` reads
`KEYCAPE_TEST_ISSUER` and exercises a running issuer over HTTP — discovery,
profile authorization surface, published keys parsed under the runtime's own
rules, and, with credentials, a real token exchange verified against those keys.
It skips when the variable is unset, so `make test` is unchanged.
It was run against an actual Keycloak 26.0, not only asserted to work: discovery,
authorization surface and key checks passed, and a `client_credentials` exchange
against a created service client produced a token that verified against
Keycloak's published JWKS through `internal/jose`.
**Finding that bears on the replacement claim:** stock Keycloak fails
`TestExcludedGrantsAreNotAdvertised`. Its discovery advertises `implicit` and
`password` alongside the profile's grants, and those are server capabilities in
Keycloak rather than per-client toggles, so no realm configuration removes them.
A migrated Keycloak therefore presents a wider grant surface than KeyCape does.
This substantiates with evidence what SCOPE already said was unestablished, and
means Scenario B legitimately reports a failure today rather than a green run.
Still open: directory migration against a live OpenLDAP, credential and MFA
preservation, and unchanged relying-party behaviour are not exercised. Scenario C
has never been run end to end. G04 does not fully close.
### G05 — Directory export can omit data without reporting it
**Priority: medium. Kind: implementation gap.**

View file

@ -1,44 +1,68 @@
#!/usr/bin/env bash
# test-scenario-b.sh — Scenario B: IAM swap (KeyCape → Keycloak, same LLDAP directory)
# test-scenario-b.sh — Scenario B: IAM swap (KeyCape -> Keycloak, same LLDAP directory).
#
# This script verifies that after migrating to Keycloak (with the same LLDAP directory),
# all profile tests pass without modification.
# Exports the canonical directory, transforms it into a Keycloak realm, starts
# Keycloak over that realm, and runs the external conformance suite against it.
#
# Prerequisites: docker, docker compose
# Prerequisites: docker, docker compose, and `make build` (binaries land in bin/).
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$REPO_ROOT"
echo "=== Scenario B: IAM Replacement Test ==="
BUILD_DIR="$REPO_ROOT/build/scenario-b"
COMPOSE="docker compose -f docker-compose.scenario-b.yml"
ISSUER_URL="http://localhost:8080/realms/netkingdom"
# Step 1: Export canonical data from LLDAP
echo "--- Step 1: Export canonical data ---"
./src/bin/lldap-export \
die() { echo "scenario-b: $*" >&2; exit 1; }
# Check every prerequisite up front: failing half way through leaves a running
# stack and a half-written realm, which is harder to diagnose than not starting.
for binary in lldap-export keycape-to-keycloak; do
[ -x "bin/$binary" ] || die "bin/$binary is missing; run 'make build' first"
done
command -v docker >/dev/null || die "docker is not installed"
command -v go >/dev/null || die "go is not installed"
cleanup() { $COMPOSE down -v >/dev/null 2>&1 || true; }
trap cleanup EXIT
mkdir -p "$BUILD_DIR/realm"
echo "=== Scenario B: IAM replacement ==="
echo "--- Step 1: start the directory ---"
$COMPOSE up -d lldap
timeout 120 bash -c 'until (exec 3<>/dev/tcp/127.0.0.1/3890) 2>/dev/null; do sleep 2; done' \
|| die "LLDAP did not accept connections within 120s"
echo "--- Step 2: export the canonical directory ---"
./bin/lldap-export \
--url "${LLDAP_URL:-ldap://localhost:3890}" \
--bind-dn "${LLDAP_BIND_DN:-cn=admin,ou=people,dc=netkingdom,dc=local}" \
--bind-pw "${LLDAP_BIND_PW:-adminpassword}" \
--base-dn "dc=netkingdom,dc=local" \
--output /tmp/canonical-export.yaml
--base-dn "${LLDAP_BASE_DN:-dc=netkingdom,dc=local}" \
--output "$BUILD_DIR/canonical-export.yaml"
# Step 2: Transform to Keycloak realm
echo "--- Step 2: Transform to Keycloak realm ---"
./src/bin/keycape-to-keycloak \
--input /tmp/canonical-export.yaml \
echo "--- Step 3: transform into a Keycloak realm ---"
./bin/keycape-to-keycloak \
--input "$BUILD_DIR/canonical-export.yaml" \
--realm netkingdom \
--issuer "${ISSUER:-https://auth.netkingdom.local}" \
--output /tmp/keycloak-realm-import.json
--issuer "${ISSUER:-$ISSUER_URL}" \
--output "$BUILD_DIR/realm/netkingdom-realm.json"
# Step 3: Start Keycloak with the imported realm
echo "--- Step 3: Start Keycloak with imported realm ---"
docker compose -f docker-compose.scenario-b.yml up -d keycloak
echo "Waiting for Keycloak to be ready..."
timeout 120 bash -c 'until curl -sf http://localhost:8080/realms/netkingdom/.well-known/openid-configuration > /dev/null; do sleep 3; done'
echo "--- Step 4: start Keycloak over the migrated realm ---"
$COMPOSE up -d keycloak
timeout 180 bash -c "until curl -sf '$ISSUER_URL/.well-known/openid-configuration' >/dev/null; do sleep 3; done" \
|| die "Keycloak did not serve discovery within 180s"
# Step 4: Run profile tests against Keycloak
echo "--- Step 4: Run profile tests against Keycloak ---"
KEYCAPE_TEST_ISSUER="http://localhost:8080/realms/netkingdom" \
/home/worsch/go/bin/go test ./src/tests/profile/... -v -count=1
echo "--- Step 5: run the external conformance suite against Keycloak ---"
# The suite reads KEYCAPE_TEST_ISSUER and talks to the running issuer over HTTP.
# Run it from the module directory: the Go module root is src/, not the repo root.
KEYCAPE_TEST_ISSUER="$ISSUER_URL" \
go -C src test ./tests/conformance/... -v -count=1
echo "=== Scenario B PASSED ==="
echo "Note: this proves the migrated realm serves a conforming OIDC surface."
echo "It does not prove credential, MFA or relying-party behaviour was preserved."

View file

@ -17,7 +17,7 @@ echo "=== Scenario C: Full Expansion Test ==="
# Step 1: Export canonical data from LLDAP
echo "--- Step 1: Export canonical data ---"
./src/bin/lldap-export \
./bin/lldap-export \
--url "${LLDAP_URL:-ldap://localhost:3890}" \
--bind-dn "${LLDAP_BIND_DN:-cn=admin,ou=people,dc=netkingdom,dc=local}" \
--bind-pw "${LLDAP_BIND_PW:-adminpassword}" \
@ -26,15 +26,15 @@ echo "--- Step 1: Export canonical data ---"
# Step 2a: Generate LDIF for OpenLDAP
echo "--- Step 2a: Generate OpenLDAP LDIF ---"
./src/bin/lldap-to-ldap \
./bin/lldap-to-ldap \
--input /tmp/canonical-export.yaml \
--target openldap \
--base-dn "dc=netkingdom,dc=local" \
--basedn "dc=netkingdom,dc=local" \
--output /tmp/migration.ldif
# Step 2b: Transform to Keycloak realm
echo "--- Step 2b: Transform to Keycloak realm ---"
./src/bin/keycape-to-keycloak \
./bin/keycape-to-keycloak \
--input /tmp/canonical-export.yaml \
--realm netkingdom \
--issuer "${ISSUER:-https://auth.netkingdom.local}" \
@ -55,6 +55,6 @@ ldapadd -x -H ldap://localhost:389 -D "cn=admin,dc=netkingdom,dc=local" -w admin
# Step 5: Run profile tests against Keycloak + OpenLDAP
echo "--- Step 5: Run profile tests ---"
KEYCAPE_TEST_ISSUER="http://localhost:8080/realms/netkingdom" \
/home/worsch/go/bin/go test ./src/tests/profile/... -v -count=1
go -C src test ./tests/conformance/... -v -count=1
echo "=== Scenario C PASSED ==="

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)
}

View file

@ -0,0 +1,130 @@
---
id: KEY-WP-0022
type: workplan
title: "Make the replacement harness runnable and target conformance externally"
domain: infotech
repo: key-cape
status: finished
owner: claude
topic_slug: replacement-harness-and-external-conformance
created: "2026-09-07"
updated: "2026-09-07"
---
Closes the runnable half of gap G04 of
`history/2026-09-05-011726-scope-intent-assessment.md`. The Scenario B and C
scripts cannot execute: they reference compose files that do not exist, call
binaries at `src/bin/` where the Makefile builds to root `bin/`, pass `--base-dn`
to `lldap-to-ldap` whose flag is `--basedn`, and invoke a hardcoded workstation Go
path from outside the Go module.
The deeper problem is that repairing the shell would not prove anything. Both
scripts set `KEYCAPE_TEST_ISSUER`, but the profile suite builds its own `httptest`
server and never reads it, so the tests pass identically whether or not the
external provider is running. A harness that cannot fail for the reason it exists
is worse than a missing one.
## Repair the harness prerequisites
```task
id: KEY-WP-0022-T01
status: done
priority: high
```
Fix the binary paths, the `--basedn` flag, and the Go invocation so the scripts
run from a clean checkout with `make build`: use the module directory rather than
a workstation-specific Go path, and fail with a clear message when a prerequisite
binary or a required environment value is absent instead of part-way through.
Scenario B rewritten: root `bin/`, `go -C src` so the module root is right,
prerequisite checks before anything starts, and a cleanup trap so a failure does
not leave a stack running. Scenario C repaired in place — `bin/` paths, `--basedn`
for `lldap-to-ldap` (`lldap-export` correctly takes `--base-dn`; the two flags
genuinely differ), and the same module-relative test invocation.
## Add the missing stacks
```task
id: KEY-WP-0022-T02
status: done
priority: high
```
Provide `docker-compose.scenario-b.yml` (Keycloak over the migrated realm) and
`docker-compose.scenario-c.yml` (OpenLDAP seeded from generated LDIF, plus
Keycloak). Validate them with `docker compose config` rather than assuming they
parse. Whether a full scenario passes end to end is a separate claim from whether
the stack definition is valid, and only the second is established here.
Both files added and validated with `docker compose config`. Scenario B was also
started for real; Scenario C's stack has not been run.
## Target conformance at an external issuer
```task
id: KEY-WP-0022-T03
status: done
priority: high
```
Add a suite that reads `KEYCAPE_TEST_ISSUER` and exercises that issuer over HTTP:
discovery metadata, the JWKS parsing under the same strict rules the runtime
applies, and — when client credentials are supplied — a real token exchange whose
claims are verified against the published keys. It must skip cleanly when the
variable is unset so ordinary `make test` is unaffected, and it must fail when
pointed at an issuer that does not meet the profile, which is the property the
current harness lacks.
`src/tests/conformance` checks that the issuer advertises itself, the profile
authorization surface (code, authorization_code, S256, RS256), that published
keys parse under `internal/jose` — the same rules the runtime applies, so a
provider publishing keys KeyCape would refuse fails here rather than at first
login — that excluded grants are absent, and, with credentials, that an issued
token verifies against the published keys and carries a matching issuer.
## Prove the suite against a real provider
```task
id: KEY-WP-0022-T04
status: done
priority: high
```
Run the new suite against an actual Keycloak instance, not only against KeyCape,
since the point of the scenario is a replacement provider. Record what genuinely
passed and what remains unproven. Do not describe a partially exercised scenario
as a demonstrated live swap.
Run against Keycloak 26.0. Discovery, authorization surface and published-key
checks passed. A `client_credentials` exchange against a service client created
through the admin API returned a token that verified against Keycloak's JWKS via
`internal/jose`, with a matching issuer claim — the end-to-end property a relying
party depends on, proved against a replacement provider rather than against
KeyCape's own handlers.
The suite also failed, correctly. Stock Keycloak advertises `implicit` and
`password` in its discovery document. In Keycloak these are server capabilities,
not per-client toggles, so no realm the transformer emits can remove them: a
migrated Keycloak presents a wider grant surface than KeyCape. Scenario B
therefore reports a real failure today, which is the harness working. KeyCape
itself was not targeted in this run — the dev stack needs key material absent
from the checkout — so the suite is proved against the replacement provider, not
yet against both sides of the swap.
## Reconcile the records
```task
id: KEY-WP-0022-T05
status: done
priority: medium
```
Update `SCOPE.md` and G04's status with what the harness now does and what
replacement claims remain unproven — in particular that directory migration, MFA
and unchanged relying-party behaviour against a live Keycloak are not established
by this work. G04 does not fully close here.
SCOPE.md and G04's status record both the working harness and the grant-surface
finding, which substantiates with evidence what SCOPE previously asserted without
it. Scenario C has still never been run end to end.