From d4f5443ac87e58cd1eaefe3de1c2ff474d0d3b14 Mon Sep 17 00:00:00 2001 From: Galin Karabadzhakov Date: Wed, 15 Jul 2026 18:04:00 +0300 Subject: [PATCH 1/6] 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/6] 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/6] 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/6] 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/6] 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, "") } } From 3de94aadccfbfb50f396032bd3d7e55678eb19d8 Mon Sep 17 00:00:00 2001 From: Galin Karabadzhakov Date: Fri, 4 Sep 2026 14:48:11 +0300 Subject: [PATCH 6/6] fix(ske): refine cluster config precedence --- docs/stackit_ske_kubeconfig_login.md | 9 ++ internal/cmd/ske/kubeconfig/login/login.go | 18 ++- .../cmd/ske/kubeconfig/login/login_test.go | 109 ++++++++++++++++-- 3 files changed, 124 insertions(+), 12 deletions(-) diff --git a/docs/stackit_ske_kubeconfig_login.md b/docs/stackit_ske_kubeconfig_login.md index c657f599e..950931d68 100644 --- a/docs/stackit_ske_kubeconfig_login.md +++ b/docs/stackit_ske_kubeconfig_login.md @@ -8,6 +8,15 @@ Login plugin for kubernetes clients, that creates short-lived credentials to aut First you need to obtain a kubeconfig for use with the login command (first or second example). Secondly you use the kubeconfig with your chosen Kubernetes client (third example), the client will automatically retrieve the credentials via the STACKIT CLI. +Project ID and region are resolved with the following precedence: + +| Explicit flag | Global config | Exec cluster config | Result | +| --- | --- | --- | --- | +| Not set | Set | Not set | Global config | +| Set | Set | Not set | Explicit flag | +| Not set | Set | Set | Exec cluster config | +| Set | Set | Set | Explicit flag | + ``` stackit ske kubeconfig login [flags] ``` diff --git a/internal/cmd/ske/kubeconfig/login/login.go b/internal/cmd/ske/kubeconfig/login/login.go index 2639f641c..248d6f746 100644 --- a/internal/cmd/ske/kubeconfig/login/login.go +++ b/internal/cmd/ske/kubeconfig/login/login.go @@ -59,7 +59,14 @@ func NewCmd(params *types.CmdParams) *cobra.Command { Long: fmt.Sprintf("%s\n%s\n%s", "Login plugin for kubernetes clients, that creates short-lived credentials to authenticate against a STACKIT Kubernetes Engine (SKE) cluster.", "First you need to obtain a kubeconfig for use with the login command (first or second example).", - "Secondly you use the kubeconfig with your chosen Kubernetes client (third example), the client will automatically retrieve the credentials via the STACKIT CLI.", + "Secondly you use the kubeconfig with your chosen Kubernetes client (third example), the client will automatically retrieve the credentials via the STACKIT CLI.\n\n"+ + "Project ID and region are resolved with the following precedence:\n\n"+ + "| Explicit flag | Global config | Exec cluster config | Result |\n"+ + "| --- | --- | --- | --- |\n"+ + "| Not set | Set | Not set | Global config |\n"+ + "| Set | Set | Not set | Explicit flag |\n"+ + "| Not set | Set | Set | Exec cluster config |\n"+ + "| Set | Set | Set | Explicit flag |", ), Args: args.NoArgs, Example: examples.Build( @@ -195,10 +202,10 @@ func parseClusterConfig(p *print.Printer, cmd *cobra.Command, idpMode, workloadI clusterConfig.OrganizationID = organizationID } globalFlags := globalflags.Parse(p, cmd) - if globalFlags.ProjectId != "" { + if flagWasExplicitlySet(cmd, globalflags.ProjectIdFlag) || clusterConfig.STACKITProjectID == "" { clusterConfig.STACKITProjectID = globalFlags.ProjectId } - if globalFlags.Region != "" { + if flagWasExplicitlySet(cmd, globalflags.RegionFlag) || clusterConfig.Region == "" { clusterConfig.Region = globalFlags.Region } @@ -229,6 +236,11 @@ func parseClusterConfig(p *print.Printer, cmd *cobra.Command, idpMode, workloadI return clusterConfig, nil } +func flagWasExplicitlySet(cmd *cobra.Command, name string) bool { + flag := cmd.Flag(name) + return flag != nil && flag.Changed +} + func loadExecCredentialFromEnv() (runtime.Object, error) { execInfo := os.Getenv("KUBERNETES_EXEC_INFO") if execInfo == "" { diff --git a/internal/cmd/ske/kubeconfig/login/login_test.go b/internal/cmd/ske/kubeconfig/login/login_test.go index 9af97e194..8973f838c 100644 --- a/internal/cmd/ske/kubeconfig/login/login_test.go +++ b/internal/cmd/ske/kubeconfig/login/login_test.go @@ -26,6 +26,7 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/auth" "github.com/stackitcloud/stackit-cli/internal/pkg/config" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/testparams" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" ) @@ -112,15 +113,104 @@ func TestParseClusterConfigWithoutExecClusterInfo(t *testing.T) { } } -func TestParseClusterConfigPrefersExplicitConfiguration(t *testing.T) { +func TestParseClusterConfigPrecedence(t *testing.T) { + globalProjectID := uuid.NewString() + flagProjectID := uuid.NewString() + globalRegion := "global-region" + flagRegion := "flag-region" + + tests := []struct { + name string + clusterConfig bool + explicitFlags bool + expectedID string + expectedRegion string + }{ + { + name: "global config without exec cluster config", + expectedID: globalProjectID, + expectedRegion: globalRegion, + }, + { + name: "explicit flags without exec cluster config", + explicitFlags: true, + expectedID: flagProjectID, + expectedRegion: flagRegion, + }, + { + name: "exec cluster config overrides global config", + clusterConfig: true, + expectedID: testProjectId, + expectedRegion: testRegion, + }, + { + name: "explicit flags override exec cluster config", + clusterConfig: true, + explicitFlags: true, + expectedID: flagProjectID, + expectedRegion: flagRegion, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + viper.Reset() + t.Cleanup(viper.Reset) + t.Setenv(envServiceAccountEmail, "workload@sa.stackit.cloud") + + globalConfig := fmt.Sprintf(`{"project_id":%q,"region":%q}`, globalProjectID, globalRegion) + viper.SetConfigType("json") + if err := viper.ReadConfig(strings.NewReader(globalConfig)); err != nil { + t.Fatalf("read global config: %v", err) + } + + execConfig := fixtureClusterConfig() + if !tt.clusterConfig { + execConfig.STACKITProjectID = "" + execConfig.Region = "" + } + configJSON, err := json.Marshal(execConfig) + 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{} + if err := globalflags.Configure(cmd.Flags()); err != nil { + t.Fatalf("configure global flags: %v", err) + } + configureFlags(cmd) + if tt.explicitFlags { + if err := cmd.Flags().Set(globalflags.ProjectIdFlag, flagProjectID); err != nil { + t.Fatalf("set project ID flag: %v", err) + } + if err := cmd.Flags().Set(globalflags.RegionFlag, flagRegion); err != nil { + t.Fatalf("set region 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 = tt.expectedID + config.Region = tt.expectedRegion + }) + if diff := cmp.Diff(actual, expected, cmpopts.IgnoreFields(clusterConfig{}, "cacheKey")); diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestParseClusterConfigPrefersExplicitClusterIdentity(t *testing.T) { viper.Reset() t.Cleanup(viper.Reset) - 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()) @@ -132,6 +222,8 @@ func TestParseClusterConfigPrefersExplicitConfiguration(t *testing.T) { Config: runtime.RawExtension{Raw: configJSON}, }) + explicitClusterName := "explicit-cluster" + explicitOrganizationID := uuid.NewString() params := testparams.NewTestParams() cmd := &cobra.Command{} configureFlags(cmd) @@ -141,13 +233,12 @@ func TestParseClusterConfigPrefersExplicitConfiguration(t *testing.T) { 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 })