diff --git a/Makefile b/Makefile index 50a7ba7b6..ce03adb0e 100644 --- a/Makefile +++ b/Makefile @@ -58,11 +58,8 @@ mocks: $(GOBIN)/mockgen $(GOBIN)/commander: cd && GO111MODULE=auto go get github.com/commander-cli/commander/cmd/commander -$(GOBIN)/auth0-cli-config-generator: - go install ./pkg/auth0-cli-config-generator - run-integration: - auth0-cli-config-generator && commander test commander.yaml + auth0 config init && commander test commander.yaml .PHONY: run-integration # Delete all test apps created during integration testing @@ -70,7 +67,7 @@ integration-cleanup: ./integration/test-cleanup.sh .PHONY: integration-cleanup -integration: build $(GOBIN)/auth0-cli-config-generator $(GOBIN)/commander +integration: build $(GOBIN)/commander $(MAKE) run-integration; \ ret=$$?; \ $(MAKE) integration-cleanup; \ diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 64e93587f..f5e66dfe7 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -42,6 +42,17 @@ var requiredScopes = []string{ // RequiredScopes returns the scopes used for login. func RequiredScopes() []string { return requiredScopes } +// RequiredScopesMin returns minimum scopes used for login in integration tests. +func RequiredScopesMin() []string { + min := []string{} + for _, s := range requiredScopes { + if s != "offline_access" && s != "openid" { + min = append(min, s) + } + } + return min +} + // SecretStore provides access to stored sensitive data. type SecretStore interface { // Get gets the secret diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 781b42eba..22bdc06fa 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -445,7 +445,7 @@ func (c *cli) init() error { func (c *cli) initContext() (err error) { if c.path == "" { - c.path = path.Join(os.Getenv("HOME"), ".config", "auth0", "config.json") + c.path = defaultConfigPath() } if _, err := os.Stat(c.path); os.IsNotExist(err) { @@ -472,6 +472,18 @@ func (c *cli) initContext() (err error) { return nil } +func defaultConfigPath() string { + return path.Join(os.Getenv("HOME"), ".config", "auth0", "config.json") +} + +func (c *cli) setPath(p string) { + if p == "" { + c.path = defaultConfigPath() + return + } + c.path = p +} + func canPrompt(cmd *cobra.Command) bool { noInput, err := cmd.Root().Flags().GetBool("no-input") diff --git a/internal/cli/config.go b/internal/cli/config.go new file mode 100644 index 000000000..7bffd8039 --- /dev/null +++ b/internal/cli/config.go @@ -0,0 +1,132 @@ +package cli + +import ( + "context" + "fmt" + "net/url" + "strings" + + "github.com/auth0/auth0-cli/internal/auth" + "github.com/spf13/cobra" + "github.com/spf13/viper" + "golang.org/x/oauth2/clientcredentials" +) + +var desiredInputs = `Config init is intended for non-interactive use, +ensure the following env variables are set: + +AUTH0_CLI_CLIENT_DOMAIN +AUTH0_CLI_CLIENT_ID +AUTH0_CLI_CLIENT_SECRET + +Interactive logins should use "auth0 login" instead.` + +type params struct { + filePath string + clientDomain string + clientID string + clientSecret string +} + +func (p params) validate() error { + if p.clientDomain == "" { + return fmt.Errorf("missing client domain:\n%s", desiredInputs) + } + + u, err := url.Parse(p.clientDomain) + if err != nil { + return fmt.Errorf("failed to parse client domain: %s", p.clientDomain) + } + + if u.Scheme != "" { + return fmt.Errorf("client domain cant include a scheme: %s", p.clientDomain) + } + + if p.clientID == "" { + return fmt.Errorf("missing client id:\n%s", desiredInputs) + } + + if p.clientSecret == "" { + return fmt.Errorf("missing client secret:\n%s", desiredInputs) + } + return nil +} + +func configCmd(cli *cli) *cobra.Command { + cmd := &cobra.Command{ + Use: "config", + Short: "Manage auth0-cli config", + Long: "Manage auth0-cli config", + Hidden: true, + } + + cmd.AddCommand(initCmd(cli)) + return cmd +} + +func initCmd(cli *cli) *cobra.Command { + cmd := &cobra.Command{ + Use: "init", + Short: "Configure the CLI from environment variables", + RunE: func(command *cobra.Command, args []string) error { + filePath := viper.GetString("FILEPATH") + clientDomain := viper.GetString("CLIENT_DOMAIN") + clientID := viper.GetString("CLIENT_ID") + clientSecret := viper.GetString("CLIENT_SECRET") + + cli.setPath(filePath) + p := params{filePath, clientDomain, clientID, clientSecret} + if err := p.validate(); err != nil { + return err + } + + u, err := url.Parse("https://" + p.clientDomain) + if err != nil { + return err + } + + c := &clientcredentials.Config{ + ClientID: p.clientID, + ClientSecret: p.clientSecret, + TokenURL: u.String() + "/oauth/token", + EndpointParams: url.Values{ + "client_id": {p.clientID}, + "scope": {strings.Join(auth.RequiredScopesMin(), " ")}, + "audience": {u.String() + "/api/v2/"}, + }, + } + + token, err := c.Token(context.Background()) + if err != nil { + return err + } + + t := tenant{ + Name: p.clientDomain, + Domain: p.clientDomain, + AccessToken: token.AccessToken, + ExpiresAt: token.Expiry, + Scopes: auth.RequiredScopes(), + } + + if err := cli.addTenant(t); err != nil { + return fmt.Errorf("unexpected error adding tenant to config: %w", err) + } + return nil + }, + } + viper.SetEnvPrefix("AUTH0_CLI") + viper.AutomaticEnv() + + flags := cmd.Flags() + flags.String("filepath", defaultConfigPath(), "Filepath for the auth0 cli config") + _ = viper.BindPFlag("FILEPATH", flags.Lookup("filepath")) + flags.String("client-id", "", "Client ID to set within config") + _ = viper.BindPFlag("CLIENT_ID", flags.Lookup("client-id")) + flags.String("client-secret", "", "Client secret to use to generate token which is set within config") + _ = viper.BindPFlag("CLIENT_SECRET", flags.Lookup("client-secret")) + flags.String("client-domain", "", "Client domain to use to generate token which is set within config") + _ = viper.BindPFlag("CLIENT_DOMAIN", flags.Lookup("client-domain")) + + return cmd +} diff --git a/internal/cli/root.go b/internal/cli/root.go index 5ffdfd338..b5199c0fb 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -66,6 +66,11 @@ func Execute() { return nil } + // config init shouldn't trigger a login. + if cmd.CalledAs() == "init" && cmd.Parent().Use == "config" { + return nil + } + defer cli.tracker.TrackCommandRun(cmd, cli.config.InstallID) // Initialize everything once. Later callers can then @@ -96,6 +101,7 @@ func Execute() { // order of the comamnds here matters // so add new commands in a place that reflect its relevance or relation with other commands: rootCmd.AddCommand(loginCmd(cli)) + rootCmd.AddCommand(configCmd(cli)) rootCmd.AddCommand(tenantsCmd(cli)) rootCmd.AddCommand(usersCmd(cli)) rootCmd.AddCommand(appsCmd(cli)) @@ -144,7 +150,7 @@ func Execute() { os.Exit(1) } - ctx, cancel := context.WithTimeout(cli.context, 3 * time.Second) + ctx, cancel := context.WithTimeout(cli.context, 3*time.Second) // defers are executed in LIFO order defer cancel() defer cli.tracker.Wait(ctx) // No event should be tracked after this has run, or it will panic e.g. in earlier deferred functions diff --git a/pkg/auth0-cli-config-generator/main.go b/pkg/auth0-cli-config-generator/main.go deleted file mode 100644 index d2ced99e5..000000000 --- a/pkg/auth0-cli-config-generator/main.go +++ /dev/null @@ -1,227 +0,0 @@ -// auth0-cli-config-generator: A command that generates a valid config file that can be used with auth0-cli. -// -// Currently this command is only used to generator a config using environment variables which is then used for integration tests. -package main - -import ( - "context" - "encoding/json" - "fmt" - "net/url" - "os" - "path" - "path/filepath" - "strings" - "time" - - "github.com/lestrrat-go/jwx/jwt" - "github.com/spf13/cobra" - "github.com/spf13/viper" - "golang.org/x/oauth2/clientcredentials" -) - -type params struct { - filePath string - clientDomain string - clientID string - clientSecret string -} - -var requiredScopes = []string{ - //"openid", - //"offline_access", // <-- to get a refresh token. - "create:clients", "delete:clients", "read:clients", "update:clients", - "create:resource_servers", "delete:resource_servers", "read:resource_servers", "update:resource_servers", - "create:roles", "delete:roles", "read:roles", "update:roles", - "create:rules", "delete:rules", "read:rules", "update:rules", - "create:users", "delete:users", "read:users", "update:users", - "read:branding", "update:branding", - "read:connections", "update:connections", - "read:client_keys", "read:logs", "read:tenant_settings", "read:custom_domains", - "read:anomaly_blocks", "delete:anomaly_blocks", - "create:log_streams", "delete:log_streams", "read:log_streams", "update:log_streams", -} - -func (p params) validate() error { - if p.clientDomain == "" { - return fmt.Errorf("Missing client domain") - } - - u, err := url.Parse(p.clientDomain) - if err != nil { - return fmt.Errorf("Failed to parse client domain: %s", p.clientDomain) - } - - if u.Scheme != "" { - return fmt.Errorf("Client domain cant include a scheme: %s", p.clientDomain) - } - - if p.clientID == "" { - return fmt.Errorf("Missing client id") - } - - if p.clientSecret == "" { - return fmt.Errorf("Missing client secret") - } - return nil -} - -type config struct { - DefaultTenant string `json:"default_tenant"` - Tenants map[string]tenant `json:"tenants"` -} - -type tenant struct { - Name string `json:"name"` - Domain string `json:"domain"` - AccessToken string `json:"access_token,omitempty"` - ExpiresAt time.Time `json:"expires_at"` - Scopes []string `json:"scopes,omitempty"` -} - -func isLoggedIn(filePath string) bool { - var c config - var buf []byte - var err error - if buf, err = os.ReadFile(filePath); err != nil { - return false - } - - if err := json.Unmarshal(buf, &c); err != nil { - return false - } - - if c.Tenants == nil { - return false - } - - if c.DefaultTenant == "" { - return false - } - - t, err := jwt.ParseString(c.Tenants[c.DefaultTenant].AccessToken) - if err != nil { - return false - } - - if err = jwt.Validate(t); err != nil { - return false - } - - return true -} - -func persistConfig(filePath string, c config, overwrite bool) error { - dir := filepath.Dir(filePath) - if _, err := os.Stat(dir); os.IsNotExist(err) { - if err := os.MkdirAll(dir, 0700); err != nil { - return err - } - } - - buf, err := json.MarshalIndent(c, "", " ") - if err != nil { - return err - } - - if _, err := os.Stat(filePath); err == nil && !overwrite { - return fmt.Errorf("Not overwriting existing config file: %s", filePath) - } - - if err = os.WriteFile(filePath, buf, 0600); err != nil { - return err - } - - return nil -} - -func main() { - var cmd = &cobra.Command{ - Use: "auth0-cli-config-generator", - Short: "A tool that generates valid auth0-cli config files", - SilenceErrors: true, - SilenceUsage: true, - RunE: func(command *cobra.Command, args []string) error { - reuseConfig := viper.GetBool("REUSE_CONFIG") - overwrite := viper.GetBool("OVERWRITE") - filePath := viper.GetString("FILEPATH") - clientDomain := viper.GetString("CLIENT_DOMAIN") - clientID := viper.GetString("CLIENT_ID") - clientSecret := viper.GetString("CLIENT_SECRET") - - if reuseConfig { - if !isLoggedIn(filePath) { - return fmt.Errorf("Config file is not valid: %s", filePath) - } - fmt.Printf("Reusing valid config file: %s\n", filePath) - return nil - } - - p := params{filePath, clientDomain, clientID, clientSecret} - if err := p.validate(); err != nil { - return err - } - - u, err := url.Parse("https://" + p.clientDomain) - if err != nil { - return err - } - - c := &clientcredentials.Config{ - ClientID: p.clientID, - ClientSecret: p.clientSecret, - TokenURL: u.String() + "/oauth/token", - EndpointParams: url.Values{ - "client_id": {p.clientID}, - "scope": {strings.Join(requiredScopes, " ")}, - "audience": {u.String() + "/api/v2/"}, - }, - } - - token, err := c.Token(context.Background()) - if err != nil { - return err - } - - t := tenant{ - Name: p.clientDomain, - Domain: p.clientDomain, - AccessToken: token.AccessToken, - ExpiresAt: token.Expiry, - Scopes: append([]string{"openid", "offline_access"}, requiredScopes...), - } - - cfg := config{ - DefaultTenant: p.clientDomain, - Tenants: map[string]tenant{p.clientDomain: t}, - } - if err := persistConfig(p.filePath, cfg, overwrite); err != nil { - return err - } - fmt.Printf("Config file generated: %s\n", filePath) - - return nil - }, - } - viper.SetEnvPrefix("AUTH0_CLI") - viper.AutomaticEnv() - - flags := cmd.Flags() - flags.String("filepath", path.Join(os.Getenv("HOME"), ".config", "auth0", "config.json"), "Filepath for the auth0 cli config") - _ = viper.BindPFlag("FILEPATH", flags.Lookup("filepath")) - flags.String("client-id", "", "Client ID to set within config") - _ = viper.BindPFlag("CLIENT_ID", flags.Lookup("client-id")) - flags.String("client-secret", "", "Client secret to use to generate token which is set within config") - _ = viper.BindPFlag("CLIENT_SECRET", flags.Lookup("client-secret")) - flags.String("client-domain", "", "Client domain to use to generate token which is set within config") - _ = viper.BindPFlag("CLIENT_DOMAIN", flags.Lookup("client-domain")) - flags.Bool("reuse-config", true, "Reuse an existing config if found") - _ = viper.BindPFlag("REUSE_CONFIG", flags.Lookup("reuse-config")) - flags.Bool("overwrite", false, "Overwrite an existing config") - _ = viper.BindPFlag("OVERWRITE", flags.Lookup("overwrite")) - - if err := cmd.Execute(); err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } -}