diff --git a/docs/auth0_login.md b/docs/auth0_login.md index e22cb0f2e..dd1ae31a4 100644 --- a/docs/auth0_login.md +++ b/docs/auth0_login.md @@ -18,6 +18,7 @@ auth0 login [flags] ``` auth0 login auth0 login --domain --client-id --client-secret +auth0 login --scopes "read:client_grants,create:client_grants" ``` ### Options @@ -27,6 +28,7 @@ auth0 login --domain --client-id --client-secret = http.StatusBadRequest { + if response.StatusCode != http.StatusOK { bodyBytes, err := io.ReadAll(response.Body) if err != nil { return State{}, fmt.Errorf( @@ -243,8 +207,7 @@ func (a *Authenticator) getDeviceCode(ctx context.Context) (State, error) { } var state State - err = json.NewDecoder(response.Body).Decode(&state) - if err != nil { + if err = json.NewDecoder(response.Body).Decode(&state); err != nil { return State{}, fmt.Errorf("failed to decode the response: %w", err) } @@ -277,14 +240,14 @@ func parseTenant(accessToken string) (tenant, domain string, err error) { return "", "", fmt.Errorf("audience not found for %s", audiencePath) } -// ClientCredentials encapsulates all data to facilitate access token creation with client credentials (client ID and client secret) +// ClientCredentials encapsulates all data to facilitate access token creation with client credentials (client ID and client secret). type ClientCredentials struct { ClientID string ClientSecret string Domain string } -// GetAccessTokenFromClientCreds generates an access token from client credentials +// GetAccessTokenFromClientCreds generates an access token from client credentials. func GetAccessTokenFromClientCreds(ctx context.Context, args ClientCredentials) (Result, error) { u, err := url.Parse("https://" + args.Domain) if err != nil { diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index 0d360d326..cb73cebc0 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -6,21 +6,15 @@ func TestRequiredScopes(t *testing.T) { t.Run("Verify CRUD scopes", func(t *testing.T) { crudResources := []string{ "clients", - "client_grants", - "connections", "log_streams", "resource_servers", "roles", "rules", "users", + "custom_domains", + "log_streams", "actions", - "hooks", "organizations", - "organization_connections", - "custom_domains", - "email_provider", - "shields", - "users_app_metadata", } crudPrefixes := []string{"create:", "delete:", "read:", "update:"} @@ -37,27 +31,15 @@ func TestRequiredScopes(t *testing.T) { t.Run("Verify special scopes", func(t *testing.T) { list := []string{ - "read:branding", "update:branding", "delete:branding", - "read:triggers", "update:triggers", - "read:client_keys", - "read:logs", - "read:tenant_settings", "update:tenant_settings", + "read:branding", "update:branding", + "read:connections", "update:connections", + "read:email_templates", "update:email_templates", + "read:custom_domains", "create:custom_domains", "update:custom_domains", "delete:custom_domains", + "read:client_keys", "read:logs", "read:tenant_settings", "read:anomaly_blocks", "delete:anomaly_blocks", - "read:attack_protection", "update:attack_protection", + "read:organization_members", "read:organization_member_roles", "read:prompts", "update:prompts", - "read:stats", - "read:insights", - "create:user_tickets", - "blacklist:tokens", - "read:grants", "delete:grants", - "read:mfa_policies", "update:mfa_policies", - "read:guardian_factors", "update:guardian_factors", - "read:guardian_enrollments", "delete:guardian_enrollments", - "create:guardian_enrollment_tickets", - "read:user_idp_tokens", - "create:passwords_checking_job", "delete:passwords_checking_job", - "read:limits", "update:limits", - "read:entitlements", + "read:attack_protection", "update:attack_protection", } for _, v := range list { diff --git a/internal/cli/api.go b/internal/cli/api.go index 3b466f2fe..960cc59ab 100644 --- a/internal/cli/api.go +++ b/internal/cli/api.go @@ -147,6 +147,10 @@ func apiCmdRun(cli *cli, inputs *apiCmdInputs) func(cmd *cobra.Command, args []s } defer response.Body.Close() + if err := isInsufficientScopeError(response); err != nil { + return err + } + rawBodyJSON, err := io.ReadAll(response.Body) if err != nil { return err @@ -257,3 +261,34 @@ func (i *apiCmdInputs) parseRaw(args []string) { i.RawURI = args[lenArgs-1] } + +func isInsufficientScopeError(r *http.Response) error { + if r.StatusCode != 403 { + return nil + } + + type ErrorBody struct { + ErrorCode string `json:"errorCode"` + Message string `json:"message"` + } + + var body ErrorBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + return nil + } + + if body.ErrorCode != "insufficient_scope" { + return nil + } + + missingScopes := strings.Split(body.Message, "Insufficient scope, expected any of: ")[1] + recommendedScopeToAdd := strings.Split(missingScopes, ",")[0] + + return fmt.Errorf( + "request failed because access token lacks scope: %s.\n "+ + "If authenticated via client credentials, add this scope to the designated client. "+ + "If authenticated as a user, request this scope during login by running `auth0 login --scopes %s`.", + recommendedScopeToAdd, + recommendedScopeToAdd, + ) +} diff --git a/internal/cli/api_test.go b/internal/cli/api_test.go index 581ff60ca..52f92bd81 100644 --- a/internal/cli/api_test.go +++ b/internal/cli/api_test.go @@ -1,6 +1,8 @@ package cli import ( + "bytes" + "io" "net/http" "testing" @@ -85,3 +87,74 @@ func TestAPICmdInputs_FromArgs(t *testing.T) { }) } } + +func TestAPICmd_IsInsufficientScopeError(t *testing.T) { + var testCases = []struct { + name string + inputStatusCode int + inputResponseBody string + expectedError string + }{ + { + name: "it does not detect 404 error", + inputStatusCode: 404, + inputResponseBody: `{ + "statusCode": 404, + "error": "Not Found", + "message": "Not Found" + }`, + expectedError: "", + }, + { + name: "it does not detect a 200 HTTP response", + inputStatusCode: 200, + inputResponseBody: `{ + "allowed_logout_urls": [], + "change_password": { + "enabled": true, + "html": "LOL" + }, + "default_audience": "", + }`, + expectedError: "", + }, + { + name: "it correctly detects an insufficient scope error", + inputStatusCode: 403, + inputResponseBody: `{ + "statusCode": 403, + "error": "Forbidden", + "message": "Insufficient scope, expected any of: create:client_grants", + "errorCode": "insufficient_scope" + }`, + expectedError: "request failed because access token lacks scope: create:client_grants.\n If authenticated via client credentials, add this scope to the designated client. If authenticated as a user, request this scope during login by running `auth0 login --scopes create:client_grants`.", + }, + { + name: "it correctly detects an insufficient scope error with multiple scope", + inputStatusCode: 403, + inputResponseBody: `{ + "statusCode": 403, + "error": "Forbidden", + "message": "Insufficient scope, expected any of: read:clients, read:client_summary", + "errorCode": "insufficient_scope" + }`, + expectedError: "request failed because access token lacks scope: read:clients.\n If authenticated via client credentials, add this scope to the designated client. If authenticated as a user, request this scope during login by running `auth0 login --scopes read:clients`.", + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + input := http.Response{ + Body: io.NopCloser(bytes.NewReader([]byte(testCase.inputResponseBody))), + StatusCode: testCase.inputStatusCode, + } + + err := isInsufficientScopeError(&input) + if testCase.expectedError == "" { + assert.NoError(t, err) + } else { + assert.EqualError(t, err, testCase.expectedError) + } + }) + } +} diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 8a5be713d..00870a0fc 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -9,7 +9,6 @@ import ( "os" "path" "path/filepath" - "sort" "strings" "sync" "time" @@ -108,6 +107,27 @@ func (t *Tenant) hasExpiredToken() bool { return time.Now().Add(accessTokenExpThreshold).After(t.ExpiresAt) } +func (t *Tenant) additionalRequestedScopes() []string { + additionallyRequestedScopes := make([]string, 0) + + for _, scope := range t.Scopes { + found := false + + for _, defaultScope := range auth.RequiredScopes() { + if scope == defaultScope { + found = true + break + } + } + + if !found { + additionallyRequestedScopes = append(additionallyRequestedScopes, scope) + } + } + + return additionallyRequestedScopes +} + func (t *Tenant) regenerateAccessToken(ctx context.Context, c *cli) error { if t.authenticatedWithClientCredentials() { token, err := auth.GetAccessTokenFromClientCreds( @@ -211,9 +231,9 @@ func (c *cli) prepareTenant(ctx context.Context) (Tenant, error) { return Tenant{}, err } - if scopesChanged(t) && t.authenticatedWithDeviceCodeFlow() { + if !hasAllRequiredScopes(t) && t.authenticatedWithDeviceCodeFlow() { c.renderer.Warnf("Required scopes have changed. Please log in to re-authorize the CLI.\n") - return RunLoginAsUser(ctx, c) + return RunLoginAsUser(ctx, c, t.additionalRequestedScopes()) } if t.AccessToken != "" && !t.hasExpiredToken() { @@ -232,7 +252,7 @@ func (c *cli) prepareTenant(ctx context.Context) (Tenant, error) { c.renderer.Warnf("Failed to renew access token. Please log in to re-authorize the CLI.\n") - return RunLoginAsUser(ctx, c) + return RunLoginAsUser(ctx, c, t.additionalRequestedScopes()) } if err := c.addTenant(t); err != nil { @@ -242,30 +262,16 @@ func (c *cli) prepareTenant(ctx context.Context) (Tenant, error) { return t, nil } -// scopesChanged compare the tenant scopes +// hasAllRequiredScopes compare the tenant scopes // with the currently required scopes. -func scopesChanged(t Tenant) bool { - want := auth.RequiredScopes() - got := t.Scopes - - sort.Strings(want) - sort.Strings(got) - - if (want == nil) != (got == nil) { - return true - } - - if len(want) != len(got) { - return true - } - - for i := range t.Scopes { - if want[i] != got[i] { - return true +func hasAllRequiredScopes(t Tenant) bool { + for _, requiredScope := range auth.RequiredScopes() { + if !containsStr(t.Scopes, requiredScope) { + return false } } - return false + return true } // getTenant fetches the default tenant configured (or the tenant specified via diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index e8bbae59c..ce86c6e0d 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -13,6 +13,7 @@ import ( "github.com/olekukonko/tablewriter" "github.com/stretchr/testify/assert" + "github.com/auth0/auth0-cli/internal/auth" "github.com/auth0/auth0-cli/internal/display" ) @@ -119,3 +120,29 @@ func TestIsLoggedIn(t *testing.T) { }) } } + +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", "read:client_grants"), + expectedScopes: []string{"read:stats", "read: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()) + }) + } +} diff --git a/internal/cli/login.go b/internal/cli/login.go index 950cc1a72..0ac82ad8c 100644 --- a/internal/cli/login.go +++ b/internal/cli/login.go @@ -36,18 +36,31 @@ var ( IsRequired: false, AlwaysPrompt: false, } + + loginAdditionalScopes = Flag{ + Name: "Additional Scopes", + LongForm: "scopes", + Help: "Additional scopes to request when authenticating via device code flow. By default, only scopes for first-class functions are requested. Primarily useful when using the api command to execute arbitrary Management API requests.", + IsRequired: false, + AlwaysPrompt: false, + } ) type LoginInputs struct { - Domain string - ClientID string - ClientSecret string + Domain string + ClientID string + ClientSecret string + AdditionalScopes []string } func (i *LoginInputs) isLoggingInAsAMachine() bool { return i.ClientID != "" || i.ClientSecret != "" || i.Domain != "" } +func (i *LoginInputs) isLoggingInWithAdditionalScopes() bool { + return len(i.AdditionalScopes) > 0 +} + func loginCmd(cli *cli) *cobra.Command { var inputs LoginInputs @@ -57,15 +70,16 @@ func loginCmd(cli *cli) *cobra.Command { Short: "Authenticate the Auth0 CLI", Long: "Authenticates the Auth0 CLI either as a user using personal credentials or as a machine using client credentials.", Example: `auth0 login -auth0 login --domain --client-id --client-secret `, +auth0 login --domain --client-id --client-secret +auth0 login --scopes "read:client_grants,create:client_grants"`, RunE: func(cmd *cobra.Command, args []string) error { var selectedLoginType string const loginAsUser, loginAsMachine = "As a user", "As a machine" // We want to prompt if we don't pass the following flags: - // --no-input, --client-id, --client-secret, --domain. + // --no-input, --scopes, --client-id, --client-secret, --domain. // Because then the prompt is unnecessary as we know the login type. - shouldPrompt := !inputs.isLoggingInAsAMachine() && !cli.noInput + shouldPrompt := !inputs.isLoggingInAsAMachine() && !cli.noInput && !inputs.isLoggingInWithAdditionalScopes() if shouldPrompt { cli.renderer.Output( fmt.Sprintf( @@ -94,11 +108,11 @@ auth0 login --domain --client-id --client-secret --client-id --client-secret --client-id --client-secret