diff --git a/go.mod b/go.mod index fd639123..36c4cb32 100644 --- a/go.mod +++ b/go.mod @@ -76,6 +76,7 @@ require ( github.com/yuin/goldmark v1.5.2 // indirect github.com/yuin/goldmark-emoji v1.0.1 // indirect golang.org/x/crypto v0.7.0 // indirect + golang.org/x/exp v0.0.0-20230321023759-10a507213a29 // indirect golang.org/x/mod v0.8.0 // indirect golang.org/x/net v0.9.0 // indirect golang.org/x/tools v0.6.0 // indirect diff --git a/go.sum b/go.sum index 06ff3abc..62f64b4b 100644 --- a/go.sum +++ b/go.sum @@ -190,6 +190,8 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20220427172511-eb4f295cb31f/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.7.0 h1:AvwMYaRytfdeVt3u6mLaxYtErKYjxA2OXjJ1HHq6t3A= golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= +golang.org/x/exp v0.0.0-20230321023759-10a507213a29 h1:ooxPy7fPvB4kwsA2h+iBNHkAbp/4JxTSwCmvdjEYmug= +golang.org/x/exp v0.0.0-20230321023759-10a507213a29/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.8.0 h1:LUYupSeNrTNCGzR/hVBk2NHZO4hXcVaW1k4Qx7rjPx8= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= diff --git a/internal/cli/actions.go b/internal/cli/actions.go index 873612f8..00d395cc 100644 --- a/internal/cli/actions.go +++ b/internal/cli/actions.go @@ -468,7 +468,7 @@ func openActionCmd(cli *cli) *cobra.Command { inputs.ID = args[0] } - openManageURL(cli, cli.config.DefaultTenant, formatActionDetailsPath(url.PathEscape(inputs.ID))) + openManageURL(cli, cli.Config.DefaultTenant, formatActionDetailsPath(url.PathEscape(inputs.ID))) return nil }, } diff --git a/internal/cli/apis.go b/internal/cli/apis.go index eb76f914..951cc90e 100644 --- a/internal/cli/apis.go +++ b/internal/cli/apis.go @@ -480,7 +480,7 @@ func openAPICmd(cli *cli) *cobra.Command { } } - openManageURL(cli, cli.config.DefaultTenant, formatAPISettingsPath(inputs.ID)) + openManageURL(cli, cli.Config.DefaultTenant, formatAPISettingsPath(inputs.ID)) return nil }, } diff --git a/internal/cli/apps.go b/internal/cli/apps.go index 7e4fcdf5..9d814755 100644 --- a/internal/cli/apps.go +++ b/internal/cli/apps.go @@ -179,7 +179,7 @@ func useAppCmd(cli *cli) *cobra.Command { } } - if err := cli.setDefaultAppID(inputs.ID); err != nil { + if err := cli.Config.SaveNewDefaultAppIDForTenant(cli.tenant, inputs.ID); err != nil { return err } @@ -479,7 +479,7 @@ func createAppCmd(cli *cli) *cobra.Command { return fmt.Errorf("Unable to create application: %v", err) } - if err := cli.setDefaultAppID(a.GetClientID()); err != nil { + if err := cli.Config.SaveNewDefaultAppIDForTenant(cli.tenant, a.GetClientID()); err != nil { return err } @@ -737,7 +737,7 @@ func openAppCmd(cli *cli) *cobra.Command { inputs.ID = args[0] } - openManageURL(cli, cli.config.DefaultTenant, formatAppSettingsPath(inputs.ID)) + openManageURL(cli, cli.Config.DefaultTenant, formatAppSettingsPath(inputs.ID)) return nil }, } @@ -879,7 +879,7 @@ func (c *cli) appPickerOptions(requestOpts ...management.RequestOption) pickerOp return nil, err } - tenant, err := c.getTenant() + tenant, err := c.Config.GetTenant(c.tenant) if err != nil { return nil, err } diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 55bba46e..f5e84a5e 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -2,60 +2,24 @@ package cli import ( "context" - "encoding/json" - "errors" "fmt" - "net/http" - "os" - "path" - "path/filepath" "strings" - "sync" - "time" "github.com/auth0/go-auth0/management" - "github.com/google/uuid" "github.com/lestrrat-go/jwx/jwt" "github.com/spf13/cobra" "github.com/spf13/pflag" "github.com/auth0/auth0-cli/internal/analytics" "github.com/auth0/auth0-cli/internal/ansi" - "github.com/auth0/auth0-cli/internal/auth" "github.com/auth0/auth0-cli/internal/auth0" "github.com/auth0/auth0-cli/internal/buildinfo" + "github.com/auth0/auth0-cli/internal/config" "github.com/auth0/auth0-cli/internal/display" "github.com/auth0/auth0-cli/internal/iostream" - "github.com/auth0/auth0-cli/internal/keyring" ) -const ( - userAgent = "Auth0 CLI" - accessTokenExpThreshold = 5 * time.Minute -) - -// config defines the exact set of tenants, access tokens, which only exists -// for a particular user's machine. -type config struct { - InstallID string `json:"install_id,omitempty"` - 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 { - Name string `json:"name"` - Domain string `json:"domain"` - AccessToken string `json:"access_token,omitempty"` - Scopes []string `json:"scopes,omitempty"` - ExpiresAt time.Time `json:"expires_at"` - DefaultAppID string `json:"default_app_id,omitempty"` - ClientID string `json:"client_id"` -} - -var errUnauthenticated = errors.New("Not logged in. Try 'auth0 login'.") +const userAgent = "Auth0 CLI" // cli provides all the foundational things for all the commands in the CLI, // specifically: @@ -82,90 +46,10 @@ type cli struct { noInput bool noColor bool - // Config state management. - initOnce sync.Once - errOnce error - path string - config config -} - -func (t *Tenant) authenticatedWithClientCredentials() bool { - return t.ClientID != "" -} - -func (t *Tenant) authenticatedWithDeviceCodeFlow() bool { - return t.ClientID == "" -} - -func (t *Tenant) hasExpiredToken() bool { - return time.Now().Add(accessTokenExpThreshold).After(t.ExpiresAt) -} - -func (t *Tenant) additionalRequestedScopes() []string { - additionallyRequestedScopes := make([]string, 0) - - for _, scope := range t.Scopes { - found := false - - for _, defaultScope := range auth.RequiredScopes { - if scope == defaultScope { - found = true - break - } - } - - if !found { - additionallyRequestedScopes = append(additionallyRequestedScopes, scope) - } - } - - return additionallyRequestedScopes + Config config.Config } -func (t *Tenant) regenerateAccessToken(ctx context.Context) error { - if t.authenticatedWithClientCredentials() { - clientSecret, err := keyring.GetClientSecret(t.Domain) - if err != nil { - return fmt.Errorf("failed to retrieve client secret from keyring: %w", err) - } - - token, err := auth.GetAccessTokenFromClientCreds( - ctx, - auth.ClientCredentials{ - ClientID: t.ClientID, - ClientSecret: clientSecret, - Domain: t.Domain, - }, - ) - if err != nil { - return err - } - - t.AccessToken = token.AccessToken - t.ExpiresAt = token.ExpiresAt - } - - if t.authenticatedWithDeviceCodeFlow() { - tokenResponse, err := auth.RefreshAccessToken(http.DefaultClient, t.Domain) - if err != nil { - return err - } - - t.AccessToken = tokenResponse.AccessToken - t.ExpiresAt = time.Now().Add( - time.Duration(tokenResponse.ExpiresIn) * time.Second, - ) - } - - err := keyring.StoreAccessToken(t.Domain, t.AccessToken) - if err != nil { - t.AccessToken = "" - } - - return nil -} - -// isLoggedIn encodes the domain logic for determining whether or not we're +// isLoggedIn encodes the domain logic for determining whether 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. @@ -176,7 +60,7 @@ func (c *cli) isLoggedIn() bool { } // Parse the access token for the tenant. - t, err := jwt.ParseString(c.config.Tenants[c.tenant].AccessToken) + t, err := jwt.ParseString(c.Config.Tenants[c.tenant].AccessToken) if err != nil { return false } @@ -208,7 +92,7 @@ func (c *cli) setup(ctx context.Context) error { api, err := management.New( t.Domain, - management.WithStaticToken(getAccessToken(t)), + management.WithStaticToken(t.GetAccessToken()), management.WithUserAgent(userAgent), ) if err != nil { @@ -219,37 +103,28 @@ func (c *cli) setup(ctx context.Context) error { return nil } -func getAccessToken(t Tenant) string { - accessToken, err := keyring.GetAccessToken(t.Domain) - if err == nil && accessToken != "" { - return accessToken - } - - return t.AccessToken -} - // prepareTenant loads the tenant, refreshing its token if necessary. // The tenant access token needs a refresh if: // 1. The tenant scopes are different than the currently required scopes. // 2. The access token is expired. -func (c *cli) prepareTenant(ctx context.Context) (Tenant, error) { - t, err := c.getTenant() +func (c *cli) prepareTenant(ctx context.Context) (config.Tenant, error) { + t, err := c.Config.GetTenant(c.tenant) if err != nil { - return Tenant{}, err + return config.Tenant{}, err } - if !hasAllRequiredScopes(t) && t.authenticatedWithDeviceCodeFlow() { + if !t.HasAllRequiredScopes() && t.IsAuthenticatedWithDeviceCodeFlow() { c.renderer.Warnf("Required scopes have changed. Please log in to re-authorize the CLI.\n") - return RunLoginAsUser(ctx, c, t.additionalRequestedScopes()) + return RunLoginAsUser(ctx, c, t.GetExtraRequestedScopes()) } - accessToken := getAccessToken(t) - if accessToken != "" && !t.hasExpiredToken() { + accessToken := t.GetAccessToken() + if accessToken != "" && !t.HasExpiredToken() { return t, nil } - if err := t.regenerateAccessToken(ctx); err != nil { - if t.authenticatedWithClientCredentials() { + if err := t.RegenerateAccessToken(ctx); err != nil { + if t.IsAuthenticatedWithClientCredentials() { errorMessage := fmt.Errorf( "failed to fetch access token using client credentials: %w\n\nThis may occur if the designated Auth0 application has been deleted, the client secret has been rotated or previous failure to store client secret in the keyring.\n\nPlease re-authenticate by running: %s", err, @@ -262,183 +137,36 @@ func (c *cli) prepareTenant(ctx context.Context) (Tenant, error) { c.renderer.Warnf("Failed to renew access token: %s", err) c.renderer.Warnf("Please log in to re-authorize the CLI.\n") - return RunLoginAsUser(ctx, c, t.additionalRequestedScopes()) + return RunLoginAsUser(ctx, c, t.GetExtraRequestedScopes()) } - if err := c.addTenant(t); err != nil { - return Tenant{}, fmt.Errorf("unexpected error adding tenant to config: %w", err) + if err := c.Config.AddTenant(t); err != nil { + return config.Tenant{}, fmt.Errorf("unexpected error adding tenant to config: %w", err) } return t, nil } -// hasAllRequiredScopes compare the tenant scopes -// with the currently required scopes. -func hasAllRequiredScopes(t Tenant) bool { - for _, requiredScope := range auth.RequiredScopes { - if !containsStr(t.Scopes, requiredScope) { - return false - } - } - - return true -} - -// 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.config.Tenants[c.tenant] - if !ok { - return Tenant{}, fmt.Errorf("Unable to find tenant: %s; run 'auth0 tenants use' to see your configured tenants or run 'auth0 login' to configure a new tenant", c.tenant) - } - - return t, nil -} - -// listTenants fetches all the configured tenants. -func (c *cli) listTenants() ([]Tenant, error) { - if err := c.init(); err != nil { - return []Tenant{}, err - } - - tenants := make([]Tenant, 0, len(c.config.Tenants)) - for _, t := range c.config.Tenants { - tenants = append(tenants, t) - } - - return tenants, nil -} - -// 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.Domain - } - - // 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{} - } - - c.config.Tenants[ten.Domain] = ten - - if err := c.persistConfig(); err != nil { - return fmt.Errorf("unexpected error persisting config: %w", err) - } - - return nil -} - -func (c *cli) removeTenant(ten string) 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 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{} - } - - delete(c.config.Tenants, ten) - - // If the default tenant is being removed, we'll pick the first tenant - // that's not the one being removed, and make that the new default. - if c.config.DefaultTenant == ten { - if len(c.config.Tenants) == 0 { - c.config.DefaultTenant = "" - } else { - Loop: - for t := range c.config.Tenants { - if t != ten { - c.config.DefaultTenant = t - break Loop - } - } - } - } - - if err := c.persistConfig(); err != nil { - return fmt.Errorf("failed to persist config: %w", err) - } - - if err := keyring.DeleteSecretsForTenant(ten); err != nil { - return fmt.Errorf("failed to delete tenant secrets: %w", err) - } - - return nil -} +func (c *cli) init() error { + cobra.EnableCommandSorting = false -func (c *cli) setDefaultAppID(id string) error { - tenant, err := c.getTenant() - if err != nil { + if err := c.Config.Initialize(); err != nil { return err } - tenant.DefaultAppID = id - - c.config.Tenants[tenant.Domain] = tenant - if err := c.persistConfig(); err != nil { - return fmt.Errorf("Unexpected error persisting config: %w", err) - } - - return nil -} - -func checkInstallID(c *cli) error { - if c.config.InstallID == "" { - c.config.InstallID = uuid.NewString() - - if err := c.persistConfig(); err != nil { - return fmt.Errorf("unexpected error persisting config: %w", err) - } - - c.tracker.TrackFirstLogin(c.config.InstallID) + if c.Config.DefaultTenant == "" && len(c.Config.Tenants) == 0 { + return nil // Nothing to remove. } - return nil -} - -func (c *cli) persistConfig() error { - dir := filepath.Dir(c.path) - if _, err := os.Stat(dir); os.IsNotExist(err) { - if err := os.MkdirAll(dir, 0700); err != nil { - return err - } + if c.Config.DefaultTenant != "" && len(c.Config.Tenants) == 0 { + return nil // Nothing to remove. } - buf, err := json.MarshalIndent(c.config, "", " ") - if err != nil { - return err + if c.tenant == "" { + c.tenant = c.Config.DefaultTenant } - err = os.WriteFile(c.path, buf, 0600) - - return err -} - -func (c *cli) init() error { - c.initOnce.Do(func() { - if c.errOnce = c.initContext(); c.errOnce != nil { - return - } - - c.renderer.Tenant = c.tenant - - cobra.EnableCommandSorting = false - }) + c.renderer.Tenant = c.tenant if c.json { c.renderer.Format = display.OutputFormatJSON @@ -446,44 +174,9 @@ func (c *cli) init() error { c.renderer.Tenant = c.tenant - // 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 = defaultConfigPath() - } - - if _, err := os.Stat(c.path); os.IsNotExist(err) { - return errUnauthenticated - } - - var buf []byte - if buf, err = os.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 errUnauthenticated - } - - if c.tenant == "" { - c.tenant = c.config.DefaultTenant - } - return nil } -func defaultConfigPath() string { - return path.Join(os.Getenv("HOME"), ".config", "auth0", "config.json") -} - func canPrompt(cmd *cobra.Command) bool { noInput, err := cmd.Root().Flags().GetBool("no-input") if err != nil { diff --git a/internal/cli/log_streams.go b/internal/cli/log_streams.go index 17a94810..010c2d16 100644 --- a/internal/cli/log_streams.go +++ b/internal/cli/log_streams.go @@ -238,7 +238,7 @@ func openLogStreamsCmd(cli *cli) *cobra.Command { inputs.ID = args[0] } - openManageURL(cli, cli.config.DefaultTenant, formatLogStreamSettingsPath(inputs.ID)) + openManageURL(cli, cli.Config.DefaultTenant, formatLogStreamSettingsPath(inputs.ID)) return nil }, diff --git a/internal/cli/login.go b/internal/cli/login.go index b2e72389..fe7a1df3 100644 --- a/internal/cli/login.go +++ b/internal/cli/login.go @@ -11,6 +11,7 @@ import ( "github.com/auth0/auth0-cli/internal/ansi" "github.com/auth0/auth0-cli/internal/auth" + "github.com/auth0/auth0-cli/internal/config" "github.com/auth0/auth0-cli/internal/keyring" "github.com/auth0/auth0-cli/internal/prompt" ) @@ -126,9 +127,9 @@ func loginCmd(cli *cli) *cobra.Command { } } - cli.tracker.TrackCommandRun(cmd, cli.config.InstallID) + cli.tracker.TrackCommandRun(cmd, cli.Config.InstallID) - if len(cli.config.Tenants) > 1 { + if len(cli.Config.Tenants) > 1 { cli.renderer.Infof("%s Switch between authenticated tenants with `auth0 tenants use `", ansi.Faint("Hint:"), ) @@ -155,10 +156,10 @@ func loginCmd(cli *cli) *cobra.Command { // RunLoginAsUser runs the login flow guiding the user through the process // by showing the login instructions, opening the browser. -func RunLoginAsUser(ctx context.Context, cli *cli, additionalScopes []string) (Tenant, error) { +func RunLoginAsUser(ctx context.Context, cli *cli, additionalScopes []string) (config.Tenant, error) { state, err := auth.GetDeviceCode(ctx, http.DefaultClient, additionalScopes) if err != nil { - return Tenant{}, fmt.Errorf("failed to get the device code: %w", err) + return config.Tenant{}, fmt.Errorf("failed to get the device code: %w", err) } message := fmt.Sprintf("\n%s\n\n", @@ -174,7 +175,7 @@ func RunLoginAsUser(ctx context.Context, cli *cli, additionalScopes []string) (T cli.renderer.Infof(message, ansi.Green("Press Enter"), ansi.Red("^C")) if _, err = fmt.Scanln(); err != nil { - return Tenant{}, err + return config.Tenant{}, err } if err = browser.OpenURL(state.VerificationURI); err != nil { @@ -189,7 +190,7 @@ func RunLoginAsUser(ctx context.Context, cli *cli, additionalScopes []string) (T return err }) if err != nil { - return Tenant{}, fmt.Errorf("login error: %w", err) + return config.Tenant{}, fmt.Errorf("login error: %w", err) } cli.renderer.Newline() @@ -197,7 +198,7 @@ func RunLoginAsUser(ctx context.Context, cli *cli, additionalScopes []string) (T cli.renderer.Infof("Tenant: %s", result.Domain) cli.renderer.Newline() - tenant := Tenant{ + tenant := config.Tenant{ Name: result.Tenant, Domain: result.Domain, ExpiresAt: result.ExpiresAt, @@ -215,27 +216,28 @@ func RunLoginAsUser(ctx context.Context, cli *cli, additionalScopes []string) (T tenant.AccessToken = result.AccessToken } - err = cli.addTenant(tenant) + err = cli.Config.AddTenant(tenant) if err != nil { - return Tenant{}, fmt.Errorf("Failed to add the tenant to the config: %w", err) + return config.Tenant{}, fmt.Errorf("Failed to add the tenant to the config: %w", err) } - if err := checkInstallID(cli); err != nil { - return Tenant{}, fmt.Errorf("Failed to update the config: %w", err) + if err := cli.Config.AssignInstallID(); err != nil { + return config.Tenant{}, fmt.Errorf("Failed to update the config: %w", err) } - if cli.config.DefaultTenant != result.Domain { + cli.tracker.TrackFirstLogin(cli.Config.InstallID) + + if cli.Config.DefaultTenant != result.Domain { message = fmt.Sprintf( "Your default tenant is %s. Do you want to change it to %s?", - cli.config.DefaultTenant, + cli.Config.DefaultTenant, result.Domain, ) if confirmed := prompt.Confirm(message); !confirmed { - return Tenant{}, nil + return config.Tenant{}, nil } - cli.config.DefaultTenant = result.Domain - if err := cli.persistConfig(); err != nil { + if err := cli.Config.SaveNewDefaultTenant(result.Domain); err != nil { message = "Failed to set the default tenant, please try 'auth0 tenants use %s' instead: %w" cli.renderer.Warnf(message, result.Domain, err) } @@ -270,7 +272,7 @@ func RunLoginAsMachine(ctx context.Context, inputs LoginInputs, cli *cli, cmd *c return fmt.Errorf("failed to fetch access token using client credentials. \n\nEnsure that the provided client-id, client-secret and domain are correct. \n\nerror: %w\n", err) } - t := Tenant{ + tenant := config.Tenant{ Name: strings.Split(inputs.Domain, ".")[0], Domain: inputs.Domain, ExpiresAt: token.ExpiresAt, @@ -285,10 +287,10 @@ func RunLoginAsMachine(ctx context.Context, inputs LoginInputs, cli *cli, cmd *c if err := keyring.StoreAccessToken(inputs.Domain, token.AccessToken); err != nil { // In case we don't have a keyring, we want the // access token to be saved in the config file. - t.AccessToken = token.AccessToken + tenant.AccessToken = token.AccessToken } - if err = cli.addTenant(t); err != nil { + if err = cli.Config.AddTenant(tenant); err != nil { return fmt.Errorf("unexpected error when attempting to save tenant data: %w", err) } @@ -296,9 +298,11 @@ func RunLoginAsMachine(ctx context.Context, inputs LoginInputs, cli *cli, cmd *c cli.renderer.Infof("Successfully logged in.") cli.renderer.Infof("Tenant: %s", inputs.Domain) - if err := checkInstallID(cli); err != nil { + if err := cli.Config.AssignInstallID(); err != nil { return fmt.Errorf("failed to update the config: %w", err) } + cli.tracker.TrackFirstLogin(cli.Config.InstallID) + return nil } diff --git a/internal/cli/logout.go b/internal/cli/logout.go index 3c63fc14..013e2cd6 100644 --- a/internal/cli/logout.go +++ b/internal/cli/logout.go @@ -4,6 +4,8 @@ import ( "fmt" "github.com/spf13/cobra" + + "github.com/auth0/auth0-cli/internal/keyring" ) func logoutCmd(cli *cli) *cobra.Command { @@ -21,10 +23,14 @@ func logoutCmd(cli *cli) *cobra.Command { return err } - if err := cli.removeTenant(selectedTenant); err != nil { + if err := cli.Config.RemoveTenant(selectedTenant); err != nil { return fmt.Errorf("failed to log out from the tenant %s: %w", selectedTenant, err) } + if err := keyring.DeleteSecretsForTenant(selectedTenant); err != nil { + return fmt.Errorf("failed to delete tenant secrets: %w", err) + } + cli.renderer.Infof("Successfully logged out from tenant: %s", selectedTenant) return nil }, diff --git a/internal/cli/organizations.go b/internal/cli/organizations.go index 3db6f778..ce4547dd 100644 --- a/internal/cli/organizations.go +++ b/internal/cli/organizations.go @@ -481,7 +481,7 @@ func openOrganizationCmd(cli *cli) *cobra.Command { inputs.ID = args[0] } - openManageURL(cli, cli.config.DefaultTenant, formatOrganizationDetailsPath(url.PathEscape(inputs.ID))) + openManageURL(cli, cli.Config.DefaultTenant, formatOrganizationDetailsPath(url.PathEscape(inputs.ID))) return nil }, } diff --git a/internal/cli/roles_permissions.go b/internal/cli/roles_permissions.go index eac08c97..675acec9 100644 --- a/internal/cli/roles_permissions.go +++ b/internal/cli/roles_permissions.go @@ -248,23 +248,15 @@ func removeRolePermissionsCmd(cli *cli) *cobra.Command { } func (c *cli) apiPickerOptionsWithoutAuth0() (pickerOptions, error) { - ten, err := c.getTenant() - if err != nil { - return nil, err - } - return c.filteredAPIPickerOptions(func(r *management.ResourceServer) bool { - u, err := url.Parse(r.GetIdentifier()) + parsedURL, err := url.Parse(r.GetIdentifier()) if err != nil { - // We really should't get an error here, but for - // correctness it's indeterminate, therefore we return - // false. return false } - // We only allow API Identifiers not matching the tenant - // domain, similar to the dashboard UX. - return u.Host != ten.Domain + // We only allow API Identifiers not matching the + // tenant domain, similar to the dashboard UX. + return parsedURL.Host != c.tenant }) } diff --git a/internal/cli/root.go b/internal/cli/root.go index 7c288ce8..dd5fa17e 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -91,8 +91,8 @@ func buildRootCmd(cli *cli) *cobra.Command { // We're tracking the login command in its Run method, so // we'll only add this defer if the command is not login. defer func() { - if cli.tracker != nil && cmd.Name() != "login" && cli.isLoggedIn() { - cli.tracker.TrackCommandRun(cmd, cli.config.InstallID) + if cli.tracker != nil && cmd.CommandPath() != "auth0 login" && cli.isLoggedIn() { + cli.tracker.TrackCommandRun(cmd, cli.Config.InstallID) } }() @@ -125,7 +125,7 @@ func commandRequiresAuthentication(invokedCommandName string) bool { func addPersistentFlags(rootCmd *cobra.Command, cli *cli) { rootCmd.PersistentFlags().StringVar(&cli.tenant, - "tenant", cli.config.DefaultTenant, "Specific tenant to use.") + "tenant", cli.Config.DefaultTenant, "Specific tenant to use.") rootCmd.PersistentFlags().BoolVar(&cli.debug, "debug", false, "Enable debug mode.") diff --git a/internal/cli/tenants.go b/internal/cli/tenants.go index 847cc491..f158b8b5 100644 --- a/internal/cli/tenants.go +++ b/internal/cli/tenants.go @@ -35,7 +35,7 @@ func listTenantCmd(cli *cli) *cobra.Command { Example: ` auth0 tenants list auth0 tenants ls`, RunE: func(cmd *cobra.Command, args []string) error { - tenants, err := cli.listTenants() + tenants, err := cli.Config.ListAllTenants() if err != nil { return fmt.Errorf("failed to load tenants: %w", err) } @@ -68,8 +68,7 @@ func useTenantCmd(cli *cli) *cobra.Command { return err } - cli.config.DefaultTenant = selectedTenant - if err := cli.persistConfig(); err != nil { + if err := cli.Config.SaveNewDefaultTenant(selectedTenant); err != nil { return fmt.Errorf("failed to set the default tenant: %w", err) } @@ -113,7 +112,7 @@ func selectValidTenantFromConfig(cli *cli, cmd *cobra.Command, args []string) (s } selectedTenant = args[0] - if _, ok := cli.config.Tenants[selectedTenant]; !ok { + if _, ok := cli.Config.Tenants[selectedTenant]; !ok { return "", fmt.Errorf( "failed to find tenant %s.\n\nRun 'auth0 login' to configure a new tenant.", selectedTenant, @@ -124,7 +123,7 @@ func selectValidTenantFromConfig(cli *cli, cmd *cobra.Command, args []string) (s } func (c *cli) tenantPickerOptions() (pickerOptions, error) { - tenants, err := c.listTenants() + tenants, err := c.Config.ListAllTenants() if err != nil { return nil, fmt.Errorf("failed to load tenants: %w", err) } @@ -133,7 +132,7 @@ func (c *cli) tenantPickerOptions() (pickerOptions, error) { for _, tenant := range tenants { opt := pickerOption{value: tenant.Domain, label: tenant.Domain} - if tenant.Domain == c.config.DefaultTenant { + if tenant.Domain == c.Config.DefaultTenant { priorityOpts = append(priorityOpts, opt) } else { opts = append(opts, opt) @@ -141,7 +140,7 @@ func (c *cli) tenantPickerOptions() (pickerOptions, error) { } if len(opts)+len(priorityOpts) == 0 { - return nil, fmt.Errorf("there are currently no tenants to pick from") + return nil, fmt.Errorf("There are no tenants to pick from. Add tenants by running `auth0 login`.") } return append(priorityOpts, opts...), nil diff --git a/internal/cli/test.go b/internal/cli/test.go index 7f8c41ea..73940360 100644 --- a/internal/cli/test.go +++ b/internal/cli/test.go @@ -132,14 +132,8 @@ func testLoginCmd(cli *cli) *cobra.Command { } } - tenant, err := cli.getTenant() - if err != nil { - return err - } - tokenResponse, err := runLoginFlow( cli, - tenant, client, inputs.ConnectionName, inputs.Audience, @@ -153,7 +147,7 @@ func testLoginCmd(cli *cli) *cobra.Command { var userInfo *authutil.UserInfo if err := ansi.Spinner("Fetching user metadata", func() (err error) { - userInfo, err = authutil.FetchUserInfo(http.DefaultClient, tenant.Domain, tokenResponse.AccessToken) + userInfo, err = authutil.FetchUserInfo(http.DefaultClient, cli.tenant, tokenResponse.AccessToken) return err }); err != nil { return fmt.Errorf("failed to fetch user info: %w", err) @@ -201,20 +195,15 @@ func testTokenCmd(cli *cli) *cobra.Command { return err } - tenant, err := cli.getTenant() - if err != nil { - return err - } - appType := client.GetAppType() - cli.renderer.Infof("Domain : " + ansi.Blue(tenant.Domain)) + cli.renderer.Infof("Domain : " + ansi.Blue(cli.tenant)) cli.renderer.Infof("Client ID : " + ansi.Bold(client.GetClientID())) cli.renderer.Infof("Type : " + display.ApplyColorToFriendlyAppType(display.FriendlyAppType(appType))) cli.renderer.Newline() if appType == appTypeNonInteractive { - tokenResponse, err := runClientCredentialsFlow(cli, client, inputs.Audience, tenant.Domain) + tokenResponse, err := runClientCredentialsFlow(cli, client, inputs.Audience, cli.tenant) if err != nil { return fmt.Errorf( "failed to log in with client credentials for client with ID %q: %w", @@ -234,7 +223,6 @@ func testTokenCmd(cli *cli) *cobra.Command { tokenResponse, err := runLoginFlow( cli, - tenant, client, "", // Specifying a connection is only supported for the test login command. inputs.Audience, @@ -317,11 +305,6 @@ func (c *cli) customDomainPickerOptions() (pickerOptions, error) { return nil, err } - tenant, err := c.getTenant() - if err != nil { - return nil, err - } - for _, d := range domains { if d.GetStatus() != "ready" { continue @@ -334,7 +317,7 @@ func (c *cli) customDomainPickerOptions() (pickerOptions, error) { return nil, errNoCustomDomains } - opts = append(opts, pickerOption{value: "", label: fmt.Sprintf("none (use %s)", tenant.Domain)}) + opts = append(opts, pickerOption{value: "", label: fmt.Sprintf("none (use %s)", c.tenant)}) return opts, nil } diff --git a/internal/cli/users.go b/internal/cli/users.go index fbe266ce..3b94bdd9 100644 --- a/internal/cli/users.go +++ b/internal/cli/users.go @@ -523,7 +523,7 @@ func openUserCmd(cli *cli) *cobra.Command { inputs.ID = args[0] } - openManageURL(cli, cli.config.DefaultTenant, formatUserDetailsPath(url.PathEscape(inputs.ID))) + openManageURL(cli, cli.Config.DefaultTenant, formatUserDetailsPath(url.PathEscape(inputs.ID))) return nil }, } diff --git a/internal/cli/utils_shared.go b/internal/cli/utils_shared.go index 8d56f2b4..c0ecb15d 100644 --- a/internal/cli/utils_shared.go +++ b/internal/cli/utils_shared.go @@ -15,6 +15,7 @@ import ( "github.com/auth0/auth0-cli/internal/ansi" "github.com/auth0/auth0-cli/internal/auth/authutil" "github.com/auth0/auth0-cli/internal/auth0" + "github.com/auth0/auth0-cli/internal/config" "github.com/auth0/auth0-cli/internal/prompt" ) @@ -118,7 +119,7 @@ func runLoginFlowPreflightChecks(cli *cli, c *management.Client) (abort bool) { // runLoginFlow initiates a full user-facing login flow, waits for a response // and returns the retrieved tokens to the caller when done. -func runLoginFlow(cli *cli, t Tenant, c *management.Client, connName, audience, prompt string, scopes []string, customDomain string) (*authutil.TokenResponse, error) { +func runLoginFlow(cli *cli, c *management.Client, connName, audience, prompt string, scopes []string, customDomain string) (*authutil.TokenResponse, error) { var tokenResponse *authutil.TokenResponse err := ansi.Spinner("Waiting for login flow to complete", func() error { @@ -132,7 +133,7 @@ func runLoginFlow(cli *cli, t Tenant, c *management.Client, connName, audience, return err } - domain := t.Domain + domain := cli.tenant if customDomain != "" { domain = customDomain } @@ -166,7 +167,7 @@ func runLoginFlow(cli *cli, t Tenant, c *management.Client, connName, audience, // token. tokenResponse, err = authutil.ExchangeCodeForToken( http.DefaultClient, - t.Domain, + cli.tenant, c.GetClientID(), c.GetClientSecret(), authCode, @@ -268,7 +269,7 @@ func containsStr(s []string, u string) bool { } func openManageURL(cli *cli, tenant string, path string) { - manageTenantURL := formatManageTenantURL(tenant, cli.config) + manageTenantURL := formatManageTenantURL(tenant, &cli.Config) if len(manageTenantURL) == 0 || len(path) == 0 { cli.renderer.Warnf("Unable to format the correct URL, please ensure you have run 'auth0 login' and try again.") return @@ -286,7 +287,7 @@ func openManageURL(cli *cli, tenant string, path string) { } } -func formatManageTenantURL(tenant string, cfg config) string { +func formatManageTenantURL(tenant string, cfg *config.Config) string { if len(tenant) == 0 { return "" } diff --git a/internal/cli/utils_shared_test.go b/internal/cli/utils_shared_test.go index f9afe66e..89a9f726 100644 --- a/internal/cli/utils_shared_test.go +++ b/internal/cli/utils_shared_test.go @@ -10,6 +10,7 @@ import ( "github.com/auth0/auth0-cli/internal/auth0" "github.com/auth0/auth0-cli/internal/auth0/mock" + "github.com/auth0/auth0-cli/internal/config" ) func TestBuildOauthTokenURL(t *testing.T) { @@ -32,20 +33,20 @@ func TestHasLocalCallbackURL(t *testing.T) { } func TestFormatManageTenantURL(t *testing.T) { - assert.Empty(t, formatManageTenantURL("", config{})) + assert.Empty(t, formatManageTenantURL("", &config.Config{})) - assert.Empty(t, formatManageTenantURL("invalid-tenant-url", config{})) + assert.Empty(t, formatManageTenantURL("invalid-tenant-url", &config.Config{})) - assert.Empty(t, formatManageTenantURL("valid-tenant-url-not-in-config.us.auth0", config{})) + assert.Empty(t, formatManageTenantURL("valid-tenant-url-not-in-config.us.auth0", &config.Config{})) tenantDomain := "some-tenant.us.auth0" - assert.Equal(t, formatManageTenantURL(tenantDomain, config{Tenants: map[string]Tenant{tenantDomain: {Name: "some-tenant"}}}), "https://manage.auth0.com/dashboard/us/some-tenant/") + assert.Equal(t, formatManageTenantURL(tenantDomain, &config.Config{Tenants: map[string]config.Tenant{tenantDomain: {Name: "some-tenant"}}}), "https://manage.auth0.com/dashboard/us/some-tenant/") tenantDomain = "some-eu-tenant.eu.auth0.com" - assert.Equal(t, formatManageTenantURL(tenantDomain, config{Tenants: map[string]Tenant{tenantDomain: {Name: "some-tenant"}}}), "https://manage.auth0.com/dashboard/eu/some-tenant/") + assert.Equal(t, formatManageTenantURL(tenantDomain, &config.Config{Tenants: map[string]config.Tenant{tenantDomain: {Name: "some-tenant"}}}), "https://manage.auth0.com/dashboard/eu/some-tenant/") tenantDomain = "dev-tti06f6y.auth0.com" - assert.Equal(t, formatManageTenantURL(tenantDomain, config{Tenants: map[string]Tenant{tenantDomain: {Name: "some-tenant"}}}), "https://manage.auth0.com/dashboard/us/some-tenant/") + assert.Equal(t, formatManageTenantURL(tenantDomain, &config.Config{Tenants: map[string]config.Tenant{tenantDomain: {Name: "some-tenant"}}}), "https://manage.auth0.com/dashboard/us/some-tenant/") } func TestContainsStr(t *testing.T) { diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 00000000..bfbd5789 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,188 @@ +package config + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path" + "path/filepath" + "sync" + + "github.com/google/uuid" +) + +// ErrConfigFileMissing is thrown when the config.json file is missing. +var ErrConfigFileMissing = errors.New("config.json file is missing") + +// Config holds cli configuration settings. +type Config struct { + onlyOnce sync.Once + initError error + path string + + InstallID string `json:"install_id,omitempty"` + DefaultTenant string `json:"default_tenant"` + Tenants Tenants `json:"tenants"` +} + +// Initialize will load the config settings into memory. +func (c *Config) Initialize() error { + c.onlyOnce.Do(func() { + c.initError = c.loadFromDisk() + }) + + return c.initError +} + +// AssignInstallID assigns and saves the installation ID to the config. +func (c *Config) AssignInstallID() error { + if c.InstallID != "" { + return nil + } + + c.InstallID = uuid.NewString() + + return c.saveToDisk() +} + +// GetTenant retrieves all the tenant information from the config. +func (c *Config) GetTenant(tenantName string) (Tenant, error) { + if err := c.Initialize(); err != nil { + return Tenant{}, err + } + + tenant, ok := c.Tenants[tenantName] + if !ok { + return Tenant{}, fmt.Errorf( + "failed to find tenant: %s. Run 'auth0 tenants use' to see your configured tenants "+ + "or run 'auth0 login' to configure a new tenant", + tenantName, + ) + } + + return tenant, nil +} + +// AddTenant adds a tenant to the config. +// This is called after a login has completed. +func (c *Config) AddTenant(tenant Tenant) error { + // Ignore error as we could be + // logging in the first time. + _ = c.Initialize() + + if c.DefaultTenant == "" { + c.DefaultTenant = tenant.Domain + } + + if c.Tenants == nil { + c.Tenants = make(map[string]Tenant) + } + + c.Tenants[tenant.Domain] = tenant + + return c.saveToDisk() +} + +// RemoveTenant removes a tenant from the config. +// This is called after a logout has completed. +func (c *Config) RemoveTenant(tenant string) error { + if err := c.Initialize(); err != nil { + if errors.Is(err, ErrConfigFileMissing) { + return nil // Config file is missing, so nothing to remove. + } + return err + } + + if c.DefaultTenant == "" && len(c.Tenants) == 0 { + return nil // Nothing to remove. + } + + if c.DefaultTenant != "" && len(c.Tenants) == 0 { + c.DefaultTenant = "" // Reset possible corruption of config file. + return c.saveToDisk() + } + + delete(c.Tenants, tenant) + + if c.DefaultTenant == tenant { + for otherTenant := range c.Tenants { + c.DefaultTenant = otherTenant + break // Pick first tenant and exit as we called delete above. + } + } + + return c.saveToDisk() +} + +// ListAllTenants retrieves a list with all configured tenants. +func (c *Config) ListAllTenants() ([]Tenant, error) { + if err := c.Initialize(); err != nil { + return nil, err + } + + tenants := make([]Tenant, 0, len(c.Tenants)) + for _, tenant := range c.Tenants { + tenants = append(tenants, tenant) + } + + return tenants, nil +} + +// SaveNewDefaultTenant saves the new default tenant to the disk. +func (c *Config) SaveNewDefaultTenant(tenant string) error { + c.DefaultTenant = tenant + return c.saveToDisk() +} + +// SaveNewDefaultAppIDForTenant saves the new default app id for the tenant to the disk. +func (c *Config) SaveNewDefaultAppIDForTenant(tenantName, appID string) error { + tenant, err := c.GetTenant(tenantName) + if err != nil { + return err + } + + tenant.DefaultAppID = appID + c.Tenants[tenant.Domain] = tenant + + return c.saveToDisk() +} + +func (c *Config) loadFromDisk() error { + if c.path == "" { + c.path = defaultPath() + } + + if _, err := os.Stat(c.path); os.IsNotExist(err) { + return ErrConfigFileMissing + } + + buffer, err := os.ReadFile(c.path) + if err != nil { + return err + } + + return json.Unmarshal(buffer, c) +} + +func (c *Config) saveToDisk() error { + dir := filepath.Dir(c.path) + if _, err := os.Stat(dir); os.IsNotExist(err) { + const dirPerm os.FileMode = 0700 // Directory permissions (read, write, and execute for the owner only). + if err := os.MkdirAll(dir, dirPerm); err != nil { + return err + } + } + + buffer, err := json.MarshalIndent(c, "", " ") + if err != nil { + return err + } + + const filePerm os.FileMode = 0600 // File permissions (read and write for the owner only). + return os.WriteFile(c.path, buffer, filePerm) +} + +func defaultPath() string { + return path.Join(os.Getenv("HOME"), ".config", "auth0", "config.json") +} diff --git a/internal/config/tenant.go b/internal/config/tenant.go new file mode 100644 index 00000000..2328579c --- /dev/null +++ b/internal/config/tenant.go @@ -0,0 +1,126 @@ +package config + +import ( + "context" + "fmt" + "net/http" + "time" + + "golang.org/x/exp/slices" + + "github.com/auth0/auth0-cli/internal/auth" + "github.com/auth0/auth0-cli/internal/keyring" +) + +const accessTokenExpThreshold = 5 * time.Minute + +type ( + Tenants map[string]Tenant + + Tenant struct { + Name string `json:"name"` + Domain string `json:"domain"` + AccessToken string `json:"access_token,omitempty"` + Scopes []string `json:"scopes,omitempty"` + ExpiresAt time.Time `json:"expires_at"` + DefaultAppID string `json:"default_app_id,omitempty"` + ClientID string `json:"client_id"` + } +) + +// HasAllRequiredScopes returns true if the tenant +// has all the required scopes, false otherwise. +func (t *Tenant) HasAllRequiredScopes() bool { + for _, requiredScope := range auth.RequiredScopes { + if !slices.Contains(t.Scopes, requiredScope) { + return false + } + } + + return true +} + +func (t *Tenant) GetExtraRequestedScopes() []string { + additionallyRequestedScopes := make([]string, 0) + + for _, scope := range t.Scopes { + found := false + + for _, defaultScope := range auth.RequiredScopes { + if scope == defaultScope { + found = true + break + } + } + + if !found { + additionallyRequestedScopes = append(additionallyRequestedScopes, scope) + } + } + + return additionallyRequestedScopes +} + +func (t *Tenant) IsAuthenticatedWithClientCredentials() bool { + return t.ClientID != "" +} + +func (t *Tenant) IsAuthenticatedWithDeviceCodeFlow() bool { + return t.ClientID == "" +} + +func (t *Tenant) HasExpiredToken() bool { + return time.Now().Add(accessTokenExpThreshold).After(t.ExpiresAt) +} + +func (t *Tenant) GetAccessToken() string { + accessToken, err := keyring.GetAccessToken(t.Domain) + if err == nil && accessToken != "" { + return accessToken + } + + return t.AccessToken +} + +func (t *Tenant) RegenerateAccessToken(ctx context.Context) error { + if t.IsAuthenticatedWithClientCredentials() { + clientSecret, err := keyring.GetClientSecret(t.Domain) + if err != nil { + return fmt.Errorf("failed to retrieve client secret from keyring: %w", err) + } + + token, err := auth.GetAccessTokenFromClientCreds( + ctx, + auth.ClientCredentials{ + ClientID: t.ClientID, + ClientSecret: clientSecret, + Domain: t.Domain, + }, + ) + if err != nil { + return err + } + + t.AccessToken = token.AccessToken + t.ExpiresAt = token.ExpiresAt + } + + if t.IsAuthenticatedWithDeviceCodeFlow() { + tokenResponse, err := auth.RefreshAccessToken(http.DefaultClient, t.Domain) + if err != nil { + return err + } + + t.AccessToken = tokenResponse.AccessToken + t.ExpiresAt = time.Now().Add( + time.Duration(tokenResponse.ExpiresIn) * time.Second, + ) + } + + err := keyring.StoreAccessToken(t.Domain, t.AccessToken) + if err != nil { + t.AccessToken = "" + } + + return nil +}