Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
d4f5443
feat(ske): support stateless WIF kubeconfig login
Galin-Karabadzhakov Jul 15, 2026
b483121
Merge branch 'main' into feat/support-wif-ske-exec
Galin-Karabadzhakov Jul 20, 2026
8dea116
Merge remote-tracking branch 'origin/main' into feat/support-wif-ske-…
Galin-Karabadzhakov Jul 22, 2026
971d995
Merge branch 'main' into feat/support-wif-ske-exec
Galin-Karabadzhakov Jul 30, 2026
c963011
Merge branch 'main' into feat/support-wif-ske-exec
Galin-Karabadzhakov Aug 4, 2026
e98a479
Merge branch 'main' into feat/support-wif-ske-exec
Galin-Karabadzhakov Aug 12, 2026
1d33eab
Merge branch 'main' into feat/support-wif-ske-exec
Galin-Karabadzhakov Aug 18, 2026
a4feb08
Merge branch 'main' into feat/support-wif-ske-exec
Galin-Karabadzhakov Aug 20, 2026
b188de2
Merge branch 'main' into feat/support-wif-ske-exec
Galin-Karabadzhakov Aug 20, 2026
b91442f
fix(ske): address stateless login review feedback
Galin-Karabadzhakov Aug 21, 2026
1cb1cf2
Merge branch 'main' into feat/support-wif-ske-exec
Galin-Karabadzhakov Aug 21, 2026
71e0173
Merge branch 'main' into feat/support-wif-ske-exec
Galin-Karabadzhakov Aug 25, 2026
ee80445
Merge branch 'main' into feat/support-wif-ske-exec
Galin-Karabadzhakov Aug 26, 2026
1743472
Merge branch 'main' into feat/support-wif-ske-exec
Galin-Karabadzhakov Aug 27, 2026
b28413c
Merge branch 'main' into feat/support-wif-ske-exec
Galin-Karabadzhakov Aug 28, 2026
cf96e14
Merge branch 'main' into feat/support-wif-ske-exec
Galin-Karabadzhakov Aug 28, 2026
9ef98c3
Merge branch 'main' into feat/support-wif-ske-exec
Galin-Karabadzhakov Aug 31, 2026
a27507c
Merge branch 'main' into feat/support-wif-ske-exec
Galin-Karabadzhakov Aug 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions docs/stackit_ske_kubeconfig_login.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
219 changes: 183 additions & 36 deletions internal/cmd/ske/kubeconfig/login/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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 {
Expand All @@ -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.",
Expand All @@ -82,22 +92,37 @@ 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
}
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)
}
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
Expand All @@ -118,6 +143,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 {
Expand All @@ -129,8 +156,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)
}
Expand All @@ -148,33 +175,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 clusterConfig.ClusterName == "" {
clusterConfig.ClusterName = flags.FlagToStringValue(p, cmd, clusterNameFlag)
}
if clusterConfig.OrganizationID == "" {
clusterConfig.OrganizationID = flags.FlagToStringValue(p, cmd, organizationFlag)
}
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 {
Expand Down Expand Up @@ -300,7 +409,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
Expand All @@ -315,30 +437,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
Expand Down Expand Up @@ -367,8 +512,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
}
Expand All @@ -378,10 +523,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)
}

Expand Down
Loading