diff --git a/internal/auth/oidc/integration_keycloak_rotate_test.go b/internal/auth/oidc/integration_keycloak_rotate_test.go new file mode 100644 index 0000000..169201a --- /dev/null +++ b/internal/auth/oidc/integration_keycloak_rotate_test.go @@ -0,0 +1,102 @@ +//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, "", "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, "", "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") + } +} diff --git a/internal/auth/oidc/integration_keycloak_test.go b/internal/auth/oidc/integration_keycloak_test.go index 1139177..e06cc24 100644 --- a/internal/auth/oidc/integration_keycloak_test.go +++ b/internal/auth/oidc/integration_keycloak_test.go @@ -203,23 +203,27 @@ func (s *itestSessionMinter) Revoke(cookieValue string) { type itestPreLogin struct { rows map[string]itestPreLoginRow } -type itestPreLoginRow struct{ providerID, state, nonce, verifier string } +type itestPreLoginRow struct { + providerID, state, nonce, verifier string + // Audit 2026-05-10 MED-16 — UA/IP binding capture. + clientIP, userAgent string +} func newItestPreLogin() *itestPreLogin { return &itestPreLogin{rows: make(map[string]itestPreLoginRow)} } -func (s *itestPreLogin) CreatePreLogin(_ context.Context, providerID, state, nonce, verifier string) (string, string, error) { +func (s *itestPreLogin) CreatePreLogin(_ context.Context, providerID, state, nonce, verifier, clientIP, userAgent string) (string, string, error) { cookieVal := fmt.Sprintf("pl-keycloak-itest-%d", len(s.rows)+1) - s.rows[cookieVal] = itestPreLoginRow{providerID, state, nonce, verifier} + s.rows[cookieVal] = itestPreLoginRow{providerID, state, nonce, verifier, clientIP, userAgent} return cookieVal, "ses-" + cookieVal, nil } -func (s *itestPreLogin) LookupAndConsume(_ context.Context, cookie string) (string, string, string, string, error) { +func (s *itestPreLogin) LookupAndConsume(_ context.Context, cookie string) (string, string, string, string, string, string, error) { r, ok := s.rows[cookie] if !ok { - return "", "", "", "", oidc.ErrPreLoginNotFound + return "", "", "", "", "", "", oidc.ErrPreLoginNotFound } delete(s.rows, cookie) - return r.providerID, r.state, r.nonce, r.verifier, nil + return r.providerID, r.state, r.nonce, r.verifier, r.clientIP, r.userAgent, nil } // ---------------------------------------------------------------------------