@@ -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
4657func 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
119140func 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
123146type 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 = "\x00 idp"
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+
178283func 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