diff --git a/cmd/cluster-authentication-operator-tests-ext/main.go b/cmd/cluster-authentication-operator-tests-ext/main.go index 98af196201..d5d7145689 100644 --- a/cmd/cluster-authentication-operator-tests-ext/main.go +++ b/cmd/cluster-authentication-operator-tests-ext/main.go @@ -13,6 +13,7 @@ import ( "github.com/openshift/cluster-authentication-operator/pkg/version" _ "github.com/openshift/cluster-authentication-operator/test/e2e" + _ "github.com/openshift/cluster-authentication-operator/test/e2e-component-proxy" _ "github.com/openshift/cluster-authentication-operator/test/e2e-encryption" _ "github.com/openshift/cluster-authentication-operator/test/e2e-encryption-kms" _ "github.com/openshift/cluster-authentication-operator/test/e2e-encryption-perf" @@ -85,6 +86,17 @@ func prepareOperatorTestsRegistry() (*oteextension.Registry, error) { }, }) + // ClusterStability set to Disruptive: component-proxy tests intentionally + // degrade the authentication operator to validate error handling. + extension.AddSuite(oteextension.Suite{ + Name: "openshift/cluster-authentication-operator/component-proxy/disruptive", + Parallelism: 1, + ClusterStability: oteextension.ClusterStabilityDisruptive, + Qualifiers: []string{ + `name.contains("[ComponentProxy]")`, + }, + }) + // The following suite runs tests that are disruptive to the cluster. extension.AddSuite(oteextension.Suite{ Name: "openshift/cluster-authentication-operator/operator/disruptive", diff --git a/test/e2e-component-proxy/component_proxy.go b/test/e2e-component-proxy/component_proxy.go new file mode 100644 index 0000000000..4262edc9a0 --- /dev/null +++ b/test/e2e-component-proxy/component_proxy.go @@ -0,0 +1,387 @@ +package component_proxy + +import ( + "context" + "fmt" + "time" + + g "github.com/onsi/ginkgo/v2" + o "github.com/onsi/gomega" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/wait" + + configv1 "github.com/openshift/api/config/v1" + "github.com/openshift/api/features" + operatorv1 "github.com/openshift/api/operator/v1" + "github.com/openshift/library-go/pkg/operator/v1helpers" + + test "github.com/openshift/cluster-authentication-operator/test/library" +) + +var _ = g.Describe("[sig-auth] authentication operator", func() { + g.It("[Serial][Operator][ComponentProxy] should validate OIDC IdP through component proxy", func() { + testOIDCIdPThroughComponentProxy(false) + }) + g.It("[Serial][Operator][ComponentProxy] should validate OIDC IdP through component proxy with trustedCA", func() { + testOIDCIdPThroughComponentProxy(true) + }) + g.It("[Serial][Operator][ComponentProxy] should fall back on spec.proxy removal", func() { + testFallbackOnProxyRemoval() + }) + g.It("[Serial][Operator][ComponentProxy] should set Degraded when spec.proxy points to an unreachable proxy", func() { + testDegradedOnBadProxyURL() + }) + g.It("[Serial][Operator][ComponentProxy] should emit IdPEndpointUnreachable warning when IdP is unreachable through proxy", func() { + testWarningOnUnreachableIdP() + }) +}) + +func testOIDCIdPThroughComponentProxy(withTrustedCA bool) { + ctx := context.Background() + t := g.GinkgoTB() + kubeConfig := test.NewClientConfigForTest(t) + + g.By("Creating test clients") + clients := test.NewTestClients(t) + + test.CheckFeatureGateEnabledOrSkip(t, clients.ConfigClient, features.FeatureGateAuthenticationComponentProxy) + + g.By("Waiting for authentication operator to be stable before test") + err := test.WaitForClusterOperatorAvailableNotProgressingNotDegraded(t, clients.ConfigClient.ConfigV1(), "authentication") + o.Expect(err).NotTo(o.HaveOccurred()) + + g.By("Deploying Squid forward proxy") + httpProxyURL, httpsProxyURL, caCertPEM, proxyNamespace, proxyCleanup := test.DeploySquidProxy(t, clients.KubeClient) + g.DeferCleanup(proxyCleanup) + + var proxyURL string + const trustedCAConfigMapName = "e2e-proxy-ca" + if withTrustedCA { + proxyURL = httpsProxyURL + + g.By("Creating trustedCA ConfigMap in openshift-config") + _, err = clients.KubeClient.CoreV1().ConfigMaps("openshift-config").Create(ctx, &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: trustedCAConfigMapName, + Labels: test.CAOE2ETestLabels(), + }, + Data: map[string]string{ + "ca-bundle.crt": string(caCertPEM), + }, + }, metav1.CreateOptions{}) + o.Expect(err).NotTo(o.HaveOccurred()) + g.DeferCleanup(func() { + g.GinkgoWriter.Println("cleaning up: removing trustedCA ConfigMap") + _ = clients.KubeClient.CoreV1().ConfigMaps("openshift-config").Delete(ctx, trustedCAConfigMapName, metav1.DeleteOptions{}) + }) + } else { + proxyURL = httpProxyURL + } + g.GinkgoWriter.Printf("Squid proxy URL: %s\n", proxyURL) + + g.By("Deploying Keycloak (without registering IdP yet)") + kcSetup := test.DeployKeycloak(t, kubeConfig) + g.DeferCleanup(test.IDPCleanupWrapper(func() { + g.GinkgoWriter.Println("cleaning up: removing Keycloak") + for _, cleanup := range kcSetup.Cleanups { + cleanup() + } + })) + g.GinkgoWriter.Printf("Keycloak issuer URL: %s\n", kcSetup.IssuerURL) + g.GinkgoWriter.Printf("Keycloak namespace: %s\n", kcSetup.Namespace) + + g.By("Deploying NetworkPolicy to restrict Keycloak ingress to proxy namespace only") + networkPolicyCleanup := test.DeployProxyNetworkPolicies(t, clients.KubeClient, proxyNamespace, kcSetup.Namespace) + g.DeferCleanup(func() { + g.GinkgoWriter.Println("cleaning up: removing proxy NetworkPolicies") + networkPolicyCleanup() + }) + + g.By("Setting component-scoped proxy") + operatorAuth, proxyRestore := test.SaveAndRestoreProxyConfig(t, clients.OperatorClient, clients.ConfigClient) + g.DeferCleanup(proxyRestore) + + operatorAuth.Spec.Proxy = operatorv1.AuthenticationProxyConfig{ + HTTPSProxy: proxyURL, + } + if withTrustedCA { + operatorAuth.Spec.Proxy.TrustedCA = operatorv1.AuthenticationConfigMapReference{Name: trustedCAConfigMapName} + } + _, err = clients.OperatorClient.OperatorV1().Authentications().Update(ctx, operatorAuth, metav1.UpdateOptions{}) + o.Expect(err).NotTo(o.HaveOccurred()) + + g.By("Registering Keycloak as OIDC IdP (operator discovers it through the proxy)") + idpCleanups := test.AddKeycloakOIDCIdP(t, kubeConfig, kcSetup, false) + g.DeferCleanup(test.IDPCleanupWrapper(func() { + g.GinkgoWriter.Println("cleaning up: removing OIDC IdP") + for _, cleanup := range idpCleanups { + cleanup() + } + })) + + g.By("Verifying operator is Available and not Degraded") + err = test.WaitForClusterOperatorAvailableNotProgressingNotDegraded(t, clients.ConfigClient.ConfigV1(), "authentication") + o.Expect(err).NotTo(o.HaveOccurred()) + + g.By("Verifying OAuth server deployment has proxy env vars and trustedCA volume/mount") + test.VerifyOAuthServerDeploymentProxyConfig(t, clients.KubeClient, "", proxyURL, ".cluster.local,.svc,127.0.0.1,localhost", withTrustedCA) + + if withTrustedCA { + g.By("Verifying trustedCA ConfigMap was synced to openshift-authentication") + test.VerifyTrustedCAConfigMapSynced(t, clients.KubeClient, trustedCAConfigMapName) + } + + g.By("Verifying traffic went through the Squid proxy") + err = test.WaitForSquidProxyTraffic(t, clients.KubeClient, proxyNamespace, 5*time.Minute) + o.Expect(err).NotTo(o.HaveOccurred()) +} + +// No NetworkPolicy is deployed here intentionally: after proxy removal the +// operator must fall back to direct connectivity, so Keycloak must remain +// reachable without a proxy. +func testFallbackOnProxyRemoval() { + ctx := context.Background() + t := g.GinkgoTB() + kubeConfig := test.NewClientConfigForTest(t) + + g.By("Creating test clients") + clients := test.NewTestClients(t) + + test.CheckFeatureGateEnabledOrSkip(t, clients.ConfigClient, features.FeatureGateAuthenticationComponentProxy) + + g.By("Waiting for authentication operator to be stable before test") + err := test.WaitForClusterOperatorAvailableNotProgressingNotDegraded(t, clients.ConfigClient.ConfigV1(), "authentication") + o.Expect(err).NotTo(o.HaveOccurred()) + + g.By("Deploying Squid forward proxy") + httpProxyURL, _, _, _, proxyCleanup := test.DeploySquidProxy(t, clients.KubeClient) + g.DeferCleanup(proxyCleanup) + g.GinkgoWriter.Printf("Squid proxy URL: %s\n", httpProxyURL) + + g.By("Saving original proxy config and setting component-scoped proxy") + operatorAuth, proxyRestore := test.SaveAndRestoreProxyConfig(t, clients.OperatorClient, clients.ConfigClient) + g.DeferCleanup(proxyRestore) + + operatorAuth.Spec.Proxy = operatorv1.AuthenticationProxyConfig{ + HTTPSProxy: httpProxyURL, + } + _, err = clients.OperatorClient.OperatorV1().Authentications().Update(ctx, operatorAuth, metav1.UpdateOptions{}) + o.Expect(err).NotTo(o.HaveOccurred()) + + g.By("Deploying Keycloak and adding OIDC IdP") + _, _, keycloakCleanups := test.AddKeycloakIDP(t, kubeConfig, false) + g.DeferCleanup(test.IDPCleanupWrapper(func() { + g.GinkgoWriter.Println("cleaning up: removing Keycloak and IdP") + for _, cleanup := range keycloakCleanups { + cleanup() + } + })) + + g.By("Verifying operator is stable with proxy configured") + err = test.WaitForClusterOperatorAvailableNotProgressingNotDegraded(t, clients.ConfigClient.ConfigV1(), "authentication") + o.Expect(err).NotTo(o.HaveOccurred()) + + // Removing spec.proxy causes the operator to contact Keycloak again. + g.By("Removing spec.proxy from Authentication CR") + operatorAuth, err = clients.OperatorClient.OperatorV1().Authentications().Get(ctx, "cluster", metav1.GetOptions{}) + o.Expect(err).NotTo(o.HaveOccurred()) + operatorAuth.Spec.Proxy = operatorv1.AuthenticationProxyConfig{} + _, err = clients.OperatorClient.OperatorV1().Authentications().Update(ctx, operatorAuth, metav1.UpdateOptions{}) + o.Expect(err).NotTo(o.HaveOccurred()) + + g.By("Waiting for operator to pick up proxy removal and stabilize") + err = test.WaitForOperatorToPickUpChanges(t, clients.ConfigClient.ConfigV1(), "authentication") + o.Expect(err).NotTo(o.HaveOccurred()) + + g.By("Verifying proxy env vars are no longer set on OAuth server deployment") + test.VerifyOAuthServerDeploymentProxyConfig(t, clients.KubeClient, "", "", "", false) +} + +func testDegradedOnBadProxyURL() { + ctx := context.Background() + t := g.GinkgoTB() + kubeConfig := test.NewClientConfigForTest(t) + + g.By("Creating test clients") + clients := test.NewTestClients(t) + + test.CheckFeatureGateEnabledOrSkip(t, clients.ConfigClient, features.FeatureGateAuthenticationComponentProxy) + + g.By("Waiting for authentication operator to be stable before test") + err := test.WaitForClusterOperatorAvailableNotProgressingNotDegraded(t, clients.ConfigClient.ConfigV1(), "authentication") + o.Expect(err).NotTo(o.HaveOccurred()) + + g.By("Deploying Squid forward proxy") + httpProxyURL, _, _, _, proxyCleanup := test.DeploySquidProxy(t, clients.KubeClient) + g.DeferCleanup(proxyCleanup) + + g.By("Saving original proxy config and setting a working proxy") + operatorAuth, proxyRestore := test.SaveAndRestoreProxyConfig(t, clients.OperatorClient, clients.ConfigClient) + g.DeferCleanup(proxyRestore) + + operatorAuth.Spec.Proxy = operatorv1.AuthenticationProxyConfig{ + HTTPSProxy: httpProxyURL, + } + _, err = clients.OperatorClient.OperatorV1().Authentications().Update(ctx, operatorAuth, metav1.UpdateOptions{}) + o.Expect(err).NotTo(o.HaveOccurred()) + + g.By("Deploying Keycloak and adding OIDC IdP") + _, _, keycloakCleanups := test.AddKeycloakIDP(t, kubeConfig, false) + g.DeferCleanup(test.IDPCleanupWrapper(func() { + g.GinkgoWriter.Println("cleaning up: removing Keycloak and IdP") + for _, cleanup := range keycloakCleanups { + cleanup() + } + })) + + g.By("Verifying operator is stable with working proxy") + err = test.WaitForClusterOperatorAvailableNotProgressingNotDegraded(t, clients.ConfigClient.ConfigV1(), "authentication") + o.Expect(err).NotTo(o.HaveOccurred()) + + g.By("Setting spec.proxy.httpsProxy to an unreachable host") + operatorAuth, err = clients.OperatorClient.OperatorV1().Authentications().Get(ctx, "cluster", metav1.GetOptions{}) + o.Expect(err).NotTo(o.HaveOccurred()) + operatorAuth.Spec.Proxy = operatorv1.AuthenticationProxyConfig{ + HTTPSProxy: "http://does-not-exist.invalid:3128", + } + _, err = clients.OperatorClient.OperatorV1().Authentications().Update(ctx, operatorAuth, metav1.UpdateOptions{}) + o.Expect(err).NotTo(o.HaveOccurred()) + + g.By("Waiting for ProxyConfigControllerDegraded=True on the operator CR") + var lastCondition *operatorv1.OperatorCondition + err = wait.PollUntilContextTimeout(ctx, 10*time.Second, 10*time.Minute, true, func(ctx context.Context) (bool, error) { + config, err := clients.OperatorClient.OperatorV1().Authentications().Get(ctx, "cluster", metav1.GetOptions{}) + if err != nil { + g.GinkgoWriter.Printf("failed to get operator auth: %v\n", err) + return false, nil + } + lastCondition = v1helpers.FindOperatorCondition(config.Status.Conditions, "ProxyConfigControllerDegraded") + return lastCondition != nil && lastCondition.Status == operatorv1.ConditionTrue, nil + }) + o.Expect(err).NotTo(o.HaveOccurred(), "ProxyConfigControllerDegraded never became True") + g.GinkgoWriter.Printf("ProxyConfigControllerDegraded: status=%s reason=%s message=%s\n", lastCondition.Status, lastCondition.Reason, lastCondition.Message) + + g.By("Verifying ClusterOperator authentication is Degraded") + err = test.WaitForClusterOperatorDegraded(t, clients.ConfigClient.ConfigV1(), "authentication") + o.Expect(err).NotTo(o.HaveOccurred()) +} + +func testWarningOnUnreachableIdP() { + ctx := context.Background() + t := g.GinkgoTB() + + g.By("Creating test clients") + clients := test.NewTestClients(t) + + test.CheckFeatureGateEnabledOrSkip(t, clients.ConfigClient, features.FeatureGateAuthenticationComponentProxy) + + g.By("Waiting for authentication operator to be stable before test") + err := test.WaitForClusterOperatorAvailableNotProgressingNotDegraded(t, clients.ConfigClient.ConfigV1(), "authentication") + o.Expect(err).NotTo(o.HaveOccurred()) + + g.By("Deploying Squid forward proxy") + httpProxyURL, _, _, proxyNamespace, squidCleanup := test.DeploySquidProxy(t, clients.KubeClient) + g.DeferCleanup(squidCleanup) + proxyURL := httpProxyURL + g.GinkgoWriter.Printf("Squid proxy URL: %s\n", proxyURL) + + g.By("Saving original proxy config for cleanup") + operatorAuth, proxyRestore := test.SaveAndRestoreProxyConfig(t, clients.OperatorClient, clients.ConfigClient) + + const ( + fakeIDPName = "e2e-unreachable-idp" + fakeIDPSecretName = "e2e-unreachable-idp-secret" + ) + + g.DeferCleanup(func() { + g.GinkgoWriter.Println("cleaning up: removing fake IdP from OAuth config") + test.CleanIDPConfigByName(t, clients.ConfigClient.ConfigV1().OAuths(), fakeIDPName) + + g.GinkgoWriter.Println("cleaning up: deleting fake IdP secret") + _ = clients.KubeClient.CoreV1().Secrets("openshift-config").Delete(ctx, fakeIDPSecretName, metav1.DeleteOptions{}) + + proxyRestore() + }) + + g.By("Creating fake IdP client secret in openshift-config") + _, err = clients.KubeClient.CoreV1().Secrets("openshift-config").Create(ctx, &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: fakeIDPSecretName, + Labels: test.CAOE2ETestLabels(), + }, + Data: map[string][]byte{ + "clientSecret": []byte("fake-secret"), + }, + }, metav1.CreateOptions{}) + o.Expect(err).NotTo(o.HaveOccurred()) + + g.By("Adding fake OpenID IdP to OAuth config") + oauthConfig, err := clients.ConfigClient.ConfigV1().OAuths().Get(ctx, "cluster", metav1.GetOptions{}) + o.Expect(err).NotTo(o.HaveOccurred()) + oauthCopy := oauthConfig.DeepCopy() + oauthCopy.Spec.IdentityProviders = append(oauthCopy.Spec.IdentityProviders, configv1.IdentityProvider{ + Name: fakeIDPName, + MappingMethod: configv1.MappingMethodClaim, + IdentityProviderConfig: configv1.IdentityProviderConfig{ + Type: configv1.IdentityProviderTypeOpenID, + OpenID: &configv1.OpenIDIdentityProvider{ + ClientID: "fake-client", + ClientSecret: configv1.SecretNameReference{ + Name: fakeIDPSecretName, + }, + Issuer: "https://unreachable-idp.invalid", + Claims: configv1.OpenIDClaims{ + PreferredUsername: []string{"preferred_username"}, + }, + }, + }, + }) + _, err = clients.ConfigClient.ConfigV1().OAuths().Update(ctx, oauthCopy, metav1.UpdateOptions{}) + o.Expect(err).NotTo(o.HaveOccurred()) + + g.By("Setting component-scoped proxy pointing to the Squid instance") + startTime := time.Now() + operatorAuth, err = clients.OperatorClient.OperatorV1().Authentications().Get(ctx, "cluster", metav1.GetOptions{}) + o.Expect(err).NotTo(o.HaveOccurred()) + operatorAuth.Spec.Proxy = operatorv1.AuthenticationProxyConfig{ + HTTPSProxy: proxyURL, + } + _, err = clients.OperatorClient.OperatorV1().Authentications().Update(ctx, operatorAuth, metav1.UpdateOptions{}) + o.Expect(err).NotTo(o.HaveOccurred()) + + g.By("Waiting for IdPEndpointUnreachable warning event") + err = wait.PollUntilContextTimeout(ctx, 10*time.Second, 10*time.Minute, true, func(ctx context.Context) (bool, error) { + events, err := clients.KubeClient.CoreV1().Events("openshift-authentication-operator").List(ctx, metav1.ListOptions{ + FieldSelector: "reason=IdPEndpointUnreachable", + }) + if err != nil { + g.GinkgoWriter.Printf("failed to list events: %v\n", err) + return false, nil + } + for _, event := range events.Items { + eventTime := event.LastTimestamp.Time + if eventTime.IsZero() { + eventTime = event.EventTime.Time + } + if event.Type == "Warning" && eventTime.After(startTime) { + g.GinkgoWriter.Printf("found IdPEndpointUnreachable event: %s\n", event.Message) + return true, nil + } + } + return false, nil + }) + o.Expect(err).NotTo(o.HaveOccurred(), "IdPEndpointUnreachable warning event was not emitted") + + g.By("Verifying the request went through the Squid proxy") + err = test.WaitForSquidProxyTraffic(t, clients.KubeClient, proxyNamespace, 2*time.Minute) + o.Expect(err).NotTo(o.HaveOccurred()) + + g.By("Verifying operator is NOT Degraded") + ok, conditions, checkErr := test.CheckClusterOperatorStatus(t, ctx, clients.ConfigClient.ConfigV1(), "authentication", + configv1.ClusterOperatorStatusCondition{Type: configv1.OperatorDegraded, Status: configv1.ConditionFalse}, + ) + o.Expect(checkErr).NotTo(o.HaveOccurred()) + o.Expect(ok).To(o.BeTrue(), fmt.Sprintf("operator should NOT be degraded, conditions: %v", conditions)) +} diff --git a/test/e2e-component-proxy/component_proxy_oidc_login.go b/test/e2e-component-proxy/component_proxy_oidc_login.go new file mode 100644 index 0000000000..a70c798c84 --- /dev/null +++ b/test/e2e-component-proxy/component_proxy_oidc_login.go @@ -0,0 +1,565 @@ +package component_proxy + +import ( + "context" + "crypto/x509" + "encoding/pem" + "fmt" + "io" + "net/url" + "os/exec" + "testing" + "time" + + g "github.com/onsi/ginkgo/v2" + o "github.com/onsi/gomega" + + authnv1 "k8s.io/api/authentication/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/sets" + "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + + "github.com/openshift/api/features" + operatorv1 "github.com/openshift/api/operator/v1" + "github.com/openshift/library-go/pkg/oauth/tokenrequest" + "github.com/openshift/library-go/pkg/oauth/tokenrequest/challengehandlers" + + test "github.com/openshift/cluster-authentication-operator/test/library" +) + +var _ = g.Describe("[sig-auth] authentication operator", func() { + g.It("[Serial][Operator][ComponentProxy] should set partial and full env vars when configured", func() { + testPartialFullProxyEnvVars() + }) + g.It("[Serial][Operator][ComponentProxy] should apply proxy config and perform full OIDC login flow", func() { + testProxyOIDCLoginFlow() + }) + g.It("[Serial][Operator][ComponentProxy] should hot-reload mounted CA file on change when spec.proxy.trustedCA is set", func() { + testTrustedCAHotReload() + }) + g.It("[Serial][Operator][ComponentProxy] should bypass proxy for noProxy hosts", func() { + testNoProxy() + }) +}) + +func testPartialFullProxyEnvVars() { + ctx := context.Background() + t := g.GinkgoTB() + clients := test.NewTestClients(t) + + test.CheckFeatureGateEnabledOrSkip(t, clients.ConfigClient, features.FeatureGateAuthenticationComponentProxy) + + g.By("Waiting for authentication operator to be stable before test") + err := test.WaitForClusterOperatorAvailableNotProgressingNotDegraded(t, clients.ConfigClient.ConfigV1(), "authentication") + o.Expect(err).NotTo(o.HaveOccurred()) + + g.By("Deploying Squid forward proxy") + httpProxyURL, httpsProxyURL, caPEM, _, proxyCleanup := test.DeploySquidProxy(t, clients.KubeClient) + g.DeferCleanup(proxyCleanup) + + g.By("Saving original proxy config") + operatorAuth, proxyConfigCleanup := test.SaveAndRestoreProxyConfig(t, clients.OperatorClient, clients.ConfigClient) + g.DeferCleanup(proxyConfigCleanup) + + g.By("Setting only httpsProxy in component proxy config") + operatorAuth.Spec.Proxy = operatorv1.AuthenticationProxyConfig{ + HTTPSProxy: httpProxyURL, + } + _, err = clients.OperatorClient.OperatorV1().Authentications().Update(ctx, operatorAuth, metav1.UpdateOptions{}) + o.Expect(err).NotTo(o.HaveOccurred()) + + g.By("Waiting for operator to reconcile proxy config") + err = test.WaitForOperatorToPickUpChanges(t, clients.ConfigClient.ConfigV1(), "authentication") + o.Expect(err).NotTo(o.HaveOccurred()) + + g.By("Verifying oauth-server has HTTPS_PROXY but not HTTP_PROXY") + test.VerifyOAuthServerDeploymentProxyConfig(t, clients.KubeClient, "", httpProxyURL, ".cluster.local,.svc,127.0.0.1,localhost", false) + + configMapName := "e2e-proxy-trusted-ca" + caConfigMap := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: configMapName, + Namespace: "openshift-config", + }, + Data: map[string]string{ + "ca-bundle.crt": string(caPEM), + }, + } + _, err = clients.KubeClient.CoreV1().ConfigMaps("openshift-config").Create(ctx, caConfigMap, metav1.CreateOptions{}) + o.Expect(err).NotTo(o.HaveOccurred(), "should be able to create trustedCA ConfigMap") + g.DeferCleanup(func() { + if err := clients.KubeClient.CoreV1().ConfigMaps("openshift-config").Delete(ctx, configMapName, metav1.DeleteOptions{}); err != nil { + g.GinkgoWriter.Printf("failed to clean up ConfigMap %s: %v\n", configMapName, err) + } + }) + + noProxyHost := "noproxy.example.com" + + operatorAuth, err = clients.OperatorClient.OperatorV1().Authentications().Get(ctx, "cluster", metav1.GetOptions{}) + o.Expect(err).NotTo(o.HaveOccurred()) + + g.By("Setting httpProxy, httpsProxy, noProxy, and trustedCA in component proxy config") + operatorAuth.Spec.Proxy = operatorv1.AuthenticationProxyConfig{ + HTTPProxy: httpProxyURL, + HTTPSProxy: httpsProxyURL, + NoProxy: []string{noProxyHost}, + TrustedCA: operatorv1.AuthenticationConfigMapReference{ + Name: configMapName, + }, + } + _, err = clients.OperatorClient.OperatorV1().Authentications().Update(ctx, operatorAuth, metav1.UpdateOptions{}) + o.Expect(err).NotTo(o.HaveOccurred()) + + g.By("Waiting for operator to reconcile proxy config") + err = test.WaitForOperatorToPickUpChanges(t, clients.ConfigClient.ConfigV1(), "authentication") + o.Expect(err).NotTo(o.HaveOccurred()) + + g.By("Verifying oauth-server has HTTP_PROXY, HTTPS_PROXY, and NO_PROXY with custom entry") + test.VerifyOAuthServerDeploymentProxyConfig(t, clients.KubeClient, httpProxyURL, httpsProxyURL, ".cluster.local,.svc,127.0.0.1,localhost,noproxy.example.com", true) +} + +func testProxyOIDCLoginFlow() { + ctx := context.Background() + t := g.GinkgoTB() + kubeConfig := test.NewClientConfigForTest(t) + clients := test.NewTestClients(t) + + test.CheckFeatureGateEnabledOrSkip(t, clients.ConfigClient, features.FeatureGateAuthenticationComponentProxy) + + g.By("Waiting for authentication operator to be stable before test") + err := test.WaitForClusterOperatorAvailableNotProgressingNotDegraded(t, clients.ConfigClient.ConfigV1(), "authentication") + o.Expect(err).NotTo(o.HaveOccurred()) + + g.By("Deploying Squid forward proxy") + httpProxyURL, _, _, proxyNamespace, proxyCleanup := test.DeploySquidProxy(t, clients.KubeClient) + g.DeferCleanup(proxyCleanup) + + g.By("Deploying Keycloak (without registering IdP yet)") + setup := test.DeployKeycloak(t, kubeConfig) + keycloakCleanups := setup.Cleanups + g.DeferCleanup(test.IDPCleanupWrapper(func() { + g.GinkgoWriter.Println("cleaning up: removing Keycloak and IdP") + for _, cleanup := range keycloakCleanups { + cleanup() + } + })) + + kcClient := setup.Client + + g.By("Enabling Direct Access Grants on Keycloak client for ROPC flow") + enableDirectAccessGrants(kcClient, setup.ClientID) + + g.By("Deploying NetworkPolicy to restrict Keycloak ingress to proxy namespace only") + keycloakNamespace := setup.Namespace + networkPolicyCleanup := test.DeployProxyNetworkPolicies(t, clients.KubeClient, proxyNamespace, keycloakNamespace) + g.DeferCleanup(func() { + g.GinkgoWriter.Println("cleaning up: removing proxy NetworkPolicies") + networkPolicyCleanup() + }) + + g.By("Saving original proxy config and setting component-scoped proxy") + operatorAuth, proxyConfigCleanup := test.SaveAndRestoreProxyConfig(t, clients.OperatorClient, clients.ConfigClient) + g.DeferCleanup(proxyConfigCleanup) + + operatorAuth.Spec.Proxy = operatorv1.AuthenticationProxyConfig{ + HTTPSProxy: httpProxyURL, + } + _, err = clients.OperatorClient.OperatorV1().Authentications().Update(ctx, operatorAuth, metav1.UpdateOptions{}) + o.Expect(err).NotTo(o.HaveOccurred()) + + g.By("Waiting for operator to reconcile proxy config") + err = test.WaitForOperatorToPickUpChanges(t, clients.ConfigClient.ConfigV1(), "authentication") + o.Expect(err).NotTo(o.HaveOccurred()) + + g.By("Registering Keycloak as OIDC IdP (operator discovers it through the proxy)") + idpCleans := test.AddKeycloakOIDCIdP(t, kubeConfig, setup, false) + keycloakCleanups = append(keycloakCleanups, idpCleans...) + + g.By("Creating Keycloak test user and group") + group := "ocp-test-proxy-login-group" + o.Expect(kcClient.CreateGroup(group)).To(o.Succeed()) + + username := "proxy-login-test-user" + password := "proxy-login-test-password" + o.Expect(kcClient.CreateUser(username, "", password, []string{group}, nil)).To(o.Succeed()) + + logCutOff := time.Now() + + g.By("Performing full OIDC login flow through component proxy") + assertOIDCLogin(t, kubeConfig, *clients, username, password, group) + + g.By("Verifying traffic went through the Squid proxy") + + issuerURL, err := url.Parse(kcClient.IssuerURL()) + o.Expect(err).NotTo(o.HaveOccurred()) + keycloakHost := issuerURL.Hostname() + + g.By("Waiting for squid logs to settle before checking for proxy traffic") + time.Sleep(2 * time.Minute) + + logs, err := test.GetSquidProxyLogsSince(clients.KubeClient, proxyNamespace, logCutOff) + o.Expect(err).NotTo(o.HaveOccurred()) + o.Expect(logs).To(o.ContainSubstring(keycloakHost), "squid logs should contain keycloak traffic after proxy login") + + g.By("Creating Keycloak test user and group") + group1 := "ocp-test-direct-fallback-group" + o.Expect(kcClient.CreateGroup(group1)).To(o.Succeed()) + + username1 := "direct-fallback-test-user" + password1 := "direct-fallback-test-password" + o.Expect(kcClient.CreateUser(username1, "", password1, []string{group1}, nil)).To(o.Succeed()) + + g.By("Removing component-scoped proxy config") + operatorAuth, err = clients.OperatorClient.OperatorV1().Authentications().Get(ctx, "cluster", metav1.GetOptions{}) + o.Expect(err).NotTo(o.HaveOccurred()) + operatorAuth.Spec.Proxy = operatorv1.AuthenticationProxyConfig{} + _, err = clients.OperatorClient.OperatorV1().Authentications().Update(ctx, operatorAuth, metav1.UpdateOptions{}) + o.Expect(err).NotTo(o.HaveOccurred()) + + g.By("Waiting for operator to reconcile proxy removal") + err = test.WaitForOperatorToPickUpChanges(t, clients.ConfigClient.ConfigV1(), "authentication") + o.Expect(err).NotTo(o.HaveOccurred()) + + logCutoff := time.Now() + + g.By("Performing OIDC login flow via direct IdP connectivity after proxy removal") + assertOIDCLogin(t, kubeConfig, *clients, username1, password1, group1) + + g.By("Waiting for squid logs to settle before checking for absence of proxy traffic") + time.Sleep(2 * time.Minute) + + postRemovalLogs, err := test.GetSquidProxyLogsSince(clients.KubeClient, proxyNamespace, logCutoff) + o.Expect(err).NotTo(o.HaveOccurred()) + o.Expect(postRemovalLogs).NotTo(o.ContainSubstring(keycloakHost), "squid logs after proxy removal should not contain keycloak connect") +} + +func testTrustedCAHotReload() { + ctx := context.Background() + t := g.GinkgoTB() + kubeConfig := test.NewClientConfigForTest(t) + clients := test.NewTestClients(t) + + test.CheckFeatureGateEnabledOrSkip(t, clients.ConfigClient, features.FeatureGateAuthenticationComponentProxy) + + g.By("Waiting for authentication operator to be stable before test") + err := test.WaitForClusterOperatorAvailableNotProgressingNotDegraded(t, clients.ConfigClient.ConfigV1(), "authentication") + o.Expect(err).NotTo(o.HaveOccurred()) + + g.By("Deploying Squid forward proxy") + _, httpsProxyURL, caFile, proxyNamespace, proxyCleanup := test.DeploySquidProxy(t, clients.KubeClient) + g.DeferCleanup(proxyCleanup) + + g.By("Saving original proxy config") + operatorAuth, proxyConfigCleanup := test.SaveAndRestoreProxyConfig(t, clients.OperatorClient, clients.ConfigClient) + g.DeferCleanup(proxyConfigCleanup) + + g.By("Deploying Keycloak (without registering IdP yet)") + setup := test.DeployKeycloak(t, kubeConfig) + keycloakCleanups := setup.Cleanups + g.DeferCleanup(test.IDPCleanupWrapper(func() { + g.GinkgoWriter.Println("cleaning up: removing Keycloak and IdP") + for _, cleanup := range keycloakCleanups { + cleanup() + } + })) + + kcClient := setup.Client + + g.By("Enabling Direct Access Grants on Keycloak client for ROPC flow") + enableDirectAccessGrants(kcClient, setup.ClientID) + + g.By("Deploying NetworkPolicy to restrict Keycloak ingress to proxy namespace only") + keycloakNamespace := setup.Namespace + networkPolicyCleanup := test.DeployProxyNetworkPolicies(t, clients.KubeClient, proxyNamespace, keycloakNamespace) + g.DeferCleanup(func() { + g.GinkgoWriter.Println("cleaning up: removing proxy NetworkPolicies") + networkPolicyCleanup() + }) + + g.By("Creating config map with trustedCA") + configMapName := "e2e-proxy-trusted-ca" + caConfigMap := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: configMapName, + Namespace: "openshift-config", + }, + Data: map[string]string{ + "ca-bundle.crt": string(caFile), + }, + } + _, err = clients.KubeClient.CoreV1().ConfigMaps("openshift-config").Create(ctx, caConfigMap, metav1.CreateOptions{}) + o.Expect(err).NotTo(o.HaveOccurred(), "should be able to create trustedCA ConfigMap") + g.DeferCleanup(func() { + if err := clients.KubeClient.CoreV1().ConfigMaps("openshift-config").Delete(ctx, configMapName, metav1.DeleteOptions{}); err != nil { + g.GinkgoWriter.Printf("failed to clean up ConfigMap %s: %v\n", configMapName, err) + } + }) + + g.By("Setting component-scoped proxy with trustedCA") + operatorAuth, err = clients.OperatorClient.OperatorV1().Authentications().Get(ctx, "cluster", metav1.GetOptions{}) + o.Expect(err).NotTo(o.HaveOccurred()) + operatorAuth.Spec.Proxy = operatorv1.AuthenticationProxyConfig{ + HTTPSProxy: httpsProxyURL, + TrustedCA: operatorv1.AuthenticationConfigMapReference{ + Name: configMapName, + }, + } + _, err = clients.OperatorClient.OperatorV1().Authentications().Update(ctx, operatorAuth, metav1.UpdateOptions{}) + o.Expect(err).NotTo(o.HaveOccurred()) + + g.By("Waiting for operator to reconcile proxy config with trustedCA") + err = test.WaitForOperatorToPickUpChanges(t, clients.ConfigClient.ConfigV1(), "authentication") + o.Expect(err).NotTo(o.HaveOccurred()) + + g.By("Registering Keycloak as OIDC IdP (operator discovers it through the proxy)") + idpCleans := test.AddKeycloakOIDCIdP(t, kubeConfig, setup, false) + keycloakCleanups = append(keycloakCleanups, idpCleans...) + + g.By("Creating Keycloak test user and group") + // Re-authenticate: the admin token from initial setup may have expired + // during the operator reconciliation wait (~15 min). + o.Expect(kcClient.AuthenticatePassword("admin-cli", "", "admin", "password")).To(o.Succeed()) + group := "ocp-test-ca-reload-group" + o.Expect(kcClient.CreateGroup(group)).To(o.Succeed()) + + username := "ca-reload-test-user" + password := "ca-reload-test-password" + o.Expect(kcClient.CreateUser(username, "", password, []string{group}, nil)).To(o.Succeed()) + + logCutOff := time.Now() + + g.By("Verifying OIDC login works after setting proxy with trustedCA") + assertOIDCLogin(t, kubeConfig, *clients, username, password, group) + + g.By("Verifying traffic went through the Squid proxy") + issuerURL, err := url.Parse(kcClient.IssuerURL()) + o.Expect(err).NotTo(o.HaveOccurred()) + keycloakHost := issuerURL.Hostname() + + time.Sleep(2 * time.Minute) + logs, err := test.GetSquidProxyLogsSince(clients.KubeClient, proxyNamespace, logCutOff) + o.Expect(err).NotTo(o.HaveOccurred()) + o.Expect(logs).To(o.ContainSubstring(keycloakHost)) + + g.By("Verifying trustedCA ConfigMap is synced to openshift-authentication namespace") + test.VerifyTrustedCAConfigMapSynced(t, clients.KubeClient, configMapName) + + g.By("Recording oauth-server pod names before CA rotation") + oauthServerPodList, err := clients.KubeClient.CoreV1().Pods("openshift-authentication").List(ctx, metav1.ListOptions{LabelSelector: "app=oauth-openshift"}) + o.Expect(err).NotTo(o.HaveOccurred()) + o.Expect(oauthServerPodList.Items).NotTo(o.BeEmpty()) + + podNamesBefore := sets.New[string]() + for _, pod := range oauthServerPodList.Items { + podNamesBefore.Insert(pod.Name) + } + + g.By("Rotating CA: generating new CA and server cert") + newCA := test.NewCertificateAuthorityCertificate(t, nil) + serviceDNS := fmt.Sprintf("squid-proxy.%s.svc.cluster.local", proxyNamespace) + newServerCert := test.NewServerCertificate(t, newCA, serviceDNS) + + newCACertPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: newCA.Certificate.Raw}) + newServerCertPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: newServerCert.Certificate.Raw}) + newServerKeyDER, err := x509.MarshalPKCS8PrivateKey(newServerCert.PrivateKey) + o.Expect(err).NotTo(o.HaveOccurred()) + newServerKeyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: newServerKeyDER}) + + g.By("Updating squid-tls Secret with rotated cert") + tlsSecret, err := clients.KubeClient.CoreV1().Secrets(proxyNamespace).Get(ctx, "squid-tls", metav1.GetOptions{}) + o.Expect(err).NotTo(o.HaveOccurred()) + tlsSecret.Data["tls.crt"] = newServerCertPEM + tlsSecret.Data["tls.key"] = newServerKeyPEM + _, err = clients.KubeClient.CoreV1().Secrets(proxyNamespace).Update(ctx, tlsSecret, metav1.UpdateOptions{}) + o.Expect(err).NotTo(o.HaveOccurred()) + + g.By("Reconfiguring Squid to pick up new cert") + squidPods, err := clients.KubeClient.CoreV1().Pods(proxyNamespace).List(ctx, metav1.ListOptions{ + LabelSelector: "app=squid-proxy", + }) + o.Expect(err).NotTo(o.HaveOccurred()) + o.Expect(squidPods.Items).NotTo(o.BeEmpty()) + + reconfigureCmd := exec.Command("oc", "exec", + "-n", proxyNamespace, + squidPods.Items[0].Name, + "-c", "squid", + "--", "/usr/sbin/squid", "-k", "reconfigure", + ) + output, err := reconfigureCmd.CombinedOutput() + o.Expect(err).NotTo(o.HaveOccurred(), "squid reconfigure failed: %s", string(output)) + + g.By("Updating trustedCA ConfigMap with new CA") + cm, err := clients.KubeClient.CoreV1().ConfigMaps("openshift-config").Get(ctx, configMapName, metav1.GetOptions{}) + o.Expect(err).NotTo(o.HaveOccurred()) + cm.Data["ca-bundle.crt"] = string(newCACertPEM) + _, err = clients.KubeClient.CoreV1().ConfigMaps("openshift-config").Update(ctx, cm, metav1.UpdateOptions{}) + o.Expect(err).NotTo(o.HaveOccurred()) + logCutOff = time.Now() + + g.By("Verifying OIDC login works after CA rotation") + assertOIDCLogin(t, kubeConfig, *clients, username, password, group) + + g.By("Verifying traffic went through the Squid proxy") + time.Sleep(2 * time.Minute) + logs, err = test.GetSquidProxyLogsSince(clients.KubeClient, proxyNamespace, logCutOff) + o.Expect(err).NotTo(o.HaveOccurred()) + o.Expect(logs).To(o.ContainSubstring(keycloakHost)) + + g.By("Verifying oauth-server pods were NOT redeployed after CA rotation") + oauthServerPodListAfter, err := clients.KubeClient.CoreV1().Pods("openshift-authentication").List(ctx, metav1.ListOptions{LabelSelector: "app=oauth-openshift"}) + o.Expect(err).NotTo(o.HaveOccurred()) + o.Expect(oauthServerPodListAfter.Items).NotTo(o.BeEmpty()) + + podNamesAfter := sets.New[string]() + for _, pod := range oauthServerPodListAfter.Items { + podNamesAfter.Insert(pod.Name) + } + + o.Expect(podNamesAfter.Equal(podNamesBefore)).To(o.BeTrue(), "oauth-server pods should not have been redeployed after CA file change") +} + +func testNoProxy() { + ctx := context.Background() + t := g.GinkgoTB() + kubeConfig := test.NewClientConfigForTest(t) + clients := test.NewTestClients(t) + + test.CheckFeatureGateEnabledOrSkip(t, clients.ConfigClient, features.FeatureGateAuthenticationComponentProxy) + + g.By("Waiting for authentication operator to be stable before test") + err := test.WaitForClusterOperatorAvailableNotProgressingNotDegraded(t, clients.ConfigClient.ConfigV1(), "authentication") + o.Expect(err).NotTo(o.HaveOccurred()) + + g.By("Deploying Squid forward proxy") + httpProxyURL, _, _, proxyNS, proxyCleanup := test.DeploySquidProxy(t, clients.KubeClient) + g.DeferCleanup(proxyCleanup) + + g.By("Saving original proxy config") + operatorAuth, proxyConfigCleanup := test.SaveAndRestoreProxyConfig(t, clients.OperatorClient, clients.ConfigClient) + g.DeferCleanup(proxyConfigCleanup) + + g.By("Deploying Keycloak (without registering IdP yet)") + setup := test.DeployKeycloak(t, kubeConfig) + keycloakCleanups := setup.Cleanups + kcClient := setup.Client + g.DeferCleanup(test.IDPCleanupWrapper(func() { + g.GinkgoWriter.Println("cleaning up: removing Keycloak and IdP") + for _, cleanup := range keycloakCleanups { + cleanup() + } + })) + + g.By("Enabling Direct Access Grants on Keycloak client for ROPC flow") + enableDirectAccessGrants(kcClient, setup.ClientID) + + g.By("Adding OIDC IdP") + idpCleans := test.AddKeycloakOIDCIdP(t, kubeConfig, setup, false) + keycloakCleanups = append(keycloakCleanups, idpCleans...) + + issuerURL, err := url.Parse(kcClient.IssuerURL()) + o.Expect(err).NotTo(o.HaveOccurred()) + + g.By("Setting component-scoped proxy with noProxy") + operatorAuth, err = clients.OperatorClient.OperatorV1().Authentications().Get(ctx, "cluster", metav1.GetOptions{}) + o.Expect(err).NotTo(o.HaveOccurred()) + operatorAuth.Spec.Proxy = operatorv1.AuthenticationProxyConfig{ + HTTPSProxy: httpProxyURL, + NoProxy: []string{issuerURL.Hostname()}, + } + + _, err = clients.OperatorClient.OperatorV1().Authentications().Update(ctx, operatorAuth, metav1.UpdateOptions{}) + o.Expect(err).NotTo(o.HaveOccurred()) + + g.By("Waiting for operator to reconcile proxy config") + err = test.WaitForOperatorToPickUpChanges(t, clients.ConfigClient.ConfigV1(), "authentication") + o.Expect(err).NotTo(o.HaveOccurred()) + + g.By("Creating Keycloak test user and group") + group := "ocp-test-no-proxy-group" + o.Expect(kcClient.CreateGroup(group)).To(o.Succeed()) + + username := "ca-no-proxy-test-user" + password := "ca-no-proxy-test-password" + o.Expect(kcClient.CreateUser(username, "", password, []string{group}, nil)).To(o.Succeed()) + + g.By("Verifying OIDC login works after setting proxy with noProxy") + assertOIDCLogin(t, kubeConfig, *clients, username, password, group) + + keycloakHost := issuerURL.Hostname() + + g.By("Waiting for squid logs to settle before checking for absence of proxy traffic") + time.Sleep(2 * time.Minute) + + logs, err := test.GetSquidProxyLogs(clients.KubeClient, proxyNS) + o.Expect(err).NotTo(o.HaveOccurred()) + o.Expect(logs).NotTo(o.ContainSubstring(keycloakHost), "squid logs should not contain keycloak connect") +} + +func enableDirectAccessGrants(kcClient *test.KeycloakClient, clientID string) { + g.GinkgoHelper() + kcClientObj, err := kcClient.GetClientByClientID(clientID) + o.Expect(err).NotTo(o.HaveOccurred()) + o.Expect(kcClient.UpdateClientDirectAccessGrantsEnabled(kcClientObj["id"].(string), true)).To(o.Succeed()) +} + +func assertOIDCLogin(t testing.TB, kubeConfig *rest.Config, routeClient test.TestClients, username, password, expectedGroup string) { + g.GinkgoHelper() + ctx := context.Background() + + route, err := routeClient.RouteClient.RouteV1().Routes("openshift-authentication").Get(ctx, "oauth-openshift", metav1.GetOptions{}) + o.Expect(err).NotTo(o.HaveOccurred(), "should be able to get the OAuth server route") + oauthServerURL := fmt.Sprintf("https://%s", route.Spec.Host) + + err = wait.PollUntilContextTimeout(ctx, 10*time.Second, 5*time.Minute, true, func(ctx context.Context) (bool, error) { + tokenOpts := tokenrequest.NewRequestTokenOptions(rest.CopyConfig(kubeConfig), false) + tokenOpts, err := tokenOpts.WithChallengeHandlers( + challengehandlers.NewBasicChallengeHandler(oauthServerURL, "", nil, io.Discard, nil, username, password), + ) + if err != nil { + t.Logf("failed to create challenge handler: %v", err) + return false, nil + } + + token, err := tokenOpts.RequestToken() + if err != nil { + t.Logf("failed to request token: %v", err) + return false, nil + } + if token == "" { + t.Log("received empty token") + return false, nil + } + + tokenConfig := rest.AnonymousClientConfig(kubeConfig) + tokenConfig.BearerToken = token + tokenKubeClient, err := kubernetes.NewForConfig(tokenConfig) + if err != nil { + t.Logf("failed to create kube client with token: %v", err) + return false, nil + } + + ssr, err := tokenKubeClient.AuthenticationV1().SelfSubjectReviews().Create(ctx, &authnv1.SelfSubjectReview{}, metav1.CreateOptions{}) + if err != nil { + t.Logf("failed to create SelfSubjectReview: %v", err) + return false, nil + } + + if ssr.Status.UserInfo.Username == "" { + t.Log("SelfSubjectReview returned empty username") + return false, nil + } + + for _, g := range ssr.Status.UserInfo.Groups { + if g == expectedGroup { + return true, nil + } + } + t.Logf("expected group %q not found in groups: %v", expectedGroup, ssr.Status.UserInfo.Groups) + return false, nil + }) + o.Expect(err).NotTo(o.HaveOccurred(), "OIDC login flow should succeed") +} diff --git a/test/library/client.go b/test/library/client.go index 2397cb2313..c23a41db37 100644 --- a/test/library/client.go +++ b/test/library/client.go @@ -40,6 +40,10 @@ func NewClientConfigForTest(t testing.TB) *rest.Config { } require.NoError(t, err) + + config.QPS = 40 + config.Burst = 60 + return config } diff --git a/test/library/idpdeployment.go b/test/library/idpdeployment.go index 7c6e082b4d..53369425a1 100644 --- a/test/library/idpdeployment.go +++ b/test/library/idpdeployment.go @@ -307,6 +307,15 @@ func addOIDCIDentityProvider( directExternalOIDC bool) ([]func(), error) { var cleanups []func() + success := false + defer func() { + if !success { + for _, c := range cleanups { + c() + } + } + }() + secretName := idpName + "-secret" _, err := kubeClients.CoreV1().Secrets("openshift-config").Create(context.TODO(), &corev1.Secret{ @@ -321,7 +330,7 @@ func addOIDCIDentityProvider( metav1.CreateOptions{}, ) if err != nil { - return cleanups, fmt.Errorf("failed to create keycloak client secret: %v", err) + return nil, fmt.Errorf("failed to create keycloak client secret: %v", err) } cleanups = append(cleanups, func() { if err := kubeClients.CoreV1().Secrets("openshift-config").Delete(context.TODO(), secretName, metav1.DeleteOptions{}); err != nil { @@ -330,7 +339,6 @@ func addOIDCIDentityProvider( }) caCMName := idpName + "-ca" - // configure the default ingress CA as the CA for the IdP in the openshift-config NS cleanups = append(cleanups, SyncDefaultIngressCAToConfig(t, kubeClients.CoreV1(), caCMName)) if !directExternalOIDC { @@ -354,14 +362,14 @@ func addOIDCIDentityProvider( }, }, }) + cleanups = append(cleanups, idpClean...) if err != nil { - return cleanups, fmt.Errorf("failed to add identity provider to oauth server: %v", err) + return nil, fmt.Errorf("failed to add identity provider to oauth server: %v", err) } - - cleanups = append(cleanups, idpClean...) } - return cleanups, err + success = true + return cleanups, nil } func addIdentityProvider(t testing.TB, configClient *configv1client.ConfigV1Client, idp *configv1.IdentityProvider) ([]func(), error) { diff --git a/test/library/image/image.go b/test/library/image/image.go new file mode 100644 index 0000000000..5e0f65c539 --- /dev/null +++ b/test/library/image/image.go @@ -0,0 +1,65 @@ +package image + +import ( + "crypto/sha256" + "encoding/base64" + "fmt" + "regexp" + "strings" +) + +// GetMappedImages returns the images if they were mapped to the provided +// image repository. The keys of the returned map are the same as the keys +// in originalImages and the values are the equivalent name in the target +// repo. +// +// This is basically a copy of github.com/openshift/origin/test/extended/util/image/image.go +func GetMappedImages(originalImages map[string]int, repo string) map[string]string { + if len(repo) == 0 { + images := make(map[string]string) + for k := range originalImages { + images[k] = k + } + return images + } + configs := make(map[string]string) + reCharSafe := regexp.MustCompile(`[^\w]`) + reDashes := regexp.MustCompile(`-+`) + h := sha256.New() + + const ( + // length of hash in base64-url chosen to minimize possible collisions (64^16 possible) + hashLength = 16 + // maximum length of a Docker spec image tag + maxTagLength = 127 + // when building a tag, there are at most 6 characters in the format (e2e and 3 dashes), + // and we should allow up to 10 digits for the index and additional qualifiers we may add + // in the future + tagFormatCharacters = 6 + 10 + ) + + parts := strings.SplitN(repo, "/", 2) + registry, destination := parts[0], parts[1] + for pullSpec, index := range originalImages { + // Build a new tag with the index, a hash of the image spec (to be unique) and + // shorten and make the pull spec "safe" so it will fit in the tag + h.Reset() + h.Write([]byte(pullSpec)) + hash := base64.RawURLEncoding.EncodeToString(h.Sum(nil))[:hashLength] + shortName := reCharSafe.ReplaceAllLiteralString(pullSpec, "-") + shortName = reDashes.ReplaceAllLiteralString(shortName, "-") + maxLength := maxTagLength - hashLength - tagFormatCharacters + if len(shortName) > maxLength { + shortName = shortName[len(shortName)-maxLength:] + } + var newTag string + if index == -1 { + newTag = fmt.Sprintf("e2e-%s-%s", shortName, hash) + } else { + newTag = fmt.Sprintf("e2e-%d-%s-%s", index, shortName, hash) + } + + configs[pullSpec] = fmt.Sprintf("%s/%s:%s", registry, destination, newTag) + } + return configs +} diff --git a/test/library/images.go b/test/library/images.go new file mode 100644 index 0000000000..d19d1ce1a9 --- /dev/null +++ b/test/library/images.go @@ -0,0 +1,27 @@ +package library + +import ( + "os" + + "github.com/openshift/cluster-authentication-operator/test/library/image" +) + +var ( + keycloakImage string + squidImage string +) + +func init() { + const ( + keycloakPullSpec = "quay.io/keycloak/keycloak:25.0" + squidPullSpec = "registry.redhat.io/rhel10/squid:10.2-1784702318" + ) + + mappedImages := image.GetMappedImages(map[string]int{ + keycloakPullSpec: -1, + squidPullSpec: -1, + }, os.Getenv("KUBE_TEST_REPO")) + + keycloakImage = mappedImages[keycloakPullSpec] + squidImage = mappedImages[squidPullSpec] +} diff --git a/test/library/keycloakidp.go b/test/library/keycloakidp.go index 8d3b254879..02f9e02e5c 100644 --- a/test/library/keycloakidp.go +++ b/test/library/keycloakidp.go @@ -27,20 +27,28 @@ import ( routev1client "github.com/openshift/client-go/route/clientset/versioned/typed/route/v1" ) -func AddKeycloakIDP( - t testing.TB, - kubeconfig *rest.Config, - directOIDC bool, -) (kcClient *KeycloakClient, idpName string, cleanups []func()) { +// KeycloakSetup holds the results of deploying Keycloak, before the IdP is +// registered in OpenShift. Use AddKeycloakOIDCIdP to register the IdP. +type KeycloakSetup struct { + Client *KeycloakClient + IDPName string + Namespace string + ClientID string + ClientSecret string + IssuerURL string + Cleanups []func() +} + +// DeployKeycloak deploys Keycloak in a test namespace, configures a client and +// group mapper, and returns a KeycloakSetup. The IdP is NOT registered in +// OpenShift — call AddKeycloakOIDCIdP separately when ready. +func DeployKeycloak(t testing.TB, kubeconfig *rest.Config) *KeycloakSetup { kubeClients, err := kubernetes.NewForConfig(kubeconfig) require.NoError(t, err) routeClient, err := routev1client.NewForConfig(kubeconfig) require.NoError(t, err) - configClient, err := configv1client.NewForConfig(kubeconfig) - require.NoError(t, err) - readinessProbe := corev1.Probe{ ProbeHandler: corev1.ProbeHandler{ HTTPGet: &corev1.HTTPGetAction{ @@ -64,9 +72,8 @@ func AddKeycloakIDP( nsName, keycloakHost, cleanup := deployPod(t, kubeClients, routeClient, "keycloak", - "quay.io/keycloak/keycloak:25.0", + keycloakImage, []corev1.EnvVar{ - // configure password for Keycloak root user {Name: "KEYCLOAK_ADMIN", Value: "admin"}, {Name: "KEYCLOAK_ADMIN_PASSWORD", Value: "password"}, {Name: "KC_HEALTH_ENABLED", Value: "true"}, @@ -105,10 +112,15 @@ func AddKeycloakIDP( true, "/opt/keycloak/bin/kc.sh", "start-dev", ) - cleanups = []func(){cleanup} + + setup := &KeycloakSetup{ + IDPName: fmt.Sprintf("keycloak-test-%s", nsName), + Namespace: nsName, + Cleanups: []func(){cleanup}, + } defer func() { if err != nil { - for _, c := range cleanups { + for _, c := range setup.Cleanups { c() } } @@ -119,19 +131,13 @@ func AddKeycloakIDP( transport, err := rest.TransportFor(kubeconfig) require.NoError(t, err) - openshiftIDPName := fmt.Sprintf("keycloak-test-%s", nsName) - keycloakURL := keycloakBaseURL + "/realms/master" + setup.IssuerURL = keycloakURL - // create a keycloak REST client and authenticate to the API - kcClient = KeycloakClientFor(t, transport, keycloakURL, "master") + setup.Client = KeycloakClientFor(t, transport, keycloakURL, "master") - // even though configured via env vars and even though we checked Keycloak reports - // ready on /health/ready, it still appears that we may need some time to log in properly - // In resource-constrained CI environments with parallel test execution, Keycloak can take - // 40-60+ seconds to fully initialize its admin API even after passing readiness probes err = wait.PollUntilContextTimeout(context.Background(), 5*time.Second, 5*time.Minute, true, func(ctx context.Context) (bool, error) { - err := kcClient.AuthenticatePassword("admin-cli", "", "admin", "password") + err := setup.Client.AuthenticatePassword("admin-cli", "", "admin", "password") if err != nil { t.Logf("failed to authenticate to Keycloak: %v", err) return false, nil @@ -140,17 +146,16 @@ func AddKeycloakIDP( }) require.NoError(t, err) - clientList, err := kcClient.ListClients() + clientList, err := setup.Client.ListClients() require.NoError(t, err) - var adminClientId, passwdClientId, passwdClientClientId string + var adminClientId, passwdClientId string for _, c := range clientList { if clientID := c["clientId"].(string); clientID == "admin-cli" { adminClientId = c["id"].(string) } else if len(c["redirectUris"].([]interface{})) > 0 { - // just reuse one other client that's already there passwdClientId = c["id"].(string) - passwdClientClientId = clientID + setup.ClientID = clientID } if len(passwdClientId) > 0 && len(adminClientId) > 0 { @@ -158,14 +163,11 @@ func AddKeycloakIDP( } } - // change the client's access token timeout just in case we need it for the test - // Wrap in retry logic as Keycloak may still be unstable after initial authentication err = wait.PollUntilContextTimeout(context.Background(), 5*time.Second, 5*time.Minute, true, func(ctx context.Context) (bool, error) { - err := kcClient.UpdateClientAccessTokenTimeout(adminClientId, 60*30) + err := setup.Client.UpdateClientAccessTokenTimeout(adminClientId, 60*30) if err != nil { t.Logf("failed to update client access token timeout: %v, retrying", err) - // Re-authenticate in case the connection was dropped - if authErr := kcClient.AuthenticatePassword("admin-cli", "", "admin", "password"); authErr != nil { + if authErr := setup.Client.AuthenticatePassword("admin-cli", "", "admin", "password"); authErr != nil { t.Logf("failed to re-authenticate: %v", authErr) } return false, nil @@ -174,19 +176,15 @@ func AddKeycloakIDP( }) require.NoError(t, err) - // reauthenticate for a new, longer-lived token - err = kcClient.AuthenticatePassword("admin-cli", "", "admin", "password") + err = setup.Client.AuthenticatePassword("admin-cli", "", "admin", "password") require.NoError(t, err) - // Regenerate client secret with retry logic for Keycloak stability - var clientSecret string err = wait.PollUntilContextTimeout(context.Background(), 5*time.Second, 5*time.Minute, true, func(ctx context.Context) (bool, error) { var err error - clientSecret, err = kcClient.RegenerateClientSecret(passwdClientId) + setup.ClientSecret, err = setup.Client.RegenerateClientSecret(passwdClientId) if err != nil { t.Logf("failed to regenerate client secret: %v, retrying", err) - // Re-authenticate in case the connection was dropped - if authErr := kcClient.AuthenticatePassword("admin-cli", "", "admin", "password"); authErr != nil { + if authErr := setup.Client.AuthenticatePassword("admin-cli", "", "admin", "password"); authErr != nil { t.Logf("failed to re-authenticate: %v", authErr) } return false, nil @@ -195,14 +193,12 @@ func AddKeycloakIDP( }) require.NoError(t, err) - // Create client group mapper with retry logic const groupsClaimName = "groups" err = wait.PollUntilContextTimeout(context.Background(), 5*time.Second, 5*time.Minute, true, func(ctx context.Context) (bool, error) { - err := kcClient.CreateClientGroupMapper(passwdClientId, "test-groups-mapper", groupsClaimName) + err := setup.Client.CreateClientGroupMapper(passwdClientId, "test-groups-mapper", groupsClaimName) if err != nil { t.Logf("failed to create client group mapper: %v, retrying", err) - // Re-authenticate in case the connection was dropped - if authErr := kcClient.AuthenticatePassword("admin-cli", "", "admin", "password"); authErr != nil { + if authErr := setup.Client.AuthenticatePassword("admin-cli", "", "admin", "password"); authErr != nil { t.Logf("failed to re-authenticate: %v", authErr) } return false, nil @@ -211,22 +207,50 @@ func AddKeycloakIDP( }) require.NoError(t, err) + return setup +} + +// AddKeycloakOIDCIdP registers the Keycloak instance from a KeycloakSetup as an +// OIDC identity provider in OpenShift. When directOIDC is true, secrets and CA +// are created but the IdP is not added to the OAuth config. +func AddKeycloakOIDCIdP(t testing.TB, kubeconfig *rest.Config, setup *KeycloakSetup, directOIDC bool) []func() { + kubeClients, err := kubernetes.NewForConfig(kubeconfig) + require.NoError(t, err) + + configClient, err := configv1client.NewForConfig(kubeconfig) + require.NoError(t, err) + idpCleans, err := addOIDCIDentityProvider(t, kubeClients, configClient, - passwdClientClientId, clientSecret, - openshiftIDPName, - keycloakURL, + setup.ClientID, setup.ClientSecret, + setup.IDPName, + setup.IssuerURL, configv1.OpenIDClaims{ PreferredUsername: []string{"preferred_username"}, - Groups: []configv1.OpenIDClaim{groupsClaimName}, + Groups: []configv1.OpenIDClaim{"groups"}, }, directOIDC, ) - cleanups = append(cleanups, idpCleans...) require.NoError(t, err, "failed to configure the identity provider") - return kcClient, openshiftIDPName, cleanups + return idpCleans +} + +// AddKeycloakIDP deploys Keycloak and registers it as an OIDC IdP in one call. +// This is a convenience wrapper around DeployKeycloak + AddKeycloakOIDCIdP. +func AddKeycloakIDP( + t testing.TB, + kubeconfig *rest.Config, + directOIDC bool, +) (kcClient *KeycloakClient, idpName string, cleanups []func()) { + setup := DeployKeycloak(t, kubeconfig) + cleanups = setup.Cleanups + + idpCleans := AddKeycloakOIDCIdP(t, kubeconfig, setup, directOIDC) + cleanups = append(cleanups, idpCleans...) + + return setup.Client, setup.IDPName, cleanups } type KeycloakClient struct { diff --git a/test/library/proxy.go b/test/library/proxy.go new file mode 100644 index 0000000000..cf06ed7ee2 --- /dev/null +++ b/test/library/proxy.go @@ -0,0 +1,514 @@ +package library + +import ( + "context" + "crypto/x509" + "encoding/pem" + "fmt" + "reflect" + "strings" + "sync" + "testing" + "time" + + g "github.com/onsi/ginkgo/v2" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + networkingv1 "k8s.io/api/networking/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/fields" + "k8s.io/apimachinery/pkg/util/intstr" + "k8s.io/apimachinery/pkg/util/sets" + "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/apimachinery/pkg/watch" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/tools/cache" + watchtools "k8s.io/client-go/tools/watch" + "k8s.io/utils/ptr" + + configv1 "github.com/openshift/api/config/v1" + operatorv1 "github.com/openshift/api/operator/v1" + configclient "github.com/openshift/client-go/config/clientset/versioned" + operatorclient "github.com/openshift/client-go/operator/clientset/versioned" +) + +const ( + squidHTTPPort = int32(3128) + squidHTTPSPort = int32(3129) + squidServiceName = "squid-proxy" + + componentProxyCAConfigMapName = "v4-0-config-system-auth-proxy-ca" +) + +// SaveAndRestoreProxyConfig snapshots the current spec.proxy on the operator +// Authentication CR and returns a cleanup function that restores it and waits +// for the operator to reconcile. +func SaveAndRestoreProxyConfig(t testing.TB, operatorClient *operatorclient.Clientset, configClient *configclient.Clientset) (operatorAuth *operatorv1.Authentication, cleanup func()) { + ctx := context.TODO() + + auth, err := operatorClient.OperatorV1().Authentications().Get(ctx, "cluster", metav1.GetOptions{}) + if err != nil { + t.Fatalf("failed to get operator authentication CR: %v", err) + } + originalProxy := auth.Spec.Proxy.DeepCopy() + + return auth, sync.OnceFunc(func() { + t.Log("cleaning up: restoring original proxy config") + var changed bool + err := wait.PollUntilContextTimeout(ctx, 1*time.Second, 30*time.Second, true, func(ctx context.Context) (bool, error) { + fresh, err := operatorClient.OperatorV1().Authentications().Get(ctx, "cluster", metav1.GetOptions{}) + if err != nil { + t.Logf("cleanup: failed to get operator auth: %v", err) + return false, nil + } + target := operatorv1.AuthenticationProxyConfig{} + if originalProxy != nil { + target = *originalProxy + } + if reflect.DeepEqual(fresh.Spec.Proxy, target) { + t.Log("cleanup: proxy config already matches original, no update needed") + return true, nil + } + fresh.Spec.Proxy = target + if _, err := operatorClient.OperatorV1().Authentications().Update(ctx, fresh, metav1.UpdateOptions{}); err != nil { + t.Logf("cleanup: failed to update operator auth (will retry): %v", err) + return false, nil + } + changed = true + return true, nil + }) + if err != nil { + t.Logf("cleanup: failed to restore proxy config: %v", err) + return + } + if !changed { + return + } + t.Log("cleanup: waiting for operator to pick up changes and stabilize") + if err := WaitForOperatorToPickUpChanges(t, configClient.ConfigV1(), "authentication"); err != nil { + t.Logf("cleanup: operator did not recover: %v", err) + } + }) +} + +// DeploySquidProxy deploys a Squid forward proxy that listens on both plain +// HTTP (port 3128) and HTTPS (port 3129). It generates a self-signed CA and +// serving certificate internally. Returns the HTTP and HTTPS proxy URLs, +// the PEM-encoded CA certificate (for trustedCA ConfigMaps when using https), +// the namespace name, and a cleanup function. +func DeploySquidProxy(t testing.TB, kubeClient kubernetes.Interface) (httpProxyURL, httpsProxyURL string, caCertPEM []byte, namespace string, cleanup func()) { + ctx := context.TODO() + + namespace = NewTestNamespaceBuilder("e2e-proxy-"). + WithBaselinePSaEnforcement(). + WithLabels(CAOE2ETestLabels()). + Create(t, kubeClient.CoreV1().Namespaces()) + + cleanup = sync.OnceFunc(func() { + g.GinkgoWriter.Println("cleaning up: removing Squid proxy") + if err := kubeClient.CoreV1().Namespaces().Delete(ctx, namespace, metav1.DeleteOptions{}); err != nil { + t.Logf("error cleaning up proxy namespace %q: %v", namespace, err) + } + }) + + success := false + defer func() { + if !success { + cleanup() + } + }() + + ca := NewCertificateAuthorityCertificate(t, nil) + serviceDNS := fmt.Sprintf("%s.%s.svc.cluster.local", squidServiceName, namespace) + serverCert := NewServerCertificate(t, ca, serviceDNS) + + caCertPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: ca.Certificate.Raw}) + serverCertPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: serverCert.Certificate.Raw}) + + serverKeyDER, err := x509.MarshalPKCS8PrivateKey(serverCert.PrivateKey) + if err != nil { + t.Fatalf("failed to marshal server private key: %v", err) + } + serverKeyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: serverKeyDER}) + + squidConfig := fmt.Sprintf(`http_port %d +https_port %d tls-cert=/etc/squid/tls/tls.crt tls-key=/etc/squid/tls/tls.key +pid_filename /tmp/squid.pid +acl all src all +http_access allow all +access_log stdio:/dev/stdout +cache_log stdio:/dev/stderr +cache deny all +buffered_logs off +`, squidHTTPPort, squidHTTPSPort) + + _, err = kubeClient.CoreV1().ConfigMaps(namespace).Create(ctx, &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "squid-config"}, + Data: map[string]string{"squid.conf": squidConfig}, + }, metav1.CreateOptions{}) + if err != nil { + t.Fatalf("failed to create squid config: %v", err) + } + + _, err = kubeClient.CoreV1().Secrets(namespace).Create(ctx, &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "squid-tls"}, + Data: map[string][]byte{ + "tls.crt": serverCertPEM, + "tls.key": serverKeyPEM, + }, + }, metav1.CreateOptions{}) + if err != nil { + t.Fatalf("failed to create squid TLS secret: %v", err) + } + + deployment := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: squidServiceName, + Labels: map[string]string{"app": squidServiceName}, + }, + Spec: appsv1.DeploymentSpec{ + Replicas: ptr.To(int32(1)), + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"app": squidServiceName}, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{"app": squidServiceName}, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "squid", + Image: squidImage, + Ports: []corev1.ContainerPort{ + {ContainerPort: squidHTTPPort, Protocol: corev1.ProtocolTCP}, + {ContainerPort: squidHTTPSPort, Protocol: corev1.ProtocolTCP}, + }, + VolumeMounts: []corev1.VolumeMount{ + { + Name: "squid-config", + MountPath: "/etc/squid/squid.conf", + SubPath: "squid.conf", + }, + { + Name: "squid-tls", + MountPath: "/etc/squid/tls", + ReadOnly: true, + }, + }, + ReadinessProbe: &corev1.Probe{ + ProbeHandler: corev1.ProbeHandler{ + TCPSocket: &corev1.TCPSocketAction{ + Port: intstr.FromInt32(squidHTTPPort), + }, + }, + InitialDelaySeconds: 5, + PeriodSeconds: 5, + }, + }, + }, + Volumes: []corev1.Volume{ + { + Name: "squid-config", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "squid-config", + }, + }, + }, + }, + { + Name: "squid-tls", + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{ + SecretName: "squid-tls", + }, + }, + }, + }, + }, + }, + }, + } + + _, err = kubeClient.AppsV1().Deployments(namespace).Create(ctx, deployment, metav1.CreateOptions{}) + if err != nil { + t.Fatalf("failed to create squid deployment: %v", err) + } + + _, err = kubeClient.CoreV1().Services(namespace).Create(ctx, &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: squidServiceName, + Labels: map[string]string{"app": squidServiceName}, + }, + Spec: corev1.ServiceSpec{ + Selector: map[string]string{"app": squidServiceName}, + Ports: []corev1.ServicePort{ + { + Name: "http", + Port: squidHTTPPort, + TargetPort: intstr.FromInt32(squidHTTPPort), + Protocol: corev1.ProtocolTCP, + }, + { + Name: "https", + Port: squidHTTPSPort, + TargetPort: intstr.FromInt32(squidHTTPSPort), + Protocol: corev1.ProtocolTCP, + }, + }, + }, + }, metav1.CreateOptions{}) + if err != nil { + t.Fatalf("failed to create squid service: %v", err) + } + + t.Logf("waiting for squid proxy deployment in %s to be ready", namespace) + timeLimitedCtx, cancel := context.WithTimeout(ctx, 5*time.Minute) + defer cancel() + _, err = watchtools.UntilWithSync(timeLimitedCtx, + cache.NewListWatchFromClient( + kubeClient.AppsV1().RESTClient(), "deployments", namespace, + fields.OneTermEqualSelector("metadata.name", squidServiceName)), + &appsv1.Deployment{}, + nil, + func(event watch.Event) (bool, error) { + d := event.Object.(*appsv1.Deployment) + return d.Status.ReadyReplicas > 0, nil + }, + ) + if err != nil { + t.Fatalf("squid proxy deployment did not become ready: %v", err) + } + + success = true + + serviceHost := fmt.Sprintf("%s.%s.svc.cluster.local", squidServiceName, namespace) + httpProxyURL = fmt.Sprintf("http://%s:%d", serviceHost, squidHTTPPort) + httpsProxyURL = fmt.Sprintf("https://%s:%d", serviceHost, squidHTTPSPort) + success = true + t.Logf("squid proxy deployed: http=%s https=%s", httpProxyURL, httpsProxyURL) + return httpProxyURL, httpsProxyURL, caCertPEM, namespace, cleanup +} + +// DeployProxyNetworkPolicies creates a NetworkPolicy on the Keycloak namespace +// that restricts ingress to only the proxy namespace. This ensures auth +// components can only reach Keycloak through the proxy. +// +// Note: egress policies on auth namespaces are not created because the +// operator-managed NetworkPolicies already have allow-all egress rules that +// cannot be overridden additively. +func DeployProxyNetworkPolicies(t testing.TB, kubeClient kubernetes.Interface, proxyNamespace, keycloakNamespace string) func() { + ctx := context.TODO() + + keycloakPolicy := &networkingv1.NetworkPolicy{ + ObjectMeta: metav1.ObjectMeta{ + Name: "proxy-e2e-allow-only-from-proxy", + Namespace: keycloakNamespace, + Labels: CAOE2ETestLabels(), + }, + Spec: networkingv1.NetworkPolicySpec{ + PodSelector: metav1.LabelSelector{}, + PolicyTypes: []networkingv1.PolicyType{networkingv1.PolicyTypeIngress}, + Ingress: []networkingv1.NetworkPolicyIngressRule{ + { + From: []networkingv1.NetworkPolicyPeer{ + { + NamespaceSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "kubernetes.io/metadata.name": proxyNamespace, + }, + }, + }, + { + NamespaceSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "policy-group.network.openshift.io/ingress": "", + }, + }, + }, + }, + }, + }, + }, + } + + _, err := kubeClient.NetworkingV1().NetworkPolicies(keycloakNamespace).Create(ctx, keycloakPolicy, metav1.CreateOptions{}) + if err != nil { + t.Fatalf("failed to create NetworkPolicy in %s: %v", keycloakNamespace, err) + } + t.Logf("created NetworkPolicy proxy-e2e-allow-only-from-proxy in %s", keycloakNamespace) + + return func() { + if err := kubeClient.NetworkingV1().NetworkPolicies(keycloakNamespace).Delete(ctx, "proxy-e2e-allow-only-from-proxy", metav1.DeleteOptions{}); err != nil { + t.Logf("error cleaning up NetworkPolicy in %s: %v", keycloakNamespace, err) + } + } +} + +// GetSquidProxyLogs reads all Squid access log entries from the proxy pod. +func GetSquidProxyLogs(kubeClient kubernetes.Interface, namespace string) (string, error) { + return GetSquidProxyLogsSince(kubeClient, namespace, time.Time{}) +} + +// GetSquidProxyLogsSince reads the Squid access log from the proxy pod, +// returning only lines with a timestamp not before since. +func GetSquidProxyLogsSince(kubeClient kubernetes.Interface, namespace string, since time.Time) (string, error) { + ctx := context.TODO() + + pods, err := kubeClient.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{ + LabelSelector: fmt.Sprintf("app=%s", squidServiceName), + }) + if err != nil { + return "", fmt.Errorf("failed to list squid pods in %s: %w", namespace, err) + } + if len(pods.Items) == 0 { + return "", fmt.Errorf("no squid proxy pods found in namespace %s", namespace) + } + + logOpts := &corev1.PodLogOptions{Container: "squid"} + if !since.IsZero() { + t := metav1.NewTime(since) + logOpts.SinceTime = &t + } + logBytes, err := kubeClient.CoreV1().Pods(namespace).GetLogs(pods.Items[0].Name, logOpts).DoRaw(ctx) + if err != nil { + return "", fmt.Errorf("failed to get logs from squid container: %w", err) + } + + return string(logBytes), nil +} + +// WaitForSquidProxyTraffic polls the Squid proxy logs until it sees CONNECT or +// TCP_ entries, indicating traffic went through the proxy. +func WaitForSquidProxyTraffic(t testing.TB, kubeClient kubernetes.Interface, namespace string, timeout time.Duration) error { + t.Logf("waiting up to %s for traffic in squid proxy logs", timeout) + return wait.PollUntilContextTimeout(context.TODO(), 10*time.Second, timeout, true, func(ctx context.Context) (bool, error) { + logs, err := GetSquidProxyLogs(kubeClient, namespace) + if err != nil { + t.Logf("failed to read squid logs: %v", err) + return false, nil + } + if strings.Contains(logs, "CONNECT") || strings.Contains(logs, "TCP_") { + t.Logf("detected proxy traffic in squid logs") + return true, nil + } + return false, nil + }) +} + +// VerifyOAuthServerDeploymentProxyConfig asserts that the OAuth server +// deployment has the expected proxy env var values and trustedCA volume/mount. +// Proxy env vars are always set; pass empty string to assert an unset proxy. +// When expectTrustedCAVolume is true, the v4-0-config-system-auth-proxy-ca +// volume and mount must exist; when false, they must be absent. +func VerifyOAuthServerDeploymentProxyConfig(t testing.TB, kubeClient kubernetes.Interface, expectedHTTPProxy, expectedHTTPSProxy, expectedNoProxy string, expectTrustedCAVolume bool) { + ctx := context.TODO() + + var deployment *appsv1.Deployment + err := wait.PollUntilContextTimeout(ctx, 10*time.Second, 5*time.Minute, true, func(ctx context.Context) (bool, error) { + var err error + deployment, err = kubeClient.AppsV1().Deployments("openshift-authentication").Get(ctx, "oauth-openshift", metav1.GetOptions{}) + if err != nil { + t.Logf("failed to get oauth-openshift deployment: %v", err) + return false, nil + } + + envVars := make(map[string]string) + for _, container := range deployment.Spec.Template.Spec.Containers { + for _, env := range container.Env { + switch env.Name { + case "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY": + envVars[env.Name] = env.Value + } + } + } + + if envVars["HTTP_PROXY"] != expectedHTTPProxy || envVars["HTTPS_PROXY"] != expectedHTTPSProxy { + return false, nil + } + // Use superset check: the operator may add extra entries to NO_PROXY beyond + // what the caller specifies (e.g. the kubernetes service IP for KUBERNETES_SERVICE_HOST). + actualNoProxy := sets.New[string](strings.Split(envVars["NO_PROXY"], ",")...) + expectedNoProxyEntries := sets.New[string](strings.Split(expectedNoProxy, ",")...) + if !actualNoProxy.IsSuperset(expectedNoProxyEntries) { + return false, nil + } + + if matchTrustedCAVolume(deployment, expectTrustedCAVolume) { + return true, nil + } + return false, nil + }) + if err != nil { + t.Fatalf("OAuth server deployment proxy config did not match expected values within timeout") + } +} + +func matchTrustedCAVolume(deployment *appsv1.Deployment, expectPresent bool) bool { + foundVolume := false + for _, vol := range deployment.Spec.Template.Spec.Volumes { + if vol.ConfigMap != nil && vol.ConfigMap.Name == componentProxyCAConfigMapName { + foundVolume = true + break + } + } + + foundMount := false + for _, container := range deployment.Spec.Template.Spec.Containers { + for _, mount := range container.VolumeMounts { + if mount.Name == componentProxyCAConfigMapName { + foundMount = true + break + } + } + } + + if expectPresent { + return foundVolume && foundMount + } + return !foundVolume && !foundMount +} + +// VerifyTrustedCAConfigMapSynced checks that the trustedCA ConfigMap has been +// synced to the openshift-authentication namespace under the operator's +// hardcoded name (v4-0-config-system-auth-proxy-ca). +func VerifyTrustedCAConfigMapSynced(t testing.TB, kubeClient kubernetes.Interface, configMapName string) { + ctx := context.TODO() + + err := wait.PollUntilContextTimeout(ctx, 10*time.Second, 5*time.Minute, true, func(ctx context.Context) (bool, error) { + cm, err := kubeClient.CoreV1().ConfigMaps("openshift-authentication").Get(ctx, componentProxyCAConfigMapName, metav1.GetOptions{}) + if err != nil { + return false, nil + } + return len(cm.Data) > 0, nil + }) + if err != nil { + t.Fatalf("trustedCA ConfigMap %s was not synced to openshift-authentication as %s within timeout", configMapName, componentProxyCAConfigMapName) + } +} + +// CheckFeatureGateEnabledOrSkip skips the test if the given feature gate is not enabled. +func CheckFeatureGateEnabledOrSkip(t testing.TB, configClient *configclient.Clientset, featureGateName configv1.FeatureGateName) { + ctx := context.TODO() + + featureGates, err := configClient.ConfigV1().FeatureGates().Get(ctx, "cluster", metav1.GetOptions{}) + if err != nil { + t.Fatalf("failed to get feature gates: %v", err) + } + + if len(featureGates.Status.FeatureGates) != 1 { + t.Fatalf("multiple feature gate versions detected — cluster may be upgrading") + } + + for _, gate := range featureGates.Status.FeatureGates[0].Enabled { + if gate.Name == featureGateName { + t.Logf("feature gate %s is enabled", featureGateName) + return + } + } + + t.Skipf("skipping: feature gate %s is not enabled", featureGateName) +}