diff --git a/docs/stackit_ske_kubeconfig_login.md b/docs/stackit_ske_kubeconfig_login.md index 0df7567e5..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] ``` @@ -24,13 +33,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..248d6f746 100644 --- a/internal/cmd/ske/kubeconfig/login/login.go +++ b/internal/cmd/ske/kubeconfig/login/login.go @@ -11,16 +11,20 @@ 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" + 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 +44,12 @@ 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" ) func NewCmd(params *types.CmdParams) *cobra.Command { @@ -50,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( @@ -66,14 +82,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 +97,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 @@ -118,6 +148,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 +161,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 +180,120 @@ 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 flagWasExplicitlySet(cmd, globalflags.ProjectIdFlag) || clusterConfig.STACKITProjectID == "" { + clusterConfig.STACKITProjectID = globalFlags.ProjectId + } + if flagWasExplicitlySet(cmd, globalflags.RegionFlag) || 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 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 == "" { + 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 +419,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 +447,59 @@ 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 auth.IsOIDCEnabled() { + return true + } + _, err := auth.OIDCTokenFunc() + return err == nil +} - return accessToken, nil +func getWorkloadIdentityAccessToken() (string, error) { + // 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) + } + 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 +528,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 +539,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..8973f838c 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,18 @@ 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" 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/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testparams" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" ) @@ -51,6 +64,478 @@ 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 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) + 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}, + }) + + explicitClusterName := "explicit-cluster" + explicitOrganizationID := uuid.NewString() + params := testparams.NewTestParams() + cmd := &cobra.Command{} + configureFlags(cmd) + if err := cmd.Flags().Set(clusterNameFlag, explicitClusterName); err != nil { + t.Fatalf("set cluster name flag: %v", err) + } + 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.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) + } +} + +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) { + 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, + }, + } + + 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) + } + }) + } +} + +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() + + 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") + }, + }, + } + + 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) + } + }) + } +} + +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, "") + } +} + +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..56b19a337 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,41 @@ 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 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 + } + 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 +143,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..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) { @@ -201,3 +204,75 @@ 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) + } +} + +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) + } +}