From d4f5443ac87e58cd1eaefe3de1c2ff474d0d3b14 Mon Sep 17 00:00:00 2001 From: Galin Karabadzhakov Date: Wed, 15 Jul 2026 18:04:00 +0300 Subject: [PATCH 1/5] feat(ske): support stateless WIF kubeconfig login --- docs/stackit_ske_kubeconfig_login.md | 9 +- internal/cmd/ske/kubeconfig/login/login.go | 215 +++++++++++++--- .../cmd/ske/kubeconfig/login/login_test.go | 236 ++++++++++++++++++ internal/pkg/auth/exchange.go | 5 + internal/pkg/auth/utils.go | 33 ++- internal/pkg/auth/utils_test.go | 28 +++ 6 files changed, 484 insertions(+), 42 deletions(-) diff --git a/docs/stackit_ske_kubeconfig_login.md b/docs/stackit_ske_kubeconfig_login.md index 0df7567e5..c657f599e 100644 --- a/docs/stackit_ske_kubeconfig_login.md +++ b/docs/stackit_ske_kubeconfig_login.md @@ -24,13 +24,18 @@ stackit ske kubeconfig login [flags] Use the previously saved kubeconfig to authenticate to the SKE cluster, in this case with kubectl. $ kubectl cluster-info $ kubectl get pods + + Configure an IdP exec provider for a Kubernetes client that does not provide cluster information. In an SKE workload, the CLI automatically uses the projected workload identity token. + $ stackit ske kubeconfig login --idp --cluster-name my-cluster --organization-id my-organization-id --project-id my-project-id --region eu01 ``` ### Options ``` - -h, --help Help for "stackit ske kubeconfig login" - --idp Use the STACKIT IdP for authentication to the cluster. + --cluster-name string SKE cluster name. Used when the Kubernetes exec request does not provide cluster information. + -h, --help Help for "stackit ske kubeconfig login" + --idp Use the STACKIT IdP for authentication to the cluster. + --organization-id string Organization ID. Used in IdP mode when the Kubernetes exec request does not provide cluster information. ``` ### Options inherited from parent commands diff --git a/internal/cmd/ske/kubeconfig/login/login.go b/internal/cmd/ske/kubeconfig/login/login.go index 268831202..e2ec3439e 100644 --- a/internal/cmd/ske/kubeconfig/login/login.go +++ b/internal/cmd/ske/kubeconfig/login/login.go @@ -11,16 +11,21 @@ import ( "net/http" "os" "strconv" + "strings" "time" "github.com/spf13/cobra" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/kubernetes/scheme" clientauthenticationv1 "k8s.io/client-go/pkg/apis/clientauthentication/v1" "k8s.io/client-go/rest" "k8s.io/client-go/tools/auth/exec" "k8s.io/client-go/tools/clientcmd" + sdkAuth "github.com/stackitcloud/stackit-sdk-go/core/auth" + "github.com/stackitcloud/stackit-sdk-go/core/clients" + sdkConfig "github.com/stackitcloud/stackit-sdk-go/core/config" ske "github.com/stackitcloud/stackit-sdk-go/services/ske/v2api" "github.com/stackitcloud/stackit-cli/internal/pkg/args" @@ -40,7 +45,13 @@ const ( refreshBeforeDuration = 15 * time.Minute // 15 min refreshTokenBeforeDuration = 5 * time.Minute // 5 min - idpFlag = "idp" + idpFlag = "idp" + clusterNameFlag = "cluster-name" + organizationFlag = "organization-id" + + envAccessToken = "STACKIT_ACCESS_TOKEN" + envServiceAccountEmail = "STACKIT_SERVICE_ACCOUNT_EMAIL" + defaultFederatedTokenPath = "/var/run/secrets/stackit.cloud/serviceaccount/token" //nolint:gosec // Public path, not a credential. ) func NewCmd(params *types.CmdParams) *cobra.Command { @@ -66,14 +77,13 @@ func NewCmd(params *types.CmdParams) *cobra.Command { "Use the previously saved kubeconfig to authenticate to the SKE cluster, in this case with kubectl.", "$ kubectl cluster-info", "$ kubectl get pods"), + examples.NewExample( + "Configure an IdP exec provider for a Kubernetes client that does not provide cluster information. In an SKE workload, the CLI automatically uses the projected workload identity token.", + "$ stackit ske kubeconfig login --idp --cluster-name my-cluster --organization-id my-organization-id --project-id my-project-id --region eu01"), ), RunE: func(cmd *cobra.Command, _ []string) error { ctx := context.Background() - if err := cache.Init(); err != nil { - return fmt.Errorf("cache init failed: %w", err) - } - env := os.Getenv("KUBERNETES_EXEC_INFO") if env == "" { return fmt.Errorf("%s\n%s\n%s", "KUBERNETES_EXEC_INFO env var is unset or empty.", @@ -82,22 +92,33 @@ func NewCmd(params *types.CmdParams) *cobra.Command { } idpMode := flags.FlagToBoolValue(params.Printer, cmd, idpFlag) - clusterConfig, err := parseClusterConfig(params.Printer, cmd, idpMode) + workloadIdentityMode := idpMode && os.Getenv(envAccessToken) == "" && workloadIdentityConfigured() + statelessIDPMode := idpMode && (workloadIdentityMode || os.Getenv(envAccessToken) != "") + if !statelessIDPMode { + if err := cache.Init(); err != nil { + return fmt.Errorf("cache init failed: %w", err) + } + } + clusterConfig, err := parseClusterConfig(params.Printer, cmd, idpMode, workloadIdentityMode, statelessIDPMode) if err != nil { return fmt.Errorf("parseClusterConfig: %w", err) } if idpMode { - accessToken, err := getAccessToken(params) + accessToken, err := getAccessToken(params, workloadIdentityMode) if err != nil { return err } + tokenEndpoint, err := auth.GetIDPTokenEndpoint(params.Printer) + if err != nil { + return fmt.Errorf("get IDP token endpoint: %w", err) + } idpClient := &http.Client{} - token, err := retrieveTokenFromIDP(ctx, idpClient, accessToken, clusterConfig) + token, err := retrieveTokenFromIDP(ctx, idpClient, tokenEndpoint, accessToken, clusterConfig, !statelessIDPMode) if err != nil { return err } - return outputTokenKubeconfig(params.Printer, clusterConfig.cacheKey, token) + return outputTokenKubeconfig(params.Printer, clusterConfig.cacheKey, token, !statelessIDPMode) } // Configure API client @@ -118,6 +139,8 @@ func NewCmd(params *types.CmdParams) *cobra.Command { func configureFlags(cmd *cobra.Command) { cmd.Flags().Bool(idpFlag, false, "Use the STACKIT IdP for authentication to the cluster.") + cmd.Flags().String(clusterNameFlag, "", "SKE cluster name. Used when the Kubernetes exec request does not provide cluster information.") + cmd.Flags().String(organizationFlag, "", "Organization ID. Used in IdP mode when the Kubernetes exec request does not provide cluster information.") } type clusterConfig struct { @@ -129,8 +152,8 @@ type clusterConfig struct { cacheKey string } -func parseClusterConfig(p *print.Printer, cmd *cobra.Command, idpMode bool) (*clusterConfig, error) { - obj, _, err := exec.LoadExecCredentialFromEnv() +func parseClusterConfig(p *print.Printer, cmd *cobra.Command, idpMode, workloadIdentityMode, statelessIDPMode bool) (*clusterConfig, error) { + obj, err := loadExecCredentialFromEnv() if err != nil { return nil, fmt.Errorf("LoadExecCredentialFromEnv: %w", err) } @@ -148,33 +171,115 @@ func parseClusterConfig(p *print.Printer, cmd *cobra.Command, idpMode bool) (*cl if !ok { return nil, fmt.Errorf("conversion to ExecCredential failed") } - if execCredential == nil || execCredential.Spec.Cluster == nil { - return nil, fmt.Errorf("ExecCredential contains not all needed fields") + if execCredential == nil { + return nil, fmt.Errorf("ExecCredential is empty") } clusterConfig := &clusterConfig{} - err = json.Unmarshal(execCredential.Spec.Cluster.Config.Raw, clusterConfig) - if err != nil { - return nil, fmt.Errorf("unmarshal: %w", err) + clusterServer := "" + if execCredential.Spec.Cluster != nil { + clusterServer = execCredential.Spec.Cluster.Server + if len(execCredential.Spec.Cluster.Config.Raw) > 0 { + err = json.Unmarshal(execCredential.Spec.Cluster.Config.Raw, clusterConfig) + if err != nil { + return nil, fmt.Errorf("unmarshal: %w", err) + } + } + } + + if clusterName := flags.FlagToStringValue(p, cmd, clusterNameFlag); clusterName != "" { + clusterConfig.ClusterName = clusterName + } + if organizationID := flags.FlagToStringValue(p, cmd, organizationFlag); organizationID != "" { + clusterConfig.OrganizationID = organizationID + } + globalFlags := globalflags.Parse(p, cmd) + if clusterConfig.STACKITProjectID == "" { + clusterConfig.STACKITProjectID = globalFlags.ProjectId + } + if clusterConfig.Region == "" { + clusterConfig.Region = globalFlags.Region + } + + missingFields := missingClusterConfigFields(clusterConfig, idpMode) + if len(missingFields) > 0 { + return nil, fmt.Errorf("ExecCredential cluster configuration is incomplete; provide %s", strings.Join(missingFields, ", ")) } - authEmail, err := auth.GetAuthEmail() + authIdentity, err := getAuthIdentity(workloadIdentityMode, statelessIDPMode) if err != nil { - return nil, fmt.Errorf("error getting auth email: %w", err) + return nil, fmt.Errorf("error getting auth identity: %w", err) } idpSuffix := "" if idpMode { idpSuffix = "\x00idp" } - clusterConfig.cacheKey = fmt.Sprintf("ske-login-%x", sha256.Sum256([]byte(execCredential.Spec.Cluster.Server+"\x00"+authEmail+idpSuffix))) - - // NOTE: Fallback if region is not set in the kubeconfig (this was the case in the past) - if clusterConfig.Region == "" { - clusterConfig.Region = globalflags.Parse(p, cmd).Region + clusterIdentity := clusterServer + if clusterIdentity == "" { + clusterIdentity = strings.Join([]string{ + clusterConfig.OrganizationID, + clusterConfig.STACKITProjectID, + clusterConfig.Region, + clusterConfig.ClusterName, + }, "\x00") } + clusterConfig.cacheKey = fmt.Sprintf("ske-login-%x", sha256.Sum256([]byte(clusterIdentity+"\x00"+authIdentity+idpSuffix))) return clusterConfig, nil } +func loadExecCredentialFromEnv() (runtime.Object, error) { + execInfo := os.Getenv("KUBERNETES_EXEC_INFO") + if execInfo == "" { + return nil, errors.New("KUBERNETES_EXEC_INFO env var is unset or empty") + } + + // client-go's loader rejects requests without cluster information. Decode the + // v1 request directly when the Kubernetes client omits it. + var execCredential clientauthenticationv1.ExecCredential + if err := json.Unmarshal([]byte(execInfo), &execCredential); err == nil && + execCredential.APIVersion == clientauthenticationv1.SchemeGroupVersion.String() && + execCredential.Kind == "ExecCredential" && execCredential.Spec.Cluster == nil { + return &execCredential, nil + } + + obj, _, err := exec.LoadExecCredential([]byte(execInfo)) + if err != nil { + return nil, err + } + return obj, nil +} + +func missingClusterConfigFields(config *clusterConfig, idpMode bool) []string { + missingFields := make([]string, 0, 4) + if config.ClusterName == "" { + missingFields = append(missingFields, "--cluster-name") + } + if config.STACKITProjectID == "" { + missingFields = append(missingFields, "--project-id") + } + if config.Region == "" { + missingFields = append(missingFields, "--region") + } + if idpMode && config.OrganizationID == "" { + missingFields = append(missingFields, "--organization-id") + } + return missingFields +} + +func getAuthIdentity(workloadIdentityMode, statelessIDPMode bool) (string, error) { + if workloadIdentityMode { + email := os.Getenv(envServiceAccountEmail) + if email == "" { + return "", fmt.Errorf("%s is not set", envServiceAccountEmail) + } + return email, nil + } + if statelessIDPMode { + return "stateless-idp", nil + } + return auth.GetAuthEmail() +} + func retrieveLoginKubeconfig(ctx context.Context, apiClient *ske.APIClient, clusterConfig *clusterConfig) (*rest.Config, error) { cachedKubeconfig := getCachedKubeConfig(clusterConfig.cacheKey) if cachedKubeconfig == nil { @@ -300,7 +405,20 @@ func parseLoginKubeConfigToExecCredential(kubeconfig *rest.Config) ([]byte, erro return output, nil } -func getAccessToken(params *types.CmdParams) (string, error) { +func getAccessToken(params *types.CmdParams, workloadIdentityMode bool) (string, error) { + if workloadIdentityMode { + accessToken, err := getWorkloadIdentityAccessToken() + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get workload identity access token: %v", err) + return "", fmt.Errorf("get workload identity access token: %w", err) + } + return accessToken, nil + } + + if accessToken := os.Getenv(envAccessToken); accessToken != "" { + return accessToken, nil + } + userSessionExpired, err := auth.UserSessionExpired() if err != nil { return "", err @@ -315,30 +433,53 @@ func getAccessToken(params *types.CmdParams) (string, error) { return "", &cliErr.SessionExpiredError{} } - err = auth.EnsureIDPTokenEndpoint(params.Printer) - if err != nil { - return "", err + return accessToken, nil +} + +func workloadIdentityConfigured() bool { + if os.Getenv(envServiceAccountEmail) == "" { + return false + } + if os.Getenv(clients.FederatedTokenFileEnv) != "" { + return true } + fileInfo, err := os.Stat(defaultFederatedTokenPath) + return err == nil && !fileInfo.IsDir() +} - return accessToken, nil +func getWorkloadIdentityAccessToken() (string, error) { + roundTripper, err := sdkAuth.SetupAuth(&sdkConfig.Configuration{WorkloadIdentityFederation: true}) + if err != nil { + return "", fmt.Errorf("configure workload identity federation: %w", err) + } + flow, ok := roundTripper.(interface { + GetAccessToken() (string, error) + }) + if !ok { + return "", errors.New("configured authentication flow does not provide access tokens") + } + return flow.GetAccessToken() } -func retrieveTokenFromIDP(ctx context.Context, idpClient *http.Client, accessToken string, clusterConfig *clusterConfig) (string, error) { +func retrieveTokenFromIDP(ctx context.Context, idpClient *http.Client, tokenEndpoint, accessToken string, clusterConfig *clusterConfig, cacheEnabled bool) (string, error) { resource := resourceForCluster(clusterConfig) + if !cacheEnabled { + return auth.ExchangeTokenWithEndpoint(ctx, idpClient, tokenEndpoint, accessToken, resource) + } cachedToken := getCachedToken(clusterConfig.cacheKey) if cachedToken == "" { - return exchangeAndCacheToken(ctx, idpClient, accessToken, resource, clusterConfig.cacheKey) + return exchangeAndCacheToken(ctx, idpClient, tokenEndpoint, accessToken, resource, clusterConfig.cacheKey) } expiry, err := auth.TokenExpirationTime(cachedToken) if err != nil { // token is expired or invalid, request new _ = cache.DeleteObject(clusterConfig.cacheKey) - return exchangeAndCacheToken(ctx, idpClient, accessToken, resource, clusterConfig.cacheKey) + return exchangeAndCacheToken(ctx, idpClient, tokenEndpoint, accessToken, resource, clusterConfig.cacheKey) } else if time.Now().Add(refreshTokenBeforeDuration).After(expiry) { // token expires soon -> refresh - token, err := exchangeAndCacheToken(ctx, idpClient, accessToken, resource, clusterConfig.cacheKey) + token, err := exchangeAndCacheToken(ctx, idpClient, tokenEndpoint, accessToken, resource, clusterConfig.cacheKey) // try to get a new one but use cache on failure if err != nil { return cachedToken, nil @@ -367,8 +508,8 @@ func getCachedToken(key string) string { return string(token) } -func exchangeAndCacheToken(ctx context.Context, idpClient *http.Client, accessToken, resource, cacheKey string) (string, error) { - clusterToken, err := auth.ExchangeToken(ctx, idpClient, accessToken, resource) +func exchangeAndCacheToken(ctx context.Context, idpClient *http.Client, tokenEndpoint, accessToken, resource, cacheKey string) (string, error) { + clusterToken, err := auth.ExchangeTokenWithEndpoint(ctx, idpClient, tokenEndpoint, accessToken, resource) if err != nil { return "", err } @@ -378,10 +519,12 @@ func exchangeAndCacheToken(ctx context.Context, idpClient *http.Client, accessTo return clusterToken, err } -func outputTokenKubeconfig(p *print.Printer, cacheKey, token string) error { +func outputTokenKubeconfig(p *print.Printer, cacheKey, token string, cacheEnabled bool) error { output, err := parseTokenToExecCredential(token) if err != nil { - _ = cache.DeleteObject(cacheKey) + if cacheEnabled { + _ = cache.DeleteObject(cacheKey) + } return fmt.Errorf("convert to ExecCredential: %w", err) } diff --git a/internal/cmd/ske/kubeconfig/login/login_test.go b/internal/cmd/ske/kubeconfig/login/login_test.go index 683171b95..1e7f19bbe 100644 --- a/internal/cmd/ske/kubeconfig/login/login_test.go +++ b/internal/cmd/ske/kubeconfig/login/login_test.go @@ -3,6 +3,12 @@ package login import ( "context" "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "os" + "strings" "testing" "time" @@ -10,11 +16,17 @@ import ( "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" + "github.com/spf13/cobra" + "github.com/spf13/viper" + "github.com/stackitcloud/stackit-sdk-go/core/clients" ske "github.com/stackitcloud/stackit-sdk-go/services/ske/v2api" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" clientauthenticationv1 "k8s.io/client-go/pkg/apis/clientauthentication/v1" "k8s.io/client-go/rest" + "github.com/stackitcloud/stackit-cli/internal/pkg/config" + "github.com/stackitcloud/stackit-cli/internal/pkg/testparams" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" ) @@ -51,6 +63,230 @@ func fixtureLoginRequest(mods ...func(request *ske.ApiCreateKubeconfigRequest)) return request } +func setExecCredentialEnv(t *testing.T, cluster *clientauthenticationv1.Cluster) { + t.Helper() + execCredential := clientauthenticationv1.ExecCredential{ + TypeMeta: v1.TypeMeta{ + APIVersion: clientauthenticationv1.SchemeGroupVersion.String(), + Kind: "ExecCredential", + }, + Spec: clientauthenticationv1.ExecCredentialSpec{ + Cluster: cluster, + }, + } + execCredentialJSON, err := json.Marshal(execCredential) + if err != nil { + t.Fatalf("marshal ExecCredential: %v", err) + } + t.Setenv("KUBERNETES_EXEC_INFO", string(execCredentialJSON)) +} + +func TestParseClusterConfigWithoutExecClusterInfo(t *testing.T) { + viper.Reset() + t.Cleanup(viper.Reset) + viper.Set(config.ProjectIdKey, testProjectId) + viper.Set(config.RegionKey, testRegion) + t.Setenv(envServiceAccountEmail, "workload@sa.stackit.cloud") + setExecCredentialEnv(t, nil) + + params := testparams.NewTestParams() + cmd := &cobra.Command{} + configureFlags(cmd) + if err := cmd.Flags().Set(clusterNameFlag, testClusterName); err != nil { + t.Fatalf("set cluster name flag: %v", err) + } + if err := cmd.Flags().Set(organizationFlag, testOrganization); err != nil { + t.Fatalf("set organization flag: %v", err) + } + + actual, err := parseClusterConfig(params.Printer, cmd, true, true, true) + if err != nil { + t.Fatalf("parse cluster config: %v", err) + } + expected := fixtureClusterConfig() + if diff := cmp.Diff(actual, expected, cmpopts.IgnoreFields(clusterConfig{}, "cacheKey")); diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + if actual.cacheKey == "" { + t.Fatal("cache key is empty") + } +} + +func TestParseClusterConfigUsesExecClusterInfo(t *testing.T) { + viper.Reset() + t.Cleanup(viper.Reset) + t.Setenv(envServiceAccountEmail, "workload@sa.stackit.cloud") + + configJSON, err := json.Marshal(fixtureClusterConfig()) + if err != nil { + t.Fatalf("marshal cluster config: %v", err) + } + setExecCredentialEnv(t, &clientauthenticationv1.Cluster{ + Server: "https://api.example.stackit.cloud", + Config: runtime.RawExtension{Raw: configJSON}, + }) + + params := testparams.NewTestParams() + cmd := &cobra.Command{} + configureFlags(cmd) + actual, err := parseClusterConfig(params.Printer, cmd, true, true, true) + if err != nil { + t.Fatalf("parse cluster config: %v", err) + } + expected := fixtureClusterConfig() + if diff := cmp.Diff(actual, expected, cmpopts.IgnoreFields(clusterConfig{}, "cacheKey")); diff != "" { + t.Fatalf("Data does not match: %s", diff) + } +} + +func TestParseClusterConfigReportsMissingExplicitFields(t *testing.T) { + viper.Reset() + t.Cleanup(viper.Reset) + t.Setenv(envServiceAccountEmail, "workload@sa.stackit.cloud") + setExecCredentialEnv(t, nil) + + params := testparams.NewTestParams() + cmd := &cobra.Command{} + configureFlags(cmd) + _, err := parseClusterConfig(params.Printer, cmd, true, true, true) + if err == nil { + t.Fatal("Expected error but no error was returned") + } + for _, expectedFlag := range []string{"--cluster-name", "--project-id", "--region", "--organization-id"} { + if !strings.Contains(err.Error(), expectedFlag) { + t.Errorf("Expected error to mention %s, got %q", expectedFlag, err) + } + } +} + +func TestParseClusterConfigWithAccessTokenWithoutStoredAuth(t *testing.T) { + viper.Reset() + t.Cleanup(viper.Reset) + viper.Set(config.ProjectIdKey, testProjectId) + viper.Set(config.RegionKey, testRegion) + t.Setenv(envAccessToken, "environment-access-token") + setExecCredentialEnv(t, nil) + + params := testparams.NewTestParams() + cmd := &cobra.Command{} + configureFlags(cmd) + if err := cmd.Flags().Set(clusterNameFlag, testClusterName); err != nil { + t.Fatalf("set cluster name flag: %v", err) + } + if err := cmd.Flags().Set(organizationFlag, testOrganization); err != nil { + t.Fatalf("set organization flag: %v", err) + } + + actual, err := parseClusterConfig(params.Printer, cmd, true, false, true) + if err != nil { + t.Fatalf("parse cluster config: %v", err) + } + expected := fixtureClusterConfig() + if diff := cmp.Diff(actual, expected, cmpopts.IgnoreFields(clusterConfig{}, "cacheKey")); diff != "" { + t.Fatalf("Data does not match: %s", diff) + } +} + +func TestGetAccessTokenFromEnvironmentWithoutStoredSession(t *testing.T) { + const accessToken = "environment-access-token" + t.Setenv(envAccessToken, accessToken) + + params := testparams.NewTestParams() + actual, err := getAccessToken(params.CmdParams, false) + if err != nil { + t.Fatalf("get access token: %v", err) + } + if actual != accessToken { + t.Fatalf("Expected access token %q, got %q", accessToken, actual) + } +} + +func TestWorkloadIdentityConfigured(t *testing.T) { + tokenPath := t.TempDir() + "/token" + if err := os.WriteFile(tokenPath, []byte("federated-token"), 0o600); err != nil { + t.Fatalf("write federated token: %v", err) + } + t.Setenv(envServiceAccountEmail, "workload@sa.stackit.cloud") + t.Setenv(clients.FederatedTokenFileEnv, tokenPath) + + if !workloadIdentityConfigured() { + t.Fatal("Expected workload identity to be configured") + } + + t.Setenv(envServiceAccountEmail, "") + if workloadIdentityConfigured() { + t.Fatal("Expected workload identity not to be configured without a service account email") + } +} + +func TestGetWorkloadIdentityAccessToken(t *testing.T) { + const serviceAccountEmail = "workload@sa.stackit.cloud" + federatedToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(10 * time.Minute)), + }).SignedString([]byte("federated-token-signing-key")) + if err != nil { + t.Fatalf("sign federated token: %v", err) + } + accessToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)), + }).SignedString([]byte("test-signing-key")) + if err != nil { + t.Fatalf("sign access token: %v", err) + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + req.Body = http.MaxBytesReader(w, req.Body, 1<<20) + if err := req.ParseForm(); err != nil { + t.Errorf("parse form: %v", err) + } + expectedForm := url.Values{ + "grant_type": {"client_credentials"}, + "client_assertion_type": {"urn:schwarz:params:oauth:client-assertion-type:workload-jwt"}, + "client_assertion": {federatedToken}, + "client_id": {serviceAccountEmail}, + } + if diff := cmp.Diff(req.Form, expectedForm); diff != "" { + t.Errorf("Token request does not match: %s", diff) + } + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprintf(w, `{"access_token":%q,"expires_in":3600,"token_type":"Bearer"}`, accessToken) + })) + defer server.Close() + + tokenPath := t.TempDir() + "/token" + if err := os.WriteFile(tokenPath, []byte(federatedToken), 0o600); err != nil { + t.Fatalf("write federated token: %v", err) + } + t.Setenv(envServiceAccountEmail, serviceAccountEmail) + t.Setenv(clients.FederatedTokenFileEnv, tokenPath) + t.Setenv("STACKIT_IDP_TOKEN_ENDPOINT", server.URL) + + actual, err := getWorkloadIdentityAccessToken() + if err != nil { + t.Fatalf("get workload identity access token: %v", err) + } + if actual != accessToken { + t.Fatalf("Expected access token %q, got %q", accessToken, actual) + } +} + +func TestRetrieveTokenFromIDPWithoutCache(t *testing.T) { + clusterCredential := uuid.NewString() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprintf(w, `{"access_token":%q}`, clusterCredential) + })) + defer server.Close() + + actual, err := retrieveTokenFromIDP(testCtx, server.Client(), server.URL, "access-token", fixtureClusterConfig(), false) + if err != nil { + t.Fatalf("retrieve token without cache: %v", err) + } + if actual != clusterCredential { + t.Fatalf("Expected cluster credential %q, got %q", clusterCredential, actual) + } +} + func TestBuildRequest(t *testing.T) { tests := []struct { description string diff --git a/internal/pkg/auth/exchange.go b/internal/pkg/auth/exchange.go index 0c005d237..703816ebb 100644 --- a/internal/pkg/auth/exchange.go +++ b/internal/pkg/auth/exchange.go @@ -16,6 +16,11 @@ func ExchangeToken(ctx context.Context, idpClient *http.Client, accessToken, res return "", fmt.Errorf("get idp token endpoint: %w", err) } + return ExchangeTokenWithEndpoint(ctx, idpClient, tokenEndpoint, accessToken, resource) +} + +// ExchangeTokenWithEndpoint exchanges an access token without reading authentication storage. +func ExchangeTokenWithEndpoint(ctx context.Context, idpClient *http.Client, tokenEndpoint, accessToken, resource string) (string, error) { req, err := buildRequestToExchangeTokens(ctx, tokenEndpoint, accessToken, resource) if err != nil { return "", fmt.Errorf("build request: %w", err) diff --git a/internal/pkg/auth/utils.go b/internal/pkg/auth/utils.go index a1e7eaf77..6a1a80eef 100644 --- a/internal/pkg/auth/utils.go +++ b/internal/pkg/auth/utils.go @@ -46,6 +46,10 @@ func getIDPClientID() (string, error) { } func retrieveIDPWellKnownConfig(p *print.Printer) (*wellKnownConfig, error) { + return retrieveIDPWellKnownConfigWithStorage(p, true) +} + +func retrieveIDPWellKnownConfigWithStorage(p *print.Printer, persistTokenEndpoint bool) (*wellKnownConfig, error) { idpWellKnownConfigURL, err := getIDPWellKnownConfigURL() if err != nil { return nil, fmt.Errorf("get IDP well-known configuration: %w", err) @@ -60,16 +64,35 @@ func retrieveIDPWellKnownConfig(p *print.Printer) (*wellKnownConfig, error) { p.Debug(print.DebugLevel, "get IDP well-known configuration from %s", idpWellKnownConfigURL) httpClient := &http.Client{} - idpWellKnownConfig, err := parseWellKnownConfiguration(httpClient, idpWellKnownConfigURL) + idpWellKnownConfig, err := parseWellKnownConfigurationWithStorage(httpClient, idpWellKnownConfigURL, persistTokenEndpoint) if err != nil { return nil, fmt.Errorf("parse IDP well-known configuration: %w", err) } return idpWellKnownConfig, nil } +// GetIDPTokenEndpoint returns the configured IdP token endpoint without requiring +// authentication storage. Persisted configuration is reused when available. +func GetIDPTokenEndpoint(p *print.Printer) (string, error) { + tokenEndpoint, err := GetAuthField(IDP_TOKEN_ENDPOINT) + if err == nil && tokenEndpoint != "" { + return tokenEndpoint, nil + } + + wellKnownConfig, err := retrieveIDPWellKnownConfigWithStorage(p, false) + if err != nil { + return "", err + } + return wellKnownConfig.TokenEndpoint, nil +} + // parseWellKnownConfiguration gets the well-known OpenID configuration from the provided URL and returns it as a JSON // the method also stores the IDP token endpoint in the authentication storage func parseWellKnownConfiguration(httpClient apiClient, wellKnownConfigURL string) (wellKnownConfig *wellKnownConfig, err error) { + return parseWellKnownConfigurationWithStorage(httpClient, wellKnownConfigURL, true) +} + +func parseWellKnownConfigurationWithStorage(httpClient apiClient, wellKnownConfigURL string, persistTokenEndpoint bool) (wellKnownConfig *wellKnownConfig, err error) { req, _ := http.NewRequest("GET", wellKnownConfigURL, http.NoBody) res, err := httpClient.Do(req) if err != nil { @@ -114,9 +137,11 @@ func parseWellKnownConfiguration(httpClient apiClient, wellKnownConfigURL string return nil, fmt.Errorf("token endpoint is invalid") } - err = SetAuthField(IDP_TOKEN_ENDPOINT, wellKnownConfig.TokenEndpoint) - if err != nil { - return nil, fmt.Errorf("set token endpoint in the authentication storage: %w", err) + if persistTokenEndpoint { + err = SetAuthField(IDP_TOKEN_ENDPOINT, wellKnownConfig.TokenEndpoint) + if err != nil { + return nil, fmt.Errorf("set token endpoint in the authentication storage: %w", err) + } } return wellKnownConfig, err } diff --git a/internal/pkg/auth/utils_test.go b/internal/pkg/auth/utils_test.go index 3b208dcd7..292ea4191 100644 --- a/internal/pkg/auth/utils_test.go +++ b/internal/pkg/auth/utils_test.go @@ -201,3 +201,31 @@ func TestParseWellKnownConfig(t *testing.T) { }) } } + +func TestParseWellKnownConfigWithoutStorage(t *testing.T) { + keyring.MockInit() + const existingEndpoint = "https://existing.stackit.cloud/oauth" + if err := SetAuthField(IDP_TOKEN_ENDPOINT, existingEndpoint); err != nil { + t.Fatalf("Set existing token endpoint: %v", err) + } + testClient := apiClientMocked{ + false, + `{"issuer":"https://issuer.stackit.cloud/endpoint","authorization_endpoint":"https://auth.stackit.cloud/endpoint","token_endpoint":"https://token.stackit.cloud/endpoint"}`, + } + + wellKnownConfig, err := parseWellKnownConfigurationWithStorage(&testClient, "", false) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if wellKnownConfig.TokenEndpoint != "https://token.stackit.cloud/endpoint" { + t.Fatalf("Unexpected token endpoint %q", wellKnownConfig.TokenEndpoint) + } + + storedTokenEndpoint, err := GetAuthField(IDP_TOKEN_ENDPOINT) + if err != nil { + t.Fatalf("Get stored token endpoint: %v", err) + } + if storedTokenEndpoint != existingEndpoint { + t.Fatalf("Expected token endpoint not to be changed, got %q", storedTokenEndpoint) + } +} From b91442f25be52acdb6c3fd1b53790ec840965f7f Mon Sep 17 00:00:00 2001 From: Galin Karabadzhakov Date: Fri, 21 Aug 2026 11:53:43 +0300 Subject: [PATCH 2/5] fix(ske): address stateless login review feedback --- internal/cmd/ske/kubeconfig/login/login.go | 14 ++++-- .../cmd/ske/kubeconfig/login/login_test.go | 8 ++++ internal/pkg/auth/utils.go | 10 +++- internal/pkg/auth/utils_test.go | 47 +++++++++++++++++++ 4 files changed, 72 insertions(+), 7 deletions(-) diff --git a/internal/cmd/ske/kubeconfig/login/login.go b/internal/cmd/ske/kubeconfig/login/login.go index e2ec3439e..0216c8f4c 100644 --- a/internal/cmd/ske/kubeconfig/login/login.go +++ b/internal/cmd/ske/kubeconfig/login/login.go @@ -109,7 +109,11 @@ func NewCmd(params *types.CmdParams) *cobra.Command { if err != nil { return err } - tokenEndpoint, err := auth.GetIDPTokenEndpoint(params.Printer) + getTokenEndpoint := auth.GetIDPTokenEndpoint + if statelessIDPMode { + getTokenEndpoint = auth.GetIDPTokenEndpointStateless + } + tokenEndpoint, err := getTokenEndpoint(params.Printer) if err != nil { return fmt.Errorf("get IDP token endpoint: %w", err) } @@ -186,11 +190,11 @@ func parseClusterConfig(p *print.Printer, cmd *cobra.Command, idpMode, workloadI } } - if clusterName := flags.FlagToStringValue(p, cmd, clusterNameFlag); clusterName != "" { - clusterConfig.ClusterName = clusterName + if clusterConfig.ClusterName == "" { + clusterConfig.ClusterName = flags.FlagToStringValue(p, cmd, clusterNameFlag) } - if organizationID := flags.FlagToStringValue(p, cmd, organizationFlag); organizationID != "" { - clusterConfig.OrganizationID = organizationID + if clusterConfig.OrganizationID == "" { + clusterConfig.OrganizationID = flags.FlagToStringValue(p, cmd, organizationFlag) } globalFlags := globalflags.Parse(p, cmd) if clusterConfig.STACKITProjectID == "" { diff --git a/internal/cmd/ske/kubeconfig/login/login_test.go b/internal/cmd/ske/kubeconfig/login/login_test.go index 1e7f19bbe..503cb2546 100644 --- a/internal/cmd/ske/kubeconfig/login/login_test.go +++ b/internal/cmd/ske/kubeconfig/login/login_test.go @@ -115,6 +115,8 @@ func TestParseClusterConfigWithoutExecClusterInfo(t *testing.T) { func TestParseClusterConfigUsesExecClusterInfo(t *testing.T) { viper.Reset() t.Cleanup(viper.Reset) + viper.Set(config.ProjectIdKey, uuid.NewString()) + viper.Set(config.RegionKey, "conflicting-region") t.Setenv(envServiceAccountEmail, "workload@sa.stackit.cloud") configJSON, err := json.Marshal(fixtureClusterConfig()) @@ -129,6 +131,12 @@ func TestParseClusterConfigUsesExecClusterInfo(t *testing.T) { params := testparams.NewTestParams() cmd := &cobra.Command{} configureFlags(cmd) + if err := cmd.Flags().Set(clusterNameFlag, "conflicting-cluster"); err != nil { + t.Fatalf("set cluster name flag: %v", err) + } + if err := cmd.Flags().Set(organizationFlag, uuid.NewString()); err != nil { + t.Fatalf("set organization flag: %v", err) + } actual, err := parseClusterConfig(params.Printer, cmd, true, true, true) if err != nil { t.Fatalf("parse cluster config: %v", err) diff --git a/internal/pkg/auth/utils.go b/internal/pkg/auth/utils.go index 6a1a80eef..56b19a337 100644 --- a/internal/pkg/auth/utils.go +++ b/internal/pkg/auth/utils.go @@ -71,14 +71,20 @@ func retrieveIDPWellKnownConfigWithStorage(p *print.Printer, persistTokenEndpoin return idpWellKnownConfig, nil } -// GetIDPTokenEndpoint returns the configured IdP token endpoint without requiring -// authentication storage. Persisted configuration is reused when available. +// GetIDPTokenEndpoint returns the persisted IdP token endpoint when available and +// falls back to endpoint discovery. func GetIDPTokenEndpoint(p *print.Printer) (string, error) { tokenEndpoint, err := GetAuthField(IDP_TOKEN_ENDPOINT) if err == nil && tokenEndpoint != "" { return tokenEndpoint, nil } + return GetIDPTokenEndpointStateless(p) +} + +// GetIDPTokenEndpointStateless discovers the IdP token endpoint without reading +// from or writing to authentication storage. +func GetIDPTokenEndpointStateless(p *print.Printer) (string, error) { wellKnownConfig, err := retrieveIDPWellKnownConfigWithStorage(p, false) if err != nil { return "", err diff --git a/internal/pkg/auth/utils_test.go b/internal/pkg/auth/utils_test.go index 292ea4191..82b018e92 100644 --- a/internal/pkg/auth/utils_test.go +++ b/internal/pkg/auth/utils_test.go @@ -1,6 +1,8 @@ package auth import ( + "net/http" + "net/http/httptest" "testing" "github.com/google/go-cmp/cmp" @@ -8,6 +10,7 @@ import ( "github.com/zalando/go-keyring" "github.com/stackitcloud/stackit-cli/internal/pkg/config" + "github.com/stackitcloud/stackit-cli/internal/pkg/testparams" ) func TestGetWellKnownConfig(t *testing.T) { @@ -229,3 +232,47 @@ func TestParseWellKnownConfigWithoutStorage(t *testing.T) { t.Fatalf("Expected token endpoint not to be changed, got %q", storedTokenEndpoint) } } + +func TestGetIDPTokenEndpointStatelessIgnoresStorage(t *testing.T) { + viper.Reset() + t.Cleanup(viper.Reset) + keyring.MockInit() + + const storedEndpoint = "https://stored.stackit.cloud/oauth" + if err := SetAuthField(IDP_TOKEN_ENDPOINT, storedEndpoint); err != nil { + t.Fatalf("Set stored token endpoint: %v", err) + } + + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"issuer":"https://issuer.stackit.cloud/endpoint","authorization_endpoint":"https://auth.stackit.cloud/endpoint","token_endpoint":"https://discovered.stackit.cloud/oauth"}`)) + })) + defer server.Close() + + originalTransport := http.DefaultTransport + http.DefaultTransport = server.Client().Transport + t.Cleanup(func() { + http.DefaultTransport = originalTransport + }) + viper.Set(config.IdentityProviderCustomWellKnownConfigurationKey, server.URL) + viper.Set(config.AllowedUrlDomainKey, "") + + params := testparams.NewTestParams() + params.Printer.AssumeYes = true + actual, err := GetIDPTokenEndpointStateless(params.Printer) + if err != nil { + t.Fatalf("Get stateless token endpoint: %v", err) + } + const discoveredEndpoint = "https://discovered.stackit.cloud/oauth" + if actual != discoveredEndpoint { + t.Fatalf("Expected discovered endpoint %q, got %q", discoveredEndpoint, actual) + } + + actualStoredEndpoint, err := GetAuthField(IDP_TOKEN_ENDPOINT) + if err != nil { + t.Fatalf("Get stored token endpoint: %v", err) + } + if actualStoredEndpoint != storedEndpoint { + t.Fatalf("Expected stored endpoint to remain %q, got %q", storedEndpoint, actualStoredEndpoint) + } +} From 445e7b2d25bd8cccb0b7583967f3fdeefdbb84ce Mon Sep 17 00:00:00 2001 From: Galin Karabadzahkov Date: Thu, 3 Sep 2026 10:56:14 +0300 Subject: [PATCH 3/5] Update internal/cmd/ske/kubeconfig/login/login.go Co-authored-by: Marcel Jacek <72880145+marceljk@users.noreply.github.com> --- internal/cmd/ske/kubeconfig/login/login.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/cmd/ske/kubeconfig/login/login.go b/internal/cmd/ske/kubeconfig/login/login.go index 0216c8f4c..fb7664f89 100644 --- a/internal/cmd/ske/kubeconfig/login/login.go +++ b/internal/cmd/ske/kubeconfig/login/login.go @@ -444,11 +444,11 @@ func workloadIdentityConfigured() bool { if os.Getenv(envServiceAccountEmail) == "" { return false } - if os.Getenv(clients.FederatedTokenFileEnv) != "" { - return true - } - fileInfo, err := os.Stat(defaultFederatedTokenPath) - return err == nil && !fileInfo.IsDir() + if auth.IsOIDCEnabled() { + return true + } + _, err := auth.OIDCTokenFunc() + return err == nil } func getWorkloadIdentityAccessToken() (string, error) { From 309e0e818091263a109f85b78c9d0f9d6560fe1e Mon Sep 17 00:00:00 2001 From: Galin Karabadzhakov Date: Thu, 3 Sep 2026 10:59:53 +0300 Subject: [PATCH 4/5] fix(ske): prefer explicit login configuration --- internal/cmd/ske/kubeconfig/login/login.go | 28 ++++++----- .../cmd/ske/kubeconfig/login/login_test.go | 46 +++++++++++++++++-- 2 files changed, 54 insertions(+), 20 deletions(-) diff --git a/internal/cmd/ske/kubeconfig/login/login.go b/internal/cmd/ske/kubeconfig/login/login.go index fb7664f89..8e45bfd34 100644 --- a/internal/cmd/ske/kubeconfig/login/login.go +++ b/internal/cmd/ske/kubeconfig/login/login.go @@ -24,7 +24,6 @@ import ( "k8s.io/client-go/tools/clientcmd" sdkAuth "github.com/stackitcloud/stackit-sdk-go/core/auth" - "github.com/stackitcloud/stackit-sdk-go/core/clients" sdkConfig "github.com/stackitcloud/stackit-sdk-go/core/config" ske "github.com/stackitcloud/stackit-sdk-go/services/ske/v2api" @@ -49,9 +48,8 @@ const ( clusterNameFlag = "cluster-name" organizationFlag = "organization-id" - envAccessToken = "STACKIT_ACCESS_TOKEN" - envServiceAccountEmail = "STACKIT_SERVICE_ACCOUNT_EMAIL" - defaultFederatedTokenPath = "/var/run/secrets/stackit.cloud/serviceaccount/token" //nolint:gosec // Public path, not a credential. + envAccessToken = "STACKIT_ACCESS_TOKEN" + envServiceAccountEmail = "STACKIT_SERVICE_ACCOUNT_EMAIL" ) func NewCmd(params *types.CmdParams) *cobra.Command { @@ -190,17 +188,17 @@ func parseClusterConfig(p *print.Printer, cmd *cobra.Command, idpMode, workloadI } } - if clusterConfig.ClusterName == "" { - clusterConfig.ClusterName = flags.FlagToStringValue(p, cmd, clusterNameFlag) + if clusterName := flags.FlagToStringValue(p, cmd, clusterNameFlag); clusterName != "" { + clusterConfig.ClusterName = clusterName } - if clusterConfig.OrganizationID == "" { - clusterConfig.OrganizationID = flags.FlagToStringValue(p, cmd, organizationFlag) + if organizationID := flags.FlagToStringValue(p, cmd, organizationFlag); organizationID != "" { + clusterConfig.OrganizationID = organizationID } globalFlags := globalflags.Parse(p, cmd) - if clusterConfig.STACKITProjectID == "" { + if globalFlags.ProjectId != "" { clusterConfig.STACKITProjectID = globalFlags.ProjectId } - if clusterConfig.Region == "" { + if globalFlags.Region != "" { clusterConfig.Region = globalFlags.Region } @@ -444,11 +442,11 @@ func workloadIdentityConfigured() bool { if os.Getenv(envServiceAccountEmail) == "" { return false } - if auth.IsOIDCEnabled() { - return true - } - _, err := auth.OIDCTokenFunc() - return err == nil + if auth.IsOIDCEnabled() { + return true + } + _, err := auth.OIDCTokenFunc() + return err == nil } func getWorkloadIdentityAccessToken() (string, error) { diff --git a/internal/cmd/ske/kubeconfig/login/login_test.go b/internal/cmd/ske/kubeconfig/login/login_test.go index 503cb2546..9a6a22baa 100644 --- a/internal/cmd/ske/kubeconfig/login/login_test.go +++ b/internal/cmd/ske/kubeconfig/login/login_test.go @@ -112,11 +112,15 @@ func TestParseClusterConfigWithoutExecClusterInfo(t *testing.T) { } } -func TestParseClusterConfigUsesExecClusterInfo(t *testing.T) { +func TestParseClusterConfigPrefersExplicitConfiguration(t *testing.T) { viper.Reset() t.Cleanup(viper.Reset) - viper.Set(config.ProjectIdKey, uuid.NewString()) - viper.Set(config.RegionKey, "conflicting-region") + explicitProjectID := uuid.NewString() + explicitRegion := "explicit-region" + explicitClusterName := "explicit-cluster" + explicitOrganizationID := uuid.NewString() + viper.Set(config.ProjectIdKey, explicitProjectID) + viper.Set(config.RegionKey, explicitRegion) t.Setenv(envServiceAccountEmail, "workload@sa.stackit.cloud") configJSON, err := json.Marshal(fixtureClusterConfig()) @@ -131,16 +135,48 @@ func TestParseClusterConfigUsesExecClusterInfo(t *testing.T) { params := testparams.NewTestParams() cmd := &cobra.Command{} configureFlags(cmd) - if err := cmd.Flags().Set(clusterNameFlag, "conflicting-cluster"); err != nil { + if err := cmd.Flags().Set(clusterNameFlag, explicitClusterName); err != nil { t.Fatalf("set cluster name flag: %v", err) } - if err := cmd.Flags().Set(organizationFlag, uuid.NewString()); err != nil { + if err := cmd.Flags().Set(organizationFlag, explicitOrganizationID); err != nil { t.Fatalf("set organization flag: %v", err) } actual, err := parseClusterConfig(params.Printer, cmd, true, true, true) if err != nil { t.Fatalf("parse cluster config: %v", err) } + expected := fixtureClusterConfig(func(config *clusterConfig) { + config.STACKITProjectID = explicitProjectID + config.Region = explicitRegion + config.ClusterName = explicitClusterName + config.OrganizationID = explicitOrganizationID + }) + if diff := cmp.Diff(actual, expected, cmpopts.IgnoreFields(clusterConfig{}, "cacheKey")); diff != "" { + t.Fatalf("Data does not match: %s", diff) + } +} + +func TestParseClusterConfigUsesExecClusterInfoAsFallback(t *testing.T) { + viper.Reset() + t.Cleanup(viper.Reset) + t.Setenv(envServiceAccountEmail, "workload@sa.stackit.cloud") + + configJSON, err := json.Marshal(fixtureClusterConfig()) + if err != nil { + t.Fatalf("marshal cluster config: %v", err) + } + setExecCredentialEnv(t, &clientauthenticationv1.Cluster{ + Server: "https://api.example.stackit.cloud", + Config: runtime.RawExtension{Raw: configJSON}, + }) + + params := testparams.NewTestParams() + cmd := &cobra.Command{} + configureFlags(cmd) + actual, err := parseClusterConfig(params.Printer, cmd, true, true, true) + if err != nil { + t.Fatalf("parse cluster config: %v", err) + } expected := fixtureClusterConfig() if diff := cmp.Diff(actual, expected, cmpopts.IgnoreFields(clusterConfig{}, "cacheKey")); diff != "" { t.Fatalf("Data does not match: %s", diff) From 0cce847c1d8cefa9042087d97750e3947e20f41e Mon Sep 17 00:00:00 2001 From: Galin Karabadzhakov Date: Thu, 3 Sep 2026 11:11:47 +0300 Subject: [PATCH 5/5] fix(ske): use shared OIDC token adapters --- internal/cmd/ske/kubeconfig/login/login.go | 8 +- .../cmd/ske/kubeconfig/login/login_test.go | 162 +++++++++++++++--- 2 files changed, 145 insertions(+), 25 deletions(-) diff --git a/internal/cmd/ske/kubeconfig/login/login.go b/internal/cmd/ske/kubeconfig/login/login.go index 8e45bfd34..2639f641c 100644 --- a/internal/cmd/ske/kubeconfig/login/login.go +++ b/internal/cmd/ske/kubeconfig/login/login.go @@ -450,7 +450,13 @@ func workloadIdentityConfigured() bool { } func getWorkloadIdentityAccessToken() (string, error) { - roundTripper, err := sdkAuth.SetupAuth(&sdkConfig.Configuration{WorkloadIdentityFederation: true}) + // A nil token function intentionally preserves the SDK's projected-token-file fallback. + tokenFunc, _ := auth.OIDCTokenFunc() + roundTripper, err := sdkAuth.SetupAuth(&sdkConfig.Configuration{ + WorkloadIdentityFederation: true, + ServiceAccountEmail: auth.OIDCServiceAccountEmail(), + ServiceAccountFederatedTokenFunc: tokenFunc, + }) if err != nil { return "", fmt.Errorf("configure workload identity federation: %w", err) } diff --git a/internal/cmd/ske/kubeconfig/login/login_test.go b/internal/cmd/ske/kubeconfig/login/login_test.go index 9a6a22baa..9af97e194 100644 --- a/internal/cmd/ske/kubeconfig/login/login_test.go +++ b/internal/cmd/ske/kubeconfig/login/login_test.go @@ -18,13 +18,13 @@ import ( "github.com/google/uuid" "github.com/spf13/cobra" "github.com/spf13/viper" - "github.com/stackitcloud/stackit-sdk-go/core/clients" ske "github.com/stackitcloud/stackit-sdk-go/services/ske/v2api" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" clientauthenticationv1 "k8s.io/client-go/pkg/apis/clientauthentication/v1" "k8s.io/client-go/rest" + "github.com/stackitcloud/stackit-cli/internal/pkg/auth" "github.com/stackitcloud/stackit-cli/internal/pkg/config" "github.com/stackitcloud/stackit-cli/internal/pkg/testparams" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" @@ -246,20 +246,72 @@ func TestGetAccessTokenFromEnvironmentWithoutStoredSession(t *testing.T) { } func TestWorkloadIdentityConfigured(t *testing.T) { - tokenPath := t.TempDir() + "/token" - if err := os.WriteFile(tokenPath, []byte("federated-token"), 0o600); err != nil { - t.Fatalf("write federated token: %v", err) - } - t.Setenv(envServiceAccountEmail, "workload@sa.stackit.cloud") - t.Setenv(clients.FederatedTokenFileEnv, tokenPath) - - if !workloadIdentityConfigured() { - t.Fatal("Expected workload identity to be configured") + tests := []struct { + name string + configure func(t *testing.T) + expected bool + }{ + { + name: "explicit OIDC", + configure: func(t *testing.T) { + t.Setenv(auth.EnvUseOIDC, "1") + }, + expected: true, + }, + { + name: "static federated token", + configure: func(t *testing.T) { + t.Setenv(auth.EnvServiceAccountFederatedToken, "federated-token") + }, + expected: true, + }, + { + name: "federated token file", + configure: func(t *testing.T) { + t.Setenv(auth.EnvFederatedTokenFile, "/projected/token") + }, + expected: true, + }, + { + name: "GitHub Actions", + configure: func(t *testing.T) { + t.Setenv(auth.EnvGitHubRequestURL, "https://github.example.test/oidc") + t.Setenv(auth.EnvGitHubRequestToken, "request-token") + }, + expected: true, + }, + { + name: "Azure DevOps", + configure: func(t *testing.T) { + t.Setenv(auth.EnvAzureOIDCRequestURI, "https://azure.example.test/oidc") + t.Setenv(auth.EnvAzureAccessToken, "access-token") + }, + expected: true, + }, + { + name: "missing token source", + configure: func(_ *testing.T) {}, + expected: false, + }, + { + name: "missing service account email", + configure: func(t *testing.T) { + t.Setenv(envServiceAccountEmail, "") + t.Setenv(auth.EnvServiceAccountFederatedToken, "federated-token") + }, + expected: false, + }, } - t.Setenv(envServiceAccountEmail, "") - if workloadIdentityConfigured() { - t.Fatal("Expected workload identity not to be configured without a service account email") + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + clearOIDCTokenSourceEnv(t) + t.Setenv(envServiceAccountEmail, "workload@sa.stackit.cloud") + tt.configure(t) + if actual := workloadIdentityConfigured(); actual != tt.expected { + t.Fatalf("workloadIdentityConfigured() = %t, want %t", actual, tt.expected) + } + }) } } @@ -297,20 +349,82 @@ func TestGetWorkloadIdentityAccessToken(t *testing.T) { })) defer server.Close() - tokenPath := t.TempDir() + "/token" - if err := os.WriteFile(tokenPath, []byte(federatedToken), 0o600); err != nil { - t.Fatalf("write federated token: %v", err) + tests := []struct { + name string + configure func(t *testing.T) + }{ + { + name: "static federated token", + configure: func(t *testing.T) { + t.Setenv(auth.EnvServiceAccountFederatedToken, federatedToken) + }, + }, + { + name: "federated token file", + configure: func(t *testing.T) { + tokenPath := t.TempDir() + "/token" + if err := os.WriteFile(tokenPath, []byte(federatedToken), 0o600); err != nil { + t.Fatalf("write federated token: %v", err) + } + t.Setenv(auth.EnvFederatedTokenFile, tokenPath) + }, + }, + { + name: "GitHub Actions", + configure: func(t *testing.T) { + oidcServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{"value": federatedToken}) + })) + t.Cleanup(oidcServer.Close) + t.Setenv(auth.EnvGitHubRequestURL, oidcServer.URL) + t.Setenv(auth.EnvGitHubRequestToken, "request-token") + }, + }, + { + name: "Azure DevOps", + configure: func(t *testing.T) { + oidcServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{"oidcToken": federatedToken}) + })) + t.Cleanup(oidcServer.Close) + t.Setenv(auth.EnvAzureOIDCRequestURI, oidcServer.URL) + t.Setenv(auth.EnvAzureAccessToken, "access-token") + }, + }, } - t.Setenv(envServiceAccountEmail, serviceAccountEmail) - t.Setenv(clients.FederatedTokenFileEnv, tokenPath) - t.Setenv("STACKIT_IDP_TOKEN_ENDPOINT", server.URL) - actual, err := getWorkloadIdentityAccessToken() - if err != nil { - t.Fatalf("get workload identity access token: %v", err) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + clearOIDCTokenSourceEnv(t) + t.Setenv(envServiceAccountEmail, serviceAccountEmail) + t.Setenv("STACKIT_IDP_TOKEN_ENDPOINT", server.URL) + tt.configure(t) + + actual, err := getWorkloadIdentityAccessToken() + if err != nil { + t.Fatalf("get workload identity access token: %v", err) + } + if actual != accessToken { + t.Fatalf("Expected access token %q, got %q", accessToken, actual) + } + }) } - if actual != accessToken { - t.Fatalf("Expected access token %q, got %q", accessToken, actual) +} + +func clearOIDCTokenSourceEnv(t *testing.T) { + t.Helper() + for _, env := range []string{ + auth.EnvUseOIDC, + auth.EnvServiceAccountFederatedToken, + auth.EnvFederatedTokenFile, + auth.EnvGitHubRequestURL, + auth.EnvGitHubRequestToken, + auth.EnvAzureOIDCRequestURI, + auth.EnvAzureAccessToken, + } { + t.Setenv(env, "") } }