mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 15:11:29 +00:00
360e7449ad
Phase-10 live-IdP smoke (post-Enabled-true fix landing in 1b52998)
surfaced the next layer: 5 of 6 testcontainers-Keycloak integration
tests failed with 'oidc: provider advertises iss-parameter support
but callback omitted it'.
Root cause: Keycloak's discovery doc advertises
authorization_response_iss_parameter_supported=true. The Audit
2026-05-10 MED-17 closure (RFC 9207) gates the callback path:
when the IdP advertises iss-param support, HandleCallback requires
a non-empty callbackIss arg that matches the provider's IssuerURL,
else ErrIssParamMissing. The 7 HandleCallback call sites in the
integration tests were passing '' for the callbackIss arg — the
synthetic test code never simulated the real browser's
'?iss=<issuer>' query param.
Fix: replace '' with fx.IssuerURL at all 7 sites:
- integration_keycloak_test.go: 5 sites
(TestKeycloakIntegration_AuthCodeFlow_HappyPath,
TestKeycloakIntegration_LogoutRevokesSession,
TestKeycloakIntegration_JWKSRotation_RefreshKeysPicksUpNewKey
pre+post HandleCallback,
TestKeycloakIntegration_UnmappedGroupsFailsClosed)
- integration_keycloak_rotate_test.go: 2 sites
(TestKeycloakIntegration_MED6_AutoRefreshOnKidMiss pre+post)
Inline note on the first site explains the rationale so future
test-writers don't drop back to ''.
Verify (sandbox): go vet -tags=integration ./internal/auth/oidc/...
clean; gofmt clean; grep for remaining empty-iss callsites returns
0 matches. Workstation re-runs 'make keycloak-integration-test' to
confirm the 5 affected tests advance past the iss-param check
against a real Keycloak 26.x.
103 lines
4.4 KiB
Go
103 lines
4.4 KiB
Go
//go:build integration
|
|
|
|
package oidc_test
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/certctl-io/certctl/internal/auth/oidc/testfixtures"
|
|
)
|
|
|
|
// =============================================================================
|
|
// Audit 2026-05-10 Nit-5 closure — Keycloak-backed integration test for
|
|
// the MED-6 JWKS auto-refresh path.
|
|
//
|
|
// Distinct from integration_keycloak_test.go's existing
|
|
// TestKeycloakIntegration_JWKSRotation_RefreshKeysPicksUpNewKey: that
|
|
// test calls `svc.RefreshKeys` explicitly between the rotate event and
|
|
// the second login (operator-driven path). This test deliberately does
|
|
// NOT call RefreshKeys — it exercises the IMPLICIT auto-refresh that
|
|
// MED-6 added inside HandleCallback's verify-error branch.
|
|
//
|
|
// The unit-test sibling lives in service_test.go::
|
|
// TestService_HandleCallback_MED6_AutoRefreshOnKidMiss; it uses an
|
|
// in-process mockIdP. Here we run against a real Keycloak realm so
|
|
// the test pins behavior against the actual go-oidc error strings
|
|
// emitted by a production-grade JWKS endpoint with multiple active
|
|
// keys + a key-priority change.
|
|
//
|
|
// Build-tagged `integration` so it doesn't run under `make test` /
|
|
// `go test -short`. Runs via `make keycloak-integration-test` which
|
|
// boots the Keycloak testcontainer.
|
|
// =============================================================================
|
|
|
|
// TestKeycloakIntegration_MED6_AutoRefreshOnKidMiss pins the MED-6
|
|
// recovery contract: after the realm rotates its signing key, the
|
|
// next /auth/oidc/callback request that arrives WITHOUT an explicit
|
|
// operator-initiated RefreshKeys must still succeed — HandleCallback
|
|
// detects the kid-not-in-cache shape and runs the one-shot refresh +
|
|
// retry internally.
|
|
//
|
|
// Plan:
|
|
// 1. Successful baseline login under the realm's original signing key
|
|
// (primes the certctl service's JWKS cache).
|
|
// 2. Rotate the realm's RSA key via the Keycloak admin API.
|
|
// 3. Run a fresh /auth/oidc/login → /auth/oidc/callback flow.
|
|
// - Keycloak signs the new ID token under the new (higher-priority)
|
|
// key.
|
|
// - certctl's verifier holds the pre-rotate JWKS in cache.
|
|
// - The verify trips kid-not-in-cache → MED-6 auto-refresh fires →
|
|
// second verify succeeds.
|
|
// 4. Assert the callback succeeded without the test having called
|
|
// RefreshKeys (which would mask the MED-6 path).
|
|
//
|
|
// Note: this is the Keycloak-against-real-IdP variant of MED-6's
|
|
// unit test. The unit test stays the canonical regression because
|
|
// it doesn't require the testcontainer; this test is the
|
|
// belt-and-braces check that the auto-refresh works against real
|
|
// go-oidc error wording emitted by a production-grade JWKS endpoint.
|
|
func TestKeycloakIntegration_MED6_AutoRefreshOnKidMiss(t *testing.T) {
|
|
fx := keycloakFor(t)
|
|
svc, _, _, _ := buildKeycloakService(t, fx, map[string]string{
|
|
testfixtures.EngineerGroup: "r-operator",
|
|
})
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
|
defer cancel()
|
|
|
|
// Step 1 — baseline login to prime the JWKS cache.
|
|
preAuthURL, preCookie, _, err := svc.HandleAuthRequest(ctx, fx.Provider.ID, "", "")
|
|
if err != nil {
|
|
t.Fatalf("pre-rotate HandleAuthRequest: %v", err)
|
|
}
|
|
preCode, preState := driveAuthCodeFlow(t, preAuthURL, testfixtures.EngineerUser, testfixtures.EngineerPassword)
|
|
if _, err := svc.HandleCallback(ctx, preCookie, preCode, preState, fx.IssuerURL, "ip", "ua"); err != nil {
|
|
t.Fatalf("pre-rotate HandleCallback (priming): %v", err)
|
|
}
|
|
|
|
// Step 2 — rotate Keycloak's realm signing key.
|
|
fx.RotateRealmKeys(t)
|
|
|
|
// Step 3 — DELIBERATELY skip svc.RefreshKeys. The whole point of
|
|
// MED-6 is that the implicit auto-refresh inside HandleCallback
|
|
// recovers from kid-not-in-cache without operator intervention.
|
|
// If MED-6 regressed, the callback below would fail with a
|
|
// generic verify error or ErrJWKSUnreachable.
|
|
|
|
// Step 4 — post-rotate login through the implicit recovery path.
|
|
postAuthURL, postCookie, _, err := svc.HandleAuthRequest(ctx, fx.Provider.ID, "", "")
|
|
if err != nil {
|
|
t.Fatalf("post-rotate HandleAuthRequest: %v", err)
|
|
}
|
|
postCode, postState := driveAuthCodeFlow(t, postAuthURL, testfixtures.EngineerUser, testfixtures.EngineerPassword)
|
|
res, err := svc.HandleCallback(ctx, postCookie, postCode, postState, fx.IssuerURL, "ip", "ua")
|
|
if err != nil {
|
|
t.Fatalf("post-rotate HandleCallback (expected MED-6 auto-refresh): %v", err)
|
|
}
|
|
if res == nil || res.User == nil {
|
|
t.Fatalf("CallbackResult missing user after MED-6 recovery")
|
|
}
|
|
}
|