Skip to content

Commit d4f5443

Browse files
feat(ske): support stateless WIF kubeconfig login
1 parent 8c9edfa commit d4f5443

6 files changed

Lines changed: 484 additions & 42 deletions

File tree

docs/stackit_ske_kubeconfig_login.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,18 @@ stackit ske kubeconfig login [flags]
2424
Use the previously saved kubeconfig to authenticate to the SKE cluster, in this case with kubectl.
2525
$ kubectl cluster-info
2626
$ kubectl get pods
27+
28+
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.
29+
$ stackit ske kubeconfig login --idp --cluster-name my-cluster --organization-id my-organization-id --project-id my-project-id --region eu01
2730
```
2831

2932
### Options
3033

3134
```
32-
-h, --help Help for "stackit ske kubeconfig login"
33-
--idp Use the STACKIT IdP for authentication to the cluster.
35+
--cluster-name string SKE cluster name. Used when the Kubernetes exec request does not provide cluster information.
36+
-h, --help Help for "stackit ske kubeconfig login"
37+
--idp Use the STACKIT IdP for authentication to the cluster.
38+
--organization-id string Organization ID. Used in IdP mode when the Kubernetes exec request does not provide cluster information.
3439
```
3540

3641
### Options inherited from parent commands

internal/cmd/ske/kubeconfig/login/login.go

Lines changed: 179 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -11,16 +11,21 @@ import (
1111
"net/http"
1212
"os"
1313
"strconv"
14+
"strings"
1415
"time"
1516

1617
"github.com/spf13/cobra"
1718
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
19+
"k8s.io/apimachinery/pkg/runtime"
1820
"k8s.io/client-go/kubernetes/scheme"
1921
clientauthenticationv1 "k8s.io/client-go/pkg/apis/clientauthentication/v1"
2022
"k8s.io/client-go/rest"
2123
"k8s.io/client-go/tools/auth/exec"
2224
"k8s.io/client-go/tools/clientcmd"
2325

26+
sdkAuth "github.com/stackitcloud/stackit-sdk-go/core/auth"
27+
"github.com/stackitcloud/stackit-sdk-go/core/clients"
28+
sdkConfig "github.com/stackitcloud/stackit-sdk-go/core/config"
2429
ske "github.com/stackitcloud/stackit-sdk-go/services/ske/v2api"
2530

2631
"github.com/stackitcloud/stackit-cli/internal/pkg/args"
@@ -40,7 +45,13 @@ const (
4045
refreshBeforeDuration = 15 * time.Minute // 15 min
4146
refreshTokenBeforeDuration = 5 * time.Minute // 5 min
4247

43-
idpFlag = "idp"
48+
idpFlag = "idp"
49+
clusterNameFlag = "cluster-name"
50+
organizationFlag = "organization-id"
51+
52+
envAccessToken = "STACKIT_ACCESS_TOKEN"
53+
envServiceAccountEmail = "STACKIT_SERVICE_ACCOUNT_EMAIL"
54+
defaultFederatedTokenPath = "/var/run/secrets/stackit.cloud/serviceaccount/token" //nolint:gosec // Public path, not a credential.
4455
)
4556

4657
func NewCmd(params *types.CmdParams) *cobra.Command {
@@ -66,14 +77,13 @@ func NewCmd(params *types.CmdParams) *cobra.Command {
6677
"Use the previously saved kubeconfig to authenticate to the SKE cluster, in this case with kubectl.",
6778
"$ kubectl cluster-info",
6879
"$ kubectl get pods"),
80+
examples.NewExample(
81+
"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.",
82+
"$ stackit ske kubeconfig login --idp --cluster-name my-cluster --organization-id my-organization-id --project-id my-project-id --region eu01"),
6983
),
7084
RunE: func(cmd *cobra.Command, _ []string) error {
7185
ctx := context.Background()
7286

73-
if err := cache.Init(); err != nil {
74-
return fmt.Errorf("cache init failed: %w", err)
75-
}
76-
7787
env := os.Getenv("KUBERNETES_EXEC_INFO")
7888
if env == "" {
7989
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 {
8292
}
8393

8494
idpMode := flags.FlagToBoolValue(params.Printer, cmd, idpFlag)
85-
clusterConfig, err := parseClusterConfig(params.Printer, cmd, idpMode)
95+
workloadIdentityMode := idpMode && os.Getenv(envAccessToken) == "" && workloadIdentityConfigured()
96+
statelessIDPMode := idpMode && (workloadIdentityMode || os.Getenv(envAccessToken) != "")
97+
if !statelessIDPMode {
98+
if err := cache.Init(); err != nil {
99+
return fmt.Errorf("cache init failed: %w", err)
100+
}
101+
}
102+
clusterConfig, err := parseClusterConfig(params.Printer, cmd, idpMode, workloadIdentityMode, statelessIDPMode)
86103
if err != nil {
87104
return fmt.Errorf("parseClusterConfig: %w", err)
88105
}
89106

90107
if idpMode {
91-
accessToken, err := getAccessToken(params)
108+
accessToken, err := getAccessToken(params, workloadIdentityMode)
92109
if err != nil {
93110
return err
94111
}
112+
tokenEndpoint, err := auth.GetIDPTokenEndpoint(params.Printer)
113+
if err != nil {
114+
return fmt.Errorf("get IDP token endpoint: %w", err)
115+
}
95116
idpClient := &http.Client{}
96-
token, err := retrieveTokenFromIDP(ctx, idpClient, accessToken, clusterConfig)
117+
token, err := retrieveTokenFromIDP(ctx, idpClient, tokenEndpoint, accessToken, clusterConfig, !statelessIDPMode)
97118
if err != nil {
98119
return err
99120
}
100-
return outputTokenKubeconfig(params.Printer, clusterConfig.cacheKey, token)
121+
return outputTokenKubeconfig(params.Printer, clusterConfig.cacheKey, token, !statelessIDPMode)
101122
}
102123

103124
// Configure API client
@@ -118,6 +139,8 @@ func NewCmd(params *types.CmdParams) *cobra.Command {
118139

119140
func configureFlags(cmd *cobra.Command) {
120141
cmd.Flags().Bool(idpFlag, false, "Use the STACKIT IdP for authentication to the cluster.")
142+
cmd.Flags().String(clusterNameFlag, "", "SKE cluster name. Used when the Kubernetes exec request does not provide cluster information.")
143+
cmd.Flags().String(organizationFlag, "", "Organization ID. Used in IdP mode when the Kubernetes exec request does not provide cluster information.")
121144
}
122145

123146
type clusterConfig struct {
@@ -129,8 +152,8 @@ type clusterConfig struct {
129152
cacheKey string
130153
}
131154

132-
func parseClusterConfig(p *print.Printer, cmd *cobra.Command, idpMode bool) (*clusterConfig, error) {
133-
obj, _, err := exec.LoadExecCredentialFromEnv()
155+
func parseClusterConfig(p *print.Printer, cmd *cobra.Command, idpMode, workloadIdentityMode, statelessIDPMode bool) (*clusterConfig, error) {
156+
obj, err := loadExecCredentialFromEnv()
134157
if err != nil {
135158
return nil, fmt.Errorf("LoadExecCredentialFromEnv: %w", err)
136159
}
@@ -148,33 +171,115 @@ func parseClusterConfig(p *print.Printer, cmd *cobra.Command, idpMode bool) (*cl
148171
if !ok {
149172
return nil, fmt.Errorf("conversion to ExecCredential failed")
150173
}
151-
if execCredential == nil || execCredential.Spec.Cluster == nil {
152-
return nil, fmt.Errorf("ExecCredential contains not all needed fields")
174+
if execCredential == nil {
175+
return nil, fmt.Errorf("ExecCredential is empty")
153176
}
154177
clusterConfig := &clusterConfig{}
155-
err = json.Unmarshal(execCredential.Spec.Cluster.Config.Raw, clusterConfig)
156-
if err != nil {
157-
return nil, fmt.Errorf("unmarshal: %w", err)
178+
clusterServer := ""
179+
if execCredential.Spec.Cluster != nil {
180+
clusterServer = execCredential.Spec.Cluster.Server
181+
if len(execCredential.Spec.Cluster.Config.Raw) > 0 {
182+
err = json.Unmarshal(execCredential.Spec.Cluster.Config.Raw, clusterConfig)
183+
if err != nil {
184+
return nil, fmt.Errorf("unmarshal: %w", err)
185+
}
186+
}
187+
}
188+
189+
if clusterName := flags.FlagToStringValue(p, cmd, clusterNameFlag); clusterName != "" {
190+
clusterConfig.ClusterName = clusterName
191+
}
192+
if organizationID := flags.FlagToStringValue(p, cmd, organizationFlag); organizationID != "" {
193+
clusterConfig.OrganizationID = organizationID
194+
}
195+
globalFlags := globalflags.Parse(p, cmd)
196+
if clusterConfig.STACKITProjectID == "" {
197+
clusterConfig.STACKITProjectID = globalFlags.ProjectId
198+
}
199+
if clusterConfig.Region == "" {
200+
clusterConfig.Region = globalFlags.Region
201+
}
202+
203+
missingFields := missingClusterConfigFields(clusterConfig, idpMode)
204+
if len(missingFields) > 0 {
205+
return nil, fmt.Errorf("ExecCredential cluster configuration is incomplete; provide %s", strings.Join(missingFields, ", "))
158206
}
159207

160-
authEmail, err := auth.GetAuthEmail()
208+
authIdentity, err := getAuthIdentity(workloadIdentityMode, statelessIDPMode)
161209
if err != nil {
162-
return nil, fmt.Errorf("error getting auth email: %w", err)
210+
return nil, fmt.Errorf("error getting auth identity: %w", err)
163211
}
164212
idpSuffix := ""
165213
if idpMode {
166214
idpSuffix = "\x00idp"
167215
}
168-
clusterConfig.cacheKey = fmt.Sprintf("ske-login-%x", sha256.Sum256([]byte(execCredential.Spec.Cluster.Server+"\x00"+authEmail+idpSuffix)))
169-
170-
// NOTE: Fallback if region is not set in the kubeconfig (this was the case in the past)
171-
if clusterConfig.Region == "" {
172-
clusterConfig.Region = globalflags.Parse(p, cmd).Region
216+
clusterIdentity := clusterServer
217+
if clusterIdentity == "" {
218+
clusterIdentity = strings.Join([]string{
219+
clusterConfig.OrganizationID,
220+
clusterConfig.STACKITProjectID,
221+
clusterConfig.Region,
222+
clusterConfig.ClusterName,
223+
}, "\x00")
173224
}
225+
clusterConfig.cacheKey = fmt.Sprintf("ske-login-%x", sha256.Sum256([]byte(clusterIdentity+"\x00"+authIdentity+idpSuffix)))
174226

175227
return clusterConfig, nil
176228
}
177229

230+
func loadExecCredentialFromEnv() (runtime.Object, error) {
231+
execInfo := os.Getenv("KUBERNETES_EXEC_INFO")
232+
if execInfo == "" {
233+
return nil, errors.New("KUBERNETES_EXEC_INFO env var is unset or empty")
234+
}
235+
236+
// client-go's loader rejects requests without cluster information. Decode the
237+
// v1 request directly when the Kubernetes client omits it.
238+
var execCredential clientauthenticationv1.ExecCredential
239+
if err := json.Unmarshal([]byte(execInfo), &execCredential); err == nil &&
240+
execCredential.APIVersion == clientauthenticationv1.SchemeGroupVersion.String() &&
241+
execCredential.Kind == "ExecCredential" && execCredential.Spec.Cluster == nil {
242+
return &execCredential, nil
243+
}
244+
245+
obj, _, err := exec.LoadExecCredential([]byte(execInfo))
246+
if err != nil {
247+
return nil, err
248+
}
249+
return obj, nil
250+
}
251+
252+
func missingClusterConfigFields(config *clusterConfig, idpMode bool) []string {
253+
missingFields := make([]string, 0, 4)
254+
if config.ClusterName == "" {
255+
missingFields = append(missingFields, "--cluster-name")
256+
}
257+
if config.STACKITProjectID == "" {
258+
missingFields = append(missingFields, "--project-id")
259+
}
260+
if config.Region == "" {
261+
missingFields = append(missingFields, "--region")
262+
}
263+
if idpMode && config.OrganizationID == "" {
264+
missingFields = append(missingFields, "--organization-id")
265+
}
266+
return missingFields
267+
}
268+
269+
func getAuthIdentity(workloadIdentityMode, statelessIDPMode bool) (string, error) {
270+
if workloadIdentityMode {
271+
email := os.Getenv(envServiceAccountEmail)
272+
if email == "" {
273+
return "", fmt.Errorf("%s is not set", envServiceAccountEmail)
274+
}
275+
return email, nil
276+
}
277+
if statelessIDPMode {
278+
return "stateless-idp", nil
279+
}
280+
return auth.GetAuthEmail()
281+
}
282+
178283
func retrieveLoginKubeconfig(ctx context.Context, apiClient *ske.APIClient, clusterConfig *clusterConfig) (*rest.Config, error) {
179284
cachedKubeconfig := getCachedKubeConfig(clusterConfig.cacheKey)
180285
if cachedKubeconfig == nil {
@@ -300,7 +405,20 @@ func parseLoginKubeConfigToExecCredential(kubeconfig *rest.Config) ([]byte, erro
300405
return output, nil
301406
}
302407

303-
func getAccessToken(params *types.CmdParams) (string, error) {
408+
func getAccessToken(params *types.CmdParams, workloadIdentityMode bool) (string, error) {
409+
if workloadIdentityMode {
410+
accessToken, err := getWorkloadIdentityAccessToken()
411+
if err != nil {
412+
params.Printer.Debug(print.ErrorLevel, "get workload identity access token: %v", err)
413+
return "", fmt.Errorf("get workload identity access token: %w", err)
414+
}
415+
return accessToken, nil
416+
}
417+
418+
if accessToken := os.Getenv(envAccessToken); accessToken != "" {
419+
return accessToken, nil
420+
}
421+
304422
userSessionExpired, err := auth.UserSessionExpired()
305423
if err != nil {
306424
return "", err
@@ -315,30 +433,53 @@ func getAccessToken(params *types.CmdParams) (string, error) {
315433
return "", &cliErr.SessionExpiredError{}
316434
}
317435

318-
err = auth.EnsureIDPTokenEndpoint(params.Printer)
319-
if err != nil {
320-
return "", err
436+
return accessToken, nil
437+
}
438+
439+
func workloadIdentityConfigured() bool {
440+
if os.Getenv(envServiceAccountEmail) == "" {
441+
return false
442+
}
443+
if os.Getenv(clients.FederatedTokenFileEnv) != "" {
444+
return true
321445
}
446+
fileInfo, err := os.Stat(defaultFederatedTokenPath)
447+
return err == nil && !fileInfo.IsDir()
448+
}
322449

323-
return accessToken, nil
450+
func getWorkloadIdentityAccessToken() (string, error) {
451+
roundTripper, err := sdkAuth.SetupAuth(&sdkConfig.Configuration{WorkloadIdentityFederation: true})
452+
if err != nil {
453+
return "", fmt.Errorf("configure workload identity federation: %w", err)
454+
}
455+
flow, ok := roundTripper.(interface {
456+
GetAccessToken() (string, error)
457+
})
458+
if !ok {
459+
return "", errors.New("configured authentication flow does not provide access tokens")
460+
}
461+
return flow.GetAccessToken()
324462
}
325463

326-
func retrieveTokenFromIDP(ctx context.Context, idpClient *http.Client, accessToken string, clusterConfig *clusterConfig) (string, error) {
464+
func retrieveTokenFromIDP(ctx context.Context, idpClient *http.Client, tokenEndpoint, accessToken string, clusterConfig *clusterConfig, cacheEnabled bool) (string, error) {
327465
resource := resourceForCluster(clusterConfig)
466+
if !cacheEnabled {
467+
return auth.ExchangeTokenWithEndpoint(ctx, idpClient, tokenEndpoint, accessToken, resource)
468+
}
328469

329470
cachedToken := getCachedToken(clusterConfig.cacheKey)
330471
if cachedToken == "" {
331-
return exchangeAndCacheToken(ctx, idpClient, accessToken, resource, clusterConfig.cacheKey)
472+
return exchangeAndCacheToken(ctx, idpClient, tokenEndpoint, accessToken, resource, clusterConfig.cacheKey)
332473
}
333474

334475
expiry, err := auth.TokenExpirationTime(cachedToken)
335476
if err != nil {
336477
// token is expired or invalid, request new
337478
_ = cache.DeleteObject(clusterConfig.cacheKey)
338-
return exchangeAndCacheToken(ctx, idpClient, accessToken, resource, clusterConfig.cacheKey)
479+
return exchangeAndCacheToken(ctx, idpClient, tokenEndpoint, accessToken, resource, clusterConfig.cacheKey)
339480
} else if time.Now().Add(refreshTokenBeforeDuration).After(expiry) {
340481
// token expires soon -> refresh
341-
token, err := exchangeAndCacheToken(ctx, idpClient, accessToken, resource, clusterConfig.cacheKey)
482+
token, err := exchangeAndCacheToken(ctx, idpClient, tokenEndpoint, accessToken, resource, clusterConfig.cacheKey)
342483
// try to get a new one but use cache on failure
343484
if err != nil {
344485
return cachedToken, nil
@@ -367,8 +508,8 @@ func getCachedToken(key string) string {
367508
return string(token)
368509
}
369510

370-
func exchangeAndCacheToken(ctx context.Context, idpClient *http.Client, accessToken, resource, cacheKey string) (string, error) {
371-
clusterToken, err := auth.ExchangeToken(ctx, idpClient, accessToken, resource)
511+
func exchangeAndCacheToken(ctx context.Context, idpClient *http.Client, tokenEndpoint, accessToken, resource, cacheKey string) (string, error) {
512+
clusterToken, err := auth.ExchangeTokenWithEndpoint(ctx, idpClient, tokenEndpoint, accessToken, resource)
372513
if err != nil {
373514
return "", err
374515
}
@@ -378,10 +519,12 @@ func exchangeAndCacheToken(ctx context.Context, idpClient *http.Client, accessTo
378519
return clusterToken, err
379520
}
380521

381-
func outputTokenKubeconfig(p *print.Printer, cacheKey, token string) error {
522+
func outputTokenKubeconfig(p *print.Printer, cacheKey, token string, cacheEnabled bool) error {
382523
output, err := parseTokenToExecCredential(token)
383524
if err != nil {
384-
_ = cache.DeleteObject(cacheKey)
525+
if cacheEnabled {
526+
_ = cache.DeleteObject(cacheKey)
527+
}
385528
return fmt.Errorf("convert to ExecCredential: %w", err)
386529
}
387530

0 commit comments

Comments
 (0)