diff --git a/docs/cli/commands/tanzu.md b/docs/cli/commands/tanzu.md index 77c9539d8..0dcc5365b 100644 --- a/docs/cli/commands/tanzu.md +++ b/docs/cli/commands/tanzu.md @@ -10,6 +10,7 @@ The Tanzu CLI ### SEE ALSO +* [tanzu api-token](tanzu_api-token.md) - Manage API Tokens for Tanzu Platform Self-managed * [tanzu completion](tanzu_completion.md) - Output shell completion code * [tanzu config](tanzu_config.md) - Configuration for the CLI * [tanzu context](tanzu_context.md) - Configure and manage contexts for the Tanzu CLI diff --git a/docs/cli/commands/tanzu_api-token.md b/docs/cli/commands/tanzu_api-token.md new file mode 100644 index 000000000..ce09bd026 --- /dev/null +++ b/docs/cli/commands/tanzu_api-token.md @@ -0,0 +1,15 @@ +## tanzu api-token + +Manage API Tokens for Tanzu Platform Self-managed + +### Options + +``` + -h, --help help for api-token +``` + +### SEE ALSO + +* [tanzu](tanzu.md) - The Tanzu CLI +* [tanzu api-token create](tanzu_api-token_create.md) - Create a new API Token for Tanzu Platform Self-managed + diff --git a/docs/cli/commands/tanzu_api-token_create.md b/docs/cli/commands/tanzu_api-token_create.md new file mode 100644 index 000000000..5ea49f51c --- /dev/null +++ b/docs/cli/commands/tanzu_api-token_create.md @@ -0,0 +1,29 @@ +## tanzu api-token create + +Create a new API Token for Tanzu Platform Self-managed + +``` +tanzu api-token create [flags] +``` + +### Examples + +``` + + # Create an API Token for the Tanzu Platform Self-managed + tanzu api-token create + + # Note: The retrieved token can be used as the value of TANZU_API_TOKEN + # environment variable when running 'tanzu login' for non-interactive workflow. +``` + +### Options + +``` + -h, --help help for create +``` + +### SEE ALSO + +* [tanzu api-token](tanzu_api-token.md) - Manage API Tokens for Tanzu Platform Self-managed + diff --git a/docs/quickstart/quickstart.md b/docs/quickstart/quickstart.md index 3bd3a2032..7e1c66674 100644 --- a/docs/quickstart/quickstart.md +++ b/docs/quickstart/quickstart.md @@ -232,10 +232,19 @@ Notes: ##### API Token +To authenticate with the Tanzu Platform Endpoint for non-interactive login, you must first generate an API token. + +###### Generating an API Token + +- For public endpoints, generate the API token via the [CSP UI](https://console.tanzu.broadcom.com). +- For Self-Managed use cases that rely on UAA, create the API token using the `tanzu api-token create` command. + +###### Logging in with an API Token + Example command to log in using an API token: ```console -TANZU_API_TOKEN= tanzu login +TANZU_API_TOKEN= tanzu login [--endpoint ] ``` Users can persist the environment variable in the CLI configuration file, which will be used for each CLI command @@ -243,7 +252,7 @@ invocation: ```console tanzu config set env.TANZU_API_TOKEN -tanzu login +tanzu login [--endpoint ] ``` ### Creating and connecting to a new context @@ -298,6 +307,15 @@ Notes: ##### API Token +To authenticate with the Tanzu Platform Endpoint for non-interactive login, you must first generate an API token. + +###### Generating an API Token + +- For public endpoints, generate the API token via the [CSP UI](https://console.tanzu.broadcom.com). +- For Self-Managed use cases that rely on UAA, create the API token using the `tanzu api-token create` command. + +###### Logging in with an API Token + Example command for creating a tanzu context using an API token: ```console diff --git a/go.mod b/go.mod index f072d8149..54baa3f4a 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,7 @@ require ( github.com/gobwas/glob v0.2.3 github.com/golang-jwt/jwt v3.2.2+incompatible github.com/google/gnostic v0.6.9 + github.com/google/go-cmp v0.5.9 github.com/google/go-containerregistry v0.15.2 github.com/google/uuid v1.6.0 github.com/gorilla/mux v1.8.0 @@ -139,7 +140,6 @@ require ( github.com/golang/protobuf v1.5.3 // indirect github.com/golang/snappy v0.0.4 // indirect github.com/google/certificate-transparency-go v1.1.6 // indirect - github.com/google/go-cmp v0.5.9 // indirect github.com/google/go-github/v50 v50.2.0 // indirect github.com/google/go-querystring v1.1.0 // indirect github.com/google/gofuzz v1.2.0 // indirect @@ -196,6 +196,7 @@ require ( github.com/spf13/afero v1.9.3 // indirect github.com/spf13/cast v1.5.0 // indirect github.com/spf13/jwalterweatherman v1.1.0 // indirect + github.com/stretchr/objx v0.5.0 // indirect github.com/subosito/gotenv v1.4.2 // indirect github.com/syndtr/goleveldb v1.0.1-0.20220721030215-126854af5e6d // indirect github.com/thales-e-security/pool v0.0.2 // indirect diff --git a/go.sum b/go.sum index 9131aeda6..bbb0168c8 100644 --- a/go.sum +++ b/go.sum @@ -683,6 +683,7 @@ github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= diff --git a/pkg/auth/common/token.go b/pkg/auth/common/token.go index b3960f97e..1c642a602 100644 --- a/pkg/auth/common/token.go +++ b/pkg/auth/common/token.go @@ -14,14 +14,14 @@ import ( "github.com/vmware-tanzu/tanzu-plugin-runtime/config" "github.com/vmware-tanzu/tanzu-plugin-runtime/config/types" "github.com/vmware-tanzu/tanzu-plugin-runtime/log" + + timeutils "github.com/vmware-tanzu/tanzu-cli/pkg/utils/time" ) const ( extraIDToken = "id_token" ) -var currentTime = time.Now - const ( APITokenType = "api-token" IDTokenType = "id-token" @@ -99,7 +99,7 @@ func GetToken(g *types.GlobalServerAuth, tokenGetter func(refreshOrAPIToken, acc g.RefreshToken = token.RefreshToken g.AccessToken = token.AccessToken g.IDToken = token.IDToken - expiration := currentTime().Local().Add(time.Duration(token.ExpiresIn) * time.Second) + expiration := timeutils.Now().Local().Add(time.Duration(token.ExpiresIn) * time.Second) g.Expiration = expiration g.Permissions = claims.Permissions @@ -173,7 +173,7 @@ func ParseToken(tkn *oauth2.Token, idpType config.IdpType) (*Claims, error) { func IsExpired(tokenExpiry time.Time) bool { // refresh at half token life two := 2 - now := currentTime().Unix() + now := timeutils.Now().Unix() halfDur := -time.Duration((tokenExpiry.Unix()-now)/int64(two)) * time.Second return tokenExpiry.Add(halfDur).Unix() < now } diff --git a/pkg/auth/common/token_test.go b/pkg/auth/common/token_test.go index ee4674e22..f2b51fb87 100644 --- a/pkg/auth/common/token_test.go +++ b/pkg/auth/common/token_test.go @@ -15,6 +15,7 @@ import ( "github.com/stretchr/testify/assert" "golang.org/x/oauth2" + timeutils "github.com/vmware-tanzu/tanzu-cli/pkg/utils/time" "github.com/vmware-tanzu/tanzu-plugin-runtime/config" configtypes "github.com/vmware-tanzu/tanzu-plugin-runtime/config/types" ) @@ -190,7 +191,7 @@ func TestGetToken_Valid_NotExpired(t *testing.T) { func TestGetToken_Expired(t *testing.T) { var theOneNow = time.Now() // override currentTime to always returns same value - currentTime = func() time.Time { + timeutils.Now = func() time.Time { return theOneNow } @@ -200,7 +201,7 @@ func TestGetToken_Expired(t *testing.T) { `{"sub":"1234567890","username":"joe","context_name":"1516239022"}`, ) - expireTime := currentTime().Add(-time.Minute * 30) + expireTime := timeutils.Now().Add(-time.Minute * 30) serverAuth := configtypes.GlobalServerAuth{ Issuer: "https://oidc.example.com", @@ -213,7 +214,7 @@ func TestGetToken_Expired(t *testing.T) { } newRefreshToken := "LetMeInAgain" - newExpiryTime := currentTime().Local().Add(time.Minute * 30) + newExpiryTime := timeutils.Now().Local().Add(time.Minute * 30) newExpiry := int64(30 * 60) tokenGetter := createMockTokenGetter(newRefreshToken, newExpiry) diff --git a/pkg/auth/uaa/tanzu.go b/pkg/auth/uaa/tanzu.go index 96d60c1e2..f775c7aa0 100644 --- a/pkg/auth/uaa/tanzu.go +++ b/pkg/auth/uaa/tanzu.go @@ -45,7 +45,7 @@ func GetAlternateClientID() string { return clientID } -func TanzuLogin(issuerURL string, opts ...common.LoginOption) (*common.Token, error) { +var TanzuLogin = func(issuerURL string, opts ...common.LoginOption) (*common.Token, error) { issuerEndpoints := getIssuerEndpoints(issuerURL) h := common.NewTanzuLoginHandler(issuerURL, issuerEndpoints.AuthURL, issuerEndpoints.TokenURL, tanzuCLIClientID, tanzuCLIClientSecret, defaultListenAddress, defaultCallbackPath, config.UAAIdpType, nil, nil, term.IsTerminal) diff --git a/pkg/command/apitoken.go b/pkg/command/apitoken.go new file mode 100644 index 000000000..733bd1bb8 --- /dev/null +++ b/pkg/command/apitoken.go @@ -0,0 +1,104 @@ +// Copyright 2024 VMware, Inc. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package command + +import ( + "fmt" + "net/url" + + "github.com/fatih/color" + "github.com/pkg/errors" + "github.com/spf13/cobra" + + "github.com/vmware-tanzu/tanzu-plugin-runtime/config" + "github.com/vmware-tanzu/tanzu-plugin-runtime/config/types" + "github.com/vmware-tanzu/tanzu-plugin-runtime/plugin" + + commonauth "github.com/vmware-tanzu/tanzu-cli/pkg/auth/common" + "github.com/vmware-tanzu/tanzu-cli/pkg/auth/uaa" + "github.com/vmware-tanzu/tanzu-cli/pkg/cli" + "github.com/vmware-tanzu/tanzu-cli/pkg/constants" +) + +func newAPITokenCmd() *cobra.Command { + apiTokenCmd := &cobra.Command{ + Use: "api-token", + Short: "Manage API Tokens for Tanzu Platform Self-managed", + Aliases: []string{"apitoken"}, + Annotations: map[string]string{ + "group": string(plugin.SystemCmdGroup), + }, + } + + apiTokenCmd.SetUsageFunc(cli.SubCmdUsageFunc) + apiTokenCmd.AddCommand( + newAPITokenCreateCmd(), + ) + + return apiTokenCmd +} + +func newAPITokenCreateCmd() *cobra.Command { + createCmd := &cobra.Command{ + Use: "create", + Short: "Create a new API Token for Tanzu Platform Self-managed", + Aliases: []string{}, + Example: ` + # Create an API Token for the Tanzu Platform Self-managed + tanzu api-token create + + # Note: The retrieved token can be used as the value of TANZU_API_TOKEN + # environment variable when running 'tanzu login' for non-interactive workflow.`, + RunE: createAPIToken, + ValidArgsFunction: noMoreCompletions, + } + + return createCmd +} + +func createAPIToken(cmd *cobra.Command, _ []string) (err error) { + c, err := config.GetActiveContext(types.ContextTypeTanzu) + if err != nil { + return errors.New("no active context found for Tanzu Platform. Please login to Tanzu Platform first to generate an API token") + } + if c == nil || c.GlobalOpts == nil || c.GlobalOpts.Auth.Issuer == "" { + return errors.New("invalid active context found for Tanzu Platform. Please login to Tanzu Platform first to generate an API token") + } + // Make sure it is of type tanzu with tanzuIdpType as `uaa` else return error + if idpType, exist := c.AdditionalMetadata[config.TanzuIdpTypeKey]; !exist || idpType != string(config.UAAIdpType) { + return errors.New("command no supported. Please refer to documentation on how to generate an API token for a public Tanzu Platform endpoint via https://console.tanzu.broadcom.com") + } + + var token *commonauth.Token + // If user chooses to use a specific local listener port, use it + // Also specify the client ID to use for token generation + loginOptions := []commonauth.LoginOption{ + commonauth.WithListenerPortFromEnv(constants.TanzuCLIOAuthLocalListenerPort), + commonauth.WithClientID(uaa.GetAlternateClientID()), + } + + token, err = uaa.TanzuLogin(c.GlobalOpts.Auth.Issuer, loginOptions...) + if err != nil { + return errors.Wrap(err, "unable to login") + } + + // Get tanzu platform endpoint as best effort from the existing context + tpEndpoint := "" + if hubEndpoint, exist := c.AdditionalMetadata[config.TanzuHubEndpointKey]; exist && hubEndpoint != nil { + u, err := url.Parse(hubEndpoint.(string)) + if err == nil { + tpEndpoint = fmt.Sprintf("%s://%s", u.Scheme, u.Host) + } + } + + cyanBold := color.New(color.FgCyan).Add(color.Bold) + bold := color.New(color.Bold) + + fmt.Fprint(cmd.OutOrStdout(), bold.Sprint("==\n\n")) + fmt.Fprintf(cmd.OutOrStdout(), "%s Your generated API token is: %s\n\n", bold.Sprint("API Token Generation Successful!"), cyanBold.Sprint(token.RefreshToken)) + fmt.Fprintf(cmd.OutOrStdout(), "For Tanzu CLI use in non-interactive settings, set the environment variable %s before authenticating with the command %s\n\n", cyanBold.Sprintf("TANZU_API_TOKEN=%s", token.RefreshToken), cyanBold.Sprintf("tanzu login --endpoint %s", tpEndpoint)) + fmt.Fprint(cmd.OutOrStdout(), "Please copy and save your token securely. Note that you will need to regenerate a new token before expiration time and login again to continue using the CLI.\n") + + return nil +} diff --git a/pkg/command/apitoken_test.go b/pkg/command/apitoken_test.go new file mode 100644 index 000000000..33d160f45 --- /dev/null +++ b/pkg/command/apitoken_test.go @@ -0,0 +1,221 @@ +// Copyright 2024 VMware, Inc. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package command + +import ( + "bytes" + "os" + "path/filepath" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/otiai10/copy" + "github.com/pkg/errors" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + + "github.com/vmware-tanzu/tanzu-plugin-runtime/config" + configtypes "github.com/vmware-tanzu/tanzu-plugin-runtime/config/types" + + "github.com/vmware-tanzu/tanzu-cli/pkg/auth/common" + "github.com/vmware-tanzu/tanzu-cli/pkg/auth/uaa" +) + +// mockTanzuLogin is a mock implementation of the TanzuLogin function +type mockTanzuLogin struct { + mock.Mock +} + +func (m *mockTanzuLogin) TanzuLogin(issuerURL string, opts ...common.LoginOption) (*common.Token, error) { + args := m.Called(issuerURL, opts) + return args.Get(0).(*common.Token), args.Error(1) +} + +func TestCreateAPIToken(t *testing.T) { + var configFile, configFileNG *os.File + var err error + + setupEnv := func() { + configFile, err = os.CreateTemp("", "config") + assert.NoError(t, err) + + err = copy.Copy(filepath.Join("..", "fakes", "config", "tanzu_config.yaml"), configFile.Name()) + assert.NoError(t, err) + + os.Setenv("TANZU_CONFIG", configFile.Name()) + + configFileNG, err = os.CreateTemp("", "config_ng") + assert.NoError(t, err) + + os.Setenv("TANZU_CONFIG_NEXT_GEN", configFileNG.Name()) + err = copy.Copy(filepath.Join("..", "fakes", "config", "tanzu_config_ng.yaml"), configFileNG.Name()) + assert.NoError(t, err) + } + + teardownEnv := func() { + os.Unsetenv("TANZU_CONFIG") + os.Unsetenv("TANZU_CONFIG_NEXT_GEN") + os.RemoveAll(configFile.Name()) + os.RemoveAll(configFileNG.Name()) + } + + tests := []struct { + name string + context *configtypes.Context + tanzuLoginErr error + wantErr bool + errMsg string + output string + }{ + { + name: "success", + context: &configtypes.Context{ + Name: "fakecontext", + ContextType: configtypes.ContextTypeTanzu, + GlobalOpts: &configtypes.GlobalServer{ + Auth: configtypes.GlobalServerAuth{ + Issuer: "https://example.com", + }, + }, + AdditionalMetadata: map[string]interface{}{ + config.TanzuIdpTypeKey: config.UAAIdpType, + }, + }, + tanzuLoginErr: nil, + wantErr: false, + output: `== + +API Token Generation Successful! Your generated API token is: refresh-token + +For Tanzu CLI use in non-interactive settings, set the environment variable TANZU_API_TOKEN=refresh-token before authenticating with the command tanzu login --endpoint + +Please copy and save your token securely. Note that you will need to regenerate a new token before expiration time and login again to continue using the CLI. +`, + }, + { + name: "success with specific endpoint in log message", + context: &configtypes.Context{ + Name: "fakecontext", + ContextType: configtypes.ContextTypeTanzu, + GlobalOpts: &configtypes.GlobalServer{ + Auth: configtypes.GlobalServerAuth{ + Issuer: "https://example.com", + }, + }, + AdditionalMetadata: map[string]interface{}{ + config.TanzuIdpTypeKey: config.UAAIdpType, + config.TanzuHubEndpointKey: "https://platform.tanzu.broadcom.com/hub", + }, + }, + tanzuLoginErr: nil, + wantErr: false, + output: `== + +API Token Generation Successful! Your generated API token is: refresh-token + +For Tanzu CLI use in non-interactive settings, set the environment variable TANZU_API_TOKEN=refresh-token before authenticating with the command tanzu login --endpoint https://platform.tanzu.broadcom.com + +Please copy and save your token securely. Note that you will need to regenerate a new token before expiration time and login again to continue using the CLI. +`, + }, + { + name: "no active context", + context: nil, + tanzuLoginErr: nil, + wantErr: true, + errMsg: "no active context found for Tanzu Platform. Please login to Tanzu Platform first to generate an API token", + }, + { + name: "invalid active context", + context: &configtypes.Context{ + Name: "fakecontext", + ContextType: configtypes.ContextTypeTanzu, + AdditionalMetadata: map[string]interface{}{ + config.TanzuIdpTypeKey: config.UAAIdpType, + }, + }, + tanzuLoginErr: nil, + wantErr: true, + errMsg: "invalid active context found for Tanzu Platform. Please login to Tanzu Platform first to generate an API token", + }, + { + name: "invalid IDP type", + context: &configtypes.Context{ + Name: "fakecontext", + ContextType: configtypes.ContextTypeTanzu, + GlobalOpts: &configtypes.GlobalServer{ + Auth: configtypes.GlobalServerAuth{ + Issuer: "https://example.com", + }, + }, + AdditionalMetadata: map[string]interface{}{ + config.TanzuIdpTypeKey: "other", + }, + }, + tanzuLoginErr: nil, + wantErr: true, + errMsg: "command no supported. Please refer to documentation on how to generate an API token for a public Tanzu Platform endpoint via https://console.tanzu.broadcom.com", + }, + { + name: "TanzuLogin error", + context: &configtypes.Context{ + Name: "fakecontext", + ContextType: configtypes.ContextTypeTanzu, + GlobalOpts: &configtypes.GlobalServer{ + Auth: configtypes.GlobalServerAuth{ + Issuer: "https://example.com", + }, + }, + AdditionalMetadata: map[string]interface{}{ + config.TanzuIdpTypeKey: config.UAAIdpType, + }, + }, + tanzuLoginErr: errors.New("TanzuLogin error"), + wantErr: true, + errMsg: "unable to login, TanzuLogin error", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + setupEnv() + defer teardownEnv() + + cmd := &cobra.Command{} + outputBuf := &bytes.Buffer{} + cmd.SetOutput(outputBuf) + + originalTanzuLogin := uaa.TanzuLogin + defer func() { + uaa.TanzuLogin = originalTanzuLogin + }() + + mockTanzuLogin := &mockTanzuLogin{} + uaa.TanzuLogin = mockTanzuLogin.TanzuLogin + if tt.context != nil && tt.context.GlobalOpts != nil && tt.context.AdditionalMetadata[config.TanzuIdpTypeKey] == config.UAAIdpType { + mockTanzuLogin.On("TanzuLogin", tt.context.GlobalOpts.Auth.Issuer, mock.Anything). + Return(&common.Token{RefreshToken: "refresh-token", ExpiresIn: 3600}, tt.tanzuLoginErr) + } + + if tt.context != nil { + err = config.SetContext(tt.context, true) + assert.NoError(t, err) + } + + err := createAPIToken(cmd, nil) + + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + if diff := cmp.Diff(tt.output, outputBuf.String()); diff != "" { + t.Errorf("Unexpected output (-expected, +actual): %s", diff) + } + } + + mockTanzuLogin.AssertExpectations(t) + }) + } +} diff --git a/pkg/command/root.go b/pkg/command/root.go index 57c74ddf2..3840d26a1 100644 --- a/pkg/command/root.go +++ b/pkg/command/root.go @@ -98,6 +98,7 @@ func NewRootCmd() (*cobra.Command, error) { //nolint: gocyclo,funlen newCompletionCmd(), newConfigCmd(), newContextCmd(), + newAPITokenCmd(), k8sCmd, tmcCmd, opsCmd, diff --git a/pkg/utils/time/time.go b/pkg/utils/time/time.go new file mode 100644 index 000000000..4416d06d9 --- /dev/null +++ b/pkg/utils/time/time.go @@ -0,0 +1,9 @@ +// Copyright 2024 VMware, Inc. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Package time contains time specific functions and variable to make it to overwrite for unit tests +package time + +import "time" + +var Now = time.Now