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/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..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.setDefaultAppID(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.setDefaultAppID(a.GetClientID()); err != nil { + if err := cli.Config.SetDefaultAppIDForTenant(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..bf5e0d13e 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,406 +45,89 @@ 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) + Config config.Config } -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 -} - -// 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 { - if err := c.init(); 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 } - t, err := c.prepareTenant(ctx) - if 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 } - userAgent := fmt.Sprintf("%v/%v", userAgent, strings.TrimPrefix(buildinfo.Version, "v")) - - api, err := management.New( - t.Domain, - management.WithStaticToken(getAccessToken(t)), - management.WithUserAgent(userAgent), - ) + // Get the tenant from the config. + tenant, err := c.Config.GetTenant(c.tenant) if err != nil { return err } - c.api = auth0.NewAPI(api) - return nil -} - -func getAccessToken(t Tenant) string { - accessToken, err := keyring.GetAccessToken(t.Domain) - if err == nil && accessToken != "" { - return accessToken - } - - return t.AccessToken -} - -// prepareTenant loads the tenant, refreshing its token if necessary. -// The tenant access token needs a refresh if: -// 1. The tenant scopes are different than the currently required scopes. -// 2. The access token is expired. -func (c *cli) prepareTenant(ctx context.Context) (Tenant, error) { - t, err := c.getTenant() - if err != nil { - return Tenant{}, err - } - - if !hasAllRequiredScopes(t) && t.authenticatedWithDeviceCodeFlow() { + // 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") - return RunLoginAsUser(ctx, c, t.additionalRequestedScopes()) - } - - accessToken := getAccessToken(t) - if accessToken != "" && !t.hasExpiredToken() { - return t, nil - } - - if err := t.regenerateAccessToken(ctx); err != nil { - if t.authenticatedWithClientCredentials() { - 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.additionalRequestedScopes()) - } - - if err := c.addTenant(t); err != nil { - return 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 + tenant, err = RunLoginAsUser(ctx, c, tenant.GetExtraRequestedScopes()) + if err != nil { + return err } - } - - 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 - } + 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 } - } - } - - 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() + c.renderer.Warnf("Failed to renew access token: %s", err) + c.renderer.Warnf("Please log in to re-authorize the CLI.\n") - if err := c.persistConfig(); err != nil { - return fmt.Errorf("unexpected error persisting config: %w", err) + tenant, err = RunLoginAsUser(ctx, c, tenant.GetExtraRequestedScopes()) + if err != nil { + return 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 { + if err := c.Config.AddTenant(tenant); err != nil { return err } } - buf, err := json.MarshalIndent(c.config, "", " ") + userAgent := fmt.Sprintf("%v/%v", userAgent, strings.TrimPrefix(buildinfo.Version, "v")) + + api, err := management.New( + tenant.Domain, + management.WithStaticToken(tenant.GetAccessToken()), + management.WithUserAgent(userAgent), + ) if err != nil { return err } - err = os.WriteFile(c.path, buf, 0600) - - return err + c.api = auth0.NewAPI(api) + return nil } -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 - }) +func (c *cli) configureRenderer() { + c.renderer.Tenant = c.tenant 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 { 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..ea81330e4 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.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) } @@ -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..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()) @@ -91,13 +94,19 @@ 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) } }() - // Initialize everything once. - return cli.setup(cmd.Context()) + if err := cli.setupWithAuthentication(cmd.Context()); err != nil { + return err + } + + cli.configureRenderer() + return nil }, } @@ -125,7 +134,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..0eb0bd040 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.SetDefaultTenant(selectedTenant); err != nil { return fmt.Errorf("failed to set the default tenant: %w", err) } @@ -105,26 +104,22 @@ 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) { - tenants, err := c.listTenants() + tenants, err := c.Config.ListAllTenants() if err != nil { return nil, fmt.Errorf("failed to load tenants: %w", err) } @@ -133,7 +128,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 +136,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) { diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 000000000..7fcb4685b --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,253 @@ +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 + + 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 +} + +// 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) Validate() 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 list' 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 +} + +// SetDefaultTenant saves the new default tenant to the disk. +func (c *Config) SetDefaultTenant(tenantName string) error { + tenant, err := c.GetTenant(tenantName) + if err != nil { + return err + } + + c.DefaultTenant = tenant.Domain + + return c.saveToDisk() +} + +// 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 + } + + 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/config_test.go b/internal/config/config_test.go new file mode 100644 index 000000000..ae4327b72 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,892 @@ +package config + +import ( + "fmt" + "os" + "path" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/zalando/go-keyring" +) + +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 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) { + 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_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", + "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.SetDefaultTenant("auth0-mega-cli.eu.auth0.com") + assert.NoError(t, err) + 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"} + + err := config.SetDefaultTenant("tenant") + assert.EqualError(t, err, "config.json file is missing") + }) +} + +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", + "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.SetDefaultAppIDForTenant("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.SetDefaultAppIDForTenant("tenant", "appID123456") + assert.EqualError(t, err, "config.json file is missing") + }) +} + +func TestConfig_IsLoggedInWithTenant(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", + "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 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", + "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 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", + "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("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) { + 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")) + }) +} + +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", + "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.Validate() + 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.Validate() + 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.Validate() + 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.Validate() + 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.go b/internal/config/tenant.go new file mode 100644 index 000000000..e2c83cabd --- /dev/null +++ b/internal/config/tenant.go @@ -0,0 +1,157 @@ +package config + +import ( + "context" + "errors" + "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 + +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. + 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 +} + +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() { + 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) + } + + if err := keyring.StoreAccessToken(t.Domain, t.AccessToken); err != nil { + t.AccessToken = "" + } + + return nil +} diff --git a/internal/config/tenant_test.go b/internal/config/tenant_test.go new file mode 100644 index 000000000..f587420ef --- /dev/null +++ b/internal/config/tenant_test.go @@ -0,0 +1,197 @@ +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) + }) +} + +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) + }) + } +} 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{