diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 4b9d82c53..131b2d96b 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -33,7 +33,7 @@ const ( deviceCodeEndpoint = "https://auth0.auth0.com/oauth/device/code" oauthTokenEndpoint = "https://auth0.auth0.com/oauth/token" // TODO(jfatta) extend the scope as we extend the CLI: - scope = "openid read:roles" + scope = "openid read:roles read:clients read:logs" audiencePath = "/api/v2/" ) @@ -42,6 +42,7 @@ type Authenticator struct { type Result struct { Tenant string + Domain string AccessToken string ExpiresIn int64 } @@ -106,14 +107,15 @@ func (a *Authenticator) Wait(ctx context.Context, state State) (Result, error) { return Result{}, errors.New(res.ErrorDescription) } - t, err := parseTenant(res.AccessToken) + ten, domain, err := parseTenant(res.AccessToken) if err != nil { return Result{}, fmt.Errorf("cannot parse tenant from the given access token: %w", err) } return Result{ AccessToken: res.AccessToken, ExpiresIn: res.ExpiresIn, - Tenant: t, + Tenant: ten, + Domain: domain, }, nil } } @@ -139,27 +141,27 @@ func (a *Authenticator) getDeviceCode(ctx context.Context) (State, error) { return res, nil } -func parseTenant(accessToken string) (string, error) { +func parseTenant(accessToken string) (tenant, domain string, err error) { parts := strings.Split(accessToken, ".") v, err := base64.RawURLEncoding.DecodeString(parts[1]) if err != nil { - return "", err + return "", "", err } var payload struct { AUDs []string `json:"aud"` } if err := json.Unmarshal([]byte(v), &payload); err != nil { - return "", err + return "", "", err } for _, aud := range payload.AUDs { u, err := url.Parse(aud) if err != nil { - return "", err + return "", "", err } if u.Path == audiencePath { parts := strings.Split(u.Host, ".") - return parts[0], nil + return parts[0], u.Host, nil } } - return "", fmt.Errorf("audience not found for %s", audiencePath) + return "", "", fmt.Errorf("audience not found for %s", audiencePath) } diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 0ed01f258..2cb4c5d1f 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -6,66 +6,101 @@ import ( "io/ioutil" "os" "path" + "path/filepath" "strings" "sync" + "time" "github.com/auth0/auth0-cli/internal/display" "gopkg.in/auth0.v5/management" ) -type data struct { +// config defines the exact set of tenants, access tokens, which only exists +// for a particular user's machine. +type config struct { DefaultTenant string `json:"default_tenant"` Tenants map[string]tenant `json:"tenants"` } +// tenant is the cli's concept of an auth0 tenant. The fields are tailor fit +// specifically for interacting with the management API. type tenant struct { - Domain string `json:"domain"` - - ClientID string `json:"client_id"` - ClientSecret string `json:"client_secret"` - - // TODO(cyx): This will be what we do with device flow. - BearerToken string `json:"bearer_token,omitempty"` + Name string `json:"name"` + Domain string `json:"domain"` + AccessToken string `json:"access_token,omitempty"` + ExpiresAt time.Time `json:"expires_at"` } +// cli provides all the foundational things for all the commands in the CLI, +// specifically: +// +// 1. A management API instance (e.g. go-auth0/auth0) +// 2. A renderer (which provides ansi, coloring, etc). +// +// In addition, it stores a reference to all the flags passed, e.g.: +// +// 1. --format +// 2. --tenant +// 3. --verbose +// type cli struct { + // core primitives exposed to command builders. api *management.Management renderer *display.Renderer + // set of flags which are user specified. verbose bool tenant string format string + // config state management. initOnce sync.Once + errOnce error path string - data data + config config } +// isLoggedIn encodes the domain logic for determining whether or not we're +// logged in. This might check our config storage, or just in memory. +func (c *cli) isLoggedIn() bool { + // No need to check errors for initializing context. + _ = c.init() + + return c.tenant != "" +} + +// setup will try to initialize the config context, as well as figure out if +// there's a readily available tenant. A management API SDK instance is initialized IFF: +// +// 1. A tenant is found. +// 2. The tenant has an access token. func (c *cli) setup() error { + if err := c.init(); err != nil { + return err + } + t, err := c.getTenant() if err != nil { return err } - if t.BearerToken != "" { - c.api, err = management.New(t.Domain, - management.WithStaticToken(t.BearerToken), - management.WithDebug(c.verbose)) - } else { + if t.AccessToken != "" { c.api, err = management.New(t.Domain, - management.WithClientCredentials(t.ClientID, t.ClientSecret), + management.WithStaticToken(t.AccessToken), management.WithDebug(c.verbose)) } return err } +// getTenant fetches the default tenant configured (or the tenant specified via +// the --tenant flag). func (c *cli) getTenant() (tenant, error) { if err := c.init(); err != nil { return tenant{}, err } - t, ok := c.data.Tenants[c.tenant] + t, ok := c.config.Tenants[c.tenant] if !ok { return tenant{}, fmt.Errorf("Unable to find tenant: %s", c.tenant) } @@ -73,44 +108,94 @@ func (c *cli) getTenant() (tenant, error) { return t, nil } -func (c *cli) init() error { - var err error - c.initOnce.Do(func() { - if c.path == "" { - c.path = path.Join(os.Getenv("HOME"), ".config", "auth0", "config.json") - } +// addTenant assigns an existing, or new tenant. This is expected to be called +// after a login has completed. +func (c *cli) addTenant(ten tenant) error { + // init will fail here with a `no tenant found` error if we're logging + // in for the first time and that's expected. + _ = c.init() + + // If there's no existing DefaultTenant yet, might as well set the + // first successfully logged in tenant during onboarding. + if c.config.DefaultTenant == "" { + c.config.DefaultTenant = ten.Name + } - var buf []byte - if buf, err = ioutil.ReadFile(c.path); err != nil { - return - } + // If we're dealing with an empty file, we'll need to initialize this + // map. + if c.config.Tenants == nil { + c.config.Tenants = map[string]tenant{} + } - if err = json.Unmarshal(buf, &c.data); err != nil { - return - } + c.config.Tenants[ten.Name] = ten - if c.tenant == "" && c.data.DefaultTenant == "" { - err = fmt.Errorf("Not yet configured. Try `auth0 login`.") - return + dir := filepath.Dir(c.path) + if _, err := os.Stat(dir); os.IsNotExist(err) { + if err := os.MkdirAll(dir, 0700); err != nil { + return err } + } + + buf, err := json.MarshalIndent(c.config, "", " ") + if err != nil { + return err + } - if c.tenant == "" { - c.tenant = c.data.DefaultTenant + if err := ioutil.WriteFile(c.path, buf, 0600); err != nil { + return err + } + + return nil +} + +func (c *cli) init() error { + c.initOnce.Do(func() { + // Initialize the context -- e.g. the configuration + // information, tenants, etc. + if c.errOnce = c.initContext(); c.errOnce != nil { + return } + c.renderer.Tenant = c.tenant + // Determine what the desired output format is. format := strings.ToLower(c.format) if format != "" && format != string(display.OutputFormatJSON) { - err = fmt.Errorf("Invalid format. Use `--format=json` or omit this option to use the default format.") + c.errOnce = fmt.Errorf("Invalid format. Use `--format=json` or omit this option to use the default format.") return } - - c.renderer = &display.Renderer{ - Tenant: c.tenant, - MessageWriter: os.Stderr, - ResultWriter: os.Stdout, - Format: display.OutputFormat(format), - } + c.renderer.Format = display.OutputFormat(format) }) - return err + // Once initialized, we'll keep returning the same err that was + // originally encountered. + return c.errOnce +} + +func (c *cli) initContext() (err error) { + if c.path == "" { + c.path = path.Join(os.Getenv("HOME"), ".config", "auth0", "config.json") + } + + if _, err := os.Stat(c.path); os.IsNotExist(err) { + return fmt.Errorf("Not yet configured. Try `auth0 login`.") + } + + var buf []byte + if buf, err = ioutil.ReadFile(c.path); err != nil { + return err + } + + if err := json.Unmarshal(buf, &c.config); err != nil { + return err + } + + if c.tenant == "" && c.config.DefaultTenant == "" { + return fmt.Errorf("Not yet configured. Try `auth0 login`.") + } + + if c.tenant == "" { + c.tenant = c.config.DefaultTenant + } + + return nil } diff --git a/internal/cli/login.go b/internal/cli/login.go index af0f37cea..328276b3f 100644 --- a/internal/cli/login.go +++ b/internal/cli/login.go @@ -2,6 +2,7 @@ package cli import ( "fmt" + "time" "github.com/auth0/auth0-cli/internal/ansi" "github.com/auth0/auth0-cli/internal/auth" @@ -42,10 +43,17 @@ func loginCmd(cli *cli) *cobra.Command { return fmt.Errorf("login error: %w", err) } - // TODO(jfatta): update the configuration with the token, tenant, audience, etc cli.renderer.Infof("Successfully logged in.") cli.renderer.Infof("Tenant: %s", res.Tenant) - return nil + + return cli.addTenant(tenant{ + Name: res.Tenant, + Domain: res.Domain, + AccessToken: res.AccessToken, + ExpiresAt: time.Now().Add( + time.Duration(res.ExpiresIn) * time.Second, + ), + }) }, } diff --git a/internal/cli/root.go b/internal/cli/root.go index 716b3ba02..ccfcd6541 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -5,15 +5,11 @@ import ( "os" "github.com/auth0/auth0-cli/internal/display" - "github.com/spf13/afero" "github.com/spf13/cobra" ) // Execute is the primary entrypoint of the CLI app. func Execute() { - // fs is a mock friendly os.File interface. - fs := afero.NewOsFs() - // cfg contains tenant related information, e.g. `travel0-dev`, // `travel0-prod`. some of its information can be sourced via: // 1. env var (e.g. AUTH0_API_KEY) @@ -28,9 +24,15 @@ func Execute() { SilenceUsage: true, SilenceErrors: true, Short: "Command-line tool to interact with Auth0.", - Long: "Command-line tool to interact with Auth0.\n" + getLogin(&fs, cli), + Long: "Command-line tool to interact with Auth0.\n" + getLogin(cli), PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + // If the user is trying to login, no need to go + // through setup. + if cmd.Use == "login" { + return nil + } + // Initialize everything once. Later callers can then // freely assume that config is fully primed and ready // to go. @@ -40,7 +42,7 @@ func Execute() { rootCmd.SetUsageTemplate(namespaceUsageTemplate()) rootCmd.PersistentFlags().StringVar(&cli.tenant, - "tenant", "", "Specific tenant to use.") + "tenant", cli.config.DefaultTenant, "Specific tenant to use.") rootCmd.PersistentFlags().BoolVar(&cli.verbose, "verbose", false, "Enable verbose mode.") diff --git a/internal/cli/templates.go b/internal/cli/templates.go index 9a801e123..c1f9f8779 100644 --- a/internal/cli/templates.go +++ b/internal/cli/templates.go @@ -5,7 +5,6 @@ import ( "strings" "github.com/auth0/auth0-cli/internal/ansi" - "github.com/spf13/afero" "github.com/spf13/cobra" "github.com/spf13/pflag" "golang.org/x/crypto/ssh/terminal" @@ -73,25 +72,13 @@ func WrappedNonRequestParamsFlagUsages(cmd *cobra.Command) string { // Private functions // -func getLogin(fs *afero.Fs, cli *cli) string { - // // We're checking against the path because we don't initialize the config - // // at this point of execution. - // path := cfg.GetConfigFolder(os.Getenv("XDG_CONFIG_HOME")) - // file := filepath.Join(path, "config.toml") - - // exists, _ := afero.Exists(*fs, file) - - exists := false - if !exists { - return ` +func getLogin(cli *cli) string { + if !cli.isLoggedIn() { + return ansi.Italic(` Before using the CLI, you'll need to login: $ auth0 login - -If you're working with multiple tenants, you can run -the login command with the --tenant and --region flag: - - $ auth0 login --tenant travel0 --region us` +`) } return "" diff --git a/internal/display/display.go b/internal/display/display.go index aebccf155..92a14d7b1 100644 --- a/internal/display/display.go +++ b/internal/display/display.go @@ -68,6 +68,8 @@ func (r *Renderer) Errorf(format string, a ...interface{}) { } func (r *Renderer) Heading(text ...string) { + r.init() + fmt.Fprintf(r.MessageWriter, "%s %s\n", ansi.Faint("==="), strings.Join(text, " ")) } @@ -77,6 +79,8 @@ type View interface { } func (r *Renderer) Results(data []View) { + r.init() + if len(data) > 0 { switch r.Format { case OutputFormatJSON: