Skip to content

Commit 98efb23

Browse files
implement kubeconfig login --idp flow
1 parent 3e8816c commit 98efb23

File tree

6 files changed

+247
-27
lines changed

6 files changed

+247
-27
lines changed

docs/stackit_ske_kubeconfig_login.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ Login plugin for kubernetes clients
55
### Synopsis
66

77
Login plugin for kubernetes clients, that creates short-lived credentials to authenticate against a STACKIT Kubernetes Engine (SKE) cluster.
8-
First you need to obtain a kubeconfig for use with the login command (first example).
9-
Secondly you use the kubeconfig with your chosen Kubernetes client (second example), the client will automatically retrieve the credentials via the STACKIT CLI.
8+
First you need to obtain a kubeconfig for use with the login command (first or second example).
9+
Secondly you use the kubeconfig with your chosen Kubernetes client (third example), the client will automatically retrieve the credentials via the STACKIT CLI.
1010

1111
```
1212
stackit ske kubeconfig login [flags]
@@ -15,9 +15,12 @@ stackit ske kubeconfig login [flags]
1515
### Examples
1616

1717
```
18-
Get a login kubeconfig for the SKE cluster with name "my-cluster". This kubeconfig does not contain any credentials and instead obtains valid credentials via the `stackit ske kubeconfig login` command.
18+
Get an admin, login kubeconfig for the SKE cluster with name "my-cluster". This kubeconfig does not contain any credentials and instead obtains valid admin credentials via the `stackit ske kubeconfig login` command.
1919
$ stackit ske kubeconfig create my-cluster --login
2020
21+
Get an IDP kubeconfig for the SKE cluster with name "my-cluster". This kubeconfig does not contain any credentials and instead obtains valid credentials via the `stackit ske kubeconfig login` command.
22+
$ stackit ske kubeconfig create my-cluster --idp
23+
2124
Use the previously saved kubeconfig to authenticate to the SKE cluster, in this case with kubectl.
2225
$ kubectl cluster-info
2326
$ kubectl get pods
@@ -27,6 +30,7 @@ stackit ske kubeconfig login [flags]
2730

2831
```
2932
-h, --help Help for "stackit ske kubeconfig login"
33+
--idp Use the STACKIT IdP for authentication to the cluster.
3034
```
3135

3236
### Options inherited from parent commands

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

Lines changed: 221 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,12 @@ import (
88
"encoding/pem"
99
"errors"
1010
"fmt"
11+
"io"
12+
"net/http"
13+
"net/url"
1114
"os"
1215
"strconv"
16+
"strings"
1317
"time"
1418

1519
"github.com/spf13/cobra"
@@ -23,7 +27,9 @@ import (
2327
"github.com/stackitcloud/stackit-cli/internal/pkg/args"
2428
"github.com/stackitcloud/stackit-cli/internal/pkg/auth"
2529
"github.com/stackitcloud/stackit-cli/internal/pkg/cache"
30+
cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors"
2631
"github.com/stackitcloud/stackit-cli/internal/pkg/examples"
32+
"github.com/stackitcloud/stackit-cli/internal/pkg/flags"
2733
"github.com/stackitcloud/stackit-cli/internal/pkg/globalflags"
2834
"github.com/stackitcloud/stackit-cli/internal/pkg/print"
2935
"github.com/stackitcloud/stackit-cli/internal/pkg/services/ske/client"
@@ -32,8 +38,11 @@ import (
3238
)
3339

3440
const (
35-
expirationSeconds = 30 * 60 // 30 min
36-
refreshBeforeDuration = 15 * time.Minute // 15 min
41+
expirationSeconds = 30 * 60 // 30 min
42+
refreshBeforeDuration = 15 * time.Minute // 15 min
43+
refreshTokenBeforeDuration = 5 * time.Minute // 5 min
44+
45+
idpFlag = "idp"
3746
)
3847

3948
func NewCmd(params *types.CmdParams) *cobra.Command {
@@ -42,15 +51,19 @@ func NewCmd(params *types.CmdParams) *cobra.Command {
4251
Short: "Login plugin for kubernetes clients",
4352
Long: fmt.Sprintf("%s\n%s\n%s",
4453
"Login plugin for kubernetes clients, that creates short-lived credentials to authenticate against a STACKIT Kubernetes Engine (SKE) cluster.",
45-
"First you need to obtain a kubeconfig for use with the login command (first example).",
46-
"Secondly you use the kubeconfig with your chosen Kubernetes client (second example), the client will automatically retrieve the credentials via the STACKIT CLI.",
54+
"First you need to obtain a kubeconfig for use with the login command (first or second example).",
55+
"Secondly you use the kubeconfig with your chosen Kubernetes client (third example), the client will automatically retrieve the credentials via the STACKIT CLI.",
4756
),
4857
Args: args.NoArgs,
4958
Example: examples.Build(
5059
examples.NewExample(
51-
`Get a login kubeconfig for the SKE cluster with name "my-cluster". `+
52-
"This kubeconfig does not contain any credentials and instead obtains valid credentials via the `stackit ske kubeconfig login` command.",
60+
`Get an admin, login kubeconfig for the SKE cluster with name "my-cluster". `+
61+
"This kubeconfig does not contain any credentials and instead obtains valid admin credentials via the `stackit ske kubeconfig login` command.",
5362
"$ stackit ske kubeconfig create my-cluster --login"),
63+
examples.NewExample(
64+
`Get an IDP kubeconfig for the SKE cluster with name "my-cluster". `+
65+
"This kubeconfig does not contain any credentials and instead obtains valid credentials via the `stackit ske kubeconfig login` command.",
66+
"$ stackit ske kubeconfig create my-cluster --idp"),
5467
examples.NewExample(
5568
"Use the previously saved kubeconfig to authenticate to the SKE cluster, in this case with kubectl.",
5669
"$ kubectl cluster-info",
@@ -70,11 +83,25 @@ func NewCmd(params *types.CmdParams) *cobra.Command {
7083
"See `stackit ske kubeconfig login --help` for detailed usage instructions.")
7184
}
7285

73-
clusterConfig, err := parseClusterConfig(params.Printer, cmd)
86+
idpMode := flags.FlagToBoolValue(params.Printer, cmd, idpFlag)
87+
clusterConfig, err := parseClusterConfig(params.Printer, cmd, idpMode)
7488
if err != nil {
7589
return fmt.Errorf("parseClusterConfig: %w", err)
7690
}
7791

92+
if idpMode {
93+
accessToken, err := getAccessToken(params)
94+
if err != nil {
95+
return err
96+
}
97+
idpClient := &http.Client{}
98+
token, err := retrieveTokenFromIDP(ctx, idpClient, accessToken, clusterConfig)
99+
if err != nil {
100+
return err
101+
}
102+
return outputTokenKubeconfig(params.Printer, clusterConfig.cacheKey, token)
103+
}
104+
78105
// Configure API client
79106
apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion)
80107
if err != nil {
@@ -87,18 +114,24 @@ func NewCmd(params *types.CmdParams) *cobra.Command {
87114
return outputLoginKubeconfig(params.Printer, clusterConfig.cacheKey, kubeconfig)
88115
},
89116
}
117+
configureFlags(cmd)
90118
return cmd
91119
}
92120

121+
func configureFlags(cmd *cobra.Command) {
122+
cmd.Flags().Bool(idpFlag, false, "Use the STACKIT IdP for authentication to the cluster.")
123+
}
124+
93125
type clusterConfig struct {
94126
STACKITProjectID string `json:"stackitProjectID"`
95127
ClusterName string `json:"clusterName"`
96128
Region string `json:"region"`
129+
OrganizationID string `json:"organizationID"`
97130

98131
cacheKey string
99132
}
100133

101-
func parseClusterConfig(p *print.Printer, cmd *cobra.Command) (*clusterConfig, error) {
134+
func parseClusterConfig(p *print.Printer, cmd *cobra.Command, idpMode bool) (*clusterConfig, error) {
102135
obj, _, err := exec.LoadExecCredentialFromEnv()
103136
if err != nil {
104137
return nil, fmt.Errorf("LoadExecCredentialFromEnv: %w", err)
@@ -130,8 +163,11 @@ func parseClusterConfig(p *print.Printer, cmd *cobra.Command) (*clusterConfig, e
130163
if err != nil {
131164
return nil, fmt.Errorf("error getting auth email: %w", err)
132165
}
133-
134-
clusterConfig.cacheKey = fmt.Sprintf("ske-login-%x", sha256.Sum256([]byte(execCredential.Spec.Cluster.Server+"\x00"+authEmail)))
166+
idpSuffix := ""
167+
if idpMode {
168+
idpSuffix = "\x00idp"
169+
}
170+
clusterConfig.cacheKey = fmt.Sprintf("ske-login-%x", sha256.Sum256([]byte(execCredential.Spec.Cluster.Server+"\x00"+authEmail+idpSuffix)))
135171

136172
// NOTE: Fallback if region is not set in the kubeconfig (this was the case in the past)
137173
if clusterConfig.Region == "" {
@@ -163,7 +199,6 @@ func retrieveLoginKubeconfig(ctx context.Context, apiClient *ske.APIClient, clus
163199
}
164200
// cert not expired, nor will it expire in the next 15min; therefore, use the cached kubeconfig
165201
return cachedKubeconfig, nil
166-
167202
}
168203

169204
func getCachedKubeConfig(key string) *rest.Config {
@@ -266,3 +301,178 @@ func parseLoginKubeConfigToExecCredential(kubeconfig *rest.Config) ([]byte, erro
266301
}
267302
return output, nil
268303
}
304+
305+
func getAccessToken(params *types.CmdParams) (string, error) {
306+
userSessionExpired, err := auth.UserSessionExpired()
307+
if err != nil {
308+
return "", err
309+
}
310+
if userSessionExpired {
311+
return "", &cliErr.SessionExpiredError{}
312+
}
313+
314+
accessToken, err := auth.GetValidAccessToken(params.Printer)
315+
if err != nil {
316+
params.Printer.Debug(print.ErrorLevel, "get valid access token: %v", err)
317+
return "", &cliErr.SessionExpiredError{}
318+
}
319+
return accessToken, nil
320+
}
321+
322+
func retrieveTokenFromIDP(ctx context.Context, idpClient *http.Client, accessToken string, clusterConfig *clusterConfig) (string, error) {
323+
tokenEndpoint, err := auth.GetAuthField(auth.IDP_TOKEN_ENDPOINT)
324+
if err != nil {
325+
return "", fmt.Errorf("get idp token endpoint: %w", err)
326+
}
327+
328+
cachedToken := getCachedToken(clusterConfig.cacheKey)
329+
if cachedToken == "" {
330+
return exchangeToken(ctx, idpClient, tokenEndpoint, accessToken, clusterConfig)
331+
}
332+
333+
expiry, err := auth.TokenExpirationTime(cachedToken)
334+
if err != nil {
335+
// token is expired or invalid, request new
336+
_ = cache.DeleteObject(clusterConfig.cacheKey)
337+
return exchangeToken(ctx, idpClient, tokenEndpoint, accessToken, clusterConfig)
338+
} else if time.Now().Add(refreshTokenBeforeDuration).After(expiry) {
339+
// token expires soon -> refresh
340+
token, err := exchangeToken(ctx, idpClient, tokenEndpoint, accessToken, clusterConfig)
341+
// try to get a new one but use cache on failure
342+
if err != nil {
343+
return cachedToken, nil
344+
}
345+
return token, nil
346+
}
347+
// cached token is valid and won't expire soon
348+
return cachedToken, nil
349+
}
350+
351+
func getCachedToken(key string) string {
352+
token, err := cache.GetObject(key)
353+
if err != nil {
354+
return ""
355+
}
356+
return string(token)
357+
}
358+
359+
func exchangeToken(ctx context.Context, idpClient *http.Client, tokenEndpoint, accessToken string, config *clusterConfig) (string, error) {
360+
req, err := buildRequestToExchangeTokens(ctx, tokenEndpoint, accessToken, config)
361+
if err != nil {
362+
return "", fmt.Errorf("build request: %w", err)
363+
}
364+
resp, err := idpClient.Do(req)
365+
if err != nil {
366+
return "", fmt.Errorf("call API: %w", err)
367+
}
368+
defer func() {
369+
tempErr := resp.Body.Close()
370+
if tempErr != nil {
371+
err = fmt.Errorf("close response body: %w", tempErr)
372+
}
373+
}()
374+
375+
clusterToken, err := parseTokenExchangeResponse(resp)
376+
if err != nil {
377+
return "", fmt.Errorf("parse API response: %w", err)
378+
}
379+
if err = cache.PutObject(config.cacheKey, []byte(clusterToken)); err != nil {
380+
return "", fmt.Errorf("cache token: %w", err)
381+
}
382+
return clusterToken, err
383+
}
384+
385+
func buildRequestToExchangeTokens(ctx context.Context, tokenEndpoint, accessToken string, config *clusterConfig) (*http.Request, error) {
386+
idpClientID, err := auth.GetIDPClientID()
387+
if err != nil {
388+
return nil, err
389+
}
390+
391+
form := url.Values{}
392+
form.Set("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange")
393+
form.Set("client_id", idpClientID)
394+
form.Set("subject_token_type", "urn:ietf:params:oauth:token-type:access_token")
395+
form.Set("requested_token_type", "urn:ietf:params:oauth:token-type:id_token")
396+
form.Set("scope", "openid profile email groups")
397+
form.Set("subject_token", accessToken)
398+
form.Set("resource", resourceForCluster(config))
399+
400+
req, err := http.NewRequestWithContext(
401+
ctx,
402+
http.MethodPost,
403+
tokenEndpoint,
404+
strings.NewReader(form.Encode()),
405+
)
406+
if err != nil {
407+
return nil, fmt.Errorf("build exchange request: %w", err)
408+
}
409+
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
410+
411+
return req, nil
412+
}
413+
414+
func resourceForCluster(config *clusterConfig) string {
415+
return fmt.Sprintf(
416+
"resource://organizations/%s/projects/%s/regions/%s/ske/%s",
417+
config.OrganizationID,
418+
config.STACKITProjectID,
419+
config.Region,
420+
config.ClusterName,
421+
)
422+
}
423+
424+
func parseTokenExchangeResponse(resp *http.Response) (accessToken string, err error) {
425+
respBody, err := io.ReadAll(resp.Body)
426+
if err != nil {
427+
return "", fmt.Errorf("read body: %w", err)
428+
}
429+
if resp.StatusCode != http.StatusOK {
430+
return "", fmt.Errorf("non-OK %d status: %s", resp.StatusCode, string(respBody))
431+
}
432+
433+
respContent := struct {
434+
AccessToken string `json:"access_token"`
435+
}{}
436+
err = json.Unmarshal(respBody, &respContent)
437+
if err != nil {
438+
return "", fmt.Errorf("unmarshal body: %w", err)
439+
}
440+
if respContent.AccessToken == "" {
441+
return "", fmt.Errorf("no access token found")
442+
}
443+
return respContent.AccessToken, nil
444+
}
445+
446+
func outputTokenKubeconfig(p *print.Printer, cacheKey, token string) error {
447+
output, err := parseTokenToExecCredential(token)
448+
if err != nil {
449+
_ = cache.DeleteObject(cacheKey)
450+
return fmt.Errorf("convert to ExecCredential: %w", err)
451+
}
452+
453+
p.Outputf("%s", string(output))
454+
return nil
455+
}
456+
457+
func parseTokenToExecCredential(clusterToken string) ([]byte, error) {
458+
expiry, err := auth.TokenExpirationTime(clusterToken)
459+
if err != nil {
460+
return nil, fmt.Errorf("parse auth token for cluster: %w", err)
461+
}
462+
463+
outputExecCredential := clientauthenticationv1.ExecCredential{
464+
TypeMeta: v1.TypeMeta{
465+
APIVersion: clientauthenticationv1.SchemeGroupVersion.String(),
466+
Kind: "ExecCredential",
467+
},
468+
Status: &clientauthenticationv1.ExecCredentialStatus{
469+
ExpirationTimestamp: &v1.Time{Time: expiry.Add(-refreshTokenBeforeDuration)},
470+
Token: clusterToken,
471+
},
472+
}
473+
output, err := json.Marshal(&outputExecCredential)
474+
if err != nil {
475+
return nil, fmt.Errorf("marshal: %w", err)
476+
}
477+
return output, nil
478+
}

internal/pkg/auth/user_login.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ func AuthorizeUser(p *print.Printer, isReauthentication bool) error {
7070
return fmt.Errorf("parse IDP well-known configuration: %w", err)
7171
}
7272

73-
idpClientID, err := getIDPClientID()
73+
idpClientID, err := GetIDPClientID()
7474
if err != nil {
7575
return err
7676
}

0 commit comments

Comments
 (0)