From ffdcc2c2a2d3caae99c20f56d6029cfc52a518eb Mon Sep 17 00:00:00 2001 From: Bryan Cox Date: Thu, 4 Jun 2026 16:20:49 -0400 Subject: [PATCH] test(e2e): add External OIDC e2e tests for v2 framework Add platform-agnostic External OIDC testing infrastructure and tests for the v2 e2e framework, using Keycloak as the identity provider. Changes: - Widen OIDC utility function signatures from *testing.T to testing.TB for Ginkgo v2 compatibility (backward compatible) - Add Keycloak deployment utility (test/e2e/v2/util/keycloak.go) that deploys Keycloak on any management cluster with OIDC clients, test users, group mappings, and CA bundle extraction - Add 'external-oidc' cluster variant to the Azure v2 lifecycle that deploys Keycloak in PostCreate and patches the HostedCluster with OIDC authentication config - Add External OIDC validation tests covering: cluster OIDC config, OAuth server not deployed, KAS JWT authenticator configuration, and Keycloak token authentication with claim mapping verification - Register E2E_EXTERNAL_OIDC_CA_BUNDLE_FILE and E2E_EXTERNAL_OIDC_TEST_USERS environment variables Tests are platform-agnostic and skip based on authentication type, not platform. Other platforms can reuse the Keycloak utility and test file by adding an external-oidc variant to their lifecycle config. Co-Authored-By: Claude Opus 4.6 --- test/e2e/util/external_oidc.go | 14 +- test/e2e/v2/cmd/create-guests/main.go | 120 +-- test/e2e/v2/internal/env_vars.go | 11 + test/e2e/v2/lifecycle/azure.go | 163 +++- test/e2e/v2/lifecycle/platform.go | 17 + .../hosted_cluster_external_oidc_test.go | 395 ++++++++++ test/e2e/v2/util/keycloak.go | 709 ++++++++++++++++++ 7 files changed, 1372 insertions(+), 57 deletions(-) create mode 100644 test/e2e/v2/tests/hosted_cluster_external_oidc_test.go create mode 100644 test/e2e/v2/util/keycloak.go diff --git a/test/e2e/util/external_oidc.go b/test/e2e/util/external_oidc.go index adaa8d63c0ea..8cfb466145a4 100644 --- a/test/e2e/util/external_oidc.go +++ b/test/e2e/util/external_oidc.go @@ -104,6 +104,12 @@ func (config *ExtOIDCConfig) GetAuthenticationConfig() *configv1.AuthenticationS }, }, OIDCClients: []configv1.OIDCClientConfig{ + { + ClientID: config.CliClientID, + ComponentName: "cli", + ComponentNamespace: "openshift-console", + ExtraScopes: []string{"email"}, + }, { ClientID: config.ConsoleClientID, ClientSecret: configv1.SecretNameReference{ @@ -154,7 +160,7 @@ func (config *ExtOIDCConfig) GetAuthenticationConfig() *configv1.AuthenticationS } // ValidateAuthenticationSpec validates the external OIDC configuration and the expected HostedCluster authentication configuration before running the test -func ValidateAuthenticationSpec(t *testing.T, ctx context.Context, client crclient.Client, hostedCluster *hyperv1.HostedCluster, config *ExtOIDCConfig) { +func ValidateAuthenticationSpec(t testing.TB, ctx context.Context, client crclient.Client, hostedCluster *hyperv1.HostedCluster, config *ExtOIDCConfig) { g := NewWithT(t) // check auth config @@ -201,7 +207,7 @@ func ValidateAuthenticationSpec(t *testing.T, ctx context.Context, client crclie } // IsExternalOIDCCluster checks if the cluster is using external OIDC. -func IsExternalOIDCCluster(t *testing.T, ctx context.Context, clientCfg *rest.Config) (bool, error) { +func IsExternalOIDCCluster(t testing.TB, ctx context.Context, clientCfg *rest.Config) (bool, error) { configv1Client, err := configv1typedclient.NewForConfig(clientCfg) if err != nil { return false, err @@ -215,7 +221,7 @@ func IsExternalOIDCCluster(t *testing.T, ctx context.Context, clientCfg *rest.Co } // ChangeClientForKeycloakExtOIDC changes the guest client using a keycloak user config -func ChangeClientForKeycloakExtOIDC(t *testing.T, ctx context.Context, clientCfg *rest.Config, authConfig *ExtOIDCConfig) crclient.Client { +func ChangeClientForKeycloakExtOIDC(t testing.TB, ctx context.Context, clientCfg *rest.Config, authConfig *ExtOIDCConfig) crclient.Client { g := NewWithT(t) newConfig := ChangeUserForKeycloakExtOIDC(t, ctx, clientCfg, authConfig) client, err := crclient.New(newConfig, crclient.Options{Scheme: scheme}) @@ -224,7 +230,7 @@ func ChangeClientForKeycloakExtOIDC(t *testing.T, ctx context.Context, clientCfg } // ChangeUserForKeycloakExtOIDC changes the user of current CLI session for a Keycloak external OIDC cluster -func ChangeUserForKeycloakExtOIDC(t *testing.T, ctx context.Context, clientCfg *rest.Config, authConfig *ExtOIDCConfig) *rest.Config { +func ChangeUserForKeycloakExtOIDC(t testing.TB, ctx context.Context, clientCfg *rest.Config, authConfig *ExtOIDCConfig) *rest.Config { g := NewWithT(t) g.Expect(authConfig).NotTo(BeNil()) g.Expect(authConfig.ExternalOIDCProvider).Should(Equal(ProviderKeycloak)) diff --git a/test/e2e/v2/cmd/create-guests/main.go b/test/e2e/v2/cmd/create-guests/main.go index 9c843d17d76b..1646af705baf 100644 --- a/test/e2e/v2/cmd/create-guests/main.go +++ b/test/e2e/v2/cmd/create-guests/main.go @@ -37,10 +37,14 @@ import ( "sync" "time" + routev1 "github.com/openshift/api/route/v1" + configv1 "github.com/openshift/api/config/v1" hyperv1 "github.com/openshift/hypershift/api/hypershift/v1beta1" "github.com/openshift/hypershift/test/e2e/v2/lifecycle" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" utilruntime "k8s.io/apimachinery/pkg/util/runtime" @@ -53,6 +57,9 @@ var scheme = runtime.NewScheme() func init() { utilruntime.Must(hyperv1.AddToScheme(scheme)) + utilruntime.Must(corev1.AddToScheme(scheme)) + utilruntime.Must(appsv1.AddToScheme(scheme)) + utilruntime.Must(routev1.AddToScheme(scheme)) } const defaultNamespace = "clusters" @@ -134,6 +141,16 @@ func run(ctx context.Context, cfg envConfig) error { clusterNames[spec.OutputFile] = name } + // Phase 0: Platform-specific pre-create hooks (e.g., deploy OIDC providers). + log.Println("Phase 0: Running platform pre-create hooks") + mgmtClientPre, err := newMgmtClient() + if err != nil { + return fmt.Errorf("creating management cluster client for pre-create: %w", err) + } + if err := cfg.platform.PreCreate(ctx, mgmtClientPre, cfg.namespace); err != nil { + return fmt.Errorf("platform pre-create hook: %w", err) + } + // Phase 1: Create all clusters in parallel. log.Printf("Phase 1: Creating %d clusters in parallel", len(named)) createErrors := createClustersParallel(ctx, cfg, named) @@ -176,8 +193,15 @@ func run(ctx context.Context, cfg envConfig) error { } } - // Phase 4: Watch for version rollout completion on all clusters. - log.Println("Phase 4: Waiting for version rollout completion on all clusters") + // Phase 4: Platform-specific post-available hooks (e.g., waiting for + // day-2 config transitions now that control plane components exist). + log.Println("Phase 4: Running platform post-available hooks") + if err := cfg.platform.PostAvailable(ctx, mgmtClient, cfg.namespace, clusterNames); err != nil { + return fmt.Errorf("platform post-available hook: %w", err) + } + + // Phase 5: Watch for version rollout completion on all clusters. + log.Println("Phase 5: Waiting for version rollout completion on all clusters") rolloutErrors := waitForVersionRollout(ctx, mgmtClient, cfg, named) anyRolloutFailed := false for _, ns := range named { @@ -191,8 +215,15 @@ func run(ctx context.Context, cfg envConfig) error { } } - // Phase 5: Write cluster names to SHARED_DIR. - log.Println("Phase 5: Writing cluster names to SHARED_DIR") + // Phase 6: Day-2 operations that disrupt ClusterOperators (e.g., External OIDC). + // These run after VersionState=Completed so the initial rollout isn't blocked. + log.Println("Phase 6: Running platform post-version-rollout hooks (day-2 operations)") + if err := cfg.platform.PostVersionRollout(ctx, mgmtClient, cfg.namespace, clusterNames); err != nil { + return fmt.Errorf("platform post-version-rollout hook: %w", err) + } + + // Phase 7: Write cluster names to SHARED_DIR. + log.Println("Phase 7: Writing cluster names to SHARED_DIR") for _, ns := range named { outputPath := filepath.Join(cfg.sharedDir, ns.OutputFile) if err := os.WriteFile(outputPath, []byte(ns.name), 0600); err != nil { @@ -346,52 +377,55 @@ func waitForVersionRollout(ctx context.Context, cl crclient.WithWatch, cfg envCo } func watchForCondition(ctx context.Context, cl crclient.WithWatch, namespace, name string, predicate func(*hyperv1.HostedCluster) bool) error { + key := crclient.ObjectKey{Namespace: namespace, Name: name} hc := &hyperv1.HostedCluster{} - if err := cl.Get(ctx, crclient.ObjectKey{Namespace: namespace, Name: name}, hc); err == nil { - if predicate(hc) { - return nil - } - } - hcList := &hyperv1.HostedClusterList{} - watcher, err := cl.Watch(ctx, hcList, - crclient.InNamespace(namespace), - crclient.MatchingFields{"metadata.name": name}, - ) - if err != nil { - return fmt.Errorf("starting watch for %s/%s: %w", namespace, name, err) - } - defer watcher.Stop() + for { + if err := cl.Get(ctx, key, hc); err == nil { + if predicate(hc) { + return nil + } + } - if err := cl.Get(ctx, crclient.ObjectKey{Namespace: namespace, Name: name}, hc); err == nil { - if predicate(hc) { - return nil + hcList := &hyperv1.HostedClusterList{} + watcher, err := cl.Watch(ctx, hcList, + crclient.InNamespace(namespace), + crclient.MatchingFields{"metadata.name": name}, + ) + if err != nil { + return fmt.Errorf("starting watch for %s/%s: %w", namespace, name, err) } - } - for { - select { - case <-ctx.Done(): - return fmt.Errorf("timed out waiting for %s/%s: %w", namespace, name, ctx.Err()) - case event, ok := <-watcher.ResultChan(): - if !ok { - return fmt.Errorf("watch channel closed for %s/%s", namespace, name) - } - if event.Type == watch.Error { - return fmt.Errorf("watch error for %s/%s: %v", namespace, name, event.Object) - } - if event.Type != watch.Added && event.Type != watch.Modified { - continue - } - watchedHC, ok := event.Object.(*hyperv1.HostedCluster) - if !ok { - continue - } - logClusterProgress(watchedHC) - if predicate(watchedHC) { - return nil + closed := false + for !closed { + select { + case <-ctx.Done(): + watcher.Stop() + return fmt.Errorf("timed out waiting for %s/%s: %w", namespace, name, ctx.Err()) + case event, ok := <-watcher.ResultChan(): + if !ok { + closed = true + break + } + if event.Type == watch.Error { + closed = true + break + } + if event.Type != watch.Added && event.Type != watch.Modified { + continue + } + watchedHC, ok := event.Object.(*hyperv1.HostedCluster) + if !ok { + continue + } + logClusterProgress(watchedHC) + if predicate(watchedHC) { + watcher.Stop() + return nil + } } } + watcher.Stop() } } diff --git a/test/e2e/v2/internal/env_vars.go b/test/e2e/v2/internal/env_vars.go index 082405575029..ffcde754c12d 100644 --- a/test/e2e/v2/internal/env_vars.go +++ b/test/e2e/v2/internal/env_vars.go @@ -213,4 +213,15 @@ func init() { "Path to an additional pull secret file for the global pull secret lifecycle test.", false, ) + // External OIDC test environment variables + RegisterEnvVar( + "E2E_EXTERNAL_OIDC_CA_BUNDLE_FILE", + "Path to the CA bundle file for the External OIDC issuer (Keycloak). Written by the lifecycle PostCreate.", + false, + ) + RegisterEnvVar( + "E2E_EXTERNAL_OIDC_TEST_USERS", + "Comma-separated list of test users in user:password format for External OIDC testing. Written by the lifecycle PostCreate.", + false, + ) } diff --git a/test/e2e/v2/lifecycle/azure.go b/test/e2e/v2/lifecycle/azure.go index 54948d0aa220..7c1db996607e 100644 --- a/test/e2e/v2/lifecycle/azure.go +++ b/test/e2e/v2/lifecycle/azure.go @@ -12,6 +12,10 @@ import ( operatorv1 "github.com/openshift/api/operator/v1" hyperv1 "github.com/openshift/hypershift/api/hypershift/v1beta1" + e2eutil "github.com/openshift/hypershift/test/e2e/util" + v2util "github.com/openshift/hypershift/test/e2e/v2/util" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" crclient "sigs.k8s.io/controller-runtime/pkg/client" ) @@ -43,6 +47,8 @@ type AzurePlatformConfig struct { marketplaceOffer string marketplaceSKU string marketplaceVersion string + + keycloakConfig *v2util.KeycloakConfig } // NewAzurePlatformConfig reads Azure-specific configuration from @@ -138,6 +144,10 @@ func (a *AzurePlatformConfig) ClusterSpecs(releaseImage, n1Image string) []Clust Variant: "autoscaling", OutputFile: "cluster-name-autoscaling", }, + { + Variant: "external-oidc", + OutputFile: "cluster-name-external-oidc", + }, } } @@ -166,18 +176,53 @@ func (a *AzurePlatformConfig) CreateArgs() []string { return args } -// PostCreate patches the public cluster's OperatorConfiguration with -// an IngressOperator using an internal LoadBalancer. This is specific -// to Azure self-managed testing. +// PreCreate deploys infrastructure that must be ready before clusters +// are created (e.g., the Keycloak OIDC provider for the external-oidc variant). +func (a *AzurePlatformConfig) PreCreate(ctx context.Context, cl crclient.WithWatch, namespace string) error { + kcConfig, err := v2util.DeployKeycloak(ctx, cl, "https://placeholder.example.com/auth/callback") + if err != nil { + return fmt.Errorf("deploying keycloak in pre-create: %w", err) + } + a.keycloakConfig = kcConfig + log.Printf("Keycloak deployed: issuer=%s", kcConfig.IssuerURL) + return nil +} + +// PostCreate runs variant-specific post-creation hooks for each cluster +// that was created by the lifecycle orchestrator. func (a *AzurePlatformConfig) PostCreate(ctx context.Context, cl crclient.WithWatch, namespace string, clusterNames map[string]string) error { - publicName, ok := clusterNames["cluster-name-public"] - if !ok { - return nil + if publicName, ok := clusterNames["cluster-name-public"]; ok { + if err := a.postCreatePublic(ctx, cl, namespace, publicName); err != nil { + return err + } } + return nil +} +// PostAvailable runs after all clusters reach the Available condition. +// External OIDC setup runs here because the HC must be fully reconciled +// before the authentication config is patched: the HO needs to have +// created the HCP namespace and the HCCO must be running so that the +// issuer CA configmap and console client secret are propagated from the +// HC namespace → HCP namespace → guest openshift-config namespace +// before the console-operator deploys the console pod with OIDC auth. +func (a *AzurePlatformConfig) PostAvailable(ctx context.Context, cl crclient.WithWatch, namespace string, clusterNames map[string]string) error { + return nil +} + +func (a *AzurePlatformConfig) PostVersionRollout(ctx context.Context, cl crclient.WithWatch, namespace string, clusterNames map[string]string) error { + if oidcName, ok := clusterNames["cluster-name-external-oidc"]; ok { + if err := a.postCreateExternalOIDC(ctx, cl, namespace, oidcName); err != nil { + return err + } + } + return nil +} + +func (a *AzurePlatformConfig) postCreatePublic(ctx context.Context, cl crclient.Client, namespace, name string) error { hc := &hyperv1.HostedCluster{} - if err := cl.Get(ctx, crclient.ObjectKey{Namespace: namespace, Name: publicName}, hc); err != nil { - return fmt.Errorf("getting HostedCluster %s/%s: %w", namespace, publicName, err) + if err := cl.Get(ctx, crclient.ObjectKey{Namespace: namespace, Name: name}, hc); err != nil { + return fmt.Errorf("getting HostedCluster %s/%s: %w", namespace, name, err) } patch := crclient.MergeFrom(hc.DeepCopy()) @@ -193,9 +238,92 @@ func (a *AzurePlatformConfig) PostCreate(ctx context.Context, cl crclient.WithWa }, } if err := cl.Patch(ctx, hc, patch); err != nil { - return fmt.Errorf("patching HostedCluster %s/%s OperatorConfiguration: %w", namespace, publicName, err) + return fmt.Errorf("patching HostedCluster %s/%s OperatorConfiguration: %w", namespace, name, err) + } + log.Printf("Patched public cluster %s/%s with OperatorConfiguration", namespace, name) + return nil +} + +func (a *AzurePlatformConfig) postCreateExternalOIDC(ctx context.Context, cl crclient.Client, namespace, name string) error { + hc := &hyperv1.HostedCluster{} + if err := cl.Get(ctx, crclient.ObjectKey{Namespace: namespace, Name: name}, hc); err != nil { + return fmt.Errorf("getting HostedCluster %s/%s for OIDC setup: %w", namespace, name, err) + } + + kcConfig := a.keycloakConfig + if kcConfig == nil { + return fmt.Errorf("keycloak config not available; PreCreate must run before PostCreate") + } + + consoleRedirectURI := fmt.Sprintf("https://console-openshift-console.apps.%s.%s/auth/callback", + hc.Name, hc.Spec.DNS.BaseDomain) + if err := v2util.UpdateKeycloakConsoleClient(ctx, consoleRedirectURI); err != nil { + return fmt.Errorf("updating keycloak console client redirect URI: %w", err) + } + + caCM := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "oidc-ca", + Namespace: namespace, + }, + Data: map[string]string{ + "ca-bundle.crt": string(kcConfig.CABundle), + }, + } + if err := v2util.CreateOrUpdate(ctx, cl, caCM); err != nil { + return fmt.Errorf("creating OIDC CA configmap: %w", err) + } + + consoleSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "console-secret", + Namespace: namespace, + }, + Type: corev1.SecretTypeOpaque, + StringData: map[string]string{ + "clientSecret": kcConfig.ConsoleClientSecret, + }, + } + if err := v2util.CreateOrUpdate(ctx, cl, consoleSecret); err != nil { + return fmt.Errorf("creating console client secret: %w", err) + } + + extOIDCConfig := &e2eutil.ExtOIDCConfig{ + ExternalOIDCProvider: e2eutil.ProviderKeycloak, + OIDCProviderName: "keycloak oidc server", + CliClientID: kcConfig.CLIClientID, + ConsoleClientID: kcConfig.ConsoleClientID, + IssuerURL: kcConfig.IssuerURL, + GroupPrefix: "oidc-groups-test:", + UserPrefix: "oidc-user-test:", + ConsoleClientSecretName: "console-secret", + ConsoleClientSecretValue: kcConfig.ConsoleClientSecret, + IssuerCAConfigmapName: "oidc-ca", + TestUsers: kcConfig.TestUsers, + } + + patch := crclient.MergeFrom(hc.DeepCopy()) + if hc.Spec.Configuration == nil { + hc.Spec.Configuration = &hyperv1.ClusterConfiguration{} + } + hc.Spec.Configuration.Authentication = extOIDCConfig.GetAuthenticationConfig() + if err := cl.Patch(ctx, hc, patch); err != nil { + return fmt.Errorf("patching HostedCluster %s/%s with OIDC config: %w", namespace, name, err) + } + log.Printf("Patched HostedCluster %s/%s with External OIDC config", namespace, name) + + if a.sharedDir != "" { + caPath := filepath.Join(a.sharedDir, "external_oidc_ca_bundle") + if err := os.WriteFile(caPath, kcConfig.CABundle, 0600); err != nil { + return fmt.Errorf("writing CA bundle to %s: %w", caPath, err) + } + testUsersPath := filepath.Join(a.sharedDir, "external_oidc_test_users") + if err := os.WriteFile(testUsersPath, []byte(kcConfig.TestUsers), 0600); err != nil { + return fmt.Errorf("writing test users to %s: %w", testUsersPath, err) + } + log.Printf("Wrote External OIDC CA bundle and test users to SHARED_DIR") } - log.Printf("Patched public cluster %s/%s with OperatorConfiguration", namespace, publicName) + return nil } @@ -227,6 +355,12 @@ func (a *AzurePlatformConfig) TestMatrix(releaseImage string) TestMatrix { LabelFilter: "nodepool-autoscaling", JUnitFile: "junit_nodepool_autoscaling.xml", }, + { + Name: "external-oidc", + ClusterFile: "cluster-name-external-oidc", + LabelFilter: "external-oidc", + JUnitFile: "junit_self_managed_azure_external_oidc.xml", + }, }, Sequential: []SequentialGroup{ { @@ -259,6 +393,15 @@ func (a *AzurePlatformConfig) SetupTestEnv(sharedDir string) { azurePrivateNATSubnetID = strings.TrimSpace(string(data)) } os.Setenv("AZURE_PRIVATE_NAT_SUBNET_ID", azurePrivateNATSubnetID) + + // External OIDC + caPath := filepath.Join(sharedDir, "external_oidc_ca_bundle") + if _, err := os.Stat(caPath); err == nil { + os.Setenv("E2E_EXTERNAL_OIDC_CA_BUNDLE_FILE", caPath) + } + if data, err := os.ReadFile(filepath.Join(sharedDir, "external_oidc_test_users")); err == nil { + os.Setenv("E2E_EXTERNAL_OIDC_TEST_USERS", strings.TrimSpace(string(data))) + } } func (a *AzurePlatformConfig) DestroyArgs() []string { diff --git a/test/e2e/v2/lifecycle/platform.go b/test/e2e/v2/lifecycle/platform.go index a7ba5a1ebc7a..737ea3f51f32 100644 --- a/test/e2e/v2/lifecycle/platform.go +++ b/test/e2e/v2/lifecycle/platform.go @@ -63,10 +63,27 @@ type PlatformConfig interface { // "hypershift create cluster ". CreateArgs() []string + // PreCreate runs platform-specific setup before clusters are + // created (e.g., deploying OIDC providers that must be ready + // before the cluster exists). + PreCreate(ctx context.Context, cl crclient.WithWatch, namespace string) error + // PostCreate runs platform-specific setup after clusters are // created (e.g., patching OperatorConfiguration). PostCreate(ctx context.Context, cl crclient.WithWatch, namespace string, clusterNames map[string]string) error + // PostAvailable runs platform-specific operations after all + // clusters reach the Available condition (e.g., waiting for + // day-2 configuration transitions to complete). Control plane + // components are guaranteed to exist at this point. + PostAvailable(ctx context.Context, cl crclient.WithWatch, namespace string, clusterNames map[string]string) error + + // PostVersionRollout runs day-2 operations after all clusters + // reach VersionState=Completed. Use this for configuration changes + // that disrupt ClusterOperators (e.g., External OIDC), which would + // block the initial version rollout if applied earlier. + PostVersionRollout(ctx context.Context, cl crclient.WithWatch, namespace string, clusterNames map[string]string) error + // TestMatrix returns the test groups for this platform. TestMatrix(releaseImage string) TestMatrix diff --git a/test/e2e/v2/tests/hosted_cluster_external_oidc_test.go b/test/e2e/v2/tests/hosted_cluster_external_oidc_test.go new file mode 100644 index 000000000000..f5adf3fb72d4 --- /dev/null +++ b/test/e2e/v2/tests/hosted_cluster_external_oidc_test.go @@ -0,0 +1,395 @@ +//go:build e2ev2 + +/* +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tests + +import ( + "crypto/tls" + "encoding/json" + "fmt" + "io" + "math/rand" + "net/http" + "net/url" + "regexp" + "strings" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + configv1 "github.com/openshift/api/config/v1" + hyperv1 "github.com/openshift/hypershift/api/hypershift/v1beta1" + e2eutil "github.com/openshift/hypershift/test/e2e/util" + "github.com/openshift/hypershift/test/e2e/v2/internal" + + appsv1 "k8s.io/api/apps/v1" + kauthnv1 "k8s.io/api/authentication/v1" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + kauthnv1typedclient "k8s.io/client-go/kubernetes/typed/authentication/v1" + "k8s.io/client-go/rest" + crclient "sigs.k8s.io/controller-runtime/pkg/client" +) + +// RegisterExternalOIDCTests registers all External OIDC validation tests. +// Tests are platform-agnostic -- they skip based on authentication type, not platform. +// Panics if the hosted cluster cannot be fetched when OIDC is expected. +func RegisterExternalOIDCTests(getTestCtx internal.TestContextGetter) { + ExternalOIDCClusterConfigTest(getTestCtx) + ExternalOIDCOAuthNotDeployedTest(getTestCtx) + ExternalOIDCKASConfigTest(getTestCtx) + ExternalOIDCKeycloakAuthTest(getTestCtx) +} + +var _ = Describe("External OIDC", Label("external-oidc"), func() { + var testCtx *internal.TestContext + + BeforeEach(func() { + testCtx = internal.GetTestContext() + Expect(testCtx).NotTo(BeNil(), "test context should be set up in BeforeSuite") + testCtx.ValidateHostedCluster() + }) + + RegisterExternalOIDCTests(func() *internal.TestContext { return testCtx }) +}) + +// skipIfNotOIDC skips the current test if the hosted cluster does not have External OIDC configured. +func skipIfNotOIDC(hc *hyperv1.HostedCluster) { + if hc == nil { + Skip("no hosted cluster available") + } + if hc.Spec.Configuration == nil || + hc.Spec.Configuration.Authentication == nil || + hc.Spec.Configuration.Authentication.Type != configv1.AuthenticationTypeOIDC { + Skip("External OIDC tests require authentication type OIDC") + } + Expect(hc.Spec.Configuration.Authentication.OIDCProviders).NotTo(BeEmpty(), + "hosted cluster %s/%s has authentication type OIDC but no OIDC providers configured", + hc.Namespace, hc.Name) +} + +// ExternalOIDCClusterConfigTest verifies the hosted cluster has OIDC authentication configured +// and is in Available condition. Skips on non-OIDC clusters. +func ExternalOIDCClusterConfigTest(getTestCtx internal.TestContextGetter) { + Context("Cluster OIDC Configuration", Label("external-oidc"), func() { + BeforeEach(func() { + skipIfNotOIDC(getTestCtx().GetHostedCluster()) + }) + + It("should have authentication type OIDC on the hosted cluster", func() { + hc := getTestCtx().GetHostedCluster() + Expect(hc.Spec.Configuration).NotTo(BeNil(), + "hosted cluster %s/%s should have configuration set", hc.Namespace, hc.Name) + Expect(hc.Spec.Configuration.Authentication).NotTo(BeNil(), + "hosted cluster %s/%s should have authentication configured", hc.Namespace, hc.Name) + Expect(hc.Spec.Configuration.Authentication.Type).To(Equal(configv1.AuthenticationTypeOIDC), + "hosted cluster %s/%s authentication type should be OIDC", hc.Namespace, hc.Name) + Expect(hc.Spec.Configuration.Authentication.OIDCProviders).NotTo(BeEmpty(), + "hosted cluster %s/%s should have at least one OIDC provider", hc.Namespace, hc.Name) + }) + + It("should have the hosted cluster Available", func() { + hc := getTestCtx().GetHostedCluster() + found := false + for _, cond := range hc.Status.Conditions { + if cond.Type == string(hyperv1.HostedClusterAvailable) { + found = true + Expect(string(cond.Status)).To(Equal("True"), + "hosted cluster %s/%s Available condition should be True, got %s: %s", + hc.Namespace, hc.Name, cond.Status, cond.Message) + break + } + } + Expect(found).To(BeTrue(), + "hosted cluster %s/%s should have Available condition", hc.Namespace, hc.Name) + }) + }) +} + +// ExternalOIDCOAuthNotDeployedTest verifies that OAuth-related deployments are absent from the +// control plane namespace when External OIDC is configured. Skips on non-OIDC clusters. +func ExternalOIDCOAuthNotDeployedTest(getTestCtx internal.TestContextGetter) { + Context("OAuth Server Not Deployed", Label("external-oidc"), func() { + BeforeEach(func() { + skipIfNotOIDC(getTestCtx().GetHostedCluster()) + }) + + It("should not have oauth-openshift deployment in the control plane namespace", func() { + tc := getTestCtx() + deployment := &appsv1.Deployment{} + err := tc.MgmtClient.Get(tc.Context, crclient.ObjectKey{ + Namespace: tc.ControlPlaneNamespace, + Name: "oauth-openshift", + }, deployment) + Expect(apierrors.IsNotFound(err)).To(BeTrue(), + "oauth-openshift deployment should not exist in %s when OIDC is configured", + tc.ControlPlaneNamespace) + }) + + It("should not have openshift-oauth-apiserver deployment in the control plane namespace", func() { + tc := getTestCtx() + deployment := &appsv1.Deployment{} + err := tc.MgmtClient.Get(tc.Context, crclient.ObjectKey{ + Namespace: tc.ControlPlaneNamespace, + Name: "openshift-oauth-apiserver", + }, deployment) + Expect(apierrors.IsNotFound(err)).To(BeTrue(), + "openshift-oauth-apiserver deployment should not exist in %s when OIDC is configured", + tc.ControlPlaneNamespace) + }) + }) +} + +// ExternalOIDCKASConfigTest verifies KAS authentication configuration matches the OIDC provider +// spec from the hosted cluster. Validates JWT authenticator issuer URL, audiences, and absence +// of OAuth webhook config. Skips on non-OIDC clusters. +func ExternalOIDCKASConfigTest(getTestCtx internal.TestContextGetter) { + Context("KAS Authentication Configuration", Label("external-oidc"), func() { + BeforeEach(func() { + skipIfNotOIDC(getTestCtx().GetHostedCluster()) + }) + + It("should have auth-config ConfigMap with JWT authenticator matching the OIDC provider", func() { + tc := getTestCtx() + hc := tc.GetHostedCluster() + + cm := &corev1.ConfigMap{} + err := tc.MgmtClient.Get(tc.Context, crclient.ObjectKey{ + Namespace: tc.ControlPlaneNamespace, + Name: "auth-config", + }, cm) + Expect(err).NotTo(HaveOccurred(), + "auth-config ConfigMap should exist in %s", tc.ControlPlaneNamespace) + + authJSON, ok := cm.Data["auth.json"] + Expect(ok).To(BeTrue(), "auth-config ConfigMap should have auth.json key") + + var authConfig map[string]interface{} + Expect(json.Unmarshal([]byte(authJSON), &authConfig)).To(Succeed()) + + jwtArray, ok := authConfig["jwt"].([]interface{}) + Expect(ok).To(BeTrue(), "auth.json should have jwt array") + Expect(jwtArray).NotTo(BeEmpty(), "jwt array should not be empty") + + // Dynamic assertion: compare against HC spec, not hardcoded values + expectedIssuerURL := hc.Spec.Configuration.Authentication.OIDCProviders[0].Issuer.URL + Expect(expectedIssuerURL).NotTo(BeEmpty(), + "OIDC provider issuer URL should not be empty on hosted cluster %s/%s", hc.Namespace, hc.Name) + + firstJWT, ok := jwtArray[0].(map[string]interface{}) + Expect(ok).To(BeTrue(), "first jwt entry should be a map") + issuer, ok := firstJWT["issuer"].(map[string]interface{}) + Expect(ok).To(BeTrue(), "jwt entry should have issuer") + Expect(issuer["url"]).To(Equal(expectedIssuerURL), + "JWT issuer URL should match OIDC provider from hosted cluster spec") + }) + + It("should have correct audiences in JWT config", func() { + tc := getTestCtx() + hc := tc.GetHostedCluster() + + cm := &corev1.ConfigMap{} + Expect(tc.MgmtClient.Get(tc.Context, crclient.ObjectKey{ + Namespace: tc.ControlPlaneNamespace, + Name: "auth-config", + }, cm)).To(Succeed()) + + var authConfig map[string]interface{} + Expect(json.Unmarshal([]byte(cm.Data["auth.json"]), &authConfig)).To(Succeed()) + + jwtArray, ok := authConfig["jwt"].([]interface{}) + Expect(ok).To(BeTrue(), "auth.json should have jwt array") + Expect(jwtArray).NotTo(BeEmpty(), "jwt array should not be empty") + firstJWT, ok := jwtArray[0].(map[string]interface{}) + Expect(ok).To(BeTrue(), "first jwt entry should be a map") + issuer, ok := firstJWT["issuer"].(map[string]interface{}) + Expect(ok).To(BeTrue(), "jwt entry should have issuer") + audiences, ok := issuer["audiences"].([]interface{}) + Expect(ok).To(BeTrue(), "JWT issuer should have audiences array") + Expect(audiences).NotTo(BeEmpty(), "JWT audiences should not be empty") + + expectedAudiences := hc.Spec.Configuration.Authentication.OIDCProviders[0].Issuer.Audiences + for _, expected := range expectedAudiences { + Expect(audiences).To(ContainElement(string(expected)), + "JWT audiences should contain %s from hosted cluster spec", expected) + } + }) + + It("should not have OAuth webhook authentication config", func() { + tc := getTestCtx() + cm := &corev1.ConfigMap{} + Expect(tc.MgmtClient.Get(tc.Context, crclient.ObjectKey{ + Namespace: tc.ControlPlaneNamespace, + Name: "kas-config", + }, cm)).To(Succeed()) + + var kasConfig map[string]interface{} + Expect(json.Unmarshal([]byte(cm.Data["config.json"]), &kasConfig)).To(Succeed()) + + apiServerArgs, ok := kasConfig["apiServerArguments"].(map[string]interface{}) + if ok { + _, hasWebhook := apiServerArgs["authentication-token-webhook-config-file"] + Expect(hasWebhook).To(BeFalse(), + "KAS should not have authentication-token-webhook-config-file when OIDC is configured") + } + }) + }) +} + +// ExternalOIDCKeycloakAuthTest verifies Keycloak-based External OIDC authentication by obtaining +// a token and performing a SelfSubjectReview against the hosted cluster KAS. Uses Ordered with +// BeforeAll to obtain the token once and validate claim mappings in separate It blocks. +// Skips when OIDC env vars are not configured or the cluster is not OIDC. +func ExternalOIDCKeycloakAuthTest(getTestCtx internal.TestContextGetter) { + Context("Keycloak Authentication and Claims", Label("external-oidc"), Ordered, func() { + var selfSubjectReview *kauthnv1.SelfSubjectReview + var extOIDCConfig *e2eutil.ExtOIDCConfig + + BeforeAll(func() { + tc := getTestCtx() + hc := tc.GetHostedCluster() + skipIfNotOIDC(hc) + + provider := hc.Spec.Configuration.Authentication.OIDCProviders[0] + Expect(provider.Issuer.URL).NotTo(BeEmpty(), + "OIDC provider issuer URL should not be empty on hosted cluster %s/%s", hc.Namespace, hc.Name) + + testUsersStr := internal.GetEnvVarValue("E2E_EXTERNAL_OIDC_TEST_USERS") + if testUsersStr == "" { + Skip("External OIDC test users not configured") + } + + var cliClientID, consoleClientID string + for _, oidcClient := range provider.OIDCClients { + switch oidcClient.ComponentName { + case "cli": + cliClientID = oidcClient.ClientID + case "console": + consoleClientID = oidcClient.ClientID + } + } + Expect(cliClientID).NotTo(BeEmpty(), + "CLI client ID not found in OIDCProviders[0].OIDCClients for %s/%s", hc.Namespace, hc.Name) + Expect(consoleClientID).NotTo(BeEmpty(), + "console client ID not found in OIDCProviders[0].OIDCClients for %s/%s", hc.Namespace, hc.Name) + + extOIDCConfig = &e2eutil.ExtOIDCConfig{ + ExternalOIDCProvider: e2eutil.ProviderKeycloak, + CliClientID: cliClientID, + ConsoleClientID: consoleClientID, + IssuerURL: provider.Issuer.URL, + GroupPrefix: provider.ClaimMappings.Groups.Prefix, + TestUsers: testUsersStr, + } + if provider.ClaimMappings.Username.Prefix != nil { + extOIDCConfig.UserPrefix = provider.ClaimMappings.Username.Prefix.PrefixString + } + + restConfig := tc.GetHostedClusterRESTConfig() + Expect(restConfig).NotTo(BeNil(), + "hosted cluster REST config should be available for %s/%s", hc.Namespace, hc.Name) + + // KAS may need time to load the OIDC authentication config after the HC + // was patched in PostVersionRollout. Retry with a fresh token each attempt + // since the Keycloak token lifetime is short (150s). + Eventually(func(g Gomega) { + idToken := obtainKeycloakIDToken(extOIDCConfig) + GinkgoT().Logf("Obtained Keycloak ID token, attempting SelfSubjectReview against %s", restConfig.Host) + + authConfig := rest.AnonymousClientConfig(rest.CopyConfig(restConfig)) + authConfig.BearerToken = idToken + authClient, err := kauthnv1typedclient.NewForConfig(authConfig) + g.Expect(err).NotTo(HaveOccurred(), "failed to create auth client for Keycloak OIDC user") + + selfSubjectReview, err = authClient.SelfSubjectReviews().Create( + tc.Context, &kauthnv1.SelfSubjectReview{}, metav1.CreateOptions{}) + if err != nil { + GinkgoT().Logf("SelfSubjectReview failed: %v", err) + } + g.Expect(err).NotTo(HaveOccurred(), "SelfSubjectReview should succeed with Keycloak OIDC token") + }).WithTimeout(5 * time.Minute).WithPolling(15 * time.Second).Should(Succeed()) + }) + + It("should authenticate to KAS with a Keycloak-issued token", func() { + Expect(selfSubjectReview).NotTo(BeNil(), "SelfSubjectReview should have been created in BeforeAll") + Expect(selfSubjectReview.Status.UserInfo.Username).NotTo(BeEmpty(), + "SelfSubjectReview should return a non-empty username") + }) + + It("should map username claim correctly", func() { + Expect(selfSubjectReview.Status.UserInfo.Username).To(ContainSubstring(extOIDCConfig.UserPrefix), + "username should contain the configured prefix %q", extOIDCConfig.UserPrefix) + }) + + It("should map groups claim with prefix", func() { + groups := selfSubjectReview.Status.UserInfo.Groups + Expect(groups).NotTo(BeEmpty(), "SelfSubjectReview should return groups") + Expect(groups).To(ContainElement(ContainSubstring(extOIDCConfig.GroupPrefix)), + "at least one group should contain the configured prefix %q", extOIDCConfig.GroupPrefix) + }) + + It("should map UID claim correctly", func() { + Expect(selfSubjectReview.Status.UserInfo.UID).NotTo(BeEmpty(), + "SelfSubjectReview should return a non-empty UID") + }) + }) +} + +// obtainKeycloakIDToken requests an ID token from Keycloak via the resource owner password grant. +// It picks a random test user from the configured test users string and returns the raw ID token. +func obtainKeycloakIDToken(config *e2eutil.ExtOIDCConfig) string { + re := regexp.MustCompile(`([^:,]+):([^,]+)`) + testUsers := re.FindAllStringSubmatch(config.TestUsers, -1) + Expect(testUsers).NotTo(BeEmpty(), "no test users found in config") + + idx := rand.Intn(len(testUsers)) + username := testUsers[idx][1] + password := testUsers[idx][2] + GinkgoT().Logf("Random test user for use: %q.", username) + + httpClient := &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + }, + } + tokenURL := config.IssuerURL + "/protocol/openid-connect/token" + formData := url.Values{ + "client_id": {config.CliClientID}, + "grant_type": {"password"}, + "password": {password}, + "scope": {"openid email profile"}, + "username": {username}, + } + + resp, err := httpClient.Post(tokenURL, "application/x-www-form-urlencoded", strings.NewReader(formData.Encode())) + Expect(err).NotTo(HaveOccurred(), "failed to request token from Keycloak") + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + Expect(err).NotTo(HaveOccurred(), "failed to read Keycloak token response") + Expect(resp.StatusCode).To(Equal(http.StatusOK), + fmt.Sprintf("Keycloak token request failed with status %d: %s", resp.StatusCode, string(body))) + + var tokenResp map[string]interface{} + Expect(json.Unmarshal(body, &tokenResp)).To(Succeed(), "failed to parse Keycloak token response") + + idToken, ok := tokenResp["id_token"].(string) + Expect(ok).To(BeTrue(), "id_token not found or not a string in Keycloak response") + return idToken +} + diff --git a/test/e2e/v2/util/keycloak.go b/test/e2e/v2/util/keycloak.go new file mode 100644 index 000000000000..272a95cfd6f8 --- /dev/null +++ b/test/e2e/v2/util/keycloak.go @@ -0,0 +1,709 @@ +//go:build e2ev2 + +/* +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package util + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "fmt" + "log" + "math/big" + "net/http" + "net/url" + "strings" + "time" + + routev1 "github.com/openshift/api/route/v1" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/intstr" + "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/remotecommand" + "k8s.io/utils/ptr" + ctrl "sigs.k8s.io/controller-runtime" + crclient "sigs.k8s.io/controller-runtime/pkg/client" +) + +const ( + keycloakNamespace = "keycloak" + keycloakStatefulSet = "keycloak" + keycloakServiceName = "keycloak" + keycloakRouteName = "keycloak" + keycloakSetupCMName = "keycloak-setup" + keycloakCredsSecret = "keycloak-creds" + keycloakImage = "quay.io/keycloak/keycloak:26.0" + keycloakContainerPort = 8080 + defaultCLIClientID = "oc-cli-test" + defaultConsoleClientID = "console-test" + defaultTestGroup = "keycloak-testgroup-1" + defaultTestUser = "keycloak-testuser-1" + defaultRealm = "master" +) + +// KeycloakConfig holds the OIDC configuration produced by DeployKeycloak. +type KeycloakConfig struct { + // IssuerURL is the OIDC issuer URL (e.g. https://keycloak.apps.example.com/realms/master). + IssuerURL string + // CLIClientID is the public OIDC client for CLI authentication. + CLIClientID string + // ConsoleClientID is the confidential OIDC client for console authentication. + ConsoleClientID string + // ConsoleClientSecret is the secret for the console OIDC client. + ConsoleClientSecret string + // TestUsers is a colon-separated string of test user credentials (e.g. "user1:pass1"). + TestUsers string + // CABundle is the PEM-encoded CA certificate bundle for TLS verification of the issuer. + CABundle []byte +} + +// DeployKeycloak deploys a Keycloak instance on the management cluster for External OIDC testing. +// It creates all required resources (namespace, ConfigMap, Service, StatefulSet, Route), +// waits for the instance to become ready, and returns the OIDC configuration. +func DeployKeycloak(ctx context.Context, client crclient.Client, consoleRedirectURI string) (*KeycloakConfig, error) { + restConfig, err := ctrl.GetConfig() + if err != nil { + return nil, fmt.Errorf("getting kubeconfig for pod exec: %w", err) + } + + adminUser := "admin" + adminPass := generateRandomString(16) + consoleSecret := generateRandomString(32) + testUsers := fmt.Sprintf("%s:%s", defaultTestUser, generateRandomString(12)) + + // Create keycloak namespace + ns := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: keycloakNamespace, + }, + } + if err := CreateOrUpdate(ctx, client, ns); err != nil { + return nil, fmt.Errorf("failed to create keycloak namespace: %w", err) + } + + // Create setup ConfigMap (scripts and client config) + cm := buildSetupConfigMap(consoleSecret, consoleRedirectURI) + if err := CreateOrUpdate(ctx, client, cm); err != nil { + return nil, fmt.Errorf("failed to create keycloak setup configmap: %w", err) + } + + // Create credentials Secret (test user passwords) + credSecret := buildCredentialsSecret(testUsers) + if err := CreateOrUpdate(ctx, client, credSecret); err != nil { + return nil, fmt.Errorf("failed to create keycloak credentials secret: %w", err) + } + + // Create headless Service + svc := buildKeycloakService() + if err := CreateOrUpdate(ctx, client, svc); err != nil { + return nil, fmt.Errorf("failed to create keycloak service: %w", err) + } + + // Create StatefulSet + sts := buildKeycloakStatefulSet(adminUser, adminPass) + if err := CreateOrUpdate(ctx, client, sts); err != nil { + return nil, fmt.Errorf("failed to create keycloak statefulset: %w", err) + } + + // Create Route + if err := createKeycloakRoute(ctx, client); err != nil { + return nil, fmt.Errorf("failed to create keycloak route: %w", err) + } + + // Wait for StatefulSet to be ready + if err := waitForKeycloakReady(ctx, client, restConfig); err != nil { + return nil, fmt.Errorf("keycloak did not become ready: %w", err) + } + + // Get issuer URL from Route + issuerURL, err := getKeycloakIssuerURL(ctx, client) + if err != nil { + return nil, fmt.Errorf("failed to get keycloak issuer URL: %w", err) + } + + // Extract router CA + caBundle, err := extractRouterCA(ctx, client) + if err != nil { + return nil, fmt.Errorf("failed to extract router CA: %w", err) + } + + // Verify OIDC discovery endpoint + if err := verifyOIDCEndpoint(ctx, issuerURL, caBundle); err != nil { + return nil, fmt.Errorf("OIDC discovery endpoint verification failed: %w", err) + } + + return &KeycloakConfig{ + IssuerURL: issuerURL, + CLIClientID: defaultCLIClientID, + ConsoleClientID: defaultConsoleClientID, + ConsoleClientSecret: consoleSecret, + TestUsers: testUsers, + CABundle: caBundle, + }, nil +} + +// UpdateKeycloakConsoleClient updates the console client's redirect URI in the +// running Keycloak instance via the Keycloak Admin CLI. The redirect URI +// depends on the hosted cluster's apps domain, which is not known until +// after the cluster is created. +func UpdateKeycloakConsoleClient(ctx context.Context, consoleRedirectURI string) error { + restConfig, err := ctrl.GetConfig() + if err != nil { + return fmt.Errorf("getting kubeconfig for pod exec: %w", err) + } + + consoleWebOrigin := strings.TrimSuffix(consoleRedirectURI, "/auth/callback") + kcadmCfg := "/tmp/.keycloak-kcadm.config" + script := fmt.Sprintf(` +KCADM="/opt/keycloak/bin/kcadm.sh" +KCADM_CONFIG="%s" +${KCADM} config credentials --server http://localhost:%d --realm %s --user ${KEYCLOAK_ADMIN} --password ${KEYCLOAK_ADMIN_PASSWORD} --config=${KCADM_CONFIG} +CLIENT_UUID=$(${KCADM} get clients -r %s -q clientId=%s --fields id --format csv --noquotes --config=${KCADM_CONFIG} | tail -1) +if [ -z "${CLIENT_UUID}" ]; then + echo "ERROR: console client %s not found" + exit 1 +fi +${KCADM} update clients/${CLIENT_UUID} -r %s -s 'redirectUris=["%s"]' -s 'webOrigins=["%s"]' --config=${KCADM_CONFIG} +echo "Updated console client redirect URI to %s" +`, kcadmCfg, keycloakContainerPort, defaultRealm, + defaultRealm, defaultConsoleClientID, + defaultConsoleClientID, + defaultRealm, consoleRedirectURI, consoleWebOrigin, + consoleRedirectURI) + + output, err := execInKeycloakPod(ctx, restConfig, script) + if err != nil { + return fmt.Errorf("updating console client redirect URI: %w (output: %s)", err, output) + } + log.Printf("Updated Keycloak console client: %s", output) + return nil +} + +// CleanupKeycloak removes all Keycloak resources by deleting the keycloak namespace. +func CleanupKeycloak(ctx context.Context, client crclient.Client) error { + ns := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: keycloakNamespace, + }, + } + if err := client.Delete(ctx, ns); err != nil && !apierrors.IsNotFound(err) { + return fmt.Errorf("failed to delete keycloak namespace: %w", err) + } + return nil +} + +func buildSetupConfigMap(consoleSecret, consoleRedirectURI string) *corev1.ConfigMap { + cliClientJSON := `{ + "clientId": "` + defaultCLIClientID + `", + "enabled": true, + "publicClient": true, + "standardFlowEnabled": true, + "directAccessGrantsEnabled": true, + "frontchannelLogout": true, + "redirectUris": ["http://localhost:8080"], + "webOrigins": ["http://localhost:8080"], + "defaultClientScopes": ["openid", "profile", "email"], + "attributes": { + "access.token.lifespan": "150", + "client.session.idle.timeout": "7200" + } +}` + + consoleWebOrigin := strings.TrimSuffix(consoleRedirectURI, "/auth/callback") + consoleClientJSON := `{ + "clientId": "` + defaultConsoleClientID + `", + "enabled": true, + "publicClient": false, + "secret": "` + consoleSecret + `", + "standardFlowEnabled": true, + "directAccessGrantsEnabled": true, + "frontchannelLogout": true, + "redirectUris": ["` + consoleRedirectURI + `"], + "webOrigins": ["` + consoleWebOrigin + `"], + "defaultClientScopes": ["openid", "profile", "email"], + "attributes": { + "access.token.lifespan": "150", + "client.session.idle.timeout": "7200" + } +}` + + groupMapperJSON := `{ + "name": "groups", + "protocol": "openid-connect", + "protocolMapper": "oidc-group-membership-mapper", + "config": { + "full.path": "false", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "groups", + "userinfo.token.claim": "true" + } +}` + + cliAudienceMapperJSON := `{ + "name": "cli-audience", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "config": { + "included.client.audience": "` + defaultCLIClientID + `", + "id.token.claim": "true", + "access.token.claim": "true" + } +}` + + consoleAudienceMapperJSON := `{ + "name": "console-audience", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "config": { + "included.client.audience": "` + defaultConsoleClientID + `", + "id.token.claim": "true", + "access.token.claim": "true" + } +}` + + kcadmConfig := "/tmp/.keycloak-kcadm.config" + setupScript := `#!/bin/bash +set -euo pipefail + +KCADM="/opt/keycloak/bin/kcadm.sh" +KCADM_CONFIG="` + kcadmConfig + `" +REALM="` + defaultRealm + `" + +echo "Authenticating to Keycloak..." +${KCADM} config credentials --server http://localhost:` + fmt.Sprintf("%d", keycloakContainerPort) + ` --realm ${REALM} --user ${KEYCLOAK_ADMIN} --password ${KEYCLOAK_ADMIN_PASSWORD} --config=${KCADM_CONFIG} +echo "Keycloak is ready" + +# Update session timeout +${KCADM} update realms/${REALM} -s ssoSessionIdleTimeout=7200 --config=${KCADM_CONFIG} + +# Create CLI client +${KCADM} create clients -r ${REALM} -f /tmp/.keycloak/cli-client.json --config=${KCADM_CONFIG} +echo "Created CLI client: ` + defaultCLIClientID + `" + +# Create console client +${KCADM} create clients -r ${REALM} -f /tmp/.keycloak/console-client.json --config=${KCADM_CONFIG} +echo "Created console client: ` + defaultConsoleClientID + `" + +# Add group mapper to CLI client +CLI_CLIENT_UUID=$(${KCADM} get clients -r ${REALM} -q clientId=` + defaultCLIClientID + ` --fields id --format csv --noquotes --config=${KCADM_CONFIG} | tail -1) +${KCADM} create clients/${CLI_CLIENT_UUID}/protocol-mappers/models -r ${REALM} -f /tmp/.keycloak/group-mapper.json --config=${KCADM_CONFIG} +echo "Added group mapper to CLI client" + +# Add audience mapper to CLI client so aud claim includes the client ID +${KCADM} create clients/${CLI_CLIENT_UUID}/protocol-mappers/models -r ${REALM} -f /tmp/.keycloak/cli-audience-mapper.json --config=${KCADM_CONFIG} +echo "Added audience mapper to CLI client" + +# Add group mapper to console client +CONSOLE_CLIENT_UUID=$(${KCADM} get clients -r ${REALM} -q clientId=` + defaultConsoleClientID + ` --fields id --format csv --noquotes --config=${KCADM_CONFIG} | tail -1) +${KCADM} create clients/${CONSOLE_CLIENT_UUID}/protocol-mappers/models -r ${REALM} -f /tmp/.keycloak/group-mapper.json --config=${KCADM_CONFIG} +echo "Added group mapper to console client" + +# Add audience mapper to console client so aud claim includes the client ID +${KCADM} create clients/${CONSOLE_CLIENT_UUID}/protocol-mappers/models -r ${REALM} -f /tmp/.keycloak/console-audience-mapper.json --config=${KCADM_CONFIG} +echo "Added audience mapper to console client" + +# Create test group +${KCADM} create groups -r ${REALM} -s name=` + defaultTestGroup + ` --config=${KCADM_CONFIG} +GROUP_ID=$(${KCADM} get groups -r ${REALM} -q search=` + defaultTestGroup + ` --fields id --format csv --noquotes --config=${KCADM_CONFIG} | tail -1) +echo "Created group: ` + defaultTestGroup + `" + +# Create test users from testusers file (use || to handle missing trailing newline) +while IFS=: read -r USERNAME PASSWORD || [ -n "${USERNAME}" ]; do + [ -z "${USERNAME}" ] && continue + ${KCADM} create users -r ${REALM} -s username=${USERNAME} -s enabled=true -s email="${USERNAME}@example.com" -s emailVerified=true --config=${KCADM_CONFIG} + ${KCADM} set-password -r ${REALM} --username ${USERNAME} --new-password ${PASSWORD} --config=${KCADM_CONFIG} + USER_ID=$(${KCADM} get users -r ${REALM} -q username=${USERNAME} --fields id --format csv --noquotes --config=${KCADM_CONFIG} | tail -1) + ${KCADM} update users/${USER_ID}/groups/${GROUP_ID} -r ${REALM} -s realm=${REALM} -s userId=${USER_ID} -s groupId=${GROUP_ID} -n --config=${KCADM_CONFIG} + echo "Created user: ${USERNAME} in group ` + defaultTestGroup + `" +done < /tmp/.keycloak-creds/testusers + +# Verify at least one user was created +USER_COUNT=$(${KCADM} get users -r ${REALM} --fields username --format csv --noquotes --config=${KCADM_CONFIG} | grep -c "` + defaultTestUser + `" || true) +if [ "${USER_COUNT}" -eq 0 ]; then + echo "ERROR: No test users were created in Keycloak" + exit 1 +fi + +echo "Keycloak setup complete" +` + + return &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: keycloakSetupCMName, + Namespace: keycloakNamespace, + }, + Data: map[string]string{ + "cli-client.json": cliClientJSON, + "console-client.json": consoleClientJSON, + "group-mapper.json": groupMapperJSON, + "cli-audience-mapper.json": cliAudienceMapperJSON, + "console-audience-mapper.json": consoleAudienceMapperJSON, + "setup.sh": setupScript, + }, + } +} + +func buildCredentialsSecret(testUsers string) *corev1.Secret { + return &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: keycloakCredsSecret, + Namespace: keycloakNamespace, + }, + StringData: map[string]string{ + "testusers": testUsers, + }, + } +} + +func buildKeycloakService() *corev1.Service { + return &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: keycloakServiceName, + Namespace: keycloakNamespace, + }, + Spec: corev1.ServiceSpec{ + ClusterIP: corev1.ClusterIPNone, + Selector: map[string]string{ + "app": "keycloak", + }, + Ports: []corev1.ServicePort{ + { + Name: "http", + Port: keycloakContainerPort, + TargetPort: intstr.FromInt32(keycloakContainerPort), + Protocol: corev1.ProtocolTCP, + }, + }, + }, + } +} + +func buildKeycloakStatefulSet(adminUser, adminPass string) *appsv1.StatefulSet { + return &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: keycloakStatefulSet, + Namespace: keycloakNamespace, + }, + Spec: appsv1.StatefulSetSpec{ + Replicas: ptr.To[int32](1), + ServiceName: keycloakServiceName, + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "app": "keycloak", + }, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{ + "app": "keycloak", + }, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "keycloak", + Image: keycloakImage, + Args: []string{"start-dev"}, + Env: []corev1.EnvVar{ + { + Name: "KEYCLOAK_ADMIN", + Value: adminUser, + }, + { + Name: "KEYCLOAK_ADMIN_PASSWORD", + Value: adminPass, + }, + { + Name: "KC_PROXY_HEADERS", + Value: "xforwarded", + }, + { + Name: "KC_HOSTNAME_STRICT", + Value: "false", + }, + }, + Ports: []corev1.ContainerPort{ + { + Name: "http", + ContainerPort: keycloakContainerPort, + Protocol: corev1.ProtocolTCP, + }, + }, + ReadinessProbe: &corev1.Probe{ + ProbeHandler: corev1.ProbeHandler{ + HTTPGet: &corev1.HTTPGetAction{ + Path: "/realms/master", + Port: intstr.FromInt32(keycloakContainerPort), + }, + }, + InitialDelaySeconds: 30, + PeriodSeconds: 10, + TimeoutSeconds: 5, + }, + VolumeMounts: []corev1.VolumeMount{ + { + Name: "keycloak-setup", + MountPath: "/tmp/.keycloak", + }, + { + Name: "keycloak-creds", + MountPath: "/tmp/.keycloak-creds", + ReadOnly: true, + }, + }, + }, + }, + Volumes: []corev1.Volume{ + { + Name: "keycloak-setup", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: keycloakSetupCMName, + }, + DefaultMode: ptr.To[int32](0755), + }, + }, + }, + { + Name: "keycloak-creds", + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{ + SecretName: keycloakCredsSecret, + }, + }, + }, + }, + }, + }, + }, + } +} + +func createKeycloakRoute(ctx context.Context, client crclient.Client) error { + route := &routev1.Route{ + ObjectMeta: metav1.ObjectMeta{ + Name: keycloakRouteName, + Namespace: keycloakNamespace, + }, + Spec: routev1.RouteSpec{ + To: routev1.RouteTargetReference{ + Kind: "Service", + Name: keycloakServiceName, + }, + Port: &routev1.RoutePort{ + TargetPort: intstr.FromInt32(keycloakContainerPort), + }, + TLS: &routev1.TLSConfig{ + Termination: routev1.TLSTerminationEdge, + InsecureEdgeTerminationPolicy: routev1.InsecureEdgeTerminationPolicyRedirect, + }, + }, + } + return CreateOrUpdate(ctx, client, route) +} + +func waitForKeycloakReady(ctx context.Context, client crclient.Client, restConfig *rest.Config) error { + if err := wait.PollUntilContextTimeout(ctx, 15*time.Second, 10*time.Minute, true, func(ctx context.Context) (bool, error) { + sts := &appsv1.StatefulSet{} + if err := client.Get(ctx, crclient.ObjectKey{ + Namespace: keycloakNamespace, + Name: keycloakStatefulSet, + }, sts); err != nil { + return false, nil //nolint:nilerr // retry until statefulset is available + } + if sts.Status.ReadyReplicas < 1 { + return false, nil + } + if sts.Status.UpdateRevision != "" && sts.Status.CurrentRevision != sts.Status.UpdateRevision { + return false, nil + } + return true, nil + }); err != nil { + return err + } + + log.Println("Keycloak pod is ready, running setup script via exec...") + output, err := execInKeycloakPod(ctx, restConfig, "bash /tmp/.keycloak/setup.sh") + if err != nil { + return fmt.Errorf("keycloak setup script failed: %w (output: %s)", err, output) + } + log.Printf("Keycloak setup completed: %s", output) + return nil +} + +func getKeycloakIssuerURL(ctx context.Context, client crclient.Client) (string, error) { + var host string + if err := wait.PollUntilContextTimeout(ctx, 5*time.Second, 2*time.Minute, true, func(ctx context.Context) (bool, error) { + route := &routev1.Route{} + if err := client.Get(ctx, crclient.ObjectKey{ + Namespace: keycloakNamespace, + Name: keycloakRouteName, + }, route); err != nil { + return false, err + } + host = route.Spec.Host + if host == "" { + for _, ingress := range route.Status.Ingress { + if ingress.Host != "" { + host = ingress.Host + break + } + } + } + return host != "", nil + }); err != nil { + return "", fmt.Errorf("waiting for keycloak route host: %w", err) + } + + issuer := &url.URL{ + Scheme: "https", + Host: host, + Path: fmt.Sprintf("/realms/%s", defaultRealm), + } + return issuer.String(), nil +} + +func extractRouterCA(ctx context.Context, client crclient.Client) ([]byte, error) { + cm := &corev1.ConfigMap{} + if err := client.Get(ctx, crclient.ObjectKey{ + Namespace: "openshift-config-managed", + Name: "default-ingress-cert", + }, cm); err != nil { + return nil, fmt.Errorf("failed to get default-ingress-cert configmap: %w", err) + } + + caData, ok := cm.Data["ca-bundle.crt"] + if !ok { + return nil, fmt.Errorf("ca-bundle.crt not found in default-ingress-cert configmap") + } + + return []byte(caData), nil +} + +func verifyOIDCEndpoint(ctx context.Context, issuerURL string, caBundle []byte) error { + discoveryURL := issuerURL + "/.well-known/openid-configuration" + + certPool := x509.NewCertPool() + if !certPool.AppendCertsFromPEM(caBundle) { + return fmt.Errorf("failed to parse CA bundle for OIDC endpoint verification") + } + httpClient := &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + RootCAs: certPool, + MinVersion: tls.VersionTLS12, + }, + }, + Timeout: 30 * time.Second, + } + + return wait.PollUntilContextTimeout(ctx, 10*time.Second, 5*time.Minute, true, func(ctx context.Context) (bool, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, discoveryURL, nil) + if err != nil { + return false, fmt.Errorf("failed to create request: %w", err) + } + + resp, err := httpClient.Do(req) + if err != nil { + return false, nil //nolint:nilerr // retry until endpoint is reachable + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusOK { + return true, nil + } + return false, nil + }) +} + +// execInKeycloakPod executes a bash script inside the Keycloak pod and returns +// the combined stdout/stderr output. +func execInKeycloakPod(ctx context.Context, restConfig *rest.Config, script string) (string, error) { + clientset, err := kubernetes.NewForConfig(restConfig) + if err != nil { + return "", fmt.Errorf("creating clientset: %w", err) + } + + podName := keycloakStatefulSet + "-0" + req := clientset.CoreV1().RESTClient().Post(). + Resource("pods"). + Name(podName). + Namespace(keycloakNamespace). + SubResource("exec"). + Param("container", "keycloak"). + Param("command", "/bin/bash"). + Param("command", "-c"). + Param("command", script). + Param("stdout", "true"). + Param("stderr", "true") + + executor, err := remotecommand.NewSPDYExecutor(restConfig, "POST", req.URL()) + if err != nil { + return "", fmt.Errorf("creating executor: %w", err) + } + + var stdout, stderr bytes.Buffer + if err := executor.StreamWithContext(ctx, remotecommand.StreamOptions{ + Stdout: &stdout, + Stderr: &stderr, + }); err != nil { + return stdout.String() + stderr.String(), fmt.Errorf("exec failed: %w", err) + } + return strings.TrimSpace(stdout.String() + stderr.String()), nil +} + +// CreateOrUpdate creates a resource or updates it if it already exists. +func CreateOrUpdate(ctx context.Context, client crclient.Client, obj crclient.Object) error { + if err := client.Create(ctx, obj); err != nil { + if !apierrors.IsAlreadyExists(err) { + return err + } + existing := obj.DeepCopyObject().(crclient.Object) + if err := client.Get(ctx, crclient.ObjectKeyFromObject(obj), existing); err != nil { + return fmt.Errorf("failed to get existing resource for update: %w", err) + } + obj.SetResourceVersion(existing.GetResourceVersion()) + if err := client.Update(ctx, obj); err != nil { + return fmt.Errorf("failed to update existing resource: %w", err) + } + } + return nil +} + +// generateRandomString returns a random alphanumeric string of length n using crypto/rand. +func generateRandomString(n int) string { + const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + result := make([]byte, n) + for i := range result { + idx, err := rand.Int(rand.Reader, big.NewInt(int64(len(charset)))) + if err != nil { + // crypto/rand should never fail; if it does, fall back to a simple pattern + result[i] = charset[i%len(charset)] + continue + } + result[i] = charset[idx.Int64()] + } + return string(result) +}