From d4f5443ac87e58cd1eaefe3de1c2ff474d0d3b14 Mon Sep 17 00:00:00 2001 From: Galin Karabadzhakov Date: Wed, 15 Jul 2026 18:04:00 +0300 Subject: [PATCH] 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) + } +}