Skip to content
95 changes: 57 additions & 38 deletions internal/cli/test.go
Original file line number Diff line number Diff line change
Expand Up @@ -149,12 +149,6 @@ func testLoginCmd(cli *cli) *cobra.Command {
return nil
}

if inputs.Audience != "" && (client.GetAppType() == appTypeNonInteractive) {
if err := checkClientIsAuthorizedForAPI(cmd.Context(), cli, client, inputs.Audience); err != nil {
return err
}
}

Comment thread
ramya18101 marked this conversation as resolved.
if inputs.Organization != "" {
if inputs.CustomParams != nil {
inputs.CustomParams["organization"] = inputs.Organization
Expand Down Expand Up @@ -262,7 +256,11 @@ func testTokenCmd(cli *cli) *cobra.Command {
cli.renderer.Warnf("Passed in scopes do not apply to Machine to Machine applications.\n")
}

tokenResponse, err = runClientCredentialsFlow(cmd.Context(), cli, client, inputs.Audience, cli.tenant)
if err := cli.pickOrganizationForGrantIfRequired(cmd, client, inputs.Audience, &inputs.Organization); err != nil {
return err
}

tokenResponse, err = runClientCredentialsFlow(cmd.Context(), cli, client, inputs.Audience, cli.tenant, inputs.Organization)
if err != nil {
return fmt.Errorf(
"failed to log in with client credentials for client with ID %q: %w",
Expand Down Expand Up @@ -480,6 +478,58 @@ func (c *cli) audiencePickerOptions(client *management.Client) func(ctx context.
}
}

// pickOrganizationForGrantIfRequired checks if the client grant for the given
// audience requires an organization. If it does and no organization has been
// specified, it either fails with a descriptive error (if no organizations exist
// on the tenant) or opens an interactive picker to let the user select one.
func (c *cli) pickOrganizationForGrantIfRequired(cmd *cobra.Command, client *management.Client, audience string, organization *string) error {
if *organization != "" {
return nil
}

var list *management.ClientGrantList
if err := ansi.Waiting(func() (err error) {
list, err = c.api.ClientGrant.List(
cmd.Context(),
management.Parameter("audience", audience),
management.Parameter("client_id", client.GetClientID()),
)
return err
}); err != nil {
return err
}

if len(list.ClientGrants) == 0 || list.ClientGrants[0].GetOrganizationUsage() != "require" {
return nil
}

return testOrganization.Pick(cmd, organization, c.organizationPickerOptionsForGrant(audience))
}

func (c *cli) organizationPickerOptionsForGrant(audience string) pickerOptionsFunc {
return func(ctx context.Context) (pickerOptions, error) {
orgList, err := c.api.Organization.List(ctx)
if err != nil {
return nil, err
}

if len(orgList.Organizations) == 0 {
return nil, fmt.Errorf(
"the client grant for %s requires an organization, but no organizations exist on this tenant.\n\n"+
"Create one by running: 'auth0 orgs create'",
ansi.Bold(audience),
)
}

var opts pickerOptions
for _, org := range orgList.Organizations {
label := fmt.Sprintf("%s %s", org.GetName(), ansi.Faint("("+org.GetID()+")"))
opts = append(opts, pickerOption{value: org.GetID(), label: label})
}
return opts, nil
}
}

func (c *cli) pickTokenScopes(ctx context.Context, inputs *testCmdInputs) error {
resourceServer, err := c.api.ResourceServer.Read(ctx, inputs.Audience)
if err != nil {
Expand All @@ -503,34 +553,3 @@ func (c *cli) pickTokenScopes(ctx context.Context, inputs *testCmdInputs) error

return survey.AskOne(scopesPrompt, &inputs.Scopes)
}

func checkClientIsAuthorizedForAPI(ctx context.Context, cli *cli, client *management.Client, audience string) error {
var list *management.ClientGrantList
if err := ansi.Waiting(func() (err error) {
list, err = cli.api.ClientGrant.List(
ctx,
management.Parameter("audience", audience),
management.Parameter("client_id", client.GetClientID()),
)
return err
}); err != nil {
return fmt.Errorf(
"failed to find client grants for API identifier %q and client ID %q: %w",
audience,
client.GetClientID(),
err,
)
}

if len(list.ClientGrants) < 1 {
return fmt.Errorf(
"the %s application is not authorized to request access tokens for this API %s.\n\n"+
"Run: 'auth0 apps open %s' to open the dashboard and authorize the application.",
ansi.Bold(client.GetName()),
ansi.Bold(audience),
client.GetClientID(),
)
}

return nil
}
76 changes: 76 additions & 0 deletions internal/cli/test_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package cli

import (
"context"
"errors"
"testing"

"github.com/auth0/go-auth0/management"
"github.com/golang/mock/gomock"
"github.com/stretchr/testify/assert"

"github.com/auth0/auth0-cli/internal/auth0"
"github.com/auth0/auth0-cli/internal/auth0/mock"
)

func TestOrganizationPickerOptionsForGrant(t *testing.T) {
const audience = "https://cli-demo.us.auth0.com/api/v2/"

tests := []struct {
name string
orgList *management.OrganizationList
apiError error
expectedError string
expectedOpts pickerOptions
}{
{
name: "api error fetching organizations",
apiError: errors.New("unexpected error"),
expectedError: "unexpected error",
},
{
name: "no organizations exist",
orgList: &management.OrganizationList{},
expectedError: "the client grant for " + audience + " requires an organization, but no organizations exist on this tenant.\n\n" +
"Create one by running: 'auth0 orgs create'",
},
{
name: "organizations exist",
orgList: &management.OrganizationList{
Organizations: []*management.Organization{
{ID: auth0.String("org_abc123"), Name: auth0.String("My Org")},
{ID: auth0.String("org_def456"), Name: auth0.String("Other Org")},
},
},
expectedOpts: pickerOptions{
{value: "org_abc123", label: "My Org (org_abc123)"},
{value: "org_def456", label: "Other Org (org_def456)"},
},
},
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()

orgAPI := mock.NewMockOrganizationAPI(ctrl)
orgAPI.EXPECT().
List(gomock.Any(), gomock.Any()).
Return(test.orgList, test.apiError)

cli := &cli{
api: &auth0.API{Organization: orgAPI},
}

opts, err := cli.organizationPickerOptionsForGrant(audience)(context.Background())

if test.expectedError != "" {
assert.ErrorContains(t, err, test.expectedError)
} else {
assert.NoError(t, err)
assert.Equal(t, test.expectedOpts, opts)
}
})
}
}
50 changes: 47 additions & 3 deletions internal/cli/utils_shared.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,13 +46,16 @@ func BuildOauthTokenURL(domain string) string {
return u.String()
}

func BuildOauthTokenParams(clientID, clientSecret, audience string) url.Values {
func BuildOauthTokenParams(clientID, clientSecret, audience, organization string) url.Values {
q := url.Values{
"audience": {audience},
"client_id": {clientID},
"client_secret": {clientSecret},
"grant_type": {"client_credentials"},
}
if organization != "" {
q.Set("organization", organization)
}
return q
}

Expand All @@ -64,13 +67,14 @@ func runClientCredentialsFlow(
client *management.Client,
audience string,
tenantDomain string,
organization string,
) (*authutil.TokenResponse, error) {
if err := checkClientIsAuthorizedForAPI(ctx, cli, client, audience); err != nil {
if err := checkClientIsAuthorizedForAPI(ctx, cli, client, audience, organization); err != nil {
return nil, err
}

tokenURL := BuildOauthTokenURL(tenantDomain)
payload := BuildOauthTokenParams(client.GetClientID(), client.GetClientSecret(), audience)
payload := BuildOauthTokenParams(client.GetClientID(), client.GetClientSecret(), audience, organization)

var tokenResponse *authutil.TokenResponse
err := ansi.Spinner("Waiting for token", func() error {
Expand All @@ -92,6 +96,46 @@ func runClientCredentialsFlow(
return tokenResponse, err
}

func checkClientIsAuthorizedForAPI(ctx context.Context, cli *cli, client *management.Client, audience, organization string) error {
var list *management.ClientGrantList
if err := ansi.Waiting(func() (err error) {
list, err = cli.api.ClientGrant.List(
ctx,
management.Parameter("audience", audience),
management.Parameter("client_id", client.GetClientID()),
)
return err
}); err != nil {
return fmt.Errorf(
"failed to find client grants for API identifier %q and client ID %q: %w",
audience,
client.GetClientID(),
err,
)
}

if len(list.ClientGrants) < 1 {
return fmt.Errorf(
"the %s application is not authorized to request access tokens for this API %s.\n\n"+
"Run: 'auth0 apps open %s' to open the dashboard and authorize the application.",
ansi.Bold(client.GetName()),
ansi.Bold(audience),
client.GetClientID(),
)
}

grant := list.ClientGrants[0]
if grant.GetOrganizationUsage() == "require" && organization == "" {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Since organization usage is required here, we should first validate whether the tenant has any organizations configured. If none exist, we can return a clear error to the user.
If organizations are available, instead of relying solely on the --organization flag, we could improve the UX by following a pattern similar to audiencePicker, prompting the user to select from the list of existing organizations.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@ramya18101 so just to clarify this further:

If the --organization flag was not specified and the API client grant for the M2M app requires an organization, we should:

  1. Fail if no organizations exist in the tenant, with a clear error message.
  2. Else: Prompt the User to select from the list of existing organizations similar to the Audience Picker

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I'll spend some time working on this addition! Thanks for the feedback

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added this feature in 4dbf75e

Here is the example output when the audience requires an organization but none is specified.

./out/auth0 test token $CLIENT_ID --audience $AUDIENCE
 ▸    Domain    : <domain>
 ▸    Client ID : <client id>
 ▸    Type      : Machine to Machine

 Organization:  [Use arrows to move, type to filter, ? for more help]
> org_name (<org_id>)
...

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for adding this!

return fmt.Errorf(
"the client grant for %s requires an organization.\n\n"+
"Use the --organization flag to specify one.",
ansi.Bold(audience),
)
}

return nil
}

// runLoginFlowPreflightChecks checks if we need to make any updates
// to the client being tested in order to log in successfully.
// If so, it asks the user to confirm whether to proceed.
Expand Down
90 changes: 89 additions & 1 deletion internal/cli/utils_shared_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,96 @@ func TestBuildOauthTokenURL(t *testing.T) {
}

func TestBuildOauthTokenParams(t *testing.T) {
params := BuildOauthTokenParams("some-client-id", "some-client-secret", "https://cli-demo.auth0.us.auth0.com/api/v2/")
params := BuildOauthTokenParams("some-client-id", "some-client-secret", "https://cli-demo.auth0.us.auth0.com/api/v2/", "")
assert.Equal(t, "audience=https%3A%2F%2Fcli-demo.auth0.us.auth0.com%2Fapi%2Fv2%2F&client_id=some-client-id&client_secret=some-client-secret&grant_type=client_credentials", params.Encode())

params = BuildOauthTokenParams("some-client-id", "some-client-secret", "https://cli-demo.auth0.us.auth0.com/api/v2/", "org_abc123")
assert.Equal(t, "audience=https%3A%2F%2Fcli-demo.auth0.us.auth0.com%2Fapi%2Fv2%2F&client_id=some-client-id&client_secret=some-client-secret&grant_type=client_credentials&organization=org_abc123", params.Encode())
}

func TestCheckClientIsAuthorizedForAPI(t *testing.T) {
const audience = "https://cli-demo.us.auth0.com/api/v2/"

client := &management.Client{
ClientID: auth0.String("some-client-id"),
Name: auth0.String("some-client-name"),
}

tests := []struct {
name string
organization string
grantList *management.ClientGrantList
apiError error
expectedError string
}{
{
name: "no grant exists",
organization: "",
grantList: &management.ClientGrantList{},
expectedError: "the some-client-name application is not authorized to request access tokens for this API " +
audience,
},
{
name: "api error",
organization: "",
apiError: errors.New("unexpected error"),
expectedError: "failed to find client grants for API identifier " +
"\"" + audience + "\" and client ID \"some-client-id\": unexpected error",
},
{
name: "grant exists, no org required",
organization: "",
grantList: &management.ClientGrantList{
ClientGrants: []*management.ClientGrant{
{OrganizationUsage: auth0.String("allow")},
},
},
},
{
name: "grant requires org, org provided",
organization: "org_abc123",
grantList: &management.ClientGrantList{
ClientGrants: []*management.ClientGrant{
{OrganizationUsage: auth0.String("require")},
},
},
},
{
name: "grant requires org, no org provided",
organization: "",
grantList: &management.ClientGrantList{
ClientGrants: []*management.ClientGrant{
{OrganizationUsage: auth0.String("require")},
},
},
expectedError: "the client grant for " + audience + " requires an organization.\n\n" +
"Use the --organization flag to specify one.",
},
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()

clientGrantAPI := mock.NewMockClientGrantAPI(ctrl)
clientGrantAPI.EXPECT().
List(gomock.Any(), gomock.Any()).
Return(test.grantList, test.apiError)

cli := &cli{
api: &auth0.API{ClientGrant: clientGrantAPI},
}

err := checkClientIsAuthorizedForAPI(context.Background(), cli, client, audience, test.organization)

if test.expectedError != "" {
assert.ErrorContains(t, err, test.expectedError)
} else {
assert.NoError(t, err)
}
})
}
}

func TestHasLocalCallbackURL(t *testing.T) {
Expand Down
Loading