mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 13:51:36 +00:00
test(oidc): Keycloak integration test for MED-6 auto-refresh (Nit-5)
Audit 2026-05-10 Nit-5 closure.
WHAT.
New build-tagged integration test
(internal/auth/oidc/integration_keycloak_rotate_test.go,
//go:build integration) that exercises MED-6's implicit JWKS
auto-refresh against a real Keycloak realm. Distinct from the
existing TestKeycloakIntegration_JWKSRotation_RefreshKeysPicksUpNewKey
test which calls svc.RefreshKeys explicitly between the rotate
event and the second login — this test DELIBERATELY does NOT call
RefreshKeys, relying entirely on the MED-6 auto-refresh inside
HandleCallback's verify-error branch.
WHY.
The mockIdP-based unit test (TestService_HandleCallback_MED6_
AutoRefreshOnKidMiss) is the canonical regression because it runs
in the standard test path. This Keycloak-backed counterpart is the
belt-and-braces check that the kid-mismatch substring matcher
matches the actual go-oidc error wording emitted by a production-
grade JWKS endpoint with multiple active keys + key-priority
changes — wording the in-process mockIdP can't reproduce exactly.
HOW.
internal/auth/oidc/integration_keycloak_rotate_test.go (NEW):
TestKeycloakIntegration_MED6_AutoRefreshOnKidMiss
1. Baseline login under original key (primes JWKS cache).
2. fx.RotateRealmKeys(t) — rotate via Keycloak admin REST API.
3. Fresh login flow WITHOUT explicit RefreshKeys call.
4. Assert callback succeeds (proves MED-6 auto-refresh fired).
internal/auth/oidc/integration_keycloak_test.go:
itestPreLogin now satisfies the post-MED-16 PreLoginStore
signature (clientIP/userAgent on Create + LookupAndConsume).
Pre-existing TestKeycloakIntegration_JWKSRotation_RefreshKeysPicksUp
NewKey unchanged.
VERIFY.
- go vet -tags=integration ./internal/auth/oidc/... PASS
- go vet -tags='integration okta_smoke'
./internal/auth/oidc/... PASS
Note: actual integration test run requires the Keycloak testcontainer
(invoked via 'make keycloak-integration-test'); not exercised in this
session because the sandbox lacks Docker. The unit-test sibling
(TestService_HandleCallback_MED6_AutoRefreshOnKidMiss) provides
runtime coverage in the standard test path.
Refs: cowork/auth-bundles-audit-2026-05-10.md Nit-5
cowork/auth-bundles-fixes-2026-05-10/HANDOFF.md item 20
This commit is contained in:
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user