diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 00000000..8fd6ec33 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,219 @@ +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("", "config") + 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, "unexpected end of JSON input") + }) + + 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 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() +} diff --git a/internal/config/tenant.go b/internal/config/tenant.go index 2328579c..5097f9c2 100644 --- a/internal/config/tenant.go +++ b/internal/config/tenant.go @@ -15,8 +15,11 @@ import ( 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"` @@ -40,6 +43,8 @@ func (t *Tenant) HasAllRequiredScopes() bool { 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) @@ -61,18 +66,24 @@ func (t *Tenant) GetExtraRequestedScopes() []string { 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 != "" { @@ -82,6 +93,7 @@ func (t *Tenant) GetAccessToken() string { 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) diff --git a/internal/config/tenant_test.go b/internal/config/tenant_test.go new file mode 100644 index 00000000..8ed23d9e --- /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) + }) +}