Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

DXCDT-296: Supporting additional scopes when authenticating as user #561

Merged
merged 21 commits into from
Dec 16, 2022
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
253f880
Merge branch 'v1' of https://github.com/auth0/auth0-cli into v1
willvedd Dec 5, 2022
4cea50f
Merge branch 'v1' of https://github.com/auth0/auth0-cli into v1
willvedd Dec 13, 2022
2cb6f19
Adding additional scopes support via --scopes flag
willvedd Dec 15, 2022
06723ec
Adding additional scopes support via --scopes flag
willvedd Dec 15, 2022
73ea1c9
Removing logging
willvedd Dec 15, 2022
d55fd0d
Uncommenting scope, removing Start function
willvedd Dec 15, 2022
1aef346
Merge branch 'v1' into DXCDT-296-dynamic-scoping
willvedd Dec 15, 2022
b96c834
Condensing error to single line
willvedd Dec 15, 2022
c3541b0
Merge branch 'DXCDT-296-dynamic-scoping' of https://github.com/auth0/…
willvedd Dec 15, 2022
607a197
Fixing linting errors
willvedd Dec 15, 2022
e8df004
Merge branch 'v1' into DXCDT-296-dynamic-scoping
willvedd Dec 15, 2022
7d58fa9
Changing test
willvedd Dec 15, 2022
cb0e08f
Merge branch 'DXCDT-296-dynamic-scoping' of https://github.com/auth0/…
willvedd Dec 15, 2022
a2f5860
Updating docs
willvedd Dec 15, 2022
9be2ed0
Unpluralizing text, setting nil default value
willvedd Dec 15, 2022
d5caf06
Fixing bad help text
willvedd Dec 15, 2022
976f9a2
Merge branch 'v1' into DXCDT-296-dynamic-scoping
Widcket Dec 16, 2022
e23b9b2
Tiny refactors on the login cmd
sergiught Dec 16, 2022
d823184
Merge branch 'DXCDT-296-dynamic-scoping' of https://github.com/auth0/…
willvedd Dec 16, 2022
03bea62
Fixing linting error
willvedd Dec 16, 2022
cc62501
Update internal/auth/auth.go
sergiught Dec 16, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 45 additions & 45 deletions internal/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,18 +133,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) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A bit of a refactor here. Removing the Start function because it only exists as a function wrapper for getDeviceCode. Further, the name wasn't descriptive of what the function did.

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,49 +193,61 @@ func (a *Authenticator) Wait(ctx context.Context, state State) (Result, error) {
}
}

func (a *Authenticator) getDeviceCode(ctx context.Context) (State, error) {
data := url.Values{
"client_id": []string{a.ClientID},
"scope": []string{strings.Join(requiredScopes, " ")},
"audience": []string{a.Audience},
}
// 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) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The only functional change here is the addition of the additionalScopes argument, which enables the passing of additional scopes when requesting a grant.

state, err := func() (State, error) {

request, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
a.DeviceCodeEndpoint,
strings.NewReader(data.Encode()),
)
if err != nil {
return State{}, fmt.Errorf("failed to create the request: %w", err)
}
scopesToRequest := append(requiredScopes, additionalScopes...)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Change to note – combining the required scopes with additional scopes.


data := url.Values{
"client_id": []string{a.ClientID},
"scope": []string{strings.Join(scopesToRequest, " ")},
"audience": []string{a.Audience},
}

request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
a.DeviceCodeEndpoint,
strings.NewReader(data.Encode()),
)
if err != nil {
return State{}, fmt.Errorf("failed to create the request: %w, ", err)
}

response, err := http.DefaultClient.Do(request)
if err != nil {
return State{}, fmt.Errorf("failed to send the request: %w", err)
}
defer response.Body.Close()
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")

if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusBadRequest {
bodyBytes, err := io.ReadAll(response.Body)
response, err := http.DefaultClient.Do(request)
if err != nil {
return State{}, fmt.Errorf(
"received a %d response and failed to read the response",
response.StatusCode,
)
return State{}, fmt.Errorf("failed to send the request: %w", err)
}
defer response.Body.Close()

return State{}, fmt.Errorf("received a %d response: %s", response.StatusCode, bodyBytes)
}
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusBadRequest {
bodyBytes, err := io.ReadAll(response.Body)
if err != nil {
return State{}, fmt.Errorf(
"received a %d response and failed to read the response",
response.StatusCode,
)
}

return State{}, fmt.Errorf("received a %d response: %s", response.StatusCode, bodyBytes)
}

var state State
err = json.NewDecoder(response.Body).Decode(&state)
var state State
err = json.NewDecoder(response.Body).Decode(&state)
if err != nil {
return State{}, fmt.Errorf("failed to decode the response: %w", err)
}

return state, nil
}()
if err != nil {
return State{}, fmt.Errorf("failed to decode the response: %w", err)
fmt.Errorf("failed to get the device code: %w", err)
}

return state, nil
}

Expand Down
36 changes: 36 additions & 0 deletions internal/cli/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,15 @@ func apiCmdRun(cli *cli, inputs *apiCmdInputs) func(cmd *cobra.Command, args []s
}

response, err = http.DefaultClient.Do(request)

doesLackScopes, whichScope := isInsufficientScopeError(response)

if doesLackScopes {
cli.renderer.Errorf("request failed because access token lacks scope: %s.", whichScope)
cli.renderer.Errorf("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`.", whichScope)
return err
}

return err
}); err != nil {
return fmt.Errorf("failed to send request: %w", err)
Expand Down Expand Up @@ -257,3 +266,30 @@ func (i *apiCmdInputs) parseRaw(args []string) {

i.RawURI = args[lenArgs-1]
}

func isInsufficientScopeError(r *http.Response) (bool, string) {

if r.StatusCode != 403 {
return false, ""
}

type ErrorBody struct {
ErrorCode string `json:"errorCode"`
Message string `json:"message"`
}

var body ErrorBody
err := json.NewDecoder(r.Body).Decode(&body)
if err != nil {
return false, ""
}

if body.ErrorCode != "insufficient_scope" {
return false, ""
}

missingScopes := strings.Split(body.Message, "Insufficient scope, expected any of: ")[1]
recommendedScopesToAdd := strings.Split(missingScopes, ",")

return true, recommendedScopesToAdd[0]
}
77 changes: 77 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/ioutil"
"net/http"
"testing"

Expand Down Expand Up @@ -85,3 +87,78 @@ func TestAPICmdInputs_FromArgs(t *testing.T) {
})
}
}

func TestAPICmd_IsInsufficientScopeError(t *testing.T) {
var testCases = []struct {
name string
inputStatusCode int
inputResponseBody string
expectedResult bool
expectedScope string
}{
{
name: "it does not detect 404 error",
inputStatusCode: 404,
inputResponseBody: `{
"statusCode": 404,
"error": "Not Found",
"message": "Not Found"
}`,
expectedResult: false,
expectedScope: "",
},
{
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": "",
}`,
expectedResult: false,
expectedScope: "",
},
{
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"
}`,
expectedResult: true,
expectedScope: "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"
}`,
expectedResult: true,
expectedScope: "read:clients",
},
}

for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {

input := http.Response{
Body: ioutil.NopCloser(bytes.NewReader([]byte(testCase.inputResponseBody))),
StatusCode: testCase.inputStatusCode,
}

actualRespBool, actualScope := isInsufficientScopeError(&input)

assert.Equal(t, testCase.expectedResult, actualRespBool)
assert.Equal(t, testCase.expectedScope, actualScope)
})
}
}
4 changes: 2 additions & 2 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,7 @@ func (c *cli) prepareTenant(ctx context.Context) (Tenant, error) {

if scopesChanged(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, []string{})
}

if t.AccessToken != "" && !t.hasExpiredToken() {
Expand All @@ -232,7 +232,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, []string{})
}

if err := c.addTenant(t); err != nil {
Expand Down
38 changes: 27 additions & 11 deletions internal/cli/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,18 +36,31 @@ var (
IsRequired: false,
AlwaysPrompt: false,
}

additionalScopes = Flag{
Name: "Additional Scopes",
LongForm: "scopes",
Help: "Scopes to request in addition to required defaults when authenticating via device code flow. Primarily useful when using `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

Expand All @@ -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 <tenant-domain> --client-id <client-id> --client-secret <client-secret>`,
auth0 login --domain <tenant-domain> --client-id <client-id> --client-secret <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(
Expand Down Expand Up @@ -94,10 +108,10 @@ auth0 login --domain <tenant-domain> --client-id <client-id> --client-secret <cl

ctx := cmd.Context()

// Allows to skip to user login if --no-input flag is passed.
shouldLoginAsUser := (cli.noInput && !inputs.isLoggingInAsAMachine()) || selectedLoginType == loginAsUser
// Allows to skip to user login if either the --no-input or --scopes flag is passed.
shouldLoginAsUser := (cli.noInput && !inputs.isLoggingInAsAMachine()) || inputs.isLoggingInWithAdditionalScopes() || selectedLoginType == loginAsUser
if shouldLoginAsUser {
if _, err := RunLoginAsUser(ctx, cli); err != nil {
if _, err := RunLoginAsUser(ctx, cli, inputs.AdditionalScopes); err != nil {
return err
}
} else {
Expand All @@ -115,7 +129,9 @@ auth0 login --domain <tenant-domain> --client-id <client-id> --client-secret <cl
loginTenantDomain.RegisterString(cmd, &inputs.Domain, "")
loginClientID.RegisterString(cmd, &inputs.ClientID, "")
loginClientSecret.RegisterString(cmd, &inputs.ClientSecret, "")
additionalScopes.RegisterStringSlice(cmd, &inputs.AdditionalScopes, []string{})
cmd.MarkFlagsRequiredTogether("client-id", "client-secret", "domain")
cmd.MarkFlagsMutuallyExclusive("client-id", "scopes")

cmd.SetHelpFunc(func(cmd *cobra.Command, args []string) {
_ = cmd.Flags().MarkHidden("tenant")
Expand All @@ -128,8 +144,8 @@ auth0 login --domain <tenant-domain> --client-id <client-id> --client-secret <cl

// 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) (Tenant, error) {
state, err := cli.authenticator.Start(ctx)
func RunLoginAsUser(ctx context.Context, cli *cli, additionalScopes []string) (Tenant, error) {
state, err := cli.authenticator.GetDeviceCode(ctx, additionalScopes)
if err != nil {
return Tenant{}, fmt.Errorf("Failed to start the authentication process: %w.", err)
}
Expand Down