From 8f759150661343493c3ca28949705a87b437f6d2 Mon Sep 17 00:00:00 2001 From: Sergiu Ghitea <28300158+sergiught@users.noreply.github.com> Date: Mon, 17 Apr 2023 18:42:16 +0200 Subject: [PATCH 01/12] Add new config pkg --- go.mod | 1 + go.sum | 2 + internal/config/config.go | 252 ++++++++++++++++++++++++++++++++++++++ internal/config/tenant.go | 138 +++++++++++++++++++++ 4 files changed, 393 insertions(+) create mode 100644 internal/config/config.go create mode 100644 internal/config/tenant.go diff --git a/go.mod b/go.mod index fd639123b..7ec88da24 100644 --- a/go.mod +++ b/go.mod @@ -25,6 +25,7 @@ require ( github.com/stretchr/testify v1.8.2 github.com/tidwall/pretty v1.2.1 github.com/zalando/go-keyring v0.2.2 + golang.org/x/exp v0.0.0-20230321023759-10a507213a29 golang.org/x/oauth2 v0.7.0 golang.org/x/sync v0.1.0 golang.org/x/sys v0.7.0 diff --git a/go.sum b/go.sum index 06ff3abcb..62f64b4b1 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/config/config.go b/internal/config/config.go new file mode 100644 index 000000000..0d08a6294 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,252 @@ +package config + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path" + "path/filepath" + "sync" + + "github.com/google/uuid" + "github.com/lestrrat-go/jwx/jwt" +) + +// ErrConfigFileMissing is thrown when the config.json file is missing. +var ErrConfigFileMissing = errors.New("config.json file is missing") + +// ErrNoAuthenticatedTenants is thrown when the config file has no authenticated tenants. +var ErrNoAuthenticatedTenants = errors.New("Not logged in. Try `auth0 login`.") + +// Config holds cli configuration settings. +type Config struct { + onlyOnce sync.Once + initError error + + Path string `json:"-"` + + 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 +} + +// VerifyAuthentication checks to see if the config is not corrupted, +// and we have an authenticated tenant saved. +// If we have at least one tenant saved but the DefaultTenant +// is empty, it will attempt to set the first available +// tenant as the DefaultTenant and save to disk. +func (c *Config) VerifyAuthentication() error { + if err := c.Initialize(); err != nil { + return err + } + + if len(c.Tenants) == 0 { + return ErrNoAuthenticatedTenants + } + + if c.DefaultTenant != "" { + return nil + } + + for tenant := range c.Tenants { + c.DefaultTenant = tenant + break // Pick first tenant and exit. + } + + return c.saveToDisk() +} + +// IsLoggedInWithTenant checks if we're logged in with the given tenant. +func (c *Config) IsLoggedInWithTenant(tenantName string) bool { + // Ignore error as we could + // be not logged in yet. + _ = c.Initialize() + + if tenantName == "" { + tenantName = c.DefaultTenant + } + + tenant, ok := c.Tenants[tenantName] + if !ok { + return false + } + + token, err := jwt.ParseString(tenant.GetAccessToken()) + if err != nil { + return false + } + + if err = jwt.Validate(token, jwt.WithIssuer("https://auth0.auth0.com/")); err != nil { + return false + } + + return true +} + +// 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() + + c.ensureInstallIDAssigned() + + 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 { + c.DefaultTenant = "" + + 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 { + if err := c.Initialize(); err != nil { + return err + } + + 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) ensureInstallIDAssigned() { + if c.InstallID != "" { + return + } + + c.InstallID = uuid.NewString() +} + +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 000000000..5097f9c2a --- /dev/null +++ b/internal/config/tenant.go @@ -0,0 +1,138 @@ +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 keeps track of all the tenants we + // logged into. The key is the tenant domain. + Tenants map[string]Tenant + + // Tenant keeps track of auth0 config for the 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 +} + +// GetExtraRequestedScopes retrieves any extra scopes requested +// for the tenant when logging in through the device code flow. +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 +} + +// IsAuthenticatedWithClientCredentials checks to see if the +// tenant has been authenticated through client credentials. +func (t *Tenant) IsAuthenticatedWithClientCredentials() bool { + return t.ClientID != "" +} + +// IsAuthenticatedWithDeviceCodeFlow checks to see if the +// tenant has been authenticated through device code flow. +func (t *Tenant) IsAuthenticatedWithDeviceCodeFlow() bool { + return t.ClientID == "" +} + +// HasExpiredToken checks whether the tenant has an expired token. +func (t *Tenant) HasExpiredToken() bool { + return time.Now().Add(accessTokenExpThreshold).After(t.ExpiresAt) +} + +// GetAccessToken retrieves the tenant's access token. +func (t *Tenant) GetAccessToken() string { + accessToken, err := keyring.GetAccessToken(t.Domain) + if err == nil && accessToken != "" { + return accessToken + } + + return t.AccessToken +} + +// RegenerateAccessToken regenerates the access token for the tenant. +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 +} From de878571100332e13bab5abe65af973611a34ca3 Mon Sep 17 00:00:00 2001 From: Sergiu Ghitea <28300158+sergiught@users.noreply.github.com> Date: Mon, 17 Apr 2023 18:42:22 +0200 Subject: [PATCH 02/12] Add tests for new config pkg --- internal/config/config_test.go | 836 +++++++++++++++++++++++++++++++++ internal/config/tenant_test.go | 145 ++++++ 2 files changed, 981 insertions(+) create mode 100644 internal/config/config_test.go create mode 100644 internal/config/tenant_test.go diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 000000000..c53782c3a --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,836 @@ +package config + +import ( + "fmt" + "os" + "path" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDefaultPath(t *testing.T) { + homeDir, err := os.UserHomeDir() + require.NoError(t, err) + + expectedPath := path.Join(homeDir, ".config", "auth0", "config.json") + + actualPath := defaultPath() + + assert.Equal(t, expectedPath, actualPath) +} + +func TestConfig_LoadFromDisk(t *testing.T) { + t.Run("it fails to load a non existent config file", func(t *testing.T) { + config := &Config{Path: "i-am-a-non-existent-config.json"} + err := config.loadFromDisk() + assert.EqualError(t, err, "config.json file is missing") + }) + + t.Run("it fails to load config if path is a directory", func(t *testing.T) { + dirPath, err := os.MkdirTemp("", "") + require.NoError(t, err) + t.Cleanup(func() { + err := os.Remove(dirPath) + require.NoError(t, err) + }) + + config := &Config{Path: dirPath} + err = config.loadFromDisk() + + assert.EqualError(t, err, fmt.Sprintf("read %s: is a directory", dirPath)) + }) + + t.Run("it fails to load an empty config file", func(t *testing.T) { + tempFile := createTempConfigFile(t, []byte("")) + + config := &Config{Path: tempFile} + err := config.loadFromDisk() + + assert.EqualError(t, err, "unexpected end of JSON input") + }) + + t.Run("it can successfully load a config file with a logged in tenant", func(t *testing.T) { + tempFile := createTempConfigFile(t, []byte(` + { + "install_id": "3998b053-dd7f-4bfe-bb10-c4f3a96a0180", + "default_tenant": "auth0-cli.eu.auth0.com", + "tenants": { + "auth0-cli.eu.auth0.com": { + "name": "auth0-cli", + "domain": "auth0-cli.eu.auth0.com", + "access_token": "eyfSaswe", + "expires_at": "2023-04-18T11:18:07.998809Z", + "client_id": "secret" + } + } + } + `)) + + expectedConfig := &Config{ + Path: tempFile, + InstallID: "3998b053-dd7f-4bfe-bb10-c4f3a96a0180", + DefaultTenant: "auth0-cli.eu.auth0.com", + Tenants: Tenants{ + "auth0-cli.eu.auth0.com": Tenant{ + Name: "auth0-cli", + Domain: "auth0-cli.eu.auth0.com", + AccessToken: "eyfSaswe", + ExpiresAt: time.Date(2023, time.April, 18, 11, 18, 7, 998809000, time.UTC), + ClientID: "secret", + }, + }, + } + + config := &Config{Path: tempFile} + err := config.loadFromDisk() + + assert.NoError(t, err) + assert.Equal(t, expectedConfig, config) + }) + + t.Run("it can successfully load a config file with no logged in tenants", func(t *testing.T) { + tempFile := createTempConfigFile(t, []byte(` + { + "install_id": "3998b053-dd7f-4bfe-bb10-c4f3a96a0180", + "default_tenant": "", + "tenants": {} + } + `)) + + expectedConfig := &Config{ + Path: tempFile, + InstallID: "3998b053-dd7f-4bfe-bb10-c4f3a96a0180", + Tenants: map[string]Tenant{}, + } + + config := &Config{Path: tempFile} + err := config.loadFromDisk() + + assert.NoError(t, err) + assert.Equal(t, expectedConfig, config) + }) +} + +func TestConfig_SaveToDisk(t *testing.T) { + var testCases = []struct { + name string + config *Config + expectedOutput string + }{ + { + name: "valid config with a logged in tenant", + config: &Config{ + InstallID: "3998b053-dd7f-4bfe-bb10-c4f3a96a0180", + DefaultTenant: "auth0-cli.eu.auth0.com", + Tenants: Tenants{ + "auth0-cli.eu.auth0.com": Tenant{ + Name: "auth0-cli", + Domain: "auth0-cli.eu.auth0.com", + AccessToken: "eyfSaswe", + ExpiresAt: time.Date(2023, time.April, 18, 11, 18, 7, 998809000, time.UTC), + ClientID: "secret", + }, + }, + }, + expectedOutput: `{ + "install_id": "3998b053-dd7f-4bfe-bb10-c4f3a96a0180", + "default_tenant": "auth0-cli.eu.auth0.com", + "tenants": { + "auth0-cli.eu.auth0.com": { + "name": "auth0-cli", + "domain": "auth0-cli.eu.auth0.com", + "access_token": "eyfSaswe", + "expires_at": "2023-04-18T11:18:07.998809Z", + "client_id": "secret" + } + } +}`, + }, + { + name: "valid config with no logged in tenants", + config: &Config{ + InstallID: "3998b053-dd7f-4bfe-bb10-c4f3a96a0180", + Tenants: map[string]Tenant{}, + }, + expectedOutput: `{ + "install_id": "3998b053-dd7f-4bfe-bb10-c4f3a96a0180", + "default_tenant": "", + "tenants": {} +}`, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "") + require.NoError(t, err) + t.Cleanup(func() { + err := os.RemoveAll(tmpDir) + require.NoError(t, err) + }) + + testCase.config.Path = path.Join(tmpDir, "auth0", "config.json") + + err = testCase.config.saveToDisk() + assert.NoError(t, err) + + fileContent, err := os.ReadFile(testCase.config.Path) + assert.NoError(t, err) + assert.Equal(t, string(fileContent), testCase.expectedOutput) + }) + } + + t.Run("it fails to save config if file path is a read only directory", func(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "") + require.NoError(t, err) + t.Cleanup(func() { + err := os.RemoveAll(tmpDir) + require.NoError(t, err) + }) + + err = os.Chmod(tmpDir, 0555) + require.NoError(t, err) + + config := &Config{Path: path.Join(tmpDir, "auth0", "config.json")} + + err = config.saveToDisk() + assert.EqualError(t, err, fmt.Sprintf("mkdir %s/auth0: permission denied", tmpDir)) + }) +} + +func TestConfig_GetTenant(t *testing.T) { + t.Run("it can successfully retrieve a logged in tenant", func(t *testing.T) { + tempFile := createTempConfigFile(t, []byte(` + { + "install_id": "3998b053-dd7f-4bfe-bb10-c4f3a96a0180", + "default_tenant": "auth0-cli.eu.auth0.com", + "tenants": { + "auth0-cli.eu.auth0.com": { + "name": "auth0-cli", + "domain": "auth0-cli.eu.auth0.com", + "access_token": "eyfSaswe", + "expires_at": "2023-04-18T11:18:07.998809Z", + "client_id": "secret" + } + } + } + `)) + + expectedTenant := Tenant{ + Name: "auth0-cli", + Domain: "auth0-cli.eu.auth0.com", + AccessToken: "eyfSaswe", + ExpiresAt: time.Date(2023, time.April, 18, 11, 18, 7, 998809000, time.UTC), + ClientID: "secret", + } + + config := &Config{Path: tempFile} + actualTenant, err := config.GetTenant("auth0-cli.eu.auth0.com") + + assert.NoError(t, err) + assert.Equal(t, expectedTenant, actualTenant) + }) + + t.Run("it throws an error if the tenant can't be found", func(t *testing.T) { + tempFile := createTempConfigFile(t, []byte(` + { + "install_id": "3998b053-dd7f-4bfe-bb10-c4f3a96a0180", + "default_tenant": "", + "tenants": {} + } + `)) + + config := &Config{Path: tempFile} + _, err := config.GetTenant("auth0-cli.eu.auth0.com") + + assert.EqualError(t, err, "failed to find tenant: auth0-cli.eu.auth0.com. Run 'auth0 tenants use' to see your configured tenants or run 'auth0 login' to configure a new tenant") + }) + + t.Run("it throws an error if the config can't be initialized", func(t *testing.T) { + config := &Config{Path: "non-existent-config.json"} + _, err := config.GetTenant("auth0-cli.eu.auth0.com") + + assert.EqualError(t, err, "config.json file is missing") + }) +} + +func TestConfig_AddTenant(t *testing.T) { + t.Run("it can successfully add a tenant and create the config.json file", func(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "") + require.NoError(t, err) + t.Cleanup(func() { + err := os.RemoveAll(tmpDir) + require.NoError(t, err) + }) + + config := &Config{ + InstallID: "6122fd48-a634-447e-88b0-0580d41b7fb6", + Path: path.Join(tmpDir, "auth0", "config.json"), + } + + tenant := Tenant{ + Name: "auth0-cli", + Domain: "auth0-cli.eu.auth0.com", + AccessToken: "eyfSaswe", + ExpiresAt: time.Date(2023, time.April, 18, 11, 18, 7, 998809000, time.UTC), + ClientID: "secret", + } + + err = config.AddTenant(tenant) + assert.NoError(t, err) + + expectedOutput := `{ + "install_id": "6122fd48-a634-447e-88b0-0580d41b7fb6", + "default_tenant": "auth0-cli.eu.auth0.com", + "tenants": { + "auth0-cli.eu.auth0.com": { + "name": "auth0-cli", + "domain": "auth0-cli.eu.auth0.com", + "access_token": "eyfSaswe", + "expires_at": "2023-04-18T11:18:07.998809Z", + "client_id": "secret" + } + } +}` + + assertConfigFileMatches(t, config.Path, expectedOutput) + }) + + t.Run("it can successfully add another tenant to the config", func(t *testing.T) { + tempFile := createTempConfigFile(t, []byte(` + { + "install_id": "3998b053-dd7f-4bfe-bb10-c4f3a96a0180", + "default_tenant": "auth0-cli.eu.auth0.com", + "tenants": { + "auth0-cli.eu.auth0.com": { + "name": "auth0-cli", + "domain": "auth0-cli.eu.auth0.com", + "access_token": "eyfSaswe", + "expires_at": "2023-04-18T11:18:07.998809Z", + "client_id": "secret" + } + } + } + `)) + + config := &Config{ + Path: tempFile, + } + + tenant := Tenant{ + Name: "auth0-mega-cli", + Domain: "auth0-mega-cli.eu.auth0.com", + AccessToken: "eyfSaswe", + ExpiresAt: time.Date(2023, time.April, 18, 11, 18, 7, 998809000, time.UTC), + ClientID: "secret", + } + + err := config.AddTenant(tenant) + assert.NoError(t, err) + + expectedOutput := `{ + "install_id": "3998b053-dd7f-4bfe-bb10-c4f3a96a0180", + "default_tenant": "auth0-cli.eu.auth0.com", + "tenants": { + "auth0-cli.eu.auth0.com": { + "name": "auth0-cli", + "domain": "auth0-cli.eu.auth0.com", + "access_token": "eyfSaswe", + "expires_at": "2023-04-18T11:18:07.998809Z", + "client_id": "secret" + }, + "auth0-mega-cli.eu.auth0.com": { + "name": "auth0-mega-cli", + "domain": "auth0-mega-cli.eu.auth0.com", + "access_token": "eyfSaswe", + "expires_at": "2023-04-18T11:18:07.998809Z", + "client_id": "secret" + } + } +}` + + assertConfigFileMatches(t, config.Path, expectedOutput) + }) +} + +func TestConfig_RemoveTenant(t *testing.T) { + var testCases = []struct { + name string + givenConfig string + givenTenant string + expectedConfig string + }{ + { + name: "it can successfully remove a tenant from the config", + givenTenant: "auth0-mega-cli.eu.auth0.com", + givenConfig: createTempConfigFile(t, []byte(`{ + "install_id": "3998b053-dd7f-4bfe-bb10-c4f3a96a0180", + "default_tenant": "auth0-cli.eu.auth0.com", + "tenants": { + "auth0-cli.eu.auth0.com": { + "name": "auth0-cli", + "domain": "auth0-cli.eu.auth0.com", + "access_token": "eyfSaswe", + "expires_at": "2023-04-18T11:18:07.998809Z", + "client_id": "secret" + }, + "auth0-mega-cli.eu.auth0.com": { + "name": "auth0-mega-cli", + "domain": "auth0-mega-cli.eu.auth0.com", + "access_token": "eyfSaswe", + "expires_at": "2023-04-18T11:18:07.998809Z", + "client_id": "secret" + } + } + }`)), + expectedConfig: `{ + "install_id": "3998b053-dd7f-4bfe-bb10-c4f3a96a0180", + "default_tenant": "auth0-cli.eu.auth0.com", + "tenants": { + "auth0-cli.eu.auth0.com": { + "name": "auth0-cli", + "domain": "auth0-cli.eu.auth0.com", + "access_token": "eyfSaswe", + "expires_at": "2023-04-18T11:18:07.998809Z", + "client_id": "secret" + } + } +}`, + }, + { + name: "it can successfully remove the default tenant from the config", + givenTenant: "auth0-cli.eu.auth0.com", + givenConfig: createTempConfigFile(t, []byte(`{ + "install_id": "3998b053-dd7f-4bfe-bb10-c4f3a96a0180", + "default_tenant": "auth0-cli.eu.auth0.com", + "tenants": { + "auth0-cli.eu.auth0.com": { + "name": "auth0-cli", + "domain": "auth0-cli.eu.auth0.com", + "access_token": "eyfSaswe", + "expires_at": "2023-04-18T11:18:07.998809Z", + "client_id": "secret" + }, + "auth0-mega-cli.eu.auth0.com": { + "name": "auth0-mega-cli", + "domain": "auth0-mega-cli.eu.auth0.com", + "access_token": "eyfSaswe", + "expires_at": "2023-04-18T11:18:07.998809Z", + "client_id": "secret" + } + } + }`)), + expectedConfig: `{ + "install_id": "3998b053-dd7f-4bfe-bb10-c4f3a96a0180", + "default_tenant": "auth0-mega-cli.eu.auth0.com", + "tenants": { + "auth0-mega-cli.eu.auth0.com": { + "name": "auth0-mega-cli", + "domain": "auth0-mega-cli.eu.auth0.com", + "access_token": "eyfSaswe", + "expires_at": "2023-04-18T11:18:07.998809Z", + "client_id": "secret" + } + } +}`, + }, + { + name: "it can successfully remove the last tenant from the config", + givenTenant: "auth0-cli.eu.auth0.com", + givenConfig: createTempConfigFile(t, []byte(`{ + "install_id": "3998b053-dd7f-4bfe-bb10-c4f3a96a0180", + "default_tenant": "auth0-cli.eu.auth0.com", + "tenants": { + "auth0-cli.eu.auth0.com": { + "name": "auth0-cli", + "domain": "auth0-cli.eu.auth0.com", + "access_token": "eyfSaswe", + "expires_at": "2023-04-18T11:18:07.998809Z", + "client_id": "secret" + } + } + }`)), + expectedConfig: `{ + "install_id": "3998b053-dd7f-4bfe-bb10-c4f3a96a0180", + "default_tenant": "", + "tenants": {} +}`, + }, + { + name: "it doesn't do anything if config file has no logged in tenants", + givenTenant: "auth0-cli.eu.auth0.com", + givenConfig: createTempConfigFile(t, []byte(`{ + "install_id": "3998b053-dd7f-4bfe-bb10-c4f3a96a0180", + "default_tenant": "", + "tenants": {} +}`)), + expectedConfig: `{ + "install_id": "3998b053-dd7f-4bfe-bb10-c4f3a96a0180", + "default_tenant": "", + "tenants": {} +}`, + }, + { + name: "it sets the default tenant to empty if no logged in tenants are registered", + givenTenant: "auth0-cli.eu.auth0.com", + givenConfig: createTempConfigFile(t, []byte(`{ + "install_id": "3998b053-dd7f-4bfe-bb10-c4f3a96a0180", + "default_tenant": "auth0-cli.eu.auth0.com", + "tenants": {} +}`)), + expectedConfig: `{ + "install_id": "3998b053-dd7f-4bfe-bb10-c4f3a96a0180", + "default_tenant": "", + "tenants": {} +}`, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + config := &Config{Path: testCase.givenConfig} + + err := config.RemoveTenant(testCase.givenTenant) + assert.NoError(t, err) + + assertConfigFileMatches(t, config.Path, testCase.expectedConfig) + }) + } + + t.Run("it doesn't throw an error if config file is missing", func(t *testing.T) { + config := &Config{ + Path: "i-dont-exist.json", + } + + err := config.RemoveTenant("auth0-cli.eu.auth0.com") + assert.NoError(t, err) + }) +} + +func TestConfig_ListAllTenants(t *testing.T) { + t.Run("it can successfully list all tenants", func(t *testing.T) { + tempFile := createTempConfigFile(t, []byte(`{ + "install_id": "3998b053-dd7f-4bfe-bb10-c4f3a96a0180", + "default_tenant": "auth0-cli.eu.auth0.com", + "tenants": { + "auth0-cli.eu.auth0.com": { + "name": "auth0-cli", + "domain": "auth0-cli.eu.auth0.com", + "access_token": "eyfSaswe", + "expires_at": "2023-04-18T11:18:07.998809Z", + "client_id": "secret" + }, + "auth0-mega-cli.eu.auth0.com": { + "name": "auth0-mega-cli", + "domain": "auth0-mega-cli.eu.auth0.com", + "access_token": "eyfSaswe", + "expires_at": "2023-04-18T11:18:07.998809Z", + "client_id": "secret" + } + } + }`)) + + expectedTenants := []Tenant{ + { + Name: "auth0-cli", + Domain: "auth0-cli.eu.auth0.com", + AccessToken: "eyfSaswe", + ExpiresAt: time.Date(2023, time.April, 18, 11, 18, 7, 998809000, time.UTC), + ClientID: "secret", + }, + { + Name: "auth0-mega-cli", + Domain: "auth0-mega-cli.eu.auth0.com", + AccessToken: "eyfSaswe", + ExpiresAt: time.Date(2023, time.April, 18, 11, 18, 7, 998809000, time.UTC), + ClientID: "secret", + }, + } + + config := &Config{Path: tempFile} + actualTenants, err := config.ListAllTenants() + + assert.NoError(t, err) + assert.Len(t, actualTenants, 2) + assert.Equal(t, expectedTenants, actualTenants) + }) + + t.Run("it throws an error if there's an issue with the config file", func(t *testing.T) { + config := &Config{Path: "i-dont-exist.json"} + + _, err := config.ListAllTenants() + assert.EqualError(t, err, "config.json file is missing") + }) +} + +func TestConfig_SaveNewDefaultTenant(t *testing.T) { + t.Run("it can successfully save a new tenant default", func(t *testing.T) { + tempFile := createTempConfigFile(t, []byte(`{ + "install_id": "3998b053-dd7f-4bfe-bb10-c4f3a96a0180", + "default_tenant": "auth0-cli.eu.auth0.com", + "tenants": { + "auth0-cli.eu.auth0.com": { + "name": "auth0-cli", + "domain": "auth0-cli.eu.auth0.com", + "access_token": "eyfSaswe", + "expires_at": "2023-04-18T11:18:07.998809Z", + "client_id": "secret" + }, + "auth0-mega-cli.eu.auth0.com": { + "name": "auth0-mega-cli", + "domain": "auth0-mega-cli.eu.auth0.com", + "access_token": "eyfSaswe", + "expires_at": "2023-04-18T11:18:07.998809Z", + "client_id": "secret" + } + } + }`)) + + expectedConfig := `{ + "install_id": "3998b053-dd7f-4bfe-bb10-c4f3a96a0180", + "default_tenant": "auth0-mega-cli.eu.auth0.com", + "tenants": { + "auth0-cli.eu.auth0.com": { + "name": "auth0-cli", + "domain": "auth0-cli.eu.auth0.com", + "access_token": "eyfSaswe", + "expires_at": "2023-04-18T11:18:07.998809Z", + "client_id": "secret" + }, + "auth0-mega-cli.eu.auth0.com": { + "name": "auth0-mega-cli", + "domain": "auth0-mega-cli.eu.auth0.com", + "access_token": "eyfSaswe", + "expires_at": "2023-04-18T11:18:07.998809Z", + "client_id": "secret" + } + } +}` + + config := &Config{Path: tempFile} + err := config.SaveNewDefaultTenant("auth0-mega-cli.eu.auth0.com") + assert.NoError(t, err) + assertConfigFileMatches(t, config.Path, expectedConfig) + }) + + t.Run("it throws an error if there's an issue with the config file", func(t *testing.T) { + config := &Config{Path: "i-dont-exist.json"} + + err := config.SaveNewDefaultTenant("tenant") + assert.EqualError(t, err, "config.json file is missing") + }) +} + +func TestConfig_SaveNewDefaultAppIDForTenant(t *testing.T) { + t.Run("it successfully saves a new default app id for the tenant", func(t *testing.T) { + tempFile := createTempConfigFile(t, []byte(`{ + "install_id": "3998b053-dd7f-4bfe-bb10-c4f3a96a0180", + "default_tenant": "auth0-cli.eu.auth0.com", + "tenants": { + "auth0-cli.eu.auth0.com": { + "name": "auth0-cli", + "domain": "auth0-cli.eu.auth0.com", + "access_token": "eyfSaswe", + "expires_at": "2023-04-18T11:18:07.998809Z", + "client_id": "secret" + } + } + }`)) + + expectedConfig := `{ + "install_id": "3998b053-dd7f-4bfe-bb10-c4f3a96a0180", + "default_tenant": "auth0-cli.eu.auth0.com", + "tenants": { + "auth0-cli.eu.auth0.com": { + "name": "auth0-cli", + "domain": "auth0-cli.eu.auth0.com", + "access_token": "eyfSaswe", + "expires_at": "2023-04-18T11:18:07.998809Z", + "default_app_id": "appID123456", + "client_id": "secret" + } + } +}` + + config := &Config{Path: tempFile} + err := config.SaveNewDefaultAppIDForTenant("auth0-cli.eu.auth0.com", "appID123456") + assert.NoError(t, err) + assertConfigFileMatches(t, config.Path, expectedConfig) + }) + + t.Run("it throws an error if there's an issue with the config file", func(t *testing.T) { + config := &Config{Path: "i-dont-exist.json"} + + err := config.SaveNewDefaultAppIDForTenant("tenant", "appID123456") + assert.EqualError(t, err, "config.json file is missing") + }) +} + +func TestConfig_IsLoggedInWithTenant(t *testing.T) { + t.Run("it returns true when we are logged in", func(t *testing.T) { + tempFile := createTempConfigFile(t, []byte(`{ + "install_id": "3998b053-dd7f-4bfe-bb10-c4f3a96a0180", + "default_tenant": "auth0-cli.eu.auth0.com", + "tenants": { + "auth0-cli.eu.auth0.com": { + "name": "auth0-cli", + "domain": "auth0-cli.eu.auth0.com", + "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2F1dGgwLmF1dGgwLmNvbS8iLCJpYXQiOjE2ODExNDcwNjAsImV4cCI6OTY4MTgzMzQ2MH0.DsEpQkL0MIWcGJOIfEY8vr3MVS_E0GYsachNLQwBu5Q", + "expires_at": "2023-04-18T11:18:07.998809Z", + "client_id": "secret" + } + } + }`)) + + config := &Config{Path: tempFile} + assert.True(t, config.IsLoggedInWithTenant("auth0-cli.eu.auth0.com")) + }) + + t.Run("it returns false when we are not logged in", func(t *testing.T) { + config := &Config{Path: "i-dont-exist.json"} + assert.False(t, config.IsLoggedInWithTenant("auth0-cli.eu.auth0.com")) + }) + + t.Run("it returns false when we are logged in but the token is expired", func(t *testing.T) { + tempFile := createTempConfigFile(t, []byte(`{ + "install_id": "3998b053-dd7f-4bfe-bb10-c4f3a96a0180", + "default_tenant": "auth0-cli.eu.auth0.com", + "tenants": { + "auth0-cli.eu.auth0.com": { + "name": "auth0-cli", + "domain": "auth0-cli.eu.auth0.com", + "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2F1dGgwLmF1dGgwLmNvbS8iLCJpYXQiOjE2ODExNDcwNjAsImV4cCI6MTY4MTEzMzQ2MH0.dG481CD7v8VCzSsBHdApTiRDUuCZXBgk5LO__q4r2Fg", + "expires_at": "2023-04-10T11:18:07.998809Z", + "client_id": "secret" + } + } + }`)) + + config := &Config{Path: tempFile} + assert.False(t, config.IsLoggedInWithTenant("auth0-cli.eu.auth0.com")) + }) + + t.Run("it returns false when we are logged in but the token is malformed", func(t *testing.T) { + tempFile := createTempConfigFile(t, []byte(`{ + "install_id": "3998b053-dd7f-4bfe-bb10-c4f3a96a0180", + "default_tenant": "auth0-cli.eu.auth0.com", + "tenants": { + "auth0-cli.eu.auth0.com": { + "name": "auth0-cli", + "domain": "auth0-cli.eu.auth0.com", + "access_token": "dG481CD7v8VCzSsBHdApTiRDUuCZXBgk5LO__q4r2Fg", + "expires_at": "2023-04-10T11:18:07.998809Z", + "client_id": "secret" + } + } + }`)) + + config := &Config{Path: tempFile} + assert.False(t, config.IsLoggedInWithTenant("")) + }) +} + +func TestConfig_VerifyAuthentication(t *testing.T) { + t.Run("it successfully verifies that we are authenticated", func(t *testing.T) { + tempFile := createTempConfigFile(t, []byte(`{ + "install_id": "3998b053-dd7f-4bfe-bb10-c4f3a96a0180", + "default_tenant": "auth0-cli.eu.auth0.com", + "tenants": { + "auth0-cli.eu.auth0.com": { + "name": "auth0-cli", + "domain": "auth0-cli.eu.auth0.com", + "access_token": "eyfSaswe", + "expires_at": "2023-04-18T11:18:07.998809Z", + "client_id": "secret" + } + } + }`)) + + config := &Config{Path: tempFile} + err := config.VerifyAuthentication() + assert.NoError(t, err) + }) + + t.Run("it throws an error if we are not authenticated with any tenant", func(t *testing.T) { + tempFile := createTempConfigFile(t, []byte(`{ + "install_id": "3998b053-dd7f-4bfe-bb10-c4f3a96a0180", + "default_tenant": "auth0-cli.eu.auth0.com", + "tenants": {} + }`)) + + config := &Config{Path: tempFile} + err := config.VerifyAuthentication() + assert.EqualError(t, err, "Not logged in. Try `auth0 login`.") + }) + + t.Run("it fixes the default tenant if there are tenant entries and the default is empty", func(t *testing.T) { + tempFile := createTempConfigFile(t, []byte(`{ + "install_id": "3998b053-dd7f-4bfe-bb10-c4f3a96a0180", + "default_tenant": "", + "tenants": { + "auth0-cli.eu.auth0.com": { + "name": "auth0-cli", + "domain": "auth0-cli.eu.auth0.com", + "access_token": "eyfSaswe", + "expires_at": "2023-04-18T11:18:07.998809Z", + "client_id": "secret" + } + } + }`)) + + config := &Config{Path: tempFile} + err := config.VerifyAuthentication() + assert.NoError(t, err) + + expectedConfig := `{ + "install_id": "3998b053-dd7f-4bfe-bb10-c4f3a96a0180", + "default_tenant": "auth0-cli.eu.auth0.com", + "tenants": { + "auth0-cli.eu.auth0.com": { + "name": "auth0-cli", + "domain": "auth0-cli.eu.auth0.com", + "access_token": "eyfSaswe", + "expires_at": "2023-04-18T11:18:07.998809Z", + "client_id": "secret" + } + } +}` + + assertConfigFileMatches(t, config.Path, expectedConfig) + }) + + t.Run("it throws an error if there's an issue with the config file", func(t *testing.T) { + config := &Config{Path: "i-dont-exist.json"} + + err := config.VerifyAuthentication() + assert.EqualError(t, err, "config.json file is missing") + }) +} + +func createTempConfigFile(t *testing.T, data []byte) string { + t.Helper() + + tempFile, err := os.CreateTemp("", "config.json") + require.NoError(t, err) + + t.Cleanup(func() { + err := os.Remove(tempFile.Name()) + require.NoError(t, err) + }) + + _, err = tempFile.Write(data) + require.NoError(t, err) + + return tempFile.Name() +} + +func assertConfigFileMatches(t *testing.T, actualConfigPath, expectedConfig string) { + t.Helper() + + fileContent, err := os.ReadFile(actualConfigPath) + assert.NoError(t, err) + assert.Equal(t, expectedConfig, string(fileContent)) +} diff --git a/internal/config/tenant_test.go b/internal/config/tenant_test.go new file mode 100644 index 000000000..8ed23d9e5 --- /dev/null +++ b/internal/config/tenant_test.go @@ -0,0 +1,145 @@ +package config + +import ( + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/zalando/go-keyring" + + "github.com/auth0/auth0-cli/internal/auth" +) + +func TestTenant_HasAllRequiredScopes(t *testing.T) { + t.Run("tenant has all required scopes", func(t *testing.T) { + tenant := &Tenant{ + Scopes: auth.RequiredScopes, + } + + assert.True(t, tenant.HasAllRequiredScopes()) + }) + + t.Run("tenant does not have all required scopes", func(t *testing.T) { + tenant := &Tenant{ + Scopes: []string{"read:clients"}, + } + + assert.False(t, tenant.HasAllRequiredScopes()) + }) +} + +func TestTenant_GetExtraRequestedScopes(t *testing.T) { + t.Run("tenant has no extra requested scopes", func(t *testing.T) { + tenant := &Tenant{ + Scopes: auth.RequiredScopes, + } + + assert.Empty(t, tenant.GetExtraRequestedScopes()) + }) + + t.Run("tenant has extra requested scopes", func(t *testing.T) { + tenant := &Tenant{ + Scopes: []string{ + "create:organization_invitations", + "read:organization_invitations", + "delete:organization_invitations", + }, + } + + expected := []string{ + "create:organization_invitations", + "read:organization_invitations", + "delete:organization_invitations", + } + + assert.ElementsMatch(t, expected, tenant.GetExtraRequestedScopes()) + }) +} + +func TestTenant_IsAuthenticatedWithClientCredentials(t *testing.T) { + t.Run("tenant is authenticated with client credentials", func(t *testing.T) { + tenant := &Tenant{ + ClientID: "test-client-id", + } + + assert.True(t, tenant.IsAuthenticatedWithClientCredentials()) + }) + + t.Run("tenant is not authenticated with client credentials", func(t *testing.T) { + tenant := &Tenant{} + + assert.False(t, tenant.IsAuthenticatedWithClientCredentials()) + }) +} + +func TestTenant_IsAuthenticatedWithDeviceCodeFlow(t *testing.T) { + t.Run("tenant is authenticated with device code flow", func(t *testing.T) { + tenant := &Tenant{} + + assert.True(t, tenant.IsAuthenticatedWithDeviceCodeFlow()) + }) + + t.Run("tenant is not authenticated with device code flow", func(t *testing.T) { + tenant := &Tenant{ + ClientID: "test-client-id", + } + + assert.False(t, tenant.IsAuthenticatedWithDeviceCodeFlow()) + }) +} + +func TestTenant_HasExpiredToken(t *testing.T) { + t.Run("token has not expired", func(t *testing.T) { + tenant := &Tenant{ + ExpiresAt: time.Now().Add(10 * time.Minute), + } + + assert.False(t, tenant.HasExpiredToken()) + }) + + t.Run("token has expired", func(t *testing.T) { + tenant := &Tenant{ + ExpiresAt: time.Now().Add(-10 * time.Minute), + } + + assert.True(t, tenant.HasExpiredToken()) + }) +} + +func TestTenant_GetAccessToken(t *testing.T) { + const testTenantName = "auth0-cli-test.us.auth0.com" + expectedToken := "chunk0chunk1chunk2" + + keyring.MockInit() + + t.Run("token is retrieved from the keyring", func(t *testing.T) { + const secretAccessToken = "Auth0 CLI Access Token" + + err := keyring.Set(fmt.Sprintf("%s %d", secretAccessToken, 0), testTenantName, "chunk0") + assert.NoError(t, err) + err = keyring.Set(fmt.Sprintf("%s %d", secretAccessToken, 1), testTenantName, "chunk1") + assert.NoError(t, err) + err = keyring.Set(fmt.Sprintf("%s %d", secretAccessToken, 2), testTenantName, "chunk2") + assert.NoError(t, err) + + tenant := &Tenant{ + Domain: testTenantName, + } + + actualToken := tenant.GetAccessToken() + + assert.Equal(t, expectedToken, actualToken) + }) + + t.Run("token is retrieved from the config when not found in the keyring", func(t *testing.T) { + tenant := &Tenant{ + Domain: testTenantName, + AccessToken: "chunk0chunk1chunk2", + } + + actualToken := tenant.GetAccessToken() + + assert.Equal(t, expectedToken, actualToken) + }) +} From 84233706e24e81a5ac280d66c593d815a9316c5a Mon Sep 17 00:00:00 2001 From: Sergiu Ghitea <28300158+sergiught@users.noreply.github.com> Date: Mon, 17 Apr 2023 18:42:38 +0200 Subject: [PATCH 03/12] Refactor codebase to use new config pkg --- internal/cli/actions.go | 2 +- internal/cli/apis.go | 2 +- internal/cli/apps.go | 8 +- internal/cli/cli.go | 412 +++--------------------------- internal/cli/cli_test.go | 153 ----------- internal/cli/log_streams.go | 2 +- internal/cli/login.go | 42 ++- internal/cli/logout.go | 8 +- internal/cli/organizations.go | 2 +- internal/cli/roles_permissions.go | 16 +- internal/cli/root.go | 8 +- internal/cli/templates.go | 2 +- internal/cli/tenants.go | 13 +- internal/cli/test.go | 25 +- internal/cli/users.go | 2 +- internal/cli/utils_shared.go | 11 +- internal/cli/utils_shared_test.go | 13 +- 17 files changed, 101 insertions(+), 620 deletions(-) diff --git a/internal/cli/actions.go b/internal/cli/actions.go index 873612f86..00d395cc7 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 eb76f9140..951cc90e1 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 7e4fcdf5d..9d814755c 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 55bba46e2..b0332ad9d 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -2,60 +2,23 @@ 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,111 +45,7 @@ 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 -} - -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 -// 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() - - if c.tenant == "" { - return false - } - - // Parse the access token for the tenant. - t, err := jwt.ParseString(c.config.Tenants[c.tenant].AccessToken) - if err != nil { - return false - } - - // Check if token is valid. - if err = jwt.Validate(t, jwt.WithIssuer("https://auth0.auth0.com/")); err != nil { - return false - } - - return true + Config config.Config } // setup will try to initialize the config context, as well as figure out if @@ -195,11 +54,19 @@ func (c *cli) isLoggedIn() bool { // 1. A tenant is found. // 2. The tenant has an access token. func (c *cli) setup(ctx context.Context) error { - if err := c.init(); err != nil { + cobra.EnableCommandSorting = false + + if err := c.Config.VerifyAuthentication(); err != nil { return err } - t, err := c.prepareTenant(ctx) + if c.tenant == "" { + c.tenant = c.Config.DefaultTenant + } + + c.configureRenderer() + + tenant, err := c.ensureTenantAccessTokenIsUpdated(ctx) if err != nil { return err } @@ -207,8 +74,8 @@ func (c *cli) setup(ctx context.Context) error { userAgent := fmt.Sprintf("%v/%v", userAgent, strings.TrimPrefix(buildinfo.Version, "v")) api, err := management.New( - t.Domain, - management.WithStaticToken(getAccessToken(t)), + tenant.Domain, + management.WithStaticToken(tenant.GetAccessToken()), management.WithUserAgent(userAgent), ) if err != nil { @@ -219,37 +86,36 @@ 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 - } +func (c *cli) configureRenderer() { + c.renderer.Tenant = c.tenant - return t.AccessToken + if c.json { + c.renderer.Format = display.OutputFormatJSON + } } -// prepareTenant loads the tenant, refreshing its token if necessary. +// ensureTenantAccessTokenIsUpdated 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) ensureTenantAccessTokenIsUpdated(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,228 +128,16 @@ 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) setDefaultAppID(id string) error { - tenant, err := c.getTenant() - if 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) - } - - 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 - } - } - - buf, err := json.MarshalIndent(c.config, "", " ") - if err != nil { - return err - } - - 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 - }) - - if c.json { - c.renderer.Format = display.OutputFormatJSON - } - - 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/cli_test.go b/internal/cli/cli_test.go index 699d80cc8..cf5c8e8bb 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -2,53 +2,14 @@ package cli import ( "bytes" - "encoding/json" "fmt" - "os" "strings" "testing" - "time" "github.com/google/go-cmp/cmp" "github.com/olekukonko/tablewriter" - "github.com/stretchr/testify/assert" - "github.com/zalando/go-keyring" - - "github.com/auth0/auth0-cli/internal/auth" - "github.com/auth0/auth0-cli/internal/display" ) -func TestTenant_HasExpiredToken(t *testing.T) { - var testCases = []struct { - name string - givenTime time.Time - expectedTokenToBeExpired bool - }{ - { - name: "is expired", - givenTime: time.Date(2021, 01, 01, 10, 30, 30, 0, time.UTC), - expectedTokenToBeExpired: true, - }, - { - name: "expired because of the threshold", - givenTime: time.Now().Add(-2 * time.Minute), - expectedTokenToBeExpired: true, - }, - { - name: "is not expired", - givenTime: time.Now().Add(10 * time.Minute), - expectedTokenToBeExpired: false, - }, - } - - for _, testCase := range testCases { - t.Run(testCase.name, func(t *testing.T) { - tenant := Tenant{ExpiresAt: testCase.givenTime} - assert.Equal(t, testCase.expectedTokenToBeExpired, tenant.hasExpiredToken()) - }) - } -} - // TODO(cyx): think about whether we should extract this function in the // `display` package. For now duplication might be better and less premature. func expectTable(t testing.TB, got string, header []string, data [][]string) { @@ -81,117 +42,3 @@ func expectTable(t testing.TB, got string, header []string, data [][]string) { t.Fatal(cmp.Diff(want, got)) } } - -func TestIsLoggedIn(t *testing.T) { - tests := []struct { - defaultTenant string - tenants map[string]Tenant - want bool - desc string - }{ - {"", map[string]Tenant{}, false, "no tenants"}, - {"t0", map[string]Tenant{}, false, "tenant is set but no tenants map"}, - {"t0", map[string]Tenant{"t0": {}}, false, "tenants map set but invalid token"}, - } - - for _, test := range tests { - t.Run(test.desc, func(t *testing.T) { - tmpFile, err := os.CreateTemp(os.TempDir(), "isLoggedIn-") - if err != nil { - t.Fatal(err) - } - defer os.Remove(tmpFile.Name()) - - type Config struct { - DefaultTenant string `json:"default_tenant"` - Tenants map[string]Tenant `json:"tenants"` - } - - b, err := json.Marshal(&Config{test.defaultTenant, test.tenants}) - if err != nil { - t.Fatal(err) - } - - if err = os.WriteFile(tmpFile.Name(), b, 0400); err != nil { - t.Fatal(err) - } - - c := cli{renderer: display.NewRenderer(), path: tmpFile.Name()} - assert.Equal(t, test.want, c.isLoggedIn()) - }) - } -} - -func TestTenant_AdditionalRequestedScopes(t *testing.T) { - var testCases = []struct { - name string - givenScopes []string - expectedScopes []string - }{ - { - name: "it can correctly distinguish additionally requested scopes", - givenScopes: append(auth.RequiredScopes, "read:stats", "update:client_grants"), - expectedScopes: []string{"read:stats", "update:client_grants"}, - }, - { - name: "it returns an empty string slice if no additional requested scopes were given", - givenScopes: auth.RequiredScopes, - expectedScopes: []string{}, - }, - } - - for _, testCase := range testCases { - t.Run(testCase.name, func(t *testing.T) { - tenant := Tenant{Scopes: testCase.givenScopes} - assert.Equal(t, testCase.expectedScopes, tenant.additionalRequestedScopes()) - }) - } -} - -func TestGetAccessToken(t *testing.T) { - mockTenantDomain := "mock-tenant.com" - - t.Run("return empty string if no keyring and no access token on tenant struct", func(t *testing.T) { - assert.Equal(t, "", getAccessToken(Tenant{Domain: mockTenantDomain, AccessToken: ""})) - }) - - t.Run("returns access token on tenant struct if no keyring", func(t *testing.T) { - mockAccessToken := "this is the access token" - - assert.Equal(t, mockAccessToken, getAccessToken(Tenant{Domain: mockTenantDomain, AccessToken: mockAccessToken})) - }) - - t.Run("returns chunked access token if set on the keyring", func(t *testing.T) { - accessTokenChunks := []string{"access-token-chunk0", "access-token-chunk1"} - - keyring.MockInit() - err := keyring.Set("Auth0 CLI Access Token 0", mockTenantDomain, accessTokenChunks[0]) - assert.NoError(t, err) - err = keyring.Set("Auth0 CLI Access Token 1", mockTenantDomain, accessTokenChunks[1]) - assert.NoError(t, err) - - assert.Equal(t, strings.Join(accessTokenChunks, ""), getAccessToken(Tenant{Domain: mockTenantDomain, AccessToken: ""})) - assert.Equal(t, strings.Join(accessTokenChunks, ""), getAccessToken(Tenant{Domain: mockTenantDomain, AccessToken: "even if this is set for some reason"})) - }) -} - -func TestAuthenticatedWithClientCredentials(t *testing.T) { - mockTenantClientCredentials := Tenant{ClientID: "some-valid-client-id"} - assert.True(t, mockTenantClientCredentials.authenticatedWithClientCredentials()) - - mockTenantDeviceFlow := Tenant{ClientID: ""} - assert.False(t, mockTenantDeviceFlow.authenticatedWithClientCredentials()) -} - -func TestHasAllRequiredScopes(t *testing.T) { - mockTenantWithNoScopes := Tenant{Scopes: []string{}} - assert.False(t, hasAllRequiredScopes(mockTenantWithNoScopes)) - - mockTenantWithAllRequiredScopes := Tenant{Scopes: auth.RequiredScopes} - assert.True(t, hasAllRequiredScopes(mockTenantWithAllRequiredScopes)) - - requiredScopesAndMore := auth.RequiredScopes - requiredScopesAndMore = append(requiredScopesAndMore, "read:foo", "update:foo", "delete:foo") - mockTenantWithAllRequiredScopesAndMore := Tenant{Scopes: requiredScopesAndMore} - assert.True(t, hasAllRequiredScopes(mockTenantWithAllRequiredScopesAndMore)) -} diff --git a/internal/cli/log_streams.go b/internal/cli/log_streams.go index 17a94810a..010c2d16c 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 b2e723893..6e7531a1e 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,24 @@ 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) - } + cli.tracker.TrackFirstLogin(cli.Config.InstallID) - if cli.config.DefaultTenant != result.Domain { + 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 +268,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 +283,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 +294,7 @@ 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 { - 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 3c63fc141..013e2cd6a 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 3db6f7784..ce4547dd7 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 eac08c977..675acec90 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 7c288ce88..fc5ffb15d 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -91,8 +91,10 @@ 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.Config.IsLoggedInWithTenant(cli.tenant) { + cli.tracker.TrackCommandRun(cmd, cli.Config.InstallID) } }() @@ -125,7 +127,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/templates.go b/internal/cli/templates.go index 877cb849a..2a21fd2ca 100644 --- a/internal/cli/templates.go +++ b/internal/cli/templates.go @@ -39,7 +39,7 @@ func WrappedAliases(cmd *cobra.Command) string { } func getLogin(cli *cli) string { - if !cli.isLoggedIn() { + if !cli.Config.IsLoggedInWithTenant(cli.tenant) { return ansi.Italic(` Before using the CLI, you'll need to login: diff --git a/internal/cli/tenants.go b/internal/cli/tenants.go index 847cc4913..f158b8b55 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 7f8c41ea6..739403601 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 fbe266ce9..3b94bdd93 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 8d56f2b4a..c0ecb15d2 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 f9afe66ea..89a9f726f 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) { From 954f34b6a9e6a6cb4854c476e10f6a6d6c054f1d Mon Sep 17 00:00:00 2001 From: Sergiu Ghitea <28300158+sergiught@users.noreply.github.com> Date: Tue, 18 Apr 2023 13:07:44 +0200 Subject: [PATCH 04/12] Minor tweaks to naming and setting path to private --- internal/cli/apps.go | 4 +- internal/cli/login.go | 2 +- internal/cli/tenants.go | 2 +- internal/config/config.go | 22 ++++----- internal/config/config_test.go | 82 +++++++++++++++++----------------- 5 files changed, 56 insertions(+), 56 deletions(-) diff --git a/internal/cli/apps.go b/internal/cli/apps.go index 9d814755c..5cca284d8 100644 --- a/internal/cli/apps.go +++ b/internal/cli/apps.go @@ -179,7 +179,7 @@ func useAppCmd(cli *cli) *cobra.Command { } } - if err := cli.Config.SaveNewDefaultAppIDForTenant(cli.tenant, inputs.ID); err != nil { + if err := cli.Config.SetDefaultAppIDForTenant(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.Config.SaveNewDefaultAppIDForTenant(cli.tenant, a.GetClientID()); err != nil { + if err := cli.Config.SetDefaultAppIDForTenant(cli.tenant, a.GetClientID()); err != nil { return err } diff --git a/internal/cli/login.go b/internal/cli/login.go index 6e7531a1e..ea81330e4 100644 --- a/internal/cli/login.go +++ b/internal/cli/login.go @@ -233,7 +233,7 @@ func RunLoginAsUser(ctx context.Context, cli *cli, additionalScopes []string) (c return config.Tenant{}, nil } - if err := cli.Config.SaveNewDefaultTenant(result.Domain); err != nil { + if err := cli.Config.SetDefaultTenant(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) } diff --git a/internal/cli/tenants.go b/internal/cli/tenants.go index f158b8b55..688c38b5e 100644 --- a/internal/cli/tenants.go +++ b/internal/cli/tenants.go @@ -68,7 +68,7 @@ func useTenantCmd(cli *cli) *cobra.Command { return err } - if err := cli.Config.SaveNewDefaultTenant(selectedTenant); err != nil { + if err := cli.Config.SetDefaultTenant(selectedTenant); err != nil { return fmt.Errorf("failed to set the default tenant: %w", err) } diff --git a/internal/config/config.go b/internal/config/config.go index 0d08a6294..35e7ad5dd 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -24,7 +24,7 @@ type Config struct { onlyOnce sync.Once initError error - Path string `json:"-"` + path string InstallID string `json:"install_id,omitempty"` DefaultTenant string `json:"default_tenant"` @@ -180,8 +180,8 @@ func (c *Config) ListAllTenants() ([]Tenant, error) { return tenants, nil } -// SaveNewDefaultTenant saves the new default tenant to the disk. -func (c *Config) SaveNewDefaultTenant(tenant string) error { +// SetDefaultTenant saves the new default tenant to the disk. +func (c *Config) SetDefaultTenant(tenant string) error { if err := c.Initialize(); err != nil { return err } @@ -191,8 +191,8 @@ func (c *Config) SaveNewDefaultTenant(tenant string) error { return c.saveToDisk() } -// SaveNewDefaultAppIDForTenant saves the new default app id for the tenant to the disk. -func (c *Config) SaveNewDefaultAppIDForTenant(tenantName, appID string) error { +// SetDefaultAppIDForTenant saves the new default app id for the tenant to the disk. +func (c *Config) SetDefaultAppIDForTenant(tenantName, appID string) error { tenant, err := c.GetTenant(tenantName) if err != nil { return err @@ -213,15 +213,15 @@ func (c *Config) ensureInstallIDAssigned() { } func (c *Config) loadFromDisk() error { - if c.Path == "" { - c.Path = defaultPath() + if c.path == "" { + c.path = defaultPath() } - if _, err := os.Stat(c.Path); os.IsNotExist(err) { + if _, err := os.Stat(c.path); os.IsNotExist(err) { return ErrConfigFileMissing } - buffer, err := os.ReadFile(c.Path) + buffer, err := os.ReadFile(c.path) if err != nil { return err } @@ -230,7 +230,7 @@ func (c *Config) loadFromDisk() error { } func (c *Config) saveToDisk() error { - dir := filepath.Dir(c.Path) + 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 { @@ -244,7 +244,7 @@ func (c *Config) saveToDisk() error { } const filePerm os.FileMode = 0600 // File permissions (read and write for the owner only). - return os.WriteFile(c.Path, buffer, filePerm) + return os.WriteFile(c.path, buffer, filePerm) } func defaultPath() string { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index c53782c3a..f93e05e0d 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -24,7 +24,7 @@ func TestDefaultPath(t *testing.T) { func TestConfig_LoadFromDisk(t *testing.T) { t.Run("it fails to load a non existent config file", func(t *testing.T) { - config := &Config{Path: "i-am-a-non-existent-config.json"} + config := &Config{path: "i-am-a-non-existent-config.json"} err := config.loadFromDisk() assert.EqualError(t, err, "config.json file is missing") }) @@ -37,7 +37,7 @@ func TestConfig_LoadFromDisk(t *testing.T) { require.NoError(t, err) }) - config := &Config{Path: dirPath} + config := &Config{path: dirPath} err = config.loadFromDisk() assert.EqualError(t, err, fmt.Sprintf("read %s: is a directory", dirPath)) @@ -46,7 +46,7 @@ func TestConfig_LoadFromDisk(t *testing.T) { t.Run("it fails to load an empty config file", func(t *testing.T) { tempFile := createTempConfigFile(t, []byte("")) - config := &Config{Path: tempFile} + config := &Config{path: tempFile} err := config.loadFromDisk() assert.EqualError(t, err, "unexpected end of JSON input") @@ -70,7 +70,7 @@ func TestConfig_LoadFromDisk(t *testing.T) { `)) expectedConfig := &Config{ - Path: tempFile, + path: tempFile, InstallID: "3998b053-dd7f-4bfe-bb10-c4f3a96a0180", DefaultTenant: "auth0-cli.eu.auth0.com", Tenants: Tenants{ @@ -84,7 +84,7 @@ func TestConfig_LoadFromDisk(t *testing.T) { }, } - config := &Config{Path: tempFile} + config := &Config{path: tempFile} err := config.loadFromDisk() assert.NoError(t, err) @@ -101,12 +101,12 @@ func TestConfig_LoadFromDisk(t *testing.T) { `)) expectedConfig := &Config{ - Path: tempFile, + path: tempFile, InstallID: "3998b053-dd7f-4bfe-bb10-c4f3a96a0180", Tenants: map[string]Tenant{}, } - config := &Config{Path: tempFile} + config := &Config{path: tempFile} err := config.loadFromDisk() assert.NoError(t, err) @@ -172,12 +172,12 @@ func TestConfig_SaveToDisk(t *testing.T) { require.NoError(t, err) }) - testCase.config.Path = path.Join(tmpDir, "auth0", "config.json") + testCase.config.path = path.Join(tmpDir, "auth0", "config.json") err = testCase.config.saveToDisk() assert.NoError(t, err) - fileContent, err := os.ReadFile(testCase.config.Path) + fileContent, err := os.ReadFile(testCase.config.path) assert.NoError(t, err) assert.Equal(t, string(fileContent), testCase.expectedOutput) }) @@ -194,7 +194,7 @@ func TestConfig_SaveToDisk(t *testing.T) { err = os.Chmod(tmpDir, 0555) require.NoError(t, err) - config := &Config{Path: path.Join(tmpDir, "auth0", "config.json")} + config := &Config{path: path.Join(tmpDir, "auth0", "config.json")} err = config.saveToDisk() assert.EqualError(t, err, fmt.Sprintf("mkdir %s/auth0: permission denied", tmpDir)) @@ -227,7 +227,7 @@ func TestConfig_GetTenant(t *testing.T) { ClientID: "secret", } - config := &Config{Path: tempFile} + config := &Config{path: tempFile} actualTenant, err := config.GetTenant("auth0-cli.eu.auth0.com") assert.NoError(t, err) @@ -243,14 +243,14 @@ func TestConfig_GetTenant(t *testing.T) { } `)) - config := &Config{Path: tempFile} + config := &Config{path: tempFile} _, err := config.GetTenant("auth0-cli.eu.auth0.com") assert.EqualError(t, err, "failed to find tenant: auth0-cli.eu.auth0.com. Run 'auth0 tenants use' to see your configured tenants or run 'auth0 login' to configure a new tenant") }) t.Run("it throws an error if the config can't be initialized", func(t *testing.T) { - config := &Config{Path: "non-existent-config.json"} + config := &Config{path: "non-existent-config.json"} _, err := config.GetTenant("auth0-cli.eu.auth0.com") assert.EqualError(t, err, "config.json file is missing") @@ -268,7 +268,7 @@ func TestConfig_AddTenant(t *testing.T) { config := &Config{ InstallID: "6122fd48-a634-447e-88b0-0580d41b7fb6", - Path: path.Join(tmpDir, "auth0", "config.json"), + path: path.Join(tmpDir, "auth0", "config.json"), } tenant := Tenant{ @@ -296,7 +296,7 @@ func TestConfig_AddTenant(t *testing.T) { } }` - assertConfigFileMatches(t, config.Path, expectedOutput) + assertConfigFileMatches(t, config.path, expectedOutput) }) t.Run("it can successfully add another tenant to the config", func(t *testing.T) { @@ -317,7 +317,7 @@ func TestConfig_AddTenant(t *testing.T) { `)) config := &Config{ - Path: tempFile, + path: tempFile, } tenant := Tenant{ @@ -352,7 +352,7 @@ func TestConfig_AddTenant(t *testing.T) { } }` - assertConfigFileMatches(t, config.Path, expectedOutput) + assertConfigFileMatches(t, config.path, expectedOutput) }) } @@ -491,18 +491,18 @@ func TestConfig_RemoveTenant(t *testing.T) { for _, testCase := range testCases { t.Run(testCase.name, func(t *testing.T) { - config := &Config{Path: testCase.givenConfig} + config := &Config{path: testCase.givenConfig} err := config.RemoveTenant(testCase.givenTenant) assert.NoError(t, err) - assertConfigFileMatches(t, config.Path, testCase.expectedConfig) + assertConfigFileMatches(t, config.path, testCase.expectedConfig) }) } t.Run("it doesn't throw an error if config file is missing", func(t *testing.T) { config := &Config{ - Path: "i-dont-exist.json", + path: "i-dont-exist.json", } err := config.RemoveTenant("auth0-cli.eu.auth0.com") @@ -550,7 +550,7 @@ func TestConfig_ListAllTenants(t *testing.T) { }, } - config := &Config{Path: tempFile} + config := &Config{path: tempFile} actualTenants, err := config.ListAllTenants() assert.NoError(t, err) @@ -559,7 +559,7 @@ func TestConfig_ListAllTenants(t *testing.T) { }) t.Run("it throws an error if there's an issue with the config file", func(t *testing.T) { - config := &Config{Path: "i-dont-exist.json"} + config := &Config{path: "i-dont-exist.json"} _, err := config.ListAllTenants() assert.EqualError(t, err, "config.json file is missing") @@ -610,16 +610,16 @@ func TestConfig_SaveNewDefaultTenant(t *testing.T) { } }` - config := &Config{Path: tempFile} - err := config.SaveNewDefaultTenant("auth0-mega-cli.eu.auth0.com") + config := &Config{path: tempFile} + err := config.SetDefaultTenant("auth0-mega-cli.eu.auth0.com") assert.NoError(t, err) - assertConfigFileMatches(t, config.Path, expectedConfig) + assertConfigFileMatches(t, config.path, expectedConfig) }) t.Run("it throws an error if there's an issue with the config file", func(t *testing.T) { - config := &Config{Path: "i-dont-exist.json"} + config := &Config{path: "i-dont-exist.json"} - err := config.SaveNewDefaultTenant("tenant") + err := config.SetDefaultTenant("tenant") assert.EqualError(t, err, "config.json file is missing") }) } @@ -655,16 +655,16 @@ func TestConfig_SaveNewDefaultAppIDForTenant(t *testing.T) { } }` - config := &Config{Path: tempFile} - err := config.SaveNewDefaultAppIDForTenant("auth0-cli.eu.auth0.com", "appID123456") + config := &Config{path: tempFile} + err := config.SetDefaultAppIDForTenant("auth0-cli.eu.auth0.com", "appID123456") assert.NoError(t, err) - assertConfigFileMatches(t, config.Path, expectedConfig) + assertConfigFileMatches(t, config.path, expectedConfig) }) t.Run("it throws an error if there's an issue with the config file", func(t *testing.T) { - config := &Config{Path: "i-dont-exist.json"} + config := &Config{path: "i-dont-exist.json"} - err := config.SaveNewDefaultAppIDForTenant("tenant", "appID123456") + err := config.SetDefaultAppIDForTenant("tenant", "appID123456") assert.EqualError(t, err, "config.json file is missing") }) } @@ -685,12 +685,12 @@ func TestConfig_IsLoggedInWithTenant(t *testing.T) { } }`)) - config := &Config{Path: tempFile} + config := &Config{path: tempFile} assert.True(t, config.IsLoggedInWithTenant("auth0-cli.eu.auth0.com")) }) t.Run("it returns false when we are not logged in", func(t *testing.T) { - config := &Config{Path: "i-dont-exist.json"} + config := &Config{path: "i-dont-exist.json"} assert.False(t, config.IsLoggedInWithTenant("auth0-cli.eu.auth0.com")) }) @@ -709,7 +709,7 @@ func TestConfig_IsLoggedInWithTenant(t *testing.T) { } }`)) - config := &Config{Path: tempFile} + config := &Config{path: tempFile} assert.False(t, config.IsLoggedInWithTenant("auth0-cli.eu.auth0.com")) }) @@ -728,7 +728,7 @@ func TestConfig_IsLoggedInWithTenant(t *testing.T) { } }`)) - config := &Config{Path: tempFile} + config := &Config{path: tempFile} assert.False(t, config.IsLoggedInWithTenant("")) }) } @@ -749,7 +749,7 @@ func TestConfig_VerifyAuthentication(t *testing.T) { } }`)) - config := &Config{Path: tempFile} + config := &Config{path: tempFile} err := config.VerifyAuthentication() assert.NoError(t, err) }) @@ -761,7 +761,7 @@ func TestConfig_VerifyAuthentication(t *testing.T) { "tenants": {} }`)) - config := &Config{Path: tempFile} + config := &Config{path: tempFile} err := config.VerifyAuthentication() assert.EqualError(t, err, "Not logged in. Try `auth0 login`.") }) @@ -781,7 +781,7 @@ func TestConfig_VerifyAuthentication(t *testing.T) { } }`)) - config := &Config{Path: tempFile} + config := &Config{path: tempFile} err := config.VerifyAuthentication() assert.NoError(t, err) @@ -799,11 +799,11 @@ func TestConfig_VerifyAuthentication(t *testing.T) { } }` - assertConfigFileMatches(t, config.Path, expectedConfig) + assertConfigFileMatches(t, config.path, expectedConfig) }) t.Run("it throws an error if there's an issue with the config file", func(t *testing.T) { - config := &Config{Path: "i-dont-exist.json"} + config := &Config{path: "i-dont-exist.json"} err := config.VerifyAuthentication() assert.EqualError(t, err, "config.json file is missing") From b95f8bd072ab9d92e160b2c1a51d014649f2876d Mon Sep 17 00:00:00 2001 From: Sergiu Ghitea <28300158+sergiught@users.noreply.github.com> Date: Tue, 18 Apr 2023 18:50:15 +0200 Subject: [PATCH 05/12] Add tenant validation to SetDefaultTenant --- internal/config/config.go | 9 +++++---- internal/config/config_test.go | 33 ++++++++++++++++++++++++++++++--- 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 35e7ad5dd..ec1acdd6e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -102,7 +102,7 @@ func (c *Config) GetTenant(tenantName string) (Tenant, error) { 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 "+ + "failed to find tenant: %s. Run 'auth0 tenants list' to see your configured tenants "+ "or run 'auth0 login' to configure a new tenant", tenantName, ) @@ -181,12 +181,13 @@ func (c *Config) ListAllTenants() ([]Tenant, error) { } // SetDefaultTenant saves the new default tenant to the disk. -func (c *Config) SetDefaultTenant(tenant string) error { - if err := c.Initialize(); err != nil { +func (c *Config) SetDefaultTenant(tenantName string) error { + tenant, err := c.GetTenant(tenantName) + if err != nil { return err } - c.DefaultTenant = tenant + c.DefaultTenant = tenant.Domain return c.saveToDisk() } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index f93e05e0d..9fc5b244e 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -246,7 +246,7 @@ func TestConfig_GetTenant(t *testing.T) { config := &Config{path: tempFile} _, err := config.GetTenant("auth0-cli.eu.auth0.com") - assert.EqualError(t, err, "failed to find tenant: auth0-cli.eu.auth0.com. Run 'auth0 tenants use' to see your configured tenants or run 'auth0 login' to configure a new tenant") + assert.EqualError(t, err, "failed to find tenant: auth0-cli.eu.auth0.com. Run 'auth0 tenants list' to see your configured tenants or run 'auth0 login' to configure a new tenant") }) t.Run("it throws an error if the config can't be initialized", func(t *testing.T) { @@ -566,7 +566,7 @@ func TestConfig_ListAllTenants(t *testing.T) { }) } -func TestConfig_SaveNewDefaultTenant(t *testing.T) { +func TestConfig_SetDefaultTenant(t *testing.T) { t.Run("it can successfully save a new tenant default", func(t *testing.T) { tempFile := createTempConfigFile(t, []byte(`{ "install_id": "3998b053-dd7f-4bfe-bb10-c4f3a96a0180", @@ -616,6 +616,33 @@ func TestConfig_SaveNewDefaultTenant(t *testing.T) { assertConfigFileMatches(t, config.path, expectedConfig) }) + t.Run("it fails to save a new tenant default if it doesn't exist", func(t *testing.T) { + tempFile := createTempConfigFile(t, []byte(`{ + "install_id": "3998b053-dd7f-4bfe-bb10-c4f3a96a0180", + "default_tenant": "auth0-cli.eu.auth0.com", + "tenants": { + "auth0-cli.eu.auth0.com": { + "name": "auth0-cli", + "domain": "auth0-cli.eu.auth0.com", + "access_token": "eyfSaswe", + "expires_at": "2023-04-18T11:18:07.998809Z", + "client_id": "secret" + }, + "auth0-mega-cli.eu.auth0.com": { + "name": "auth0-mega-cli", + "domain": "auth0-mega-cli.eu.auth0.com", + "access_token": "eyfSaswe", + "expires_at": "2023-04-18T11:18:07.998809Z", + "client_id": "secret" + } + } + }`)) + + config := &Config{path: tempFile} + err := config.SetDefaultTenant("auth0-super-cli.eu.auth0.com") + assert.EqualError(t, err, "failed to find tenant: auth0-super-cli.eu.auth0.com. Run 'auth0 tenants list' to see your configured tenants or run 'auth0 login' to configure a new tenant") + }) + t.Run("it throws an error if there's an issue with the config file", func(t *testing.T) { config := &Config{path: "i-dont-exist.json"} @@ -624,7 +651,7 @@ func TestConfig_SaveNewDefaultTenant(t *testing.T) { }) } -func TestConfig_SaveNewDefaultAppIDForTenant(t *testing.T) { +func TestConfig_SetDefaultAppIDForTenant(t *testing.T) { t.Run("it successfully saves a new default app id for the tenant", func(t *testing.T) { tempFile := createTempConfigFile(t, []byte(`{ "install_id": "3998b053-dd7f-4bfe-bb10-c4f3a96a0180", From ab6bc8a3b67d0cdc384c3efcb75b5815ee6a8f78 Mon Sep 17 00:00:00 2001 From: Sergiu Ghitea <28300158+sergiught@users.noreply.github.com> Date: Tue, 18 Apr 2023 18:50:48 +0200 Subject: [PATCH 06/12] Add args validation when selecting a valid tenant from config --- internal/cli/tenants.go | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/internal/cli/tenants.go b/internal/cli/tenants.go index 688c38b5e..0eb0bd040 100644 --- a/internal/cli/tenants.go +++ b/internal/cli/tenants.go @@ -104,22 +104,18 @@ func openTenantCmd(cli *cli) *cobra.Command { } func selectValidTenantFromConfig(cli *cli, cmd *cobra.Command, args []string) (string, error) { - var selectedTenant string - - if len(args) == 0 { - err := tenantDomain.Pick(cmd, &selectedTenant, cli.tenantPickerOptions) - return selectedTenant, err - } + if len(args) > 0 { + tenant, err := cli.Config.GetTenant(args[0]) + if err != nil { + return "", err + } - selectedTenant = args[0] - 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, - ) + return tenant.Domain, nil } - return selectedTenant, nil + var selectedTenant string + err := tenantDomain.Pick(cmd, &selectedTenant, cli.tenantPickerOptions) + return selectedTenant, err } func (c *cli) tenantPickerOptions() (pickerOptions, error) { From 7b12289d653ab0bd59c89372f94228dc82848ee6 Mon Sep 17 00:00:00 2001 From: Sergiu Ghitea <28300158+sergiught@users.noreply.github.com> Date: Tue, 18 Apr 2023 18:51:00 +0200 Subject: [PATCH 07/12] Add hint when no tenants available on tenants list --- internal/display/tenants.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/internal/display/tenants.go b/internal/display/tenants.go index 687e86538..fbf999a30 100644 --- a/internal/display/tenants.go +++ b/internal/display/tenants.go @@ -20,6 +20,12 @@ func (v *tenantView) Object() interface{} { func (r *Renderer) TenantList(data []string) { r.Heading() + if len(data) == 0 { + r.EmptyState("tenants") + r.Infof("Use 'auth0 login' to add one") + return + } + var results []View for _, item := range data { results = append(results, &tenantView{ From aea516e2014f872c09140e7d874208d38d4c9148 Mon Sep 17 00:00:00 2001 From: Sergiu Ghitea <28300158+sergiught@users.noreply.github.com> Date: Tue, 18 Apr 2023 18:51:48 +0200 Subject: [PATCH 08/12] Fix config test --- internal/config/config_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 9fc5b244e..2c2f569bc 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -756,7 +756,7 @@ func TestConfig_IsLoggedInWithTenant(t *testing.T) { }`)) config := &Config{path: tempFile} - assert.False(t, config.IsLoggedInWithTenant("")) + assert.False(t, config.IsLoggedInWithTenant("auth0-cli.eu.auth0.com")) }) } From 7d8d10dbf86cc8995beb5e009639d6cfb015436f Mon Sep 17 00:00:00 2001 From: Sergiu Ghitea <28300158+sergiught@users.noreply.github.com> Date: Tue, 18 Apr 2023 18:59:26 +0200 Subject: [PATCH 09/12] Add test with keyring access token --- internal/config/config_test.go | 37 ++++++++++++++++++++++++++++++---- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 2c2f569bc..bfeef1ac0 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -9,6 +9,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/zalando/go-keyring" ) func TestDefaultPath(t *testing.T) { @@ -697,7 +698,7 @@ func TestConfig_SetDefaultAppIDForTenant(t *testing.T) { } func TestConfig_IsLoggedInWithTenant(t *testing.T) { - t.Run("it returns true when we are logged in", func(t *testing.T) { + t.Run("it returns true when there is a tenant in the config and its access token is valid", func(t *testing.T) { tempFile := createTempConfigFile(t, []byte(`{ "install_id": "3998b053-dd7f-4bfe-bb10-c4f3a96a0180", "default_tenant": "auth0-cli.eu.auth0.com", @@ -716,12 +717,40 @@ func TestConfig_IsLoggedInWithTenant(t *testing.T) { assert.True(t, config.IsLoggedInWithTenant("auth0-cli.eu.auth0.com")) }) - t.Run("it returns false when we are not logged in", func(t *testing.T) { + t.Run("it returns true when there is a tenant in the config and its access token taken from the keyring is valid", func(t *testing.T) { + tempFile := createTempConfigFile(t, []byte(`{ + "install_id": "3998b053-dd7f-4bfe-bb10-c4f3a96a0180", + "default_tenant": "auth0-cli.eu.auth0.com", + "tenants": { + "auth0-cli.eu.auth0.com": { + "name": "auth0-cli", + "domain": "auth0-cli.eu.auth0.com", + "expires_at": "2023-04-18T11:18:07.998809Z", + "client_id": "secret" + } + } + }`)) + + keyring.MockInit() + const secretAccessToken = "Auth0 CLI Access Token" + const testTenantName = "auth0-cli.eu.auth0.com" + err := keyring.Set(fmt.Sprintf("%s %d", secretAccessToken, 0), testTenantName, "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.") + assert.NoError(t, err) + err = keyring.Set(fmt.Sprintf("%s %d", secretAccessToken, 1), testTenantName, "eyJpc3MiOiJodHRwczovL2F1dGgwLmF1dGgwLmNvbS8iLCJpYXQiOjE2ODExNDcwNjAsImV4cCI6OTY4MTgzMzQ2MH0.") + assert.NoError(t, err) + err = keyring.Set(fmt.Sprintf("%s %d", secretAccessToken, 2), testTenantName, "DsEpQkL0MIWcGJOIfEY8vr3MVS_E0GYsachNLQwBu5Q") + assert.NoError(t, err) + + config := &Config{path: tempFile} + assert.True(t, config.IsLoggedInWithTenant("auth0-cli.eu.auth0.com")) + }) + + t.Run("it returns false when the config file doesn't exist", func(t *testing.T) { config := &Config{path: "i-dont-exist.json"} assert.False(t, config.IsLoggedInWithTenant("auth0-cli.eu.auth0.com")) }) - t.Run("it returns false when we are logged in but the token is expired", func(t *testing.T) { + t.Run("it returns false when there is a tenant in the config and its access token is expired", func(t *testing.T) { tempFile := createTempConfigFile(t, []byte(`{ "install_id": "3998b053-dd7f-4bfe-bb10-c4f3a96a0180", "default_tenant": "auth0-cli.eu.auth0.com", @@ -740,7 +769,7 @@ func TestConfig_IsLoggedInWithTenant(t *testing.T) { assert.False(t, config.IsLoggedInWithTenant("auth0-cli.eu.auth0.com")) }) - t.Run("it returns false when we are logged in but the token is malformed", func(t *testing.T) { + t.Run("it returns false when there is a tenant in the config and its access token is malformed", func(t *testing.T) { tempFile := createTempConfigFile(t, []byte(`{ "install_id": "3998b053-dd7f-4bfe-bb10-c4f3a96a0180", "default_tenant": "auth0-cli.eu.auth0.com", From f29bc744d33f8a5569b695c54be2648442962bec Mon Sep 17 00:00:00 2001 From: Sergiu Ghitea <28300158+sergiught@users.noreply.github.com> Date: Tue, 18 Apr 2023 21:18:32 +0200 Subject: [PATCH 10/12] Refactor root command setup --- internal/cli/cli.go | 104 +++++++++++++++------------------ internal/cli/root.go | 11 +++- internal/config/config.go | 4 +- internal/config/config_test.go | 10 ++-- internal/config/tenant.go | 29 +++++++-- 5 files changed, 88 insertions(+), 70 deletions(-) diff --git a/internal/cli/cli.go b/internal/cli/cli.go index b0332ad9d..bf5e0d13e 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -48,29 +48,65 @@ type cli struct { Config config.Config } -// 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(ctx context.Context) error { - cobra.EnableCommandSorting = false - - if err := c.Config.VerifyAuthentication(); err != nil { +// setupWithAuthentication will fetch the tenant from the config.json +// and regenerate its access token if needed. The access token will +// then be used to configure an instance of the Auth0 Management SDK. +func (c *cli) setupWithAuthentication(ctx context.Context) error { + // Validate that we have at least one tenant that we can use. + if err := c.Config.Validate(); err != nil { return err } + // If we didn't pass any tenant through the + // flags we're going to use the default one. if c.tenant == "" { c.tenant = c.Config.DefaultTenant } - c.configureRenderer() - - tenant, err := c.ensureTenantAccessTokenIsUpdated(ctx) + // Get the tenant from the config. + tenant, err := c.Config.GetTenant(c.tenant) if err != nil { return err } + // Check authentication status. + err = tenant.CheckAuthenticationStatus() + switch err { + case config.ErrTokenMissingRequiredScopes: + c.renderer.Warnf("Required scopes have changed. Please log in to re-authorize the CLI.\n") + tenant, err = RunLoginAsUser(ctx, c, tenant.GetExtraRequestedScopes()) + if err != nil { + return err + } + case config.ErrInvalidToken: + if err := tenant.RegenerateAccessToken(ctx); err != nil { + if tenant.IsAuthenticatedWithClientCredentials() { + errorMessage := fmt.Errorf( + "failed to fetch access token using client credentials: %w\n\n"+ + "This 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\n"+ + "Please re-authenticate by running: %s", + err, + ansi.Bold("auth0 login --domain --client-secret "), + ) + return errorMessage + } + + c.renderer.Warnf("Failed to renew access token: %s", err) + c.renderer.Warnf("Please log in to re-authorize the CLI.\n") + + tenant, err = RunLoginAsUser(ctx, c, tenant.GetExtraRequestedScopes()) + if err != nil { + return err + } + } + + if err := c.Config.AddTenant(tenant); err != nil { + return err + } + } + userAgent := fmt.Sprintf("%v/%v", userAgent, strings.TrimPrefix(buildinfo.Version, "v")) api, err := management.New( @@ -94,50 +130,6 @@ func (c *cli) configureRenderer() { } } -// ensureTenantAccessTokenIsUpdated 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) ensureTenantAccessTokenIsUpdated(ctx context.Context) (config.Tenant, error) { - t, err := c.Config.GetTenant(c.tenant) - if err != nil { - return config.Tenant{}, err - } - - 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.GetExtraRequestedScopes()) - } - - accessToken := t.GetAccessToken() - if accessToken != "" && !t.HasExpiredToken() { - return t, nil - } - - 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, - ansi.Bold("auth0 login --domain --client-secret "), - ) - - return t, errorMessage - } - - 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.GetExtraRequestedScopes()) - } - - if err := c.Config.AddTenant(t); err != nil { - return config.Tenant{}, fmt.Errorf("unexpected error adding tenant to config: %w", err) - } - - return t, nil -} - func canPrompt(cmd *cobra.Command) bool { noInput, err := cmd.Root().Flags().GetBool("no-input") if err != nil { diff --git a/internal/cli/root.go b/internal/cli/root.go index fc5ffb15d..19f7f7f0a 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -32,6 +32,9 @@ func Execute() { tracker: analytics.NewTracker(), } + // Prevent sorting of commands. + cobra.EnableCommandSorting = false + rootCmd := buildRootCmd(cli) rootCmd.SetUsageTemplate(namespaceUsageTemplate()) @@ -98,8 +101,12 @@ func buildRootCmd(cli *cli) *cobra.Command { } }() - // Initialize everything once. - return cli.setup(cmd.Context()) + if err := cli.setupWithAuthentication(cmd.Context()); err != nil { + return err + } + + cli.configureRenderer() + return nil }, } diff --git a/internal/config/config.go b/internal/config/config.go index ec1acdd6e..7fcb4685b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -40,12 +40,12 @@ func (c *Config) Initialize() error { return c.initError } -// VerifyAuthentication checks to see if the config is not corrupted, +// Validate checks to see if the config is not corrupted, // and we have an authenticated tenant saved. // If we have at least one tenant saved but the DefaultTenant // is empty, it will attempt to set the first available // tenant as the DefaultTenant and save to disk. -func (c *Config) VerifyAuthentication() error { +func (c *Config) Validate() error { if err := c.Initialize(); err != nil { return err } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index bfeef1ac0..8006352fe 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -789,7 +789,7 @@ func TestConfig_IsLoggedInWithTenant(t *testing.T) { }) } -func TestConfig_VerifyAuthentication(t *testing.T) { +func TestConfig_Validate(t *testing.T) { t.Run("it successfully verifies that we are authenticated", func(t *testing.T) { tempFile := createTempConfigFile(t, []byte(`{ "install_id": "3998b053-dd7f-4bfe-bb10-c4f3a96a0180", @@ -806,7 +806,7 @@ func TestConfig_VerifyAuthentication(t *testing.T) { }`)) config := &Config{path: tempFile} - err := config.VerifyAuthentication() + err := config.Validate() assert.NoError(t, err) }) @@ -818,7 +818,7 @@ func TestConfig_VerifyAuthentication(t *testing.T) { }`)) config := &Config{path: tempFile} - err := config.VerifyAuthentication() + err := config.Validate() assert.EqualError(t, err, "Not logged in. Try `auth0 login`.") }) @@ -838,7 +838,7 @@ func TestConfig_VerifyAuthentication(t *testing.T) { }`)) config := &Config{path: tempFile} - err := config.VerifyAuthentication() + err := config.Validate() assert.NoError(t, err) expectedConfig := `{ @@ -861,7 +861,7 @@ func TestConfig_VerifyAuthentication(t *testing.T) { t.Run("it throws an error if there's an issue with the config file", func(t *testing.T) { config := &Config{path: "i-dont-exist.json"} - err := config.VerifyAuthentication() + err := config.Validate() assert.EqualError(t, err, "config.json file is missing") }) } diff --git a/internal/config/tenant.go b/internal/config/tenant.go index 5097f9c2a..e2c83cabd 100644 --- a/internal/config/tenant.go +++ b/internal/config/tenant.go @@ -2,6 +2,7 @@ package config import ( "context" + "errors" "fmt" "net/http" "time" @@ -14,6 +15,14 @@ import ( const accessTokenExpThreshold = 5 * time.Minute +var ( + // ErrTokenMissingRequiredScopes is thrown when the token is missing required scopes. + ErrTokenMissingRequiredScopes = errors.New("token is missing required scopes") + + // ErrInvalidToken is thrown when the token is invalid. + ErrInvalidToken = errors.New("token is invalid") +) + type ( // Tenants keeps track of all the tenants we // logged into. The key is the tenant domain. @@ -93,6 +102,19 @@ func (t *Tenant) GetAccessToken() string { return t.AccessToken } +func (t *Tenant) CheckAuthenticationStatus() error { + if !t.HasAllRequiredScopes() && t.IsAuthenticatedWithDeviceCodeFlow() { + return ErrTokenMissingRequiredScopes + } + + accessToken := t.GetAccessToken() + if accessToken != "" && !t.HasExpiredToken() { + return nil + } + + return ErrInvalidToken +} + // RegenerateAccessToken regenerates the access token for the tenant. func (t *Tenant) RegenerateAccessToken(ctx context.Context) error { if t.IsAuthenticatedWithClientCredentials() { @@ -124,13 +146,10 @@ func (t *Tenant) RegenerateAccessToken(ctx context.Context) error { } t.AccessToken = tokenResponse.AccessToken - t.ExpiresAt = time.Now().Add( - time.Duration(tokenResponse.ExpiresIn) * time.Second, - ) + t.ExpiresAt = time.Now().Add(time.Duration(tokenResponse.ExpiresIn) * time.Second) } - err := keyring.StoreAccessToken(t.Domain, t.AccessToken) - if err != nil { + if err := keyring.StoreAccessToken(t.Domain, t.AccessToken); err != nil { t.AccessToken = "" } From fc7f35eade2cebfa81801452b8d7db3887c76914 Mon Sep 17 00:00:00 2001 From: Sergiu Ghitea <28300158+sergiught@users.noreply.github.com> Date: Tue, 18 Apr 2023 21:27:10 +0200 Subject: [PATCH 11/12] Swap test order --- internal/config/config_test.go | 46 +++++++++++++++++----------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 8006352fe..ae4327b72 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -717,7 +717,12 @@ func TestConfig_IsLoggedInWithTenant(t *testing.T) { assert.True(t, config.IsLoggedInWithTenant("auth0-cli.eu.auth0.com")) }) - t.Run("it returns true when there is a tenant in the config and its access token taken from the keyring is valid", func(t *testing.T) { + t.Run("it returns false when the config file doesn't exist", func(t *testing.T) { + config := &Config{path: "i-dont-exist.json"} + assert.False(t, config.IsLoggedInWithTenant("auth0-cli.eu.auth0.com")) + }) + + t.Run("it returns false when there is a tenant in the config and its access token is expired", func(t *testing.T) { tempFile := createTempConfigFile(t, []byte(`{ "install_id": "3998b053-dd7f-4bfe-bb10-c4f3a96a0180", "default_tenant": "auth0-cli.eu.auth0.com", @@ -725,32 +730,18 @@ func TestConfig_IsLoggedInWithTenant(t *testing.T) { "auth0-cli.eu.auth0.com": { "name": "auth0-cli", "domain": "auth0-cli.eu.auth0.com", - "expires_at": "2023-04-18T11:18:07.998809Z", + "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2F1dGgwLmF1dGgwLmNvbS8iLCJpYXQiOjE2ODExNDcwNjAsImV4cCI6MTY4MTEzMzQ2MH0.dG481CD7v8VCzSsBHdApTiRDUuCZXBgk5LO__q4r2Fg", + "expires_at": "2023-04-10T11:18:07.998809Z", "client_id": "secret" } } }`)) - keyring.MockInit() - const secretAccessToken = "Auth0 CLI Access Token" - const testTenantName = "auth0-cli.eu.auth0.com" - err := keyring.Set(fmt.Sprintf("%s %d", secretAccessToken, 0), testTenantName, "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.") - assert.NoError(t, err) - err = keyring.Set(fmt.Sprintf("%s %d", secretAccessToken, 1), testTenantName, "eyJpc3MiOiJodHRwczovL2F1dGgwLmF1dGgwLmNvbS8iLCJpYXQiOjE2ODExNDcwNjAsImV4cCI6OTY4MTgzMzQ2MH0.") - assert.NoError(t, err) - err = keyring.Set(fmt.Sprintf("%s %d", secretAccessToken, 2), testTenantName, "DsEpQkL0MIWcGJOIfEY8vr3MVS_E0GYsachNLQwBu5Q") - assert.NoError(t, err) - config := &Config{path: tempFile} - assert.True(t, config.IsLoggedInWithTenant("auth0-cli.eu.auth0.com")) - }) - - t.Run("it returns false when the config file doesn't exist", func(t *testing.T) { - config := &Config{path: "i-dont-exist.json"} assert.False(t, config.IsLoggedInWithTenant("auth0-cli.eu.auth0.com")) }) - t.Run("it returns false when there is a tenant in the config and its access token is expired", func(t *testing.T) { + t.Run("it returns false when there is a tenant in the config and its access token is malformed", func(t *testing.T) { tempFile := createTempConfigFile(t, []byte(`{ "install_id": "3998b053-dd7f-4bfe-bb10-c4f3a96a0180", "default_tenant": "auth0-cli.eu.auth0.com", @@ -758,7 +749,7 @@ func TestConfig_IsLoggedInWithTenant(t *testing.T) { "auth0-cli.eu.auth0.com": { "name": "auth0-cli", "domain": "auth0-cli.eu.auth0.com", - "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2F1dGgwLmF1dGgwLmNvbS8iLCJpYXQiOjE2ODExNDcwNjAsImV4cCI6MTY4MTEzMzQ2MH0.dG481CD7v8VCzSsBHdApTiRDUuCZXBgk5LO__q4r2Fg", + "access_token": "dG481CD7v8VCzSsBHdApTiRDUuCZXBgk5LO__q4r2Fg", "expires_at": "2023-04-10T11:18:07.998809Z", "client_id": "secret" } @@ -769,7 +760,7 @@ func TestConfig_IsLoggedInWithTenant(t *testing.T) { assert.False(t, config.IsLoggedInWithTenant("auth0-cli.eu.auth0.com")) }) - t.Run("it returns false when there is a tenant in the config and its access token is malformed", func(t *testing.T) { + t.Run("it returns true when there is a tenant in the config and its access token taken from the keyring is valid", func(t *testing.T) { tempFile := createTempConfigFile(t, []byte(`{ "install_id": "3998b053-dd7f-4bfe-bb10-c4f3a96a0180", "default_tenant": "auth0-cli.eu.auth0.com", @@ -777,15 +768,24 @@ func TestConfig_IsLoggedInWithTenant(t *testing.T) { "auth0-cli.eu.auth0.com": { "name": "auth0-cli", "domain": "auth0-cli.eu.auth0.com", - "access_token": "dG481CD7v8VCzSsBHdApTiRDUuCZXBgk5LO__q4r2Fg", - "expires_at": "2023-04-10T11:18:07.998809Z", + "expires_at": "2023-04-18T11:18:07.998809Z", "client_id": "secret" } } }`)) + keyring.MockInit() + const secretAccessToken = "Auth0 CLI Access Token" + const testTenantName = "auth0-cli.eu.auth0.com" + err := keyring.Set(fmt.Sprintf("%s %d", secretAccessToken, 0), testTenantName, "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.") + assert.NoError(t, err) + err = keyring.Set(fmt.Sprintf("%s %d", secretAccessToken, 1), testTenantName, "eyJpc3MiOiJodHRwczovL2F1dGgwLmF1dGgwLmNvbS8iLCJpYXQiOjE2ODExNDcwNjAsImV4cCI6OTY4MTgzMzQ2MH0.") + assert.NoError(t, err) + err = keyring.Set(fmt.Sprintf("%s %d", secretAccessToken, 2), testTenantName, "DsEpQkL0MIWcGJOIfEY8vr3MVS_E0GYsachNLQwBu5Q") + assert.NoError(t, err) + config := &Config{path: tempFile} - assert.False(t, config.IsLoggedInWithTenant("auth0-cli.eu.auth0.com")) + assert.True(t, config.IsLoggedInWithTenant("auth0-cli.eu.auth0.com")) }) } From ba5f6dd2d56d18eb0ab5b0a179a05ba592a15227 Mon Sep 17 00:00:00 2001 From: Sergiu Ghitea <28300158+sergiught@users.noreply.github.com> Date: Tue, 18 Apr 2023 21:39:36 +0200 Subject: [PATCH 12/12] Add tests for check tenant authentication --- internal/config/tenant_test.go | 52 ++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/internal/config/tenant_test.go b/internal/config/tenant_test.go index 8ed23d9e5..f587420ef 100644 --- a/internal/config/tenant_test.go +++ b/internal/config/tenant_test.go @@ -143,3 +143,55 @@ func TestTenant_GetAccessToken(t *testing.T) { assert.Equal(t, expectedToken, actualToken) }) } + +func TestTenant_CheckAuthenticationStatus(t *testing.T) { + var testCases = []struct { + name string + givenTenant Tenant + expectedError string + }{ + { + name: "it throws an error when required scopes are missing", + givenTenant: Tenant{ + Scopes: []string{"read:magazines"}, + ClientID: "", + }, + expectedError: "token is missing required scopes", + }, + { + name: "it throws an error when the token is empty", + givenTenant: Tenant{ + AccessToken: "", + ClientID: "123", + }, + expectedError: "token is invalid", + }, + { + name: "it throws an error when the token is expired and we are authenticated through client credentials", + givenTenant: Tenant{ + ExpiresAt: time.Now().Add(-time.Minute), + ClientID: "123", + }, + expectedError: "token is invalid", + }, + { + name: "tenant has a valid token", + givenTenant: Tenant{ + AccessToken: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2F1dGgwLmF1dGgwLmNvbS8iLCJpYXQiOjE2ODExNDcwNjAsImV4cCI6OTY4MTgzMzQ2MH0.DsEpQkL0MIWcGJOIfEY8vr3MVS_E0GYsachNLQwBu5Q", + ExpiresAt: time.Now().Add(10 * time.Minute), + ClientID: "123", + }, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + err := testCase.givenTenant.CheckAuthenticationStatus() + if testCase.expectedError != "" { + assert.EqualError(t, err, testCase.expectedError) + return + } + assert.NoError(t, err) + }) + } +}