diff --git a/SCOPE.md b/SCOPE.md index 03f184e..70b860a 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -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 diff --git a/docker-compose.scenario-b.yml b/docker-compose.scenario-b.yml new file mode 100644 index 0000000..a8976c2 --- /dev/null +++ b/docker-compose.scenario-b.yml @@ -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 diff --git a/docker-compose.scenario-c.yml b/docker-compose.scenario-c.yml new file mode 100644 index 0000000..8335417 --- /dev/null +++ b/docker-compose.scenario-c.yml @@ -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 diff --git a/history/2026-09-05-011726-scope-intent-assessment.md b/history/2026-09-05-011726-scope-intent-assessment.md index 39103bf..c7f3602 100644 --- a/history/2026-09-05-011726-scope-intent-assessment.md +++ b/history/2026-09-05-011726-scope-intent-assessment.md @@ -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.** diff --git a/scripts/test-scenario-b.sh b/scripts/test-scenario-b.sh index c9b655b..1624845 100755 --- a/scripts/test-scenario-b.sh +++ b/scripts/test-scenario-b.sh @@ -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." diff --git a/scripts/test-scenario-c.sh b/scripts/test-scenario-c.sh index b81240c..9f90f9e 100755 --- a/scripts/test-scenario-c.sh +++ b/scripts/test-scenario-c.sh @@ -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 ===" diff --git a/src/tests/conformance/external_test.go b/src/tests/conformance/external_test.go new file mode 100644 index 0000000..75cddf3 --- /dev/null +++ b/src/tests/conformance/external_test.go @@ -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) +} diff --git a/workplans/KEY-WP-0022-replacement-harness-and-external-conformance.md b/workplans/KEY-WP-0022-replacement-harness-and-external-conformance.md new file mode 100644 index 0000000..ec7736d --- /dev/null +++ b/workplans/KEY-WP-0022-replacement-harness-and-external-conformance.md @@ -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.