Skip to content

Commit

Permalink
DXCDT-296: Supporting additional scopes when authenticating as user (#…
Browse files Browse the repository at this point in the history
…561)

* Adding additional scopes support via --scopes flag

* Adding additional scopes support via --scopes flag

* Removing logging

* Uncommenting scope, removing Start function

* Condensing error to single line

* Fixing linting errors

* Changing test

* Updating docs

* Unpluralizing text, setting nil default value

* Fixing bad help text

* Tiny refactors on the login cmd

* Fixing linting error

* Update internal/auth/auth.go

Co-authored-by: Will Vedder <[email protected]>
Co-authored-by: Rita Zerrizuela <[email protected]>
Co-authored-by: Sergiu Ghitea <[email protected]>
Co-authored-by: Sergiu Ghitea <[email protected]>
  • Loading branch information
5 people authored Dec 16, 2022
1 parent 00ebaf5 commit ef14dc8
Show file tree
Hide file tree
Showing 9 changed files with 233 additions and 143 deletions.
2 changes: 2 additions & 0 deletions docs/auth0_login.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ auth0 login [flags]
```
auth0 login
auth0 login --domain <tenant-domain> --client-id <client-id> --client-secret <client-secret>
auth0 login --scopes "read:client_grants,create:client_grants"
```

### Options
Expand All @@ -27,6 +28,7 @@ auth0 login --domain <tenant-domain> --client-id <client-id> --client-secret <cl
--client-secret string Client secret of the application when authenticating via client credentials.
--domain string Tenant domain of the application when authenticating via client credentials.
-h, --help help for login
--scopes strings 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.
```

### Options inherited from parent commands
Expand Down
14 changes: 0 additions & 14 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -206,21 +206,8 @@ golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qx
golang.org/x/net v0.0.0-20221002022538-bcab6841153b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
golang.org/x/net v0.3.0 h1:VWL6FNY2bEEmsGVKabSlHu5Irp34xmMRoqb/9lF9lxk=
golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.3.0 h1:6l90koy8/LaBLmLu8jpHeHexzMwEita0zFfYlggy2F8=
golang.org/x/oauth2 v0.3.0/go.mod h1:rQrIauxkUhJ6CuwEXwymO2/eh4xz2ZWF1nBkcxS+tGk=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
Expand Down Expand Up @@ -248,7 +235,6 @@ golang.org/x/term v0.0.0-20210503060354-a79de5458b56/go.mod h1:tfny5GFUkzUvx4ps4
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.3.0 h1:qoo4akIqOcDME5bhc/NgxUdovd6BSS2uMsVjB56q1xI=
golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
Expand Down
89 changes: 26 additions & 63 deletions internal/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,50 +25,23 @@ const (

var requiredScopes = []string{
"openid",
"offline_access", // This is used to retrieve a refresh token.
"create:clients", "read:clients", "update:clients", "delete:clients",
"read:client_keys",
"create:client_grants", "read:client_grants", "update:client_grants", "delete:client_grants",
"create:resource_servers", "read:resource_servers", "update:resource_servers", "delete:resource_servers",
"create:connections", "read:connections", "update:connections", "delete:connections",
"create:users", "read:users", "update:users", "delete:users",
"create:roles", "read:roles", "update:roles", "delete:roles",
"create:actions", "read:actions", "update:actions", "delete:actions",
"read:triggers", "update:triggers",
"create:rules", "read:rules", "update:rules", "delete:rules",
"read:rules_configs", "update:rules_configs", "delete:rules_configs",
"create:hooks", "read:hooks", "update:hooks", "delete:hooks",
"read:attack_protection", "update:attack_protection",
"create:organizations", "read:organizations", "update:organizations", "delete:organizations",
"create:organization_members", "read:organization_members", "delete:organization_members",
"create:organization_connections", "read:organization_connections", "update:organization_connections", "delete:organization_connections",
"create:organization_member_roles", "read:organization_member_roles", "delete:organization_member_roles",
"create:organization_invitations", "read:organization_invitations", "delete:organization_invitations",
"read:prompts", "update:prompts",
"read:branding", "update:branding", "delete:branding",
"create:custom_domains", "read:custom_domains", "update:custom_domains", "delete:custom_domains",
"create:email_provider", "read:email_provider", "update:email_provider", "delete:email_provider",
"create:email_templates", "read:email_templates", "update:email_templates",
"read:tenant_settings", "update:tenant_settings",
"offline_access", // for retrieving refresh token
"create:clients", "delete:clients", "read:clients", "update:clients",
"create:resource_servers", "delete:resource_servers", "read:resource_servers", "update:resource_servers",
"create:roles", "delete:roles", "read:roles", "update:roles",
"create:rules", "delete:rules", "read:rules", "update:rules",
"create:users", "delete:users", "read:users", "update:users",
"read:branding", "update:branding",
"read:email_templates", "update:email_templates",
"read:connections", "update:connections",
"read:client_keys", "read:logs", "read:tenant_settings",
"read:custom_domains", "create:custom_domains", "update:custom_domains", "delete:custom_domains",
"read:anomaly_blocks", "delete:anomaly_blocks",
"create:log_streams", "read:log_streams", "update:log_streams", "delete:log_streams",
"read:stats",
"read:insights",
"read:logs",
"create:shields", "read:shields", "update:shields", "delete:shields",
"create:users_app_metadata", "read:users_app_metadata", "update:users_app_metadata", "delete:users_app_metadata",
"create:user_custom_blocks", "read:user_custom_blocks", "delete:user_custom_blocks",
"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",
"create:log_streams", "delete:log_streams", "read:log_streams", "update:log_streams",
"create:actions", "delete:actions", "read:actions", "update:actions",
"create:organizations", "delete:organizations", "read:organizations", "update:organizations", "read:organization_members", "read:organization_member_roles",
"read:prompts", "update:prompts",
"read:attack_protection", "update:attack_protection",
}

// Authenticator is used to facilitate the login process.
Expand Down Expand Up @@ -123,7 +96,7 @@ func (s *State) IntervalDuration() time.Duration {
return time.Duration(s.Interval+waitThresholdInSeconds) * time.Second
}

// New returns a new instance of Authenticator using Auth0 Public Cloud default values
// New returns a new instance of Authenticator using Auth0 Public Cloud default values.
func New() *Authenticator {
return &Authenticator{
Audience: "https://*.auth0.com/api/v2/",
Expand All @@ -133,18 +106,6 @@ func New() *Authenticator {
}
}

// Start kicks-off the device authentication flow by requesting
// a device code from Auth0. The returned state contains the
// URI for the next step of the flow.
func (a *Authenticator) Start(ctx context.Context) (State, error) {
state, err := a.getDeviceCode(ctx)
if err != nil {
return State{}, fmt.Errorf("failed to get the device code: %w", err)
}

return state, nil
}

// Wait waits until the user is logged in on the browser.
func (a *Authenticator) Wait(ctx context.Context, state State) (Result, error) {
t := time.NewTicker(state.IntervalDuration())
Expand Down Expand Up @@ -205,10 +166,13 @@ func (a *Authenticator) Wait(ctx context.Context, state State) (Result, error) {
}
}

func (a *Authenticator) getDeviceCode(ctx context.Context) (State, error) {
// GetDeviceCode kicks-off the device authentication flow by requesting
// a device code from Auth0. The returned state contains the
// URI for the next step of the flow.
func (a *Authenticator) GetDeviceCode(ctx context.Context, additionalScopes []string) (State, error) {
data := url.Values{
"client_id": []string{a.ClientID},
"scope": []string{strings.Join(requiredScopes, " ")},
"scope": []string{strings.Join(append(requiredScopes, additionalScopes...), " ")},
"audience": []string{a.Audience},
}

Expand All @@ -230,7 +194,7 @@ func (a *Authenticator) getDeviceCode(ctx context.Context) (State, error) {
}
defer response.Body.Close()

if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusBadRequest {
if response.StatusCode != http.StatusOK {
bodyBytes, err := io.ReadAll(response.Body)
if err != nil {
return State{}, fmt.Errorf(
Expand All @@ -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)
}

Expand Down Expand Up @@ -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 {
Expand Down
36 changes: 9 additions & 27 deletions internal/auth/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:"}

Expand All @@ -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 {
Expand Down
35 changes: 35 additions & 0 deletions internal/cli/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
)
}
73 changes: 73 additions & 0 deletions internal/cli/api_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package cli

import (
"bytes"
"io"
"net/http"
"testing"

Expand Down Expand Up @@ -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": "<html>LOL</html>"
},
"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)
}
})
}
}
Loading

0 comments on commit ef14dc8

Please sign in to comment.