Skip to content

Commit 9016c5f

Browse files
ttstarckclauderamya18101
authored
Add organization support to test token command for M2M Applications (#1475)
* refactor: move checkClientIsAuthorizedForAPI to utils_shared.go and add org support Moves checkClientIsAuthorizedForAPI out of test.go into utils_shared.go alongside the other flow helpers it depends on. Also adds organization support to the client credentials token request: - BuildOauthTokenParams now accepts an organization parameter - runClientCredentialsFlow forwards it to both the auth check and token request - checkClientIsAuthorizedForAPI errors early when organization_usage is "require" on the client grant but no organization was provided Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(test token): add organization support for M2M client credentials flow Wires the --organization flag through to the client credentials token request for Machine to Machine applications. Auth0 ignores the scope parameter for client credentials grants (all granted scopes are always returned), so --scopes now shows a warning and is ignored for M2M apps. Also adds unit tests for checkClientIsAuthorizedForAPI covering the organization_usage=require validation. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * revert unused optimization of managementAPI var from prior iteration * feat(test token): prompt org picker when M2M client grant requires organization When the client grant for the selected API has organization_usage=require and no --organization flag was provided, fetch the tenant's organizations and either fail with a descriptive error (if none exist) or open an interactive picker so the user can select one before the token request is made. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(test token): add unit tests for organizationPickerOptionsForGrant Covers three cases: API error propagation, no organizations exist (with descriptive error), and the happy path returning correctly shaped picker options. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(lint): fix receiver naming and trailing whitespace in test.go Rename receiver `cli` to `c` in `pickOrganizationForGrantIfRequired` and `organizationPickerOptionsForGrant` to be consistent with the rest of the file, and remove trailing blank lines to satisfy gofmt. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Ramya Anusri <62586490+ramya18101@users.noreply.github.com>
1 parent 9fb8898 commit 9016c5f

4 files changed

Lines changed: 269 additions & 42 deletions

File tree

internal/cli/test.go

Lines changed: 57 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -149,12 +149,6 @@ func testLoginCmd(cli *cli) *cobra.Command {
149149
return nil
150150
}
151151

152-
if inputs.Audience != "" && (client.GetAppType() == appTypeNonInteractive) {
153-
if err := checkClientIsAuthorizedForAPI(cmd.Context(), cli, client, inputs.Audience); err != nil {
154-
return err
155-
}
156-
}
157-
158152
if inputs.Organization != "" {
159153
if inputs.CustomParams != nil {
160154
inputs.CustomParams["organization"] = inputs.Organization
@@ -262,7 +256,11 @@ func testTokenCmd(cli *cli) *cobra.Command {
262256
cli.renderer.Warnf("Passed in scopes do not apply to Machine to Machine applications.\n")
263257
}
264258

265-
tokenResponse, err = runClientCredentialsFlow(cmd.Context(), cli, client, inputs.Audience, cli.tenant)
259+
if err := cli.pickOrganizationForGrantIfRequired(cmd, client, inputs.Audience, &inputs.Organization); err != nil {
260+
return err
261+
}
262+
263+
tokenResponse, err = runClientCredentialsFlow(cmd.Context(), cli, client, inputs.Audience, cli.tenant, inputs.Organization)
266264
if err != nil {
267265
return fmt.Errorf(
268266
"failed to log in with client credentials for client with ID %q: %w",
@@ -480,6 +478,58 @@ func (c *cli) audiencePickerOptions(client *management.Client) func(ctx context.
480478
}
481479
}
482480

481+
// pickOrganizationForGrantIfRequired checks if the client grant for the given
482+
// audience requires an organization. If it does and no organization has been
483+
// specified, it either fails with a descriptive error (if no organizations exist
484+
// on the tenant) or opens an interactive picker to let the user select one.
485+
func (c *cli) pickOrganizationForGrantIfRequired(cmd *cobra.Command, client *management.Client, audience string, organization *string) error {
486+
if *organization != "" {
487+
return nil
488+
}
489+
490+
var list *management.ClientGrantList
491+
if err := ansi.Waiting(func() (err error) {
492+
list, err = c.api.ClientGrant.List(
493+
cmd.Context(),
494+
management.Parameter("audience", audience),
495+
management.Parameter("client_id", client.GetClientID()),
496+
)
497+
return err
498+
}); err != nil {
499+
return err
500+
}
501+
502+
if len(list.ClientGrants) == 0 || list.ClientGrants[0].GetOrganizationUsage() != "require" {
503+
return nil
504+
}
505+
506+
return testOrganization.Pick(cmd, organization, c.organizationPickerOptionsForGrant(audience))
507+
}
508+
509+
func (c *cli) organizationPickerOptionsForGrant(audience string) pickerOptionsFunc {
510+
return func(ctx context.Context) (pickerOptions, error) {
511+
orgList, err := c.api.Organization.List(ctx)
512+
if err != nil {
513+
return nil, err
514+
}
515+
516+
if len(orgList.Organizations) == 0 {
517+
return nil, fmt.Errorf(
518+
"the client grant for %s requires an organization, but no organizations exist on this tenant.\n\n"+
519+
"Create one by running: 'auth0 orgs create'",
520+
ansi.Bold(audience),
521+
)
522+
}
523+
524+
var opts pickerOptions
525+
for _, org := range orgList.Organizations {
526+
label := fmt.Sprintf("%s %s", org.GetName(), ansi.Faint("("+org.GetID()+")"))
527+
opts = append(opts, pickerOption{value: org.GetID(), label: label})
528+
}
529+
return opts, nil
530+
}
531+
}
532+
483533
func (c *cli) pickTokenScopes(ctx context.Context, inputs *testCmdInputs) error {
484534
resourceServer, err := c.api.ResourceServer.Read(ctx, inputs.Audience)
485535
if err != nil {
@@ -503,34 +553,3 @@ func (c *cli) pickTokenScopes(ctx context.Context, inputs *testCmdInputs) error
503553

504554
return survey.AskOne(scopesPrompt, &inputs.Scopes)
505555
}
506-
507-
func checkClientIsAuthorizedForAPI(ctx context.Context, cli *cli, client *management.Client, audience string) error {
508-
var list *management.ClientGrantList
509-
if err := ansi.Waiting(func() (err error) {
510-
list, err = cli.api.ClientGrant.List(
511-
ctx,
512-
management.Parameter("audience", audience),
513-
management.Parameter("client_id", client.GetClientID()),
514-
)
515-
return err
516-
}); err != nil {
517-
return fmt.Errorf(
518-
"failed to find client grants for API identifier %q and client ID %q: %w",
519-
audience,
520-
client.GetClientID(),
521-
err,
522-
)
523-
}
524-
525-
if len(list.ClientGrants) < 1 {
526-
return fmt.Errorf(
527-
"the %s application is not authorized to request access tokens for this API %s.\n\n"+
528-
"Run: 'auth0 apps open %s' to open the dashboard and authorize the application.",
529-
ansi.Bold(client.GetName()),
530-
ansi.Bold(audience),
531-
client.GetClientID(),
532-
)
533-
}
534-
535-
return nil
536-
}

internal/cli/test_test.go

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
package cli
2+
3+
import (
4+
"context"
5+
"errors"
6+
"testing"
7+
8+
"github.com/auth0/go-auth0/management"
9+
"github.com/golang/mock/gomock"
10+
"github.com/stretchr/testify/assert"
11+
12+
"github.com/auth0/auth0-cli/internal/auth0"
13+
"github.com/auth0/auth0-cli/internal/auth0/mock"
14+
)
15+
16+
func TestOrganizationPickerOptionsForGrant(t *testing.T) {
17+
const audience = "https://cli-demo.us.auth0.com/api/v2/"
18+
19+
tests := []struct {
20+
name string
21+
orgList *management.OrganizationList
22+
apiError error
23+
expectedError string
24+
expectedOpts pickerOptions
25+
}{
26+
{
27+
name: "api error fetching organizations",
28+
apiError: errors.New("unexpected error"),
29+
expectedError: "unexpected error",
30+
},
31+
{
32+
name: "no organizations exist",
33+
orgList: &management.OrganizationList{},
34+
expectedError: "the client grant for " + audience + " requires an organization, but no organizations exist on this tenant.\n\n" +
35+
"Create one by running: 'auth0 orgs create'",
36+
},
37+
{
38+
name: "organizations exist",
39+
orgList: &management.OrganizationList{
40+
Organizations: []*management.Organization{
41+
{ID: auth0.String("org_abc123"), Name: auth0.String("My Org")},
42+
{ID: auth0.String("org_def456"), Name: auth0.String("Other Org")},
43+
},
44+
},
45+
expectedOpts: pickerOptions{
46+
{value: "org_abc123", label: "My Org (org_abc123)"},
47+
{value: "org_def456", label: "Other Org (org_def456)"},
48+
},
49+
},
50+
}
51+
52+
for _, test := range tests {
53+
t.Run(test.name, func(t *testing.T) {
54+
ctrl := gomock.NewController(t)
55+
defer ctrl.Finish()
56+
57+
orgAPI := mock.NewMockOrganizationAPI(ctrl)
58+
orgAPI.EXPECT().
59+
List(gomock.Any(), gomock.Any()).
60+
Return(test.orgList, test.apiError)
61+
62+
cli := &cli{
63+
api: &auth0.API{Organization: orgAPI},
64+
}
65+
66+
opts, err := cli.organizationPickerOptionsForGrant(audience)(context.Background())
67+
68+
if test.expectedError != "" {
69+
assert.ErrorContains(t, err, test.expectedError)
70+
} else {
71+
assert.NoError(t, err)
72+
assert.Equal(t, test.expectedOpts, opts)
73+
}
74+
})
75+
}
76+
}

internal/cli/utils_shared.go

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,13 +46,16 @@ func BuildOauthTokenURL(domain string) string {
4646
return u.String()
4747
}
4848

49-
func BuildOauthTokenParams(clientID, clientSecret, audience string) url.Values {
49+
func BuildOauthTokenParams(clientID, clientSecret, audience, organization string) url.Values {
5050
q := url.Values{
5151
"audience": {audience},
5252
"client_id": {clientID},
5353
"client_secret": {clientSecret},
5454
"grant_type": {"client_credentials"},
5555
}
56+
if organization != "" {
57+
q.Set("organization", organization)
58+
}
5659
return q
5760
}
5861

@@ -64,13 +67,14 @@ func runClientCredentialsFlow(
6467
client *management.Client,
6568
audience string,
6669
tenantDomain string,
70+
organization string,
6771
) (*authutil.TokenResponse, error) {
68-
if err := checkClientIsAuthorizedForAPI(ctx, cli, client, audience); err != nil {
72+
if err := checkClientIsAuthorizedForAPI(ctx, cli, client, audience, organization); err != nil {
6973
return nil, err
7074
}
7175

7276
tokenURL := BuildOauthTokenURL(tenantDomain)
73-
payload := BuildOauthTokenParams(client.GetClientID(), client.GetClientSecret(), audience)
77+
payload := BuildOauthTokenParams(client.GetClientID(), client.GetClientSecret(), audience, organization)
7478

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

99+
func checkClientIsAuthorizedForAPI(ctx context.Context, cli *cli, client *management.Client, audience, organization string) error {
100+
var list *management.ClientGrantList
101+
if err := ansi.Waiting(func() (err error) {
102+
list, err = cli.api.ClientGrant.List(
103+
ctx,
104+
management.Parameter("audience", audience),
105+
management.Parameter("client_id", client.GetClientID()),
106+
)
107+
return err
108+
}); err != nil {
109+
return fmt.Errorf(
110+
"failed to find client grants for API identifier %q and client ID %q: %w",
111+
audience,
112+
client.GetClientID(),
113+
err,
114+
)
115+
}
116+
117+
if len(list.ClientGrants) < 1 {
118+
return fmt.Errorf(
119+
"the %s application is not authorized to request access tokens for this API %s.\n\n"+
120+
"Run: 'auth0 apps open %s' to open the dashboard and authorize the application.",
121+
ansi.Bold(client.GetName()),
122+
ansi.Bold(audience),
123+
client.GetClientID(),
124+
)
125+
}
126+
127+
grant := list.ClientGrants[0]
128+
if grant.GetOrganizationUsage() == "require" && organization == "" {
129+
return fmt.Errorf(
130+
"the client grant for %s requires an organization.\n\n"+
131+
"Use the --organization flag to specify one.",
132+
ansi.Bold(audience),
133+
)
134+
}
135+
136+
return nil
137+
}
138+
95139
// runLoginFlowPreflightChecks checks if we need to make any updates
96140
// to the client being tested in order to log in successfully.
97141
// If so, it asks the user to confirm whether to proceed.

internal/cli/utils_shared_test.go

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,96 @@ func TestBuildOauthTokenURL(t *testing.T) {
4444
}
4545

4646
func TestBuildOauthTokenParams(t *testing.T) {
47-
params := BuildOauthTokenParams("some-client-id", "some-client-secret", "https://cli-demo.auth0.us.auth0.com/api/v2/")
47+
params := BuildOauthTokenParams("some-client-id", "some-client-secret", "https://cli-demo.auth0.us.auth0.com/api/v2/", "")
4848
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())
49+
50+
params = BuildOauthTokenParams("some-client-id", "some-client-secret", "https://cli-demo.auth0.us.auth0.com/api/v2/", "org_abc123")
51+
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())
52+
}
53+
54+
func TestCheckClientIsAuthorizedForAPI(t *testing.T) {
55+
const audience = "https://cli-demo.us.auth0.com/api/v2/"
56+
57+
client := &management.Client{
58+
ClientID: auth0.String("some-client-id"),
59+
Name: auth0.String("some-client-name"),
60+
}
61+
62+
tests := []struct {
63+
name string
64+
organization string
65+
grantList *management.ClientGrantList
66+
apiError error
67+
expectedError string
68+
}{
69+
{
70+
name: "no grant exists",
71+
organization: "",
72+
grantList: &management.ClientGrantList{},
73+
expectedError: "the some-client-name application is not authorized to request access tokens for this API " +
74+
audience,
75+
},
76+
{
77+
name: "api error",
78+
organization: "",
79+
apiError: errors.New("unexpected error"),
80+
expectedError: "failed to find client grants for API identifier " +
81+
"\"" + audience + "\" and client ID \"some-client-id\": unexpected error",
82+
},
83+
{
84+
name: "grant exists, no org required",
85+
organization: "",
86+
grantList: &management.ClientGrantList{
87+
ClientGrants: []*management.ClientGrant{
88+
{OrganizationUsage: auth0.String("allow")},
89+
},
90+
},
91+
},
92+
{
93+
name: "grant requires org, org provided",
94+
organization: "org_abc123",
95+
grantList: &management.ClientGrantList{
96+
ClientGrants: []*management.ClientGrant{
97+
{OrganizationUsage: auth0.String("require")},
98+
},
99+
},
100+
},
101+
{
102+
name: "grant requires org, no org provided",
103+
organization: "",
104+
grantList: &management.ClientGrantList{
105+
ClientGrants: []*management.ClientGrant{
106+
{OrganizationUsage: auth0.String("require")},
107+
},
108+
},
109+
expectedError: "the client grant for " + audience + " requires an organization.\n\n" +
110+
"Use the --organization flag to specify one.",
111+
},
112+
}
113+
114+
for _, test := range tests {
115+
t.Run(test.name, func(t *testing.T) {
116+
ctrl := gomock.NewController(t)
117+
defer ctrl.Finish()
118+
119+
clientGrantAPI := mock.NewMockClientGrantAPI(ctrl)
120+
clientGrantAPI.EXPECT().
121+
List(gomock.Any(), gomock.Any()).
122+
Return(test.grantList, test.apiError)
123+
124+
cli := &cli{
125+
api: &auth0.API{ClientGrant: clientGrantAPI},
126+
}
127+
128+
err := checkClientIsAuthorizedForAPI(context.Background(), cli, client, audience, test.organization)
129+
130+
if test.expectedError != "" {
131+
assert.ErrorContains(t, err, test.expectedError)
132+
} else {
133+
assert.NoError(t, err)
134+
}
135+
})
136+
}
49137
}
50138

51139
func TestHasLocalCallbackURL(t *testing.T) {

0 commit comments

Comments
 (0)