diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index f0eeca3c1..76a0a8e95 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -66,8 +66,9 @@ jobs: # Skip running if the PR is coming from a fork or is created by dependabot or snyk due to missing repo secrets. # Only run on pushes to main or PRs targeting main. + # TEMPORARY: also run on PRs targeting feat/go-auth0-v3-migration to verify the client-grants suite. Revert before merge. if: github.event.pull_request.head.repo.fork == false && (github.actor != 'dependabot[bot]' && github.actor != 'snyk-bot') && - (github.ref == 'refs/heads/main' || github.base_ref == 'main') + (github.ref == 'refs/heads/main' || github.base_ref == 'main' || github.base_ref == 'feat/go-auth0-v3-migration') steps: - name: Check out the code diff --git a/README.md b/README.md index 9c3227e83..d6bf9715b 100644 --- a/README.md +++ b/README.md @@ -269,6 +269,7 @@ Select **y** to proceed with your default tenant, or **N** to choose a different - [auth0 api](https://auth0.github.io/auth0-cli/auth0_api.html) - Makes an authenticated HTTP request to the Auth0 Management API - [auth0 apis](https://auth0.github.io/auth0-cli/auth0_apis.html) - Manage resources for APIs - [auth0 apps](https://auth0.github.io/auth0-cli/auth0_apps.html) - Manage resources for applications +- [auth0 client-grants](https://auth0.github.io/auth0-cli/auth0_client-grants.html) - Manage client grants - [auth0 completion](https://auth0.github.io/auth0-cli/auth0_completion.html) - Setup autocomplete features for this CLI on your terminal - [auth0 domains](https://auth0.github.io/auth0-cli/auth0_domains.html) - Manage custom domains - [auth0 email](https://auth0.github.io/auth0-cli/auth0_email.html) - Manage email settings diff --git a/docs/auth0_client-grants.md b/docs/auth0_client-grants.md new file mode 100644 index 000000000..c857d7651 --- /dev/null +++ b/docs/auth0_client-grants.md @@ -0,0 +1,17 @@ +--- +layout: default +has_toc: false +has_children: true +--- +# auth0 client-grants + +Manage client grants. A client grant authorizes an application (client) to request access tokens for an API (audience), optionally scoped to specific permissions or organizations. + +## Commands + +- [auth0 client-grants create](auth0_client-grants_create.md) - Create a new client grant +- [auth0 client-grants delete](auth0_client-grants_delete.md) - Delete a client grant +- [auth0 client-grants list](auth0_client-grants_list.md) - List your client grants +- [auth0 client-grants show](auth0_client-grants_show.md) - Show a client grant +- [auth0 client-grants update](auth0_client-grants_update.md) - Update a client grant + diff --git a/docs/auth0_client-grants_create.md b/docs/auth0_client-grants_create.md new file mode 100644 index 000000000..d5ebf308e --- /dev/null +++ b/docs/auth0_client-grants_create.md @@ -0,0 +1,71 @@ +--- +layout: default +parent: auth0 client-grants +has_toc: false +--- +# auth0 client-grants create + +Create a new client grant. + +To create interactively, use `auth0 client-grants create` with no flags. + +To create non-interactively, supply the audience and either a client id (`--client-id`) or a default group (`--default-for`), which are mutually exclusive, along with any optional scopes or organization settings through the flags. A grant can authorize specific scopes (`--scopes`), every scope on the API (`--allow-all-scopes`), or no scopes at all. + +Note: for the Auth0 Management API with `--subject-type user`, scopes must be a subset of the fixed current_user set and cannot be listed dynamically, so pass them inline, for example: `--scopes "read:current_user,update:current_user_metadata,delete:current_user_metadata,create:current_user_metadata,create:current_user_device_credentials,delete:current_user_device_credentials,update:current_user_identities"`. + +## Usage +``` +auth0 client-grants create [flags] +``` + +## Examples + +``` + auth0 client-grants create + auth0 client-grants create --client-id --audience + auth0 client-grants create --default-for third_party_clients --audience + auth0 client-grants create --client-id --audience --scopes "read:users,update:users" + auth0 client-grants create --client-id --audience --allow-all-scopes + auth0 client-grants create --client-id --audience --authorization-details-types "payment,transfer" + auth0 client-grants create -c -a -s "read:users" -o require --allow-any-organization=false + auth0 client-grants create -c -a --subject-type user + auth0 client-grants create -c -a --json +``` + + +## Flags + +``` + --allow-all-scopes Grant every scope configured on the API. Mutually exclusive with --scopes. + --allow-any-organization Whether any organization can be used with this grant (true) or only explicitly assigned organizations (false). + -a, --audience string Audience (API identifier) of the client grant. Cannot be changed once set. + --authorization-details-types strings Comma-separated list of authorization_details types allowed for this grant (Rich Authorization Requests). + -c, --client-id string Client ID of the application to authorize. Cannot be changed once set. Mutually exclusive with --default-for. + --default-for string Make this the default grant for a group of clients instead of authorizing a specific client. Mutually exclusive with --client-id. Possible value: third_party_clients. + --json Output in json format. + --json-compact Output in compact json format. + -o, --organization-usage string Whether organizations can be used with this grant. Possible values: deny, allow, require. + -s, --scopes strings Comma-separated list of scopes (permissions) to grant. + --subject-type string Subject type of the grant. Cannot be changed once set. Possible values: client, user, anonymous_user. +``` + + +## Inherited Flags + +``` + --debug Enable debug mode. + --no-color Disable colors. + --no-input Disable interactivity. + --tenant string Specific tenant to use. +``` + + +## Related Commands + +- [auth0 client-grants create](auth0_client-grants_create.md) - Create a new client grant +- [auth0 client-grants delete](auth0_client-grants_delete.md) - Delete a client grant +- [auth0 client-grants list](auth0_client-grants_list.md) - List your client grants +- [auth0 client-grants show](auth0_client-grants_show.md) - Show a client grant +- [auth0 client-grants update](auth0_client-grants_update.md) - Update a client grant + + diff --git a/docs/auth0_client-grants_delete.md b/docs/auth0_client-grants_delete.md new file mode 100644 index 000000000..de3620332 --- /dev/null +++ b/docs/auth0_client-grants_delete.md @@ -0,0 +1,56 @@ +--- +layout: default +parent: auth0 client-grants +has_toc: false +--- +# auth0 client-grants delete + +Delete a client grant. + +To delete interactively, use `auth0 client-grants delete` with no arguments. + +To delete non-interactively, supply the client grant id and the `--force` flag to skip confirmation. + +## Usage +``` +auth0 client-grants delete [flags] +``` + +## Examples + +``` + auth0 client-grants delete + auth0 client-grants rm + auth0 client-grants delete + auth0 client-grants delete --force + auth0 client-grants delete + auth0 client-grants delete --force +``` + + +## Flags + +``` + --force Skip confirmation. +``` + + +## Inherited Flags + +``` + --debug Enable debug mode. + --no-color Disable colors. + --no-input Disable interactivity. + --tenant string Specific tenant to use. +``` + + +## Related Commands + +- [auth0 client-grants create](auth0_client-grants_create.md) - Create a new client grant +- [auth0 client-grants delete](auth0_client-grants_delete.md) - Delete a client grant +- [auth0 client-grants list](auth0_client-grants_list.md) - List your client grants +- [auth0 client-grants show](auth0_client-grants_show.md) - Show a client grant +- [auth0 client-grants update](auth0_client-grants_update.md) - Update a client grant + + diff --git a/docs/auth0_client-grants_list.md b/docs/auth0_client-grants_list.md new file mode 100644 index 000000000..358baea9b --- /dev/null +++ b/docs/auth0_client-grants_list.md @@ -0,0 +1,63 @@ +--- +layout: default +parent: auth0 client-grants +has_toc: false +--- +# auth0 client-grants list + +List your existing client grants. To create one, run: `auth0 client-grants create`. + +Use the filter flags to narrow the results server-side by client, audience, subject type, default group or organization usage. + +## Usage +``` +auth0 client-grants list [flags] +``` + +## Examples + +``` + auth0 client-grants list + auth0 client-grants ls + auth0 client-grants ls --number 100 + auth0 client-grants ls --audience + auth0 client-grants ls --client-id --subject-type client + auth0 client-grants ls --default-for third_party_clients + auth0 client-grants ls --allow-any-organization=true + auth0 client-grants ls -n 100 --json +``` + + +## Flags + +``` + --allow-any-organization Filter by whether any organization can be used with the grant (true) or only explicitly assigned organizations (false). + -a, --audience string Filter by audience (API identifier). + -c, --client-id string Filter by client ID. Mutually exclusive with --default-for. + --default-for string Filter by the group this grant is the default for. Possible value: third_party_clients. Mutually exclusive with --client-id. + --json Output in json format. + --json-compact Output in compact json format. + -n, --number int Number of client grants to retrieve. Minimum 1, maximum 1000. (default 100) + --subject-type string Filter by subject type. Possible values: client, user, anonymous_user. +``` + + +## Inherited Flags + +``` + --debug Enable debug mode. + --no-color Disable colors. + --no-input Disable interactivity. + --tenant string Specific tenant to use. +``` + + +## Related Commands + +- [auth0 client-grants create](auth0_client-grants_create.md) - Create a new client grant +- [auth0 client-grants delete](auth0_client-grants_delete.md) - Delete a client grant +- [auth0 client-grants list](auth0_client-grants_list.md) - List your client grants +- [auth0 client-grants show](auth0_client-grants_show.md) - Show a client grant +- [auth0 client-grants update](auth0_client-grants_update.md) - Update a client grant + + diff --git a/docs/auth0_client-grants_show.md b/docs/auth0_client-grants_show.md new file mode 100644 index 000000000..a2755651e --- /dev/null +++ b/docs/auth0_client-grants_show.md @@ -0,0 +1,51 @@ +--- +layout: default +parent: auth0 client-grants +has_toc: false +--- +# auth0 client-grants show + +Display the client, audience, scopes, and other information about a client grant. + +## Usage +``` +auth0 client-grants show [flags] +``` + +## Examples + +``` + auth0 client-grants show + auth0 client-grants show + auth0 client-grants show --json + auth0 client-grants show --json-compact +``` + + +## Flags + +``` + --json Output in json format. + --json-compact Output in compact json format. +``` + + +## Inherited Flags + +``` + --debug Enable debug mode. + --no-color Disable colors. + --no-input Disable interactivity. + --tenant string Specific tenant to use. +``` + + +## Related Commands + +- [auth0 client-grants create](auth0_client-grants_create.md) - Create a new client grant +- [auth0 client-grants delete](auth0_client-grants_delete.md) - Delete a client grant +- [auth0 client-grants list](auth0_client-grants_list.md) - List your client grants +- [auth0 client-grants show](auth0_client-grants_show.md) - Show a client grant +- [auth0 client-grants update](auth0_client-grants_update.md) - Update a client grant + + diff --git a/docs/auth0_client-grants_update.md b/docs/auth0_client-grants_update.md new file mode 100644 index 000000000..f2c2d372c --- /dev/null +++ b/docs/auth0_client-grants_update.md @@ -0,0 +1,65 @@ +--- +layout: default +parent: auth0 client-grants +has_toc: false +--- +# auth0 client-grants update + +Update a client grant. + +To update interactively, use `auth0 client-grants update` with no arguments. + +The client id and audience of a grant cannot be changed. To update non-interactively, supply the scopes or organization settings through the flags. Pass `--allow-all-scopes` to grant every scope on the API instead of a specific list. + +Note: for the Auth0 Management API with `--subject-type user`, scopes must be a subset of the fixed current_user set and cannot be listed dynamically, so pass them inline, for example: `--scopes "read:current_user,update:current_user_metadata,delete:current_user_metadata,create:current_user_metadata,create:current_user_device_credentials,delete:current_user_device_credentials,update:current_user_identities"`. + +## Usage +``` +auth0 client-grants update [flags] +``` + +## Examples + +``` + auth0 client-grants update + auth0 client-grants update + auth0 client-grants update --scopes "read:users,update:users" + auth0 client-grants update --allow-all-scopes + auth0 client-grants update --authorization-details-types "payment,transfer" + auth0 client-grants update -s "read:users" -o require --allow-any-organization=false + auth0 client-grants update --json +``` + + +## Flags + +``` + --allow-all-scopes Grant every scope configured on the API. Mutually exclusive with --scopes. + --allow-any-organization Whether any organization can be used with this grant (true) or only explicitly assigned organizations (false). + --authorization-details-types strings Comma-separated list of authorization_details types allowed for this grant (Rich Authorization Requests). + --json Output in json format. + --json-compact Output in compact json format. + -o, --organization-usage string Whether organizations can be used with this grant. Possible values: deny, allow, require. + -s, --scopes strings Comma-separated list of scopes (permissions) to grant. +``` + + +## Inherited Flags + +``` + --debug Enable debug mode. + --no-color Disable colors. + --no-input Disable interactivity. + --tenant string Specific tenant to use. +``` + + +## Related Commands + +- [auth0 client-grants create](auth0_client-grants_create.md) - Create a new client grant +- [auth0 client-grants delete](auth0_client-grants_delete.md) - Delete a client grant +- [auth0 client-grants list](auth0_client-grants_list.md) - List your client grants +- [auth0 client-grants show](auth0_client-grants_show.md) - Show a client grant +- [auth0 client-grants update](auth0_client-grants_update.md) - Update a client grant + + diff --git a/docs/index.md b/docs/index.md index 0454ce86b..9a4035644 100644 --- a/docs/index.md +++ b/docs/index.md @@ -84,6 +84,7 @@ Authenticating as a user is not supported for **private cloud** tenants. Instead - [auth0 api](auth0_api.md) - Makes an authenticated HTTP request to the Auth0 Management API - [auth0 apis](auth0_apis.md) - Manage resources for APIs - [auth0 apps](auth0_apps.md) - Manage resources for applications +- [auth0 client-grants](auth0_client-grants.md) - Manage client grants - [auth0 completion](auth0_completion.md) - Setup autocomplete features for this CLI on your terminal - [auth0 domains](auth0_domains.md) - Manage custom domains - [auth0 email](auth0_email.md) - Manage email settings and configure email providers diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 758e2e92b..321a01375 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -123,7 +123,7 @@ func WaitUntilUserLogsIn(ctx context.Context, httpClient *http.Client, state Sta var RequiredScopes = []string{ "openid", "create:clients", "delete:clients", "read:clients", "update:clients", - "create:client_grants", "read:client_grants", + "create:client_grants", "read:client_grants", "update:client_grants", "delete:client_grants", "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", diff --git a/internal/auth/scopes_test.go b/internal/auth/scopes_test.go index cedf8cce9..5284f8585 100644 --- a/internal/auth/scopes_test.go +++ b/internal/auth/scopes_test.go @@ -6,6 +6,7 @@ func TestRequiredScopes(t *testing.T) { t.Run("Verify CRUD scopes", func(t *testing.T) { crudResources := []string{ "clients", + "client_grants", "log_streams", "resource_servers", "roles", diff --git a/internal/auth0/auth0.go b/internal/auth0/auth0.go index 2af9c400a..93f383e18 100644 --- a/internal/auth0/auth0.go +++ b/internal/auth0/auth0.go @@ -15,7 +15,6 @@ type API struct { Branding BrandingAPI BrandingTheme BrandingThemeAPI Client ClientAPI - ClientGrant ClientGrantAPI Connection ConnectionAPI CustomDomain CustomDomainAPI EmailTemplate EmailTemplateAPI @@ -50,7 +49,6 @@ func NewAPI(m *management.Management) *API { Branding: m.Branding, BrandingTheme: m.BrandingTheme, Client: m.Client, - ClientGrant: m.ClientGrant, Connection: m.Connection, CustomDomain: m.CustomDomain, EmailTemplate: m.EmailTemplate, @@ -79,6 +77,7 @@ func NewAPI(m *management.Management) *API { type APIV3 struct { AttackProtectionBotDetection AttackProtectionBotDetectionAPIV3 + ClientGrant ClientGrantAPIV3 Events EventsAPIV3 PhoneNotificationTemplate PhoneNotificationTemplateAPI } @@ -86,6 +85,7 @@ type APIV3 struct { func NewAPIV3(m *managementv3.Management) *APIV3 { return &APIV3{ AttackProtectionBotDetection: m.AttackProtection.BotDetection, + ClientGrant: m.ClientGrants, Events: m.Events, PhoneNotificationTemplate: m.Branding.Phone.Templates, } diff --git a/internal/auth0/client_grant.go b/internal/auth0/client_grant.go index 74296cfb9..09df32585 100644 --- a/internal/auth0/client_grant.go +++ b/internal/auth0/client_grant.go @@ -1,16 +1,75 @@ +//go:generate mockgen -source=client_grant.go -destination=mock/client_grant_mock.go -package=mock + package auth0 import ( "context" - "github.com/auth0/go-auth0/management" + managementv3 "github.com/auth0/go-auth0/v3/management" + "github.com/auth0/go-auth0/v3/management/core" + "github.com/auth0/go-auth0/v3/management/option" ) -type ClientGrantAPI interface { - // Create a new client grant, authorizing the given client for the specified API (audience). - // Returns an error if the grant already exists or the request fails. - Create(ctx context.Context, g *management.ClientGrant, opts ...management.RequestOption) error +// ClientGrantPage is the paginated response returned by the client-grants list +// endpoint. It is aliased here so the mock generator (which cannot parse +// instantiated generic types) sees a plain named type in the interface. +type ClientGrantPage = core.Page[*string, *managementv3.ClientGrantResponseContent, *managementv3.ListClientGrantPaginatedResponseContent] + +// ClientGrantAPIV3 is the interface for the /client-grants endpoint. +type ClientGrantAPIV3 interface { + // List client grants, including the scopes associated with the application/API pair. + // + // Required scope: `read:client_grants` + // + // See: https://auth0.com/docs/api/management/v2/client-grants/get-client-grants + List( + ctx context.Context, + request *managementv3.ListClientGrantsRequestParameters, + opts ...option.RequestOption, + ) (*ClientGrantPage, error) + + // Create a client grant, authorizing a client for the specified API (audience). + // + // Required scope: `create:client_grants` + // + // See: https://auth0.com/docs/api/management/v2/client-grants/post-client-grants + Create( + ctx context.Context, + request *managementv3.CreateClientGrantRequestContent, + opts ...option.RequestOption, + ) (*managementv3.CreateClientGrantResponseContent, error) + + // Get a single client grant, including the scopes associated with the application/API pair. + // + // Required scope: `read:client_grants` + // + // See: https://auth0.com/docs/api/management/v2/client-grants/get-client-grants-by-id + Get( + ctx context.Context, + id string, + opts ...option.RequestOption, + ) (*managementv3.GetClientGrantResponseContent, error) + + // Update a client grant. The client_id and audience of a grant cannot be changed. + // + // Required scope: `update:client_grants` + // + // See: https://auth0.com/docs/api/management/v2/client-grants/patch-client-grants-by-id + Update( + ctx context.Context, + id string, + request *managementv3.UpdateClientGrantRequestContent, + opts ...option.RequestOption, + ) (*managementv3.UpdateClientGrantResponseContent, error) - // List returns all client grants for the tenant, with optional filtering via opts. - List(ctx context.Context, opts ...management.RequestOption) (*management.ClientGrantList, error) + // Delete a client grant. + // + // Required scope: `delete:client_grants` + // + // See: https://auth0.com/docs/api/management/v2/client-grants/delete-client-grants-by-id + Delete( + ctx context.Context, + id string, + opts ...option.RequestOption, + ) error } diff --git a/internal/auth0/mock/client_grant_mock.go b/internal/auth0/mock/client_grant_mock.go index 74f085751..a47739932 100644 --- a/internal/auth0/mock/client_grant_mock.go +++ b/internal/auth0/mock/client_grant_mock.go @@ -1,5 +1,5 @@ // Code generated by MockGen. DO NOT EDIT. -// Source: client.go +// Source: client_grant.go // Package mock is a generated GoMock package. package mock @@ -8,68 +8,130 @@ import ( context "context" reflect "reflect" - management "github.com/auth0/go-auth0/management" + auth0 "github.com/auth0/auth0-cli/internal/auth0" + management "github.com/auth0/go-auth0/v3/management" + option "github.com/auth0/go-auth0/v3/management/option" gomock "github.com/golang/mock/gomock" ) -// MockClientGrantAPI is a mock of ClientAPI interface. -type MockClientGrantAPI struct { +// MockClientGrantAPIV3 is a mock of ClientGrantAPIV3 interface. +type MockClientGrantAPIV3 struct { ctrl *gomock.Controller - recorder *MockClientGrantAPIMockRecorder + recorder *MockClientGrantAPIV3MockRecorder } -// MockClientGrantAPIMockRecorder is the mock recorder for MockClientGrantAPI. -type MockClientGrantAPIMockRecorder struct { - mock *MockClientGrantAPI +// MockClientGrantAPIV3MockRecorder is the mock recorder for MockClientGrantAPIV3. +type MockClientGrantAPIV3MockRecorder struct { + mock *MockClientGrantAPIV3 } -// NewMockClientGrantAPI creates a new mock instance. -func NewMockClientGrantAPI(ctrl *gomock.Controller) *MockClientGrantAPI { - mock := &MockClientGrantAPI{ctrl: ctrl} - mock.recorder = &MockClientGrantAPIMockRecorder{mock} +// NewMockClientGrantAPIV3 creates a new mock instance. +func NewMockClientGrantAPIV3(ctrl *gomock.Controller) *MockClientGrantAPIV3 { + mock := &MockClientGrantAPIV3{ctrl: ctrl} + mock.recorder = &MockClientGrantAPIV3MockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. -func (m *MockClientGrantAPI) EXPECT() *MockClientGrantAPIMockRecorder { +func (m *MockClientGrantAPIV3) EXPECT() *MockClientGrantAPIV3MockRecorder { return m.recorder } // Create mocks base method. -func (m *MockClientGrantAPI) Create(ctx context.Context, g *management.ClientGrant, opts ...management.RequestOption) error { +func (m *MockClientGrantAPIV3) Create(ctx context.Context, request *management.CreateClientGrantRequestContent, opts ...option.RequestOption) (*management.CreateClientGrantResponseContent, error) { m.ctrl.T.Helper() - varargs := []interface{}{ctx, g} + varargs := []interface{}{ctx, request} for _, a := range opts { varargs = append(varargs, a) } ret := m.ctrl.Call(m, "Create", varargs...) + ret0, _ := ret[0].(*management.CreateClientGrantResponseContent) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Create indicates an expected call of Create. +func (mr *MockClientGrantAPIV3MockRecorder) Create(ctx, request interface{}, opts ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{ctx, request}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockClientGrantAPIV3)(nil).Create), varargs...) +} + +// Delete mocks base method. +func (m *MockClientGrantAPIV3) Delete(ctx context.Context, id string, opts ...option.RequestOption) error { + m.ctrl.T.Helper() + varargs := []interface{}{ctx, id} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Delete", varargs...) ret0, _ := ret[0].(error) return ret0 } -// Create indicates an expected call of Create. -func (mr *MockClientGrantAPIMockRecorder) Create(ctx, g interface{}, opts ...interface{}) *gomock.Call { +// Delete indicates an expected call of Delete. +func (mr *MockClientGrantAPIV3MockRecorder) Delete(ctx, id interface{}, opts ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - varargs := append([]interface{}{ctx, g}, opts...) - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockClientGrantAPI)(nil).Create), varargs...) + varargs := append([]interface{}{ctx, id}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Delete", reflect.TypeOf((*MockClientGrantAPIV3)(nil).Delete), varargs...) +} + +// Get mocks base method. +func (m *MockClientGrantAPIV3) Get(ctx context.Context, id string, opts ...option.RequestOption) (*management.GetClientGrantResponseContent, error) { + m.ctrl.T.Helper() + varargs := []interface{}{ctx, id} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Get", varargs...) + ret0, _ := ret[0].(*management.GetClientGrantResponseContent) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Get indicates an expected call of Get. +func (mr *MockClientGrantAPIV3MockRecorder) Get(ctx, id interface{}, opts ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{ctx, id}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockClientGrantAPIV3)(nil).Get), varargs...) } // List mocks base method. -func (m *MockClientGrantAPI) List(ctx context.Context, opts ...management.RequestOption) (*management.ClientGrantList, error) { +func (m *MockClientGrantAPIV3) List(ctx context.Context, request *management.ListClientGrantsRequestParameters, opts ...option.RequestOption) (*auth0.ClientGrantPage, error) { m.ctrl.T.Helper() - varargs := []interface{}{ctx} + varargs := []interface{}{ctx, request} for _, a := range opts { varargs = append(varargs, a) } ret := m.ctrl.Call(m, "List", varargs...) - ret0, _ := ret[0].(*management.ClientGrantList) + ret0, _ := ret[0].(*auth0.ClientGrantPage) ret1, _ := ret[1].(error) return ret0, ret1 } // List indicates an expected call of List. -func (mr *MockClientGrantAPIMockRecorder) List(ctx interface{}, opts ...interface{}) *gomock.Call { +func (mr *MockClientGrantAPIV3MockRecorder) List(ctx, request interface{}, opts ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{ctx, request}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockClientGrantAPIV3)(nil).List), varargs...) +} + +// Update mocks base method. +func (m *MockClientGrantAPIV3) Update(ctx context.Context, id string, request *management.UpdateClientGrantRequestContent, opts ...option.RequestOption) (*management.UpdateClientGrantResponseContent, error) { + m.ctrl.T.Helper() + varargs := []interface{}{ctx, id, request} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Update", varargs...) + ret0, _ := ret[0].(*management.UpdateClientGrantResponseContent) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Update indicates an expected call of Update. +func (mr *MockClientGrantAPIV3MockRecorder) Update(ctx, id, request interface{}, opts ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - varargs := append([]interface{}{ctx}, opts...) - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockClientGrantAPI)(nil).List), varargs...) + varargs := append([]interface{}{ctx, id, request}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockClientGrantAPIV3)(nil).Update), varargs...) } diff --git a/internal/cli/client_grants.go b/internal/cli/client_grants.go new file mode 100644 index 000000000..751ab929a --- /dev/null +++ b/internal/cli/client_grants.go @@ -0,0 +1,1104 @@ +package cli + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/AlecAivazis/survey/v2" + "github.com/auth0/go-auth0/management" + managementv3 "github.com/auth0/go-auth0/v3/management" + "github.com/spf13/cobra" + + "github.com/auth0/auth0-cli/internal/ansi" + "github.com/auth0/auth0-cli/internal/auth0" + "github.com/auth0/auth0-cli/internal/prompt" +) + +var clientGrantOrganizationUsageOptions = []string{"deny", "allow", "require"} + +var ( + clientGrantID = Argument{ + Name: "Id", + Help: "Id of the client grant.", + } + clientGrantClientID = Flag{ + Name: "Client ID", + LongForm: "client-id", + ShortForm: "c", + Help: "Client ID of the application to authorize. Cannot be changed once set. Mutually exclusive with --default-for.", + } + clientGrantDefaultFor = Flag{ + Name: "Default For", + LongForm: "default-for", + Help: "Make this the default grant for a group of clients instead of authorizing a specific client. Mutually exclusive with --client-id. Possible value: third_party_clients.", + } + clientGrantAuthorizationDetailsTypes = Flag{ + Name: "Authorization Details Types", + LongForm: "authorization-details-types", + Help: "Comma-separated list of authorization_details types allowed for this grant (Rich Authorization Requests).", + AlwaysPrompt: true, + } + clientGrantAudience = Flag{ + Name: "Audience", + LongForm: "audience", + ShortForm: "a", + Help: "Audience (API identifier) of the client grant. Cannot be changed once set.", + IsRequired: true, + } + clientGrantScopes = Flag{ + Name: "Scopes", + LongForm: "scopes", + ShortForm: "s", + Help: "Comma-separated list of scopes (permissions) to grant.", + AlwaysPrompt: true, + } + clientGrantAllowAllScopes = Flag{ + Name: "Allow All Scopes", + LongForm: "allow-all-scopes", + Help: "Grant every scope configured on the API. Mutually exclusive with --scopes.", + } + clientGrantOrganizationUsage = Flag{ + Name: "Organization Usage", + LongForm: "organization-usage", + ShortForm: "o", + Help: "Whether organizations can be used with this grant. Possible values: " + strings.Join(clientGrantOrganizationUsageOptions, ", ") + ".", + AlwaysPrompt: true, + } + clientGrantAllowAnyOrganization = Flag{ + Name: "Allow Any Organization", + LongForm: "allow-any-organization", + Help: "Whether any organization can be used with this grant (true) or only explicitly assigned organizations (false).", + AlwaysPrompt: true, + } + clientGrantSubjectType = Flag{ + Name: "Subject Type", + LongForm: "subject-type", + Help: "Subject type of the grant. Cannot be changed once set. Possible values: " + strings.Join(clientGrantSubjectTypeOptions, ", ") + ".", + } + clientGrantNumber = Flag{ + Name: "Number", + LongForm: "number", + ShortForm: "n", + Help: "Number of client grants to retrieve. Minimum 1, maximum 1000.", + } + + clientGrantFilterClientID = Flag{ + Name: "Client ID", + LongForm: "client-id", + ShortForm: "c", + Help: "Filter by client ID. Mutually exclusive with --default-for.", + } + clientGrantFilterAudience = Flag{ + Name: "Audience", + LongForm: "audience", + ShortForm: "a", + Help: "Filter by audience (API identifier).", + } + clientGrantFilterSubjectType = Flag{ + Name: "Subject Type", + LongForm: "subject-type", + Help: "Filter by subject type. Possible values: " + strings.Join(clientGrantSubjectTypeOptions, ", ") + ".", + } + clientGrantFilterDefaultFor = Flag{ + Name: "Default For", + LongForm: "default-for", + Help: "Filter by the group this grant is the default for. Possible value: third_party_clients. Mutually exclusive with --client-id.", + } + clientGrantFilterAllowAnyOrganization = Flag{ + Name: "Allow Any Organization", + LongForm: "allow-any-organization", + Help: "Filter by whether any organization can be used with the grant (true) or only explicitly assigned organizations (false).", + } +) + +var clientGrantSubjectTypeOptions = []string{"client", "user", "anonymous_user"} + +var clientGrantDefaultForOptions = []string{"third_party_clients"} + +// managementAPIUserScopesNote explains that, for a user subject type against the +// Auth0 Management API, the scopes are a fixed current_user set that the API +// does not expose for dynamic discovery, so they have to be passed inline with +// --scopes rather than picked interactively. +const managementAPIUserScopesNote = "Note: for the Auth0 Management API with `--subject-type user`, scopes must be a " + + "subset of the fixed current_user set and cannot be listed dynamically, so pass them inline, " + + "for example: `--scopes \"read:current_user,update:current_user_metadata,delete:current_user_metadata," + + "create:current_user_metadata,create:current_user_device_credentials,delete:current_user_device_credentials," + + "update:current_user_identities\"`." + +func clientGrantsCmd(cli *cli) *cobra.Command { + cmd := &cobra.Command{ + Use: "client-grants", + Short: "Manage client grants", + Long: "Manage client grants. A client grant authorizes an application (client) to request access tokens for an API (audience), optionally scoped to specific permissions or organizations.", + Aliases: []string{"grants"}, + } + + cmd.SetUsageTemplate(resourceUsageTemplate()) + cmd.AddCommand(listClientGrantsCmd(cli)) + cmd.AddCommand(createClientGrantCmd(cli)) + cmd.AddCommand(showClientGrantCmd(cli)) + cmd.AddCommand(updateClientGrantCmd(cli)) + cmd.AddCommand(deleteClientGrantCmd(cli)) + + return cmd +} + +func listClientGrantsCmd(cli *cli) *cobra.Command { + var inputs struct { + Number int + ClientID string + Audience string + SubjectType string + DefaultFor string + AllowAnyOrganization bool + } + + cmd := &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Args: cobra.NoArgs, + Short: "List your client grants", + Long: "List your existing client grants. To create one, run: `auth0 client-grants create`.\n\n" + + "Use the filter flags to narrow the results server-side by client, audience, subject type, " + + "default group or organization usage.", + Example: ` auth0 client-grants list + auth0 client-grants ls + auth0 client-grants ls --number 100 + auth0 client-grants ls --audience + auth0 client-grants ls --client-id --subject-type client + auth0 client-grants ls --default-for third_party_clients + auth0 client-grants ls --allow-any-organization=true + auth0 client-grants ls -n 100 --json`, + RunE: func(cmd *cobra.Command, args []string) error { + if inputs.Number < 1 || inputs.Number > 1000 { + return fmt.Errorf("number flag invalid, please pass a number between 1 and 1000") + } + + // The API requires a client_id, audience or default_for filter + // alongside subject_type; catch it early with a clearer message. + if inputs.SubjectType != "" && inputs.ClientID == "" && inputs.Audience == "" && inputs.DefaultFor == "" { + return fmt.Errorf("--subject-type must be combined with --client-id, --audience or --default-for") + } + + request := &managementv3.ListClientGrantsRequestParameters{} + if inputs.ClientID != "" { + request.ClientID = &inputs.ClientID + } + if inputs.Audience != "" { + request.Audience = &inputs.Audience + } + if inputs.SubjectType != "" { + subjectType, err := managementv3.NewClientGrantSubjectTypeEnumFromString(inputs.SubjectType) + if err != nil { + return err + } + request.SubjectType = &subjectType + } + if inputs.DefaultFor != "" { + defaultFor, err := managementv3.NewClientGrantDefaultForEnumFromString(inputs.DefaultFor) + if err != nil { + return err + } + request.DefaultFor = &defaultFor + } + if clientGrantFilterAllowAnyOrganization.IsSet(cmd) { + request.AllowAnyOrganization = &inputs.AllowAnyOrganization + } + + var grants []*managementv3.ClientGrantResponseContent + if err := ansi.Waiting(func() error { + page, err := cli.apiv3.ClientGrant.List(cmd.Context(), request) + if err != nil { + return err + } + + iter := page.Iterator() + for iter.Next(cmd.Context()) { + grants = append(grants, iter.Current()) + if len(grants) >= inputs.Number { + break + } + } + return iter.Err() + }); err != nil { + return fmt.Errorf("failed to list client grants: %w", err) + } + + cli.renderer.ClientGrantList(grants) + + return nil + }, + } + + cmd.Flags().BoolVar(&cli.json, "json", false, "Output in json format.") + cmd.Flags().BoolVar(&cli.jsonCompact, "json-compact", false, "Output in compact json format.") + cmd.MarkFlagsMutuallyExclusive("json", "json-compact") + + clientGrantNumber.RegisterInt(cmd, &inputs.Number, defaultPageSize) + clientGrantFilterClientID.RegisterString(cmd, &inputs.ClientID, "") + clientGrantFilterAudience.RegisterString(cmd, &inputs.Audience, "") + clientGrantFilterSubjectType.RegisterString(cmd, &inputs.SubjectType, "") + clientGrantFilterDefaultFor.RegisterString(cmd, &inputs.DefaultFor, "") + clientGrantFilterAllowAnyOrganization.RegisterBool(cmd, &inputs.AllowAnyOrganization, false) + + // The API rejects client_id and default_for together. + cmd.MarkFlagsMutuallyExclusive("client-id", "default-for") + + return cmd +} + +func showClientGrantCmd(cli *cli) *cobra.Command { + var inputs struct { + ID string + } + + cmd := &cobra.Command{ + Use: "show", + Args: cobra.MaximumNArgs(1), + Short: "Show a client grant", + Long: "Display the client, audience, scopes, and other information about a client grant.", + Example: ` auth0 client-grants show + auth0 client-grants show + auth0 client-grants show --json + auth0 client-grants show --json-compact`, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + if err := clientGrantID.Pick(cmd, &inputs.ID, cli.clientGrantPickerOptions); err != nil { + return err + } + } else { + inputs.ID = args[0] + } + + var grant *managementv3.GetClientGrantResponseContent + if err := ansi.Waiting(func() (err error) { + grant, err = cli.apiv3.ClientGrant.Get(cmd.Context(), inputs.ID) + return err + }); err != nil { + return fmt.Errorf("failed to read client grant with ID %q: %w", inputs.ID, err) + } + + cli.renderer.ClientGrantShow(grant) + + return nil + }, + } + + cmd.Flags().BoolVar(&cli.json, "json", false, "Output in json format.") + cmd.Flags().BoolVar(&cli.jsonCompact, "json-compact", false, "Output in compact json format.") + cmd.MarkFlagsMutuallyExclusive("json", "json-compact") + + return cmd +} + +// Target-selection modes offered before the client-id or default-for prompt. +const ( + clientGrantTargetClient = "A specific client" + clientGrantTargetDefault = "Default for a group of clients" +) + +func createClientGrantCmd(cli *cli) *cobra.Command { + var inputs struct { + ClientID string + DefaultFor string + Audience string + Scopes []string + AllowAllScopes bool + OrganizationUsage string + AllowAnyOrganization bool + SubjectType string + AuthorizationDetailsTypes []string + } + + cmd := &cobra.Command{ + Use: "create", + Args: cobra.NoArgs, + Short: "Create a new client grant", + Long: "Create a new client grant.\n\n" + + "To create interactively, use `auth0 client-grants create` with no flags.\n\n" + + "To create non-interactively, supply the audience and either a client id (`--client-id`) " + + "or a default group (`--default-for`), which are mutually exclusive, along with any optional " + + "scopes or organization settings through the flags. A grant can authorize specific " + + "scopes (`--scopes`), every scope on the API (`--allow-all-scopes`), or no scopes at all.\n\n" + + managementAPIUserScopesNote, + Example: ` auth0 client-grants create + auth0 client-grants create --client-id --audience + auth0 client-grants create --default-for third_party_clients --audience + auth0 client-grants create --client-id --audience --scopes "read:users,update:users" + auth0 client-grants create --client-id --audience --allow-all-scopes + auth0 client-grants create --client-id --audience --authorization-details-types "payment,transfer" + auth0 client-grants create -c -a -s "read:users" -o require --allow-any-organization=false + auth0 client-grants create -c -a --subject-type user + auth0 client-grants create -c -a --json`, + RunE: func(cmd *cobra.Command, args []string) error { + // A grant authorizes either a specific client or a default group, + // never both. When neither flag was passed and we can prompt, ask + // which the grant should target, then prompt for that target. When a + // flag was passed we skip the prompts and honor it directly. + if !clientGrantClientID.IsSet(cmd) && !clientGrantDefaultFor.IsSet(cmd) && canPrompt(cmd) { + if err := cli.pickClientGrantTarget(cmd, &inputs.ClientID, &inputs.DefaultFor); err != nil { + return err + } + } + + if inputs.ClientID == "" && inputs.DefaultFor == "" { + return errors.New("one of --client-id or --default-for must be set") + } + + // A default grant is a template for a group of clients rather than an + // authorization for a specific client, so subject type and organization + // settings do not apply to it. The API rejects them, so skip those + // prompts and never send those fields for a default grant. + isDefaultGrant := inputs.DefaultFor != "" + + // Auth0 rejects a default grant against a system API, so hide system + // APIs from the audience picker for a default grant. + audiencePicker := cli.apiIdentifierPickerOptions + if isDefaultGrant { + audiencePicker = cli.nonSystemAPIIdentifierPickerOptions + } + if err := clientGrantAudience.Pick(cmd, &inputs.Audience, audiencePicker); err != nil { + return err + } + + // Reject subject type or organization flags passed for a default grant + // (matching the API) rather than silently dropping them. + if isDefaultGrant { + for _, f := range []*Flag{&clientGrantSubjectType, &clientGrantOrganizationUsage, &clientGrantAllowAnyOrganization} { + if f.IsSet(cmd) { + return fmt.Errorf("--%s cannot be set with --default-for", f.LongForm) + } + } + } + + if !isDefaultGrant { + defaultSubjectType := clientGrantSubjectTypeOptions[0] + if err := clientGrantSubjectType.Select(cmd, &inputs.SubjectType, clientGrantSubjectTypeOptions, &defaultSubjectType); err != nil { + return err + } + } + + // The scope and authorization_details pickers both read the audience + // API, so read it once here and share it between them rather than + // hitting the API twice. The same read tells us whether the audience + // is a system API, which cannot carry organization settings. + askScopes := !clientGrantAllowAllScopes.IsSet(cmd) && shouldAsk(cmd, &clientGrantScopes, false) + askAuthDetailsTypes := shouldAsk(cmd, &clientGrantAuthorizationDetailsTypes, false) + var audienceIsSystemAPI bool + if askScopes || askAuthDetailsTypes { + audienceAPI, err := cli.readClientGrantAudienceAPI(cmd.Context(), inputs.Audience) + if err != nil { + return err + } + audienceIsSystemAPI = audienceAPI.GetIsSystem() + + // When neither scope flag was passed, ask how to grant scopes + // (all of them, a specific set, or none) and, for a specific set, + // show a multi-select scoped to the chosen audience so the user + // only picks from scopes that API actually defines. + if askScopes { + if err := cli.pickClientGrantScopes(audienceAPI, &inputs.Scopes, &inputs.AllowAllScopes, nil, nil, false, true); err != nil { + return err + } + } + + // The authorization_details types are defined on the audience API, + // so offer a multi-select of them (skipping silently when the API + // has none) rather than making the user recall the exact strings. + if askAuthDetailsTypes { + if err := cli.pickClientGrantAuthorizationDetailsTypes(audienceAPI, &inputs.AuthorizationDetailsTypes, nil); err != nil { + return err + } + } + } + + // Organizations cannot be used with a default grant, with the user or + // anonymous_user subject types, or against a system API (which rejects + // any organization settings), so skip the organization prompts for them. + if !isDefaultGrant && !audienceIsSystemAPI && clientGrantSubjectTypeAllowsOrganizations(inputs.SubjectType) { + if err := clientGrantOrganizationUsage.Select(cmd, &inputs.OrganizationUsage, clientGrantOrganizationUsageOptions, nil); err != nil { + return err + } + + // Allowing any organization only applies when organizations can be + // used with the grant, so only ask for it when organization usage + // is allow or require. On deny (the default) it must stay false. + if clientGrantOrganizationAllowsAny(inputs.OrganizationUsage) { + if err := clientGrantAllowAnyOrganization.AskBool(cmd, &inputs.AllowAnyOrganization, nil); err != nil { + return err + } + } + } + + if err := validateClientGrantSubjectType(inputs.SubjectType, inputs.OrganizationUsage, inputs.AllowAnyOrganization); err != nil { + return err + } + + if err := validateClientGrantOrganization(inputs.OrganizationUsage, inputs.AllowAnyOrganization); err != nil { + return err + } + + grant := &managementv3.CreateClientGrantRequestContent{ + Audience: inputs.Audience, + } + + // A grant targets either a specific client or a default group, so + // only send the one that was provided. + if inputs.ClientID != "" { + grant.ClientID = &inputs.ClientID + } + if inputs.DefaultFor != "" { + defaultFor, err := managementv3.NewClientGrantDefaultForEnumFromString(inputs.DefaultFor) + if err != nil { + return err + } + grant.DefaultFor = &defaultFor + } + + if len(inputs.AuthorizationDetailsTypes) > 0 { + grant.AuthorizationDetailsTypes = inputs.AuthorizationDetailsTypes + } + + if inputs.AllowAllScopes { + grant.AllowAllScopes = auth0.Bool(true) + } else { + // Send the scope explicitly, even when empty, so a grant with no + // scopes serializes as "scope": [] rather than being omitted or + // sent as null, both of which the API rejects. A nil slice + // marshals to null, so normalize it to a non-nil empty slice. + scopes := inputs.Scopes + if scopes == nil { + scopes = []string{} + } + grant.SetScope(scopes) + } + + if inputs.SubjectType != "" { + subjectType, err := managementv3.NewClientGrantSubjectTypeEnumFromString(inputs.SubjectType) + if err != nil { + return err + } + grant.SubjectType = &subjectType + } + + // Organization settings cannot be sent for a default grant, for the + // user or anonymous_user subject types, or against a system API, so + // only attach them when the grant targets a specific client with a + // subject type that allows it. + if !isDefaultGrant && !audienceIsSystemAPI && clientGrantSubjectTypeAllowsOrganizations(inputs.SubjectType) { + if inputs.OrganizationUsage != "" { + organizationUsage, err := managementv3.NewClientGrantOrganizationUsageEnumFromString(inputs.OrganizationUsage) + if err != nil { + return err + } + grant.OrganizationUsage = &organizationUsage + } + + // Send allow_any_organization only when the user engaged with + // organization settings, either by passing the flag or by choosing + // an organization usage (which is what the interactive prompt sets, + // so the answer is not dropped). A grant that never touches + // organizations must not carry a stray false, which the API rejects + // for reserved-identifier audiences. + if inputs.OrganizationUsage != "" || clientGrantAllowAnyOrganization.IsSet(cmd) { + grant.AllowAnyOrganization = &inputs.AllowAnyOrganization + } + } + + // Describe the grant by whichever target it authorizes, so the error + // reads sensibly for both a specific client and a default group. + target := fmt.Sprintf("client %q", inputs.ClientID) + if inputs.ClientID == "" { + target = fmt.Sprintf("default group %q", inputs.DefaultFor) + } + + var created *managementv3.CreateClientGrantResponseContent + if err := ansi.Waiting(func() (err error) { + created, err = cli.apiv3.ClientGrant.Create(cmd.Context(), grant) + return err + }); err != nil { + return fmt.Errorf( + "failed to create client grant for %s and audience %q: %w", + target, + inputs.Audience, + err, + ) + } + + cli.renderer.ClientGrantCreate(created) + + return nil + }, + } + + cmd.Flags().BoolVar(&cli.json, "json", false, "Output in json format.") + cmd.Flags().BoolVar(&cli.jsonCompact, "json-compact", false, "Output in compact json format.") + cmd.MarkFlagsMutuallyExclusive("json", "json-compact") + clientGrantClientID.RegisterString(cmd, &inputs.ClientID, "") + clientGrantDefaultFor.RegisterString(cmd, &inputs.DefaultFor, "") + clientGrantAudience.RegisterString(cmd, &inputs.Audience, "") + clientGrantScopes.RegisterStringSlice(cmd, &inputs.Scopes, nil) + clientGrantAllowAllScopes.RegisterBool(cmd, &inputs.AllowAllScopes, false) + clientGrantOrganizationUsage.RegisterString(cmd, &inputs.OrganizationUsage, "") + clientGrantAllowAnyOrganization.RegisterBool(cmd, &inputs.AllowAnyOrganization, false) + clientGrantSubjectType.RegisterString(cmd, &inputs.SubjectType, "") + clientGrantAuthorizationDetailsTypes.RegisterStringSlice(cmd, &inputs.AuthorizationDetailsTypes, nil) + + // A grant authorizes either specific scopes or all of them, never both. + cmd.MarkFlagsMutuallyExclusive("scopes", "allow-all-scopes") + + // A grant targets either a specific client or a default group, never both. + cmd.MarkFlagsMutuallyExclusive("client-id", "default-for") + + return cmd +} + +func updateClientGrantCmd(cli *cli) *cobra.Command { + var inputs struct { + ID string + Scopes []string + AllowAllScopes bool + NoScopes bool + OrganizationUsage string + AllowAnyOrganization bool + AuthorizationDetailsTypes []string + } + + cmd := &cobra.Command{ + Use: "update", + Args: cobra.MaximumNArgs(1), + Short: "Update a client grant", + Long: "Update a client grant.\n\n" + + "To update interactively, use `auth0 client-grants update` with no arguments.\n\n" + + "The client id and audience of a grant cannot be changed. To update non-interactively, " + + "supply the scopes or organization settings through the flags. Pass `--allow-all-scopes` " + + "to grant every scope on the API instead of a specific list.\n\n" + + managementAPIUserScopesNote, + Example: ` auth0 client-grants update + auth0 client-grants update + auth0 client-grants update --scopes "read:users,update:users" + auth0 client-grants update --allow-all-scopes + auth0 client-grants update --authorization-details-types "payment,transfer" + auth0 client-grants update -s "read:users" -o require --allow-any-organization=false + auth0 client-grants update --json`, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + if err := clientGrantID.Pick(cmd, &inputs.ID, cli.mutableClientGrantPickerOptions); err != nil { + return err + } + } else { + inputs.ID = args[0] + } + + var current *managementv3.GetClientGrantResponseContent + if err := ansi.Waiting(func() (err error) { + current, err = cli.apiv3.ClientGrant.Get(cmd.Context(), inputs.ID) + return err + }); err != nil { + return fmt.Errorf("failed to find client grant with ID %q: %w", inputs.ID, err) + } + + // Auth0 rejects updating a system grant, so fail before running the + // interactive flow rather than after the user has clicked through it. + if current.GetIsSystem() { + return fmt.Errorf("client grant with ID %q is a system grant and cannot be updated", inputs.ID) + } + + // Audience is immutable, so the scope and authorization_details + // pickers both read the grant's existing audience API. Read it once + // here and share it between them rather than hitting the API twice. + // The same read tells us whether the audience is a system API, which + // cannot carry organization settings. + askScopes := !clientGrantAllowAllScopes.IsSet(cmd) && shouldAsk(cmd, &clientGrantScopes, true) + askAuthDetailsTypes := shouldAsk(cmd, &clientGrantAuthorizationDetailsTypes, true) + var audienceIsSystemAPI bool + if askScopes || askAuthDetailsTypes { + audienceAPI, err := cli.readClientGrantAudienceAPI(cmd.Context(), current.GetAudience()) + if err != nil { + return err + } + audienceIsSystemAPI = audienceAPI.GetIsSystem() + + // Default the scopes mode and selection to the grant's current + // state, keeping the flow in sync with create. + if askScopes { + if err := cli.pickClientGrantScopes(audienceAPI, &inputs.Scopes, &inputs.AllowAllScopes, &inputs.NoScopes, current.GetScope(), current.GetAllowAllScopes(), true); err != nil { + return err + } + } + + // Offer the authorization_details types defined on the API + // (skipping silently when it has none), pre-selecting the grant's + // current types. + if askAuthDetailsTypes { + if err := cli.pickClientGrantAuthorizationDetailsTypes(audienceAPI, &inputs.AuthorizationDetailsTypes, current.GetAuthorizationDetailsTypes()); err != nil { + return err + } + } + } + + // Organizations cannot be used with the user or anonymous_user subject + // types, or against a system API (which rejects any organization + // settings), so skip the organization prompts entirely for them. The + // subject type is immutable, so it comes from the existing grant. + subjectType := string(current.GetSubjectType()) + if !audienceIsSystemAPI && clientGrantSubjectTypeAllowsOrganizations(subjectType) { + if err := clientGrantOrganizationUsage.SelectU(cmd, &inputs.OrganizationUsage, clientGrantOrganizationUsageOptions, stringPtr(current.OrganizationUsage)); err != nil { + return err + } + + if !clientGrantAllowAnyOrganization.IsSet(cmd) { + inputs.AllowAnyOrganization = current.GetAllowAnyOrganization() + } + + // The effective organization usage is the new value when supplied, + // otherwise whatever the grant already has (which we leave untouched). + effectiveOrganizationUsage := inputs.OrganizationUsage + if effectiveOrganizationUsage == "" { + effectiveOrganizationUsage = string(current.GetOrganizationUsage()) + } + + // Allowing any organization only applies when organizations can be + // used with the grant, so only ask for it when organization usage + // is allow or require. On deny it must stay false. + if clientGrantOrganizationAllowsAny(effectiveOrganizationUsage) { + if err := clientGrantAllowAnyOrganization.AskBoolU(cmd, &inputs.AllowAnyOrganization, current.AllowAnyOrganization); err != nil { + return err + } + } + + if err := validateClientGrantOrganization(effectiveOrganizationUsage, inputs.AllowAnyOrganization); err != nil { + return err + } + } + + // Catch organization flags passed for a subject type that cannot use + // organizations (matching create), turning the API 400 into a clear + // message on the non-interactive path. + if err := validateClientGrantSubjectType(subjectType, inputs.OrganizationUsage, inputs.AllowAnyOrganization); err != nil { + return err + } + + grant := &managementv3.UpdateClientGrantRequestContent{} + + if inputs.NoScopes { + // The user explicitly cleared the scopes. Send them with SetScope + // so an empty list serializes as "scope": [] rather than being + // omitted (which would leave the existing scopes untouched). A nil + // slice marshals to null, so normalize it to a non-nil empty slice. + grant.SetScope([]string{}) + if current.GetAllowAllScopes() { + grant.AllowAllScopes = auth0.Bool(false) + } + } else { + grant.Scope, grant.AllowAllScopes = resolveUpdateClientGrantScopes( + inputs.Scopes, + inputs.AllowAllScopes, + current.GetScope(), + current.GetAllowAllScopes(), + ) + } + + // Organization settings cannot be sent for the user or anonymous_user + // subject types, or against a system API, so only attach them when the + // subject type allows it and the audience is not a system API. + if !audienceIsSystemAPI && clientGrantSubjectTypeAllowsOrganizations(subjectType) { + grant.AllowAnyOrganization = &inputs.AllowAnyOrganization + + if inputs.OrganizationUsage != "" { + organizationUsage, err := managementv3.NewClientGrantOrganizationNullableUsageEnumFromString(inputs.OrganizationUsage) + if err != nil { + return err + } + grant.OrganizationUsage = &organizationUsage + } + } + + if len(inputs.AuthorizationDetailsTypes) > 0 { + grant.AuthorizationDetailsTypes = inputs.AuthorizationDetailsTypes + } + + var updated *managementv3.UpdateClientGrantResponseContent + if err := ansi.Waiting(func() (err error) { + updated, err = cli.apiv3.ClientGrant.Update(cmd.Context(), inputs.ID, grant) + return err + }); err != nil { + return fmt.Errorf("failed to update client grant with ID %q: %w", inputs.ID, err) + } + + cli.renderer.ClientGrantUpdate(updated) + + return nil + }, + } + + cmd.Flags().BoolVar(&cli.json, "json", false, "Output in json format.") + cmd.Flags().BoolVar(&cli.jsonCompact, "json-compact", false, "Output in compact json format.") + cmd.MarkFlagsMutuallyExclusive("json", "json-compact") + clientGrantScopes.RegisterStringSliceU(cmd, &inputs.Scopes, nil) + clientGrantAllowAllScopes.RegisterBoolU(cmd, &inputs.AllowAllScopes, false) + clientGrantOrganizationUsage.RegisterStringU(cmd, &inputs.OrganizationUsage, "") + clientGrantAllowAnyOrganization.RegisterBoolU(cmd, &inputs.AllowAnyOrganization, false) + clientGrantAuthorizationDetailsTypes.RegisterStringSliceU(cmd, &inputs.AuthorizationDetailsTypes, nil) + + // A grant authorizes either specific scopes or all of them, never both. + cmd.MarkFlagsMutuallyExclusive("scopes", "allow-all-scopes") + + return cmd +} + +func deleteClientGrantCmd(cli *cli) *cobra.Command { + cmd := &cobra.Command{ + Use: "delete", + Aliases: []string{"rm"}, + Short: "Delete a client grant", + Long: "Delete a client grant.\n\n" + + "To delete interactively, use `auth0 client-grants delete` with no arguments.\n\n" + + "To delete non-interactively, supply the client grant id and the `--force` flag to skip confirmation.", + Example: ` auth0 client-grants delete + auth0 client-grants rm + auth0 client-grants delete + auth0 client-grants delete --force + auth0 client-grants delete + auth0 client-grants delete --force`, + RunE: func(cmd *cobra.Command, args []string) error { + var ids []string + if len(args) == 0 { + if err := clientGrantID.PickMany(cmd, &ids, cli.mutableClientGrantPickerOptions); err != nil { + return err + } + } else { + ids = append(ids, args...) + } + + if !cli.force && canPrompt(cmd) { + if confirmed := prompt.Confirm("Are you sure you want to proceed?"); !confirmed { + return nil + } + } + + return ansi.ProgressBar("Deleting client grant(s)", ids, func(_ int, id string) error { + current, err := cli.apiv3.ClientGrant.Get(cmd.Context(), id) + if err != nil { + return fmt.Errorf("failed to delete client grant with ID %q: %w", id, err) + } + + // Auth0 rejects deleting a system grant, so surface a clear + // message rather than the raw API error. + if current.GetIsSystem() { + return fmt.Errorf("client grant with ID %q is a system grant and cannot be deleted", id) + } + + if err := cli.apiv3.ClientGrant.Delete(cmd.Context(), id); err != nil { + return fmt.Errorf("failed to delete client grant with ID %q: %w", id, err) + } + return nil + }) + }, + } + + cmd.Flags().BoolVar(&cli.force, "force", false, "Skip confirmation.") + + return cmd +} + +func (c *cli) clientGrantPickerOptions(ctx context.Context) (pickerOptions, error) { + return c.clientGrantPickerOptionsFiltered(ctx, false) +} + +// mutableClientGrantPickerOptions lists only the grants that can actually be +// changed, dropping system grants. Auth0 rejects updating or deleting a system +// grant, so offering them in the update/delete pickers would only lead to a +// late API error on a grant the user can never modify. +func (c *cli) mutableClientGrantPickerOptions(ctx context.Context) (pickerOptions, error) { + return c.clientGrantPickerOptionsFiltered(ctx, true) +} + +func (c *cli) clientGrantPickerOptionsFiltered(ctx context.Context, excludeSystem bool) (pickerOptions, error) { + // Fetch only the first page, matching the apis/apps pickers. This keeps the + // picker fast to open on large tenants; if a grant is not on the first page, + // the user can pass its id directly. + page, err := c.apiv3.ClientGrant.List(ctx, &managementv3.ListClientGrantsRequestParameters{}) + if err != nil { + return nil, fmt.Errorf("failed to list client grants: %w", err) + } + + var opts pickerOptions + for _, grant := range page.Results { + if excludeSystem && grant.GetIsSystem() { + continue + } + + identifier := grant.GetClientID() + if identifier == "" { + identifier = string(grant.GetDefaultFor()) + } + + label := fmt.Sprintf("%s %s", identifier, ansi.Faint("("+grant.GetAudience()+")")) + opts = append(opts, pickerOption{value: grant.GetID(), label: label}) + } + + if len(opts) == 0 { + return nil, errors.New("there are currently no client grants to choose from. Create one by running: `auth0 client-grants create`") + } + + return opts, nil +} + +// clientGrantSubjectTypeAllowsOrganizations reports whether organizations can be +// used with the given subject type. Only client grants (the default) support +// organization settings; the API rejects them for the user and anonymous_user +// subject types. +func clientGrantSubjectTypeAllowsOrganizations(subjectType string) bool { + return subjectType != "user" && subjectType != "anonymous_user" +} + +// validateClientGrantSubjectType catches the API rule that organizations cannot +// be used with the user or anonymous_user subject types, turning the raw 400 +// into a clear, actionable message for the non-interactive path. +func validateClientGrantSubjectType(subjectType, organizationUsage string, allowAnyOrganization bool) error { + if !clientGrantSubjectTypeAllowsOrganizations(subjectType) && (organizationUsage != "" || allowAnyOrganization) { + return fmt.Errorf("--organization-usage and --allow-any-organization cannot be set when --subject-type is %q", subjectType) + } + return nil +} + +// clientGrantOrganizationAllowsAny reports whether allow_any_organization is +// meaningful for the given organization usage. The API only accepts a true +// allow_any_organization when organization usage is allow or require. +func clientGrantOrganizationAllowsAny(organizationUsage string) bool { + return organizationUsage == "allow" || organizationUsage == "require" +} + +// validateClientGrantOrganization catches the API rule that allow_any_organization +// may only be true when organization_usage is allow or require, turning the raw +// 400 into a clear, actionable message. +func validateClientGrantOrganization(organizationUsage string, allowAnyOrganization bool) error { + if allowAnyOrganization && !clientGrantOrganizationAllowsAny(organizationUsage) { + return errors.New("--allow-any-organization can only be enabled when --organization-usage is 'allow' or 'require'") + } + return nil +} + +// resolveUpdateClientGrantScopes computes the scope and allow_all_scopes fields +// for a client-grant update. Choosing specific scopes and allowing every scope +// are mutually exclusive, so new scopes win, then an explicit allow-all, +// otherwise the grant keeps whatever it already authorizes (so a scope-only +// edit never drops an existing allow_all_scopes grant). When moving to specific +// scopes it also clears allow_all_scopes, because the API rejects scope while +// allow_all_scopes is still true. A nil allowAllScopes means the field is left +// unset on the request. +func resolveUpdateClientGrantScopes(newScopes []string, newAllowAll bool, currentScopes []string, currentAllowAll bool) (scope []string, allowAllScopes *bool) { + switch { + case len(newScopes) != 0: + if currentAllowAll { + return newScopes, auth0.Bool(false) + } + return newScopes, nil + case newAllowAll, currentAllowAll: + return nil, auth0.Bool(true) + default: + return currentScopes, nil + } +} + +// apiIdentifierPickerOptions lists the tenant APIs for the audience picker. A +// client grant's audience is the API identifier, so the picker value is the +// identifier rather than the API id used by the apis command's own picker. +func (c *cli) apiIdentifierPickerOptions(ctx context.Context) (pickerOptions, error) { + return c.apiIdentifierPickerOptionsFiltered(ctx, false) +} + +// nonSystemAPIIdentifierPickerOptions lists only non-system tenant APIs for the +// audience picker. Auth0 rejects a default client grant that targets a system +// API, so offering one in the default-grant flow would only lead to a late API +// error on an audience that can never be used for a default grant. +func (c *cli) nonSystemAPIIdentifierPickerOptions(ctx context.Context) (pickerOptions, error) { + return c.apiIdentifierPickerOptionsFiltered(ctx, true) +} + +func (c *cli) apiIdentifierPickerOptionsFiltered(ctx context.Context, excludeSystem bool) (pickerOptions, error) { + list, err := c.api.ResourceServer.List(ctx) + if err != nil { + return nil, fmt.Errorf("failed to list APIs: %w", err) + } + + var opts pickerOptions + for _, r := range list.ResourceServers { + if excludeSystem && r.GetIsSystem() { + continue + } + + // Some APIs have no name, so fall back to a placeholder so the row + // keeps the same "name (identifier)" shape as every other option. + name := r.GetName() + if name == "" { + name = "custom API" + } + label := fmt.Sprintf("%s %s", name, ansi.Faint("("+r.GetIdentifier()+")")) + opts = append(opts, pickerOption{value: r.GetIdentifier(), label: label}) + } + + if len(opts) == 0 { + return nil, errors.New("there are currently no APIs to choose from. Create one by running: `auth0 apis create`") + } + + return opts, nil +} + +// pickClientGrantTarget drives the interactive choice of what a new grant +// authorizes: a specific client or a default group of clients. It first asks +// which of the two to target and then prompts for that target, writing the +// answer into clientID or defaultFor. The default-group values come from +// clientGrantDefaultForOptions, so new groups become selectable here without +// touching this flow. +func (c *cli) pickClientGrantTarget(cmd *cobra.Command, clientID, defaultFor *string) error { + var target string + targetPrompt := &survey.Select{ + Message: "What should this grant authorize?", + Options: []string{clientGrantTargetClient, clientGrantTargetDefault}, + Default: clientGrantTargetClient, + } + if err := survey.AskOne(targetPrompt, &target); err != nil { + return err + } + + if target == clientGrantTargetDefault { + defaultDefaultFor := clientGrantDefaultForOptions[0] + return clientGrantDefaultFor.Select(cmd, defaultFor, clientGrantDefaultForOptions, &defaultDefaultFor) + } + + return clientGrantClientID.Ask(cmd, clientID, nil) +} + +// Scope-selection modes offered before the scopes multi-select. +const ( + clientGrantScopesModeSpecific = "Select specific scopes" + clientGrantScopesModeAll = "Always grant all permissions" + clientGrantScopesModeNone = "No scopes (grant a token with no permissions)" +) + +// readClientGrantAudienceAPI reads the API (resource server) a grant's audience +// points at. The scope and authorization_details pickers both draw their options +// from this same API, so the caller reads it once and passes it to both, keeping +// the interactive flow to a single API read instead of one per picker. +func (c *cli) readClientGrantAudienceAPI(ctx context.Context, audience string) (*management.ResourceServer, error) { + var resourceServer *management.ResourceServer + if err := ansi.Waiting(func() (err error) { + resourceServer, err = c.api.ResourceServer.Read(ctx, audience) + return err + }); err != nil { + return nil, fmt.Errorf("failed to read the API %q: %w", audience, err) + } + return resourceServer, nil +} + +// pickClientGrantScopes drives the interactive scope selection for a grant. It +// first asks how to grant scopes (every scope on the API, a specific set, or +// none when allowNone is set) and, for a specific set, shows a multi-select of +// the scopes the API defines, writing the chosen scopes into result. Any current +// scopes not defined by the API are still offered (and pre-selected) so an update +// never silently drops a scope already on the grant. When the API has no scopes +// at all, it warns and leaves the inputs untouched (an empty scope list, which +// the API accepts). +func (c *cli) pickClientGrantScopes(resourceServer *management.ResourceServer, result *[]string, allowAllScopes, noScopes *bool, currentScopes []string, currentAllowAll, allowNone bool) error { + options := make([]string, 0, len(resourceServer.GetScopes())) + seen := make(map[string]bool) + for _, scope := range resourceServer.GetScopes() { + options = append(options, scope.GetValue()) + seen[scope.GetValue()] = true + } + for _, scope := range currentScopes { + if !seen[scope] { + options = append(options, scope) + seen[scope] = true + } + } + + if len(options) == 0 { + c.renderer.Warnf("The API %s does not have any scopes defined.\n", ansi.Bold(resourceServer.GetName())) + return nil + } + + // Auth0 rejects allow_all_scopes on a system API, so only offer that mode + // for regular APIs. System APIs still support specific scopes and none. + modeOptions := []string{clientGrantScopesModeSpecific} + if !resourceServer.GetIsSystem() { + modeOptions = append(modeOptions, clientGrantScopesModeAll) + } + if allowNone { + modeOptions = append(modeOptions, clientGrantScopesModeNone) + } + + defaultMode := clientGrantScopesModeSpecific + if currentAllowAll { + defaultMode = clientGrantScopesModeAll + } + var mode string + modePrompt := &survey.Select{ + Message: "How would you like to grant scopes?", + Options: modeOptions, + Default: defaultMode, + } + if err := survey.AskOne(modePrompt, &mode); err != nil { + return err + } + + switch mode { + case clientGrantScopesModeAll: + *allowAllScopes = true + return nil + case clientGrantScopesModeNone: + *result = nil + if noScopes != nil { + *noScopes = true + } + return nil + } + + scopesPrompt := &survey.MultiSelect{ + Message: "Scopes", + Options: options, + Default: currentScopes, + } + + return survey.AskOne(scopesPrompt, result) +} + +// pickClientGrantAuthorizationDetailsTypes drives the interactive selection of +// authorization_details types for a grant. The allowed types are defined on the +// audience API, so it shows a multi-select of them, writing the chosen types +// into result. Any current types not (or no longer) defined by the API are still +// offered (and pre-selected) so an update never silently drops a type already on +// the grant. When neither the API nor the grant has any types, it leaves result +// untouched (no prompt) since there is nothing to choose. +func (c *cli) pickClientGrantAuthorizationDetailsTypes(resourceServer *management.ResourceServer, result *[]string, currentTypes []string) error { + options := make([]string, 0, len(resourceServer.GetAuthorizationDetails())) + seen := make(map[string]bool) + for _, detail := range resourceServer.GetAuthorizationDetails() { + if t := detail.GetType(); t != "" && !seen[t] { + options = append(options, t) + seen[t] = true + } + } + for _, t := range currentTypes { + if !seen[t] { + options = append(options, t) + seen[t] = true + } + } + + if len(options) == 0 { + return nil + } + + typesPrompt := &survey.MultiSelect{ + Message: "Authorization details types", + Options: options, + Default: currentTypes, + } + + return survey.AskOne(typesPrompt, result) +} diff --git a/internal/cli/client_grants_test.go b/internal/cli/client_grants_test.go new file mode 100644 index 000000000..c5bf004ee --- /dev/null +++ b/internal/cli/client_grants_test.go @@ -0,0 +1,714 @@ +package cli + +import ( + "bytes" + "context" + "errors" + "testing" + + "github.com/auth0/go-auth0/management" + managementv3 "github.com/auth0/go-auth0/v3/management" + "github.com/auth0/go-auth0/v3/management/option" + "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" + "github.com/auth0/auth0-cli/internal/display" +) + +func TestClientGrantsPickerOptions(t *testing.T) { + // The picker reads only the first page, so a page with just Results set is + // all the fixture needs. + firstPage := func(grants []*managementv3.ClientGrantResponseContent) *auth0.ClientGrantPage { + return &auth0.ClientGrantPage{Results: grants} + } + + tests := []struct { + name string + page *auth0.ClientGrantPage + apiError error + assertOutput func(t testing.TB, options pickerOptions) + assertError func(t testing.TB, err error) + }{ + { + name: "happy path", + page: firstPage([]*managementv3.ClientGrantResponseContent{ + { + ID: auth0.String("cgr_1"), + ClientID: auth0.String("client-id-1"), + Audience: auth0.String("https://travel0.com/api"), + }, + { + ID: auth0.String("cgr_2"), + ClientID: auth0.String("client-id-2"), + Audience: auth0.String("https://travel0.com/api"), + }, + }), + assertOutput: func(t testing.TB, options pickerOptions) { + assert.Len(t, options, 2) + assert.Equal(t, "client-id-1 (https://travel0.com/api)", options[0].label) + assert.Equal(t, "cgr_1", options[0].value) + assert.Equal(t, "client-id-2 (https://travel0.com/api)", options[1].label) + assert.Equal(t, "cgr_2", options[1].value) + }, + assertError: func(t testing.TB, err error) { + t.Fail() + }, + }, + { + name: "default_for grant falls back to default_for label", + page: firstPage([]*managementv3.ClientGrantResponseContent{ + { + ID: auth0.String("cgr_3"), + DefaultFor: managementv3.ClientGrantDefaultForEnumThirdPartyClients.Ptr(), + Audience: auth0.String("https://travel0.com/api"), + }, + }), + assertOutput: func(t testing.TB, options pickerOptions) { + assert.Len(t, options, 1) + assert.Equal(t, "third_party_clients (https://travel0.com/api)", options[0].label) + assert.Equal(t, "cgr_3", options[0].value) + }, + assertError: func(t testing.TB, err error) { + t.Fail() + }, + }, + { + name: "no client grants", + page: firstPage([]*managementv3.ClientGrantResponseContent{}), + assertOutput: func(t testing.TB, options pickerOptions) { + t.Fail() + }, + assertError: func(t testing.TB, err error) { + assert.ErrorContains(t, err, "there are currently no client grants to choose from. Create one by running: `auth0 client-grants create`") + }, + }, + { + name: "API error", + apiError: errors.New("error"), + assertOutput: func(t testing.TB, options pickerOptions) { + t.Fail() + }, + assertError: func(t testing.TB, err error) { + assert.Error(t, err) + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + clientGrantAPI := mock.NewMockClientGrantAPIV3(ctrl) + clientGrantAPI.EXPECT(). + List(gomock.Any(), gomock.Any()). + Return(test.page, test.apiError) + + cli := &cli{ + apiv3: &auth0.APIV3{ClientGrant: clientGrantAPI}, + } + + options, err := cli.clientGrantPickerOptions(context.Background()) + + if err != nil { + test.assertError(t, err) + } else { + test.assertOutput(t, options) + } + }) + } +} + +func TestMutableClientGrantPickerOptions(t *testing.T) { + page := &auth0.ClientGrantPage{Results: []*managementv3.ClientGrantResponseContent{ + { + ID: auth0.String("cgr_1"), + ClientID: auth0.String("client-id-1"), + Audience: auth0.String("https://travel0.com/api"), + }, + { + ID: auth0.String("cgr_system"), + ClientID: auth0.String("client-id-system"), + Audience: auth0.String("https://travel0.com/api"), + IsSystem: auth0.Bool(true), + }, + }} + + t.Run("excludes system grants", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + clientGrantAPI := mock.NewMockClientGrantAPIV3(ctrl) + clientGrantAPI.EXPECT(). + List(gomock.Any(), gomock.Any()). + Return(page, nil) + + cli := &cli{apiv3: &auth0.APIV3{ClientGrant: clientGrantAPI}} + + options, err := cli.mutableClientGrantPickerOptions(context.Background()) + + assert.NoError(t, err) + assert.Len(t, options, 1) + assert.Equal(t, "cgr_1", options[0].value) + }) + + t.Run("errors when only system grants exist", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + clientGrantAPI := mock.NewMockClientGrantAPIV3(ctrl) + clientGrantAPI.EXPECT(). + List(gomock.Any(), gomock.Any()). + Return(&auth0.ClientGrantPage{Results: []*managementv3.ClientGrantResponseContent{ + { + ID: auth0.String("cgr_system"), + ClientID: auth0.String("client-id-system"), + Audience: auth0.String("https://travel0.com/api"), + IsSystem: auth0.Bool(true), + }, + }}, nil) + + cli := &cli{apiv3: &auth0.APIV3{ClientGrant: clientGrantAPI}} + + _, err := cli.mutableClientGrantPickerOptions(context.Background()) + + assert.ErrorContains(t, err, "there are currently no client grants to choose from") + }) +} + +func TestUpdateClientGrantCmd(t *testing.T) { + tests := []struct { + name string + args []string + grant *managementv3.GetClientGrantResponseContent + expectedError string + }{ + { + name: "fails fast on a system grant", + args: []string{"cgr_system", "--scopes", "read:todos"}, + grant: &managementv3.GetClientGrantResponseContent{ + ID: auth0.String("cgr_system"), + Audience: auth0.String("https://travel0.com/api"), + IsSystem: auth0.Bool(true), + }, + expectedError: `client grant with ID "cgr_system" is a system grant and cannot be updated`, + }, + { + name: "rejects organization settings for a user subject type", + args: []string{"cgr_user", "--organization-usage", "allow"}, + grant: &managementv3.GetClientGrantResponseContent{ + ID: auth0.String("cgr_user"), + Audience: auth0.String("https://travel0.com/api"), + SubjectType: managementv3.ClientGrantSubjectTypeEnumUser.Ptr(), + }, + expectedError: `--organization-usage and --allow-any-organization cannot be set when --subject-type is "user"`, + }, + { + name: "rejects allow-any-organization for an anonymous_user subject type", + args: []string{"cgr_anon", "--allow-any-organization=true"}, + grant: &managementv3.GetClientGrantResponseContent{ + ID: auth0.String("cgr_anon"), + Audience: auth0.String("https://travel0.com/api"), + SubjectType: managementv3.ClientGrantSubjectTypeEnumAnonymousUser.Ptr(), + }, + expectedError: `--organization-usage and --allow-any-organization cannot be set when --subject-type is "anonymous_user"`, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + clientGrantAPI := mock.NewMockClientGrantAPIV3(ctrl) + clientGrantAPI.EXPECT(). + Get(gomock.Any(), test.grant.GetID()). + Return(test.grant, nil) + + cli := &cli{apiv3: &auth0.APIV3{ClientGrant: clientGrantAPI}} + cli.noInput = true // Non-interactive mode. + + cmd := updateClientGrantCmd(cli) + cmd.SetArgs(test.args) + + assert.EqualError(t, cmd.Execute(), test.expectedError) + }) + } +} + +func TestCreateClientGrantCmd(t *testing.T) { + t.Run("errors when neither client-id nor default-for is set", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + cli := &cli{apiv3: &auth0.APIV3{ClientGrant: mock.NewMockClientGrantAPIV3(ctrl)}} + cli.noInput = true // Non-interactive mode. + + cmd := createClientGrantCmd(cli) + cmd.SetArgs([]string{"--audience", "https://travel0.com/api"}) + + assert.EqualError(t, cmd.Execute(), "one of --client-id or --default-for must be set") + }) + + t.Run("errors when client-id and default-for are both set", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + cli := &cli{apiv3: &auth0.APIV3{ClientGrant: mock.NewMockClientGrantAPIV3(ctrl)}} + cli.noInput = true // Non-interactive mode. + + cmd := createClientGrantCmd(cli) + cmd.SetArgs([]string{ + "--audience", "https://travel0.com/api", + "--client-id", "client-id-1", + "--default-for", "third_party_clients", + }) + + assert.ErrorContains(t, cmd.Execute(), "[client-id default-for]") + }) + + t.Run("sends default_for when --default-for is set", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + var captured *managementv3.CreateClientGrantRequestContent + clientGrantAPI := mock.NewMockClientGrantAPIV3(ctrl) + clientGrantAPI.EXPECT(). + Create(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, req *managementv3.CreateClientGrantRequestContent, _ ...option.RequestOption) (*managementv3.CreateClientGrantResponseContent, error) { + captured = req + return &managementv3.CreateClientGrantResponseContent{ID: auth0.String("cgr_1")}, nil + }) + + cli := &cli{ + apiv3: &auth0.APIV3{ClientGrant: clientGrantAPI}, + renderer: &display.Renderer{MessageWriter: &bytes.Buffer{}, ResultWriter: &bytes.Buffer{}}, + } + cli.noInput = true // Non-interactive mode. + + cmd := createClientGrantCmd(cli) + cmd.SetArgs([]string{ + "--audience", "https://travel0.com/api", + "--default-for", "third_party_clients", + }) + + assert.NoError(t, cmd.Execute()) + assert.Nil(t, captured.ClientID) + assert.Equal(t, managementv3.ClientGrantDefaultForEnumThirdPartyClients, *captured.DefaultFor) + // Organization and subject-type settings do not apply to a default grant + // and the API rejects them, so they must never be sent. + assert.Nil(t, captured.SubjectType) + assert.Nil(t, captured.OrganizationUsage) + assert.Nil(t, captured.AllowAnyOrganization) + }) + + t.Run("rejects organization flags with --default-for", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + cli := &cli{apiv3: &auth0.APIV3{ClientGrant: mock.NewMockClientGrantAPIV3(ctrl)}} + cli.noInput = true // Non-interactive mode. + + cmd := createClientGrantCmd(cli) + cmd.SetArgs([]string{ + "--audience", "https://travel0.com/api", + "--default-for", "third_party_clients", + "--organization-usage", "allow", + }) + + assert.EqualError(t, cmd.Execute(), "--organization-usage cannot be set with --default-for") + }) + + t.Run("sends authorization_details_types when --authorization-details-types is set", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + var captured *managementv3.CreateClientGrantRequestContent + clientGrantAPI := mock.NewMockClientGrantAPIV3(ctrl) + clientGrantAPI.EXPECT(). + Create(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, req *managementv3.CreateClientGrantRequestContent, _ ...option.RequestOption) (*managementv3.CreateClientGrantResponseContent, error) { + captured = req + return &managementv3.CreateClientGrantResponseContent{ID: auth0.String("cgr_1")}, nil + }) + + cli := &cli{ + apiv3: &auth0.APIV3{ClientGrant: clientGrantAPI}, + renderer: &display.Renderer{MessageWriter: &bytes.Buffer{}, ResultWriter: &bytes.Buffer{}}, + } + cli.noInput = true // Non-interactive mode. + + cmd := createClientGrantCmd(cli) + cmd.SetArgs([]string{ + "--client-id", "client-id-1", + "--audience", "https://travel0.com/api", + "--authorization-details-types", "payment,transfer", + }) + + assert.NoError(t, cmd.Execute()) + assert.Equal(t, []string{"payment", "transfer"}, captured.AuthorizationDetailsTypes) + }) + + t.Run("does not send organization settings when no organization flags are passed", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + var captured *managementv3.CreateClientGrantRequestContent + clientGrantAPI := mock.NewMockClientGrantAPIV3(ctrl) + clientGrantAPI.EXPECT(). + Create(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, req *managementv3.CreateClientGrantRequestContent, _ ...option.RequestOption) (*managementv3.CreateClientGrantResponseContent, error) { + captured = req + return &managementv3.CreateClientGrantResponseContent{ID: auth0.String("cgr_1")}, nil + }) + + cli := &cli{ + apiv3: &auth0.APIV3{ClientGrant: clientGrantAPI}, + renderer: &display.Renderer{MessageWriter: &bytes.Buffer{}, ResultWriter: &bytes.Buffer{}}, + } + cli.noInput = true // Non-interactive mode. + + cmd := createClientGrantCmd(cli) + cmd.SetArgs([]string{ + "--client-id", "client-id-1", + "--audience", "https://travel0.com/api", + }) + + // A grant that never touches organizations must not carry a stray + // allow_any_organization, which the API rejects for system APIs. + assert.NoError(t, cmd.Execute()) + assert.Nil(t, captured.OrganizationUsage) + assert.Nil(t, captured.AllowAnyOrganization) + }) +} + +func TestDeleteClientGrantCmd(t *testing.T) { + t.Run("fails fast on a system grant", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + clientGrantAPI := mock.NewMockClientGrantAPIV3(ctrl) + clientGrantAPI.EXPECT(). + Get(gomock.Any(), "cgr_system"). + Return(&managementv3.GetClientGrantResponseContent{ + ID: auth0.String("cgr_system"), + IsSystem: auth0.Bool(true), + }, nil) + + cli := &cli{apiv3: &auth0.APIV3{ClientGrant: clientGrantAPI}} + cli.noInput = true // Non-interactive mode. + + cmd := deleteClientGrantCmd(cli) + cmd.SetArgs([]string{"cgr_system", "--force"}) + + assert.EqualError(t, cmd.Execute(), `client grant with ID "cgr_system" is a system grant and cannot be deleted`) + }) +} + +func TestValidateClientGrantSubjectType(t *testing.T) { + tests := []struct { + name string + subjectType string + organizationUsage string + allowAnyOrganization bool + wantErr bool + }{ + { + name: "client subject type with no org settings is valid", + subjectType: "client", + }, + { + name: "client subject type with org settings is valid", + subjectType: "client", + organizationUsage: "allow", + allowAnyOrganization: true, + }, + { + name: "user subject type with no org settings is valid", + subjectType: "user", + }, + { + name: "user subject type with organization usage is rejected", + subjectType: "user", + organizationUsage: "allow", + wantErr: true, + }, + { + name: "user subject type with allow-any-organization is rejected", + subjectType: "user", + allowAnyOrganization: true, + wantErr: true, + }, + { + name: "anonymous_user subject type with no org settings is valid", + subjectType: "anonymous_user", + }, + { + name: "anonymous_user subject type with organization usage is rejected", + subjectType: "anonymous_user", + organizationUsage: "allow", + wantErr: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := validateClientGrantSubjectType(test.subjectType, test.organizationUsage, test.allowAnyOrganization) + if test.wantErr { + assert.ErrorContains(t, err, "cannot be set when --subject-type is") + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestValidateClientGrantOrganization(t *testing.T) { + tests := []struct { + name string + organizationUsage string + allowAnyOrganization bool + wantErr bool + }{ + { + name: "allow-any-organization off is always valid", + organizationUsage: "", + allowAnyOrganization: false, + }, + { + name: "allow-any-organization on with allow usage is valid", + organizationUsage: "allow", + allowAnyOrganization: true, + }, + { + name: "allow-any-organization on with require usage is valid", + organizationUsage: "require", + allowAnyOrganization: true, + }, + { + name: "allow-any-organization on with deny usage is rejected", + organizationUsage: "deny", + allowAnyOrganization: true, + wantErr: true, + }, + { + name: "allow-any-organization on with no usage is rejected", + organizationUsage: "", + allowAnyOrganization: true, + wantErr: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := validateClientGrantOrganization(test.organizationUsage, test.allowAnyOrganization) + if test.wantErr { + assert.ErrorContains(t, err, "--allow-any-organization can only be enabled") + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestResolveUpdateClientGrantScopes(t *testing.T) { + tests := []struct { + name string + newScopes []string + newAllowAll bool + currentScopes []string + currentAllowAll bool + wantScope []string + wantAllowAll *bool + }{ + { + name: "new scopes win over an existing specific grant", + newScopes: []string{"read:users"}, + currentScopes: []string{"read:foo"}, + wantScope: []string{"read:users"}, + wantAllowAll: nil, + }, + { + name: "switching allow-all to specific clears allow_all_scopes", + newScopes: []string{"read:users"}, + currentAllowAll: true, + wantScope: []string{"read:users"}, + wantAllowAll: auth0.Bool(false), + }, + { + name: "explicit allow-all sets allow_all_scopes", + newAllowAll: true, + wantScope: nil, + wantAllowAll: auth0.Bool(true), + }, + { + name: "no changes preserves an existing allow-all grant", + currentAllowAll: true, + wantScope: nil, + wantAllowAll: auth0.Bool(true), + }, + { + name: "no changes preserves existing specific scopes", + currentScopes: []string{"read:foo", "read:bar"}, + wantScope: []string{"read:foo", "read:bar"}, + wantAllowAll: nil, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + scope, allowAll := resolveUpdateClientGrantScopes(test.newScopes, test.newAllowAll, test.currentScopes, test.currentAllowAll) + assert.Equal(t, test.wantScope, scope) + assert.Equal(t, test.wantAllowAll, allowAll) + }) + } +} + +func TestAPIIdentifierPickerOptions(t *testing.T) { + tests := []struct { + name string + apis []*management.ResourceServer + apiError error + assertOutput func(t testing.TB, options pickerOptions) + assertError func(t testing.TB, err error) + }{ + { + name: "picker value is the identifier, not the API id", + apis: []*management.ResourceServer{ + { + ID: auth0.String("api-id-1"), + Identifier: auth0.String("https://travel0.com/api"), + Name: auth0.String("Travel0 API"), + }, + }, + assertOutput: func(t testing.TB, options pickerOptions) { + assert.Len(t, options, 1) + assert.Equal(t, "Travel0 API (https://travel0.com/api)", options[0].label) + assert.Equal(t, "https://travel0.com/api", options[0].value) + }, + assertError: func(t testing.TB, err error) { + t.Fail() + }, + }, + { + name: "falls back to a custom API label when the API has no name", + apis: []*management.ResourceServer{ + { + ID: auth0.String("api-id-1"), + Identifier: auth0.String("https://travel0.com/api"), + }, + }, + assertOutput: func(t testing.TB, options pickerOptions) { + assert.Len(t, options, 1) + assert.Equal(t, "custom API (https://travel0.com/api)", options[0].label) + assert.Equal(t, "https://travel0.com/api", options[0].value) + }, + assertError: func(t testing.TB, err error) { + t.Fail() + }, + }, + { + name: "no apis", + apis: []*management.ResourceServer{}, + assertOutput: func(t testing.TB, options pickerOptions) { + t.Fail() + }, + assertError: func(t testing.TB, err error) { + assert.ErrorContains(t, err, "there are currently no APIs to choose from. Create one by running: `auth0 apis create`") + }, + }, + { + name: "API error", + apiError: errors.New("error"), + assertOutput: func(t testing.TB, options pickerOptions) { + t.Fail() + }, + assertError: func(t testing.TB, err error) { + assert.Error(t, err) + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + apiAPI := mock.NewMockResourceServerAPI(ctrl) + apiAPI.EXPECT(). + List(gomock.Any()). + Return(&management.ResourceServerList{ResourceServers: test.apis}, test.apiError) + + cli := &cli{ + api: &auth0.API{ResourceServer: apiAPI}, + } + + options, err := cli.apiIdentifierPickerOptions(context.Background()) + + if err != nil { + test.assertError(t, err) + } else { + test.assertOutput(t, options) + } + }) + } +} + +func TestNonSystemAPIIdentifierPickerOptions(t *testing.T) { + apis := []*management.ResourceServer{ + { + ID: auth0.String("api-id-1"), + Identifier: auth0.String("https://travel0.com/api"), + Name: auth0.String("Travel0 API"), + }, + { + ID: auth0.String("api-id-mgmt"), + Identifier: auth0.String("https://travel0.us.auth0.com/api/v2/"), + Name: auth0.String("Auth0 Management API"), + IsSystem: auth0.Bool(true), + }, + } + + t.Run("excludes system APIs", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + apiAPI := mock.NewMockResourceServerAPI(ctrl) + apiAPI.EXPECT(). + List(gomock.Any()). + Return(&management.ResourceServerList{ResourceServers: apis}, nil) + + cli := &cli{api: &auth0.API{ResourceServer: apiAPI}} + + options, err := cli.nonSystemAPIIdentifierPickerOptions(context.Background()) + + assert.NoError(t, err) + assert.Len(t, options, 1) + assert.Equal(t, "https://travel0.com/api", options[0].value) + }) + + t.Run("errors when only system APIs exist", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + apiAPI := mock.NewMockResourceServerAPI(ctrl) + apiAPI.EXPECT(). + List(gomock.Any()). + Return(&management.ResourceServerList{ResourceServers: []*management.ResourceServer{ + { + ID: auth0.String("api-id-mgmt"), + Identifier: auth0.String("https://travel0.us.auth0.com/api/v2/"), + Name: auth0.String("Auth0 Management API"), + IsSystem: auth0.Bool(true), + }, + }}, nil) + + cli := &cli{api: &auth0.API{ResourceServer: apiAPI}} + + _, err := cli.nonSystemAPIIdentifierPickerOptions(context.Background()) + + assert.ErrorContains(t, err, "there are currently no APIs to choose from") + }) +} diff --git a/internal/cli/quickstarts.go b/internal/cli/quickstarts.go index b3507d167..e7bff2894 100644 --- a/internal/cli/quickstarts.go +++ b/internal/cli/quickstarts.go @@ -16,6 +16,7 @@ import ( "strings" "github.com/auth0/go-auth0/management" + managementv3 "github.com/auth0/go-auth0/v3/management" "github.com/spf13/cobra" "golang.org/x/text/cases" "golang.org/x/text/language" @@ -1227,14 +1228,14 @@ func createQuickstartAPI(ctx context.Context, cli *cli, inputs SetupInputs) erro // Link the app to the API via a client grant if an app was selected/created. if inputs.LinkedAppID != "" { - emptyScopes := []string{} - grant := &management.ClientGrant{ + grant := &managementv3.CreateClientGrantRequestContent{ ClientID: &inputs.LinkedAppID, - Audience: &inputs.Identifier, - Scope: &emptyScopes, + Audience: inputs.Identifier, + Scope: []string{}, } if grantErr := ansi.Waiting(func() error { - return cli.api.ClientGrant.Create(ctx, grant) + _, err := cli.apiv3.ClientGrant.Create(ctx, grant) + return err }); grantErr != nil { cli.renderer.Warnf("Failed to link application to API: %v", grantErr) } diff --git a/internal/cli/quickstarts_test.go b/internal/cli/quickstarts_test.go index b8be14fd0..36b4c97c1 100644 --- a/internal/cli/quickstarts_test.go +++ b/internal/cli/quickstarts_test.go @@ -943,16 +943,18 @@ func TestCreateQuickstartAPI_CreatesResourceServerAndGrant(t *testing.T) { return nil }) - grantAPI := mock.NewMockClientGrantAPI(ctrl) + grantAPI := mock.NewMockClientGrantAPIV3(ctrl) grantAPI.EXPECT(). Create(gomock.Any(), gomock.Any()). - Return(nil) + Return(nil, nil) testCLI := &cli{ renderer: &display.Renderer{MessageWriter: &bytes.Buffer{}, ResultWriter: &bytes.Buffer{}}, api: &auth0.API{ ResourceServer: rsAPI, - ClientGrant: grantAPI, + }, + apiv3: &auth0.APIV3{ + ClientGrant: grantAPI, }, } @@ -985,13 +987,15 @@ func TestCreateQuickstartAPI_NoLinkedApp_SkipsGrant(t *testing.T) { }) // No grant creation expected when linkedAppClientID is empty. - grantAPI := mock.NewMockClientGrantAPI(ctrl) + grantAPI := mock.NewMockClientGrantAPIV3(ctrl) testCLI := &cli{ renderer: &display.Renderer{MessageWriter: &bytes.Buffer{}, ResultWriter: &bytes.Buffer{}}, api: &auth0.API{ ResourceServer: rsAPI, - ClientGrant: grantAPI, + }, + apiv3: &auth0.APIV3{ + ClientGrant: grantAPI, }, } diff --git a/internal/cli/root.go b/internal/cli/root.go index 0bfe7a849..09f3587dd 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -176,6 +176,7 @@ func addSubCommands(rootCmd *cobra.Command, cli *cli) { rootCmd.AddCommand(rulesCmd(cli)) rootCmd.AddCommand(actionsCmd(cli)) rootCmd.AddCommand(apisCmd(cli)) + rootCmd.AddCommand(clientGrantsCmd(cli)) rootCmd.AddCommand(rolesCmd(cli)) rootCmd.AddCommand(organizationsCmd(cli)) rootCmd.AddCommand(universalLoginCmd(cli)) diff --git a/internal/cli/terraform.go b/internal/cli/terraform.go index 51762d578..5ddee1b59 100644 --- a/internal/cli/terraform.go +++ b/internal/cli/terraform.go @@ -84,7 +84,7 @@ func (i *terraformInputs) parseResourceFetchers(api *auth0.API, apiv3 *auth0.API case "auth0_client", "auth0_client_credentials": fetchers = append(fetchers, &clientResourceFetcher{api}) case "auth0_client_grant": - fetchers = append(fetchers, &clientGrantResourceFetcher{api}) + fetchers = append(fetchers, &clientGrantResourceFetcher{apiv3}) case "auth0_connection", "auth0_connection_clients": fetchers = append(fetchers, &connectionResourceFetcher{api}) case "auth0_custom_domain": diff --git a/internal/cli/terraform_fetcher.go b/internal/cli/terraform_fetcher.go index e12d35208..05655e5f2 100644 --- a/internal/cli/terraform_fetcher.go +++ b/internal/cli/terraform_fetcher.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/auth0/go-auth0/management" + managementv3 "github.com/auth0/go-auth0/v3/management" "github.com/google/uuid" "github.com/auth0/auth0-cli/internal/auth0" @@ -57,7 +58,7 @@ type ( } clientGrantResourceFetcher struct { - api *auth0.API + apiv3 *auth0.APIV3 } connectionResourceFetcher struct { @@ -263,32 +264,25 @@ func (f *clientResourceFetcher) FetchData(ctx context.Context) (importDataList, func (f *clientGrantResourceFetcher) FetchData(ctx context.Context) (importDataList, error) { var data importDataList - var page int - for { - grants, err := f.api.ClientGrant.List( - ctx, - management.Page(page), - ) - if err != nil { - return nil, err - } - - for _, grant := range grants.ClientGrants { - identifier := grant.GetClientID() - if identifier == "" { - identifier = grant.GetDefaultFor() - } - data = append(data, importDataItem{ - ResourceName: "auth0_client_grant." + sanitizeResourceName(identifier+"_"+grant.GetAudience()), - ImportID: grant.GetID(), - }) - } + grants, err := f.apiv3.ClientGrant.List(ctx, &managementv3.ListClientGrantsRequestParameters{}) + if err != nil { + return nil, err + } - if !grants.HasNext() { - break + iter := grants.Iterator() + for iter.Next(ctx) { + grant := iter.Current() + identifier := grant.GetClientID() + if identifier == "" { + identifier = string(grant.GetDefaultFor()) } - - page++ + data = append(data, importDataItem{ + ResourceName: "auth0_client_grant." + sanitizeResourceName(identifier+"_"+grant.GetAudience()), + ImportID: grant.GetID(), + }) + } + if err := iter.Err(); err != nil { + return nil, err } return data, nil diff --git a/internal/cli/terraform_fetcher_test.go b/internal/cli/terraform_fetcher_test.go index 996504aa5..574763c3f 100644 --- a/internal/cli/terraform_fetcher_test.go +++ b/internal/cli/terraform_fetcher_test.go @@ -9,6 +9,7 @@ import ( "github.com/auth0/go-auth0/management" managementv3 "github.com/auth0/go-auth0/v3/management" + "github.com/auth0/go-auth0/v3/management/core" "github.com/golang/mock/gomock" "github.com/stretchr/testify/assert" @@ -621,62 +622,50 @@ func TestClientResourceFetcher_FetchData(t *testing.T) { } func TestClientGrantResourceFetcher_FetchData(t *testing.T) { + terminalPage := func(grants []*managementv3.ClientGrantResponseContent) *auth0.ClientGrantPage { + return &auth0.ClientGrantPage{ + Results: grants, + NextPageFunc: func(context.Context) (*auth0.ClientGrantPage, error) { + return nil, core.ErrNoPages + }, + } + } + t.Run("it successfully retrieves client grant data", func(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() - clientGrantAPI := mock.NewMockClientGrantAPI(ctrl) + clientGrantAPI := mock.NewMockClientGrantAPIV3(ctrl) clientGrantAPI.EXPECT(). List(gomock.Any(), gomock.Any()). Return( - &management.ClientGrantList{ - List: management.List{ - Start: 0, - Limit: 2, - Total: 4, + terminalPage([]*managementv3.ClientGrantResponseContent{ + { + ID: auth0.String("cgr_1"), + ClientID: auth0.String("client-id-1"), + Audience: auth0.String("https://travel0.com/api"), }, - ClientGrants: []*management.ClientGrant{ - { - ID: auth0.String("cgr_1"), - ClientID: auth0.String("client-id-1"), - Audience: auth0.String("https://travel0.com/api"), - }, - { - ID: auth0.String("cgr_2"), - ClientID: auth0.String("client-id-2"), - Audience: auth0.String("https://travel0.com/api"), - }, + { + ID: auth0.String("cgr_2"), + ClientID: auth0.String("client-id-2"), + Audience: auth0.String("https://travel0.com/api"), }, - }, - nil, - ) - clientGrantAPI.EXPECT(). - List(gomock.Any(), gomock.Any()). - Return( - &management.ClientGrantList{ - List: management.List{ - Start: 2, - Limit: 4, - Total: 4, + { + ID: auth0.String("cgr_3"), + ClientID: auth0.String("client-id-1"), + Audience: auth0.String("https://travel0.us.auth0.com/api/v2/"), }, - ClientGrants: []*management.ClientGrant{ - { - ID: auth0.String("cgr_3"), - ClientID: auth0.String("client-id-1"), - Audience: auth0.String("https://travel0.us.auth0.com/api/v2/"), - }, - { - ID: auth0.String("cgr_4"), - ClientID: auth0.String("client-id-2"), - Audience: auth0.String("https://travel0.us.auth0.com/api/v2/"), - }, + { + ID: auth0.String("cgr_4"), + ClientID: auth0.String("client-id-2"), + Audience: auth0.String("https://travel0.us.auth0.com/api/v2/"), }, - }, + }), nil, ) fetcher := clientGrantResourceFetcher{ - api: &auth0.API{ + apiv3: &auth0.APIV3{ ClientGrant: clientGrantAPI, }, } @@ -709,13 +698,13 @@ func TestClientGrantResourceFetcher_FetchData(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() - clientGrantAPI := mock.NewMockClientGrantAPI(ctrl) + clientGrantAPI := mock.NewMockClientGrantAPIV3(ctrl) clientGrantAPI.EXPECT(). List(gomock.Any(), gomock.Any()). Return(nil, fmt.Errorf("failed to list clients")) fetcher := clientGrantResourceFetcher{ - api: &auth0.API{ + apiv3: &auth0.APIV3{ ClientGrant: clientGrantAPI, }, } @@ -728,39 +717,32 @@ func TestClientGrantResourceFetcher_FetchData(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() - clientGrantAPI := mock.NewMockClientGrantAPI(ctrl) + clientGrantAPI := mock.NewMockClientGrantAPIV3(ctrl) clientGrantAPI.EXPECT(). List(gomock.Any(), gomock.Any()). Return( - &management.ClientGrantList{ - List: management.List{ - Start: 0, - Limit: 3, - Total: 3, + terminalPage([]*managementv3.ClientGrantResponseContent{ + { + ID: auth0.String("cgr_1"), + ClientID: auth0.String("client-id-1"), + Audience: auth0.String("https://travel0.com/api"), }, - ClientGrants: []*management.ClientGrant{ - { - ID: auth0.String("cgr_1"), - ClientID: auth0.String("client-id-1"), - Audience: auth0.String("https://travel0.com/api"), - }, - { - ID: auth0.String("cgr_2"), - DefaultFor: auth0.String("third_party_clients"), - Audience: auth0.String("https://travel0.com/api"), - }, - { - ID: auth0.String("cgr_3"), - DefaultFor: auth0.String("third_party_clients"), - Audience: auth0.String("https://partner-api.example.com"), - }, + { + ID: auth0.String("cgr_2"), + DefaultFor: managementv3.ClientGrantDefaultForEnumThirdPartyClients.Ptr(), + Audience: auth0.String("https://travel0.com/api"), }, - }, + { + ID: auth0.String("cgr_3"), + DefaultFor: managementv3.ClientGrantDefaultForEnumThirdPartyClients.Ptr(), + Audience: auth0.String("https://partner-api.example.com"), + }, + }), nil, ) fetcher := clientGrantResourceFetcher{ - api: &auth0.API{ + apiv3: &auth0.APIV3{ ClientGrant: clientGrantAPI, }, } diff --git a/internal/cli/test.go b/internal/cli/test.go index 73b29205b..f407988bf 100644 --- a/internal/cli/test.go +++ b/internal/cli/test.go @@ -9,6 +9,8 @@ import ( "github.com/AlecAivazis/survey/v2" "github.com/atotto/clipboard" "github.com/auth0/go-auth0/management" + managementv3 "github.com/auth0/go-auth0/v3/management" + "github.com/auth0/go-auth0/v3/management/core" "github.com/spf13/cobra" "github.com/auth0/auth0-cli/internal/ansi" @@ -422,16 +424,18 @@ func (c *cli) audiencePickerOptions(client *management.Client) func(ctx context. switch client.GetAppType() { case "non_interactive": - clientGrants, err := c.api.ClientGrant.List( + clientGrants, err := c.apiv3.ClientGrant.List( ctx, - management.PerPage(100), - management.Parameter("client_id", client.GetClientID()), + &managementv3.ListClientGrantsRequestParameters{ + Take: auth0.Int(100), + ClientID: auth0.String(client.GetClientID()), + }, ) if err != nil { return nil, err } - if len(clientGrants.ClientGrants) == 0 { + if len(clientGrants.Results) == 0 { return nil, fmt.Errorf( "the %s application is not authorized to request access tokens for any APIs.\n\n"+ "Run: 'auth0 apps open %s' to open the dashboard and authorize the application", @@ -440,7 +444,7 @@ func (c *cli) audiencePickerOptions(client *management.Client) func(ctx context. ) } - for _, grant := range clientGrants.ClientGrants { + for _, grant := range clientGrants.Results { resourceServer, err := c.api.ResourceServer.Read(ctx, grant.GetAudience()) if err != nil { return nil, err @@ -489,19 +493,21 @@ func (c *cli) pickOrganizationForGrantIfRequired(cmd *cobra.Command, client *man return nil } - var list *management.ClientGrantList + var list *core.Page[*string, *managementv3.ClientGrantResponseContent, *managementv3.ListClientGrantPaginatedResponseContent] if err := ansi.Waiting(func() (err error) { - list, err = c.api.ClientGrant.List( + list, err = c.apiv3.ClientGrant.List( cmd.Context(), - management.Parameter("audience", audience), - management.Parameter("client_id", client.GetClientID()), + &managementv3.ListClientGrantsRequestParameters{ + Audience: auth0.String(audience), + ClientID: auth0.String(client.GetClientID()), + }, ) return err }); err != nil { return err } - if len(list.ClientGrants) == 0 || list.ClientGrants[0].GetOrganizationUsage() != "require" { + if len(list.Results) == 0 || list.Results[0].GetOrganizationUsage() != managementv3.ClientGrantOrganizationUsageEnumRequire { return nil } diff --git a/internal/cli/utils_shared.go b/internal/cli/utils_shared.go index 8b484fc2b..6a874be77 100644 --- a/internal/cli/utils_shared.go +++ b/internal/cli/utils_shared.go @@ -13,6 +13,8 @@ import ( "time" "github.com/auth0/go-auth0/management" + managementv3 "github.com/auth0/go-auth0/v3/management" + "github.com/auth0/go-auth0/v3/management/core" "github.com/pkg/browser" "github.com/auth0/auth0-cli/internal/ansi" @@ -97,12 +99,14 @@ func runClientCredentialsFlow( } func checkClientIsAuthorizedForAPI(ctx context.Context, cli *cli, client *management.Client, audience, organization string) error { - var list *management.ClientGrantList + var list *core.Page[*string, *managementv3.ClientGrantResponseContent, *managementv3.ListClientGrantPaginatedResponseContent] if err := ansi.Waiting(func() (err error) { - list, err = cli.api.ClientGrant.List( + list, err = cli.apiv3.ClientGrant.List( ctx, - management.Parameter("audience", audience), - management.Parameter("client_id", client.GetClientID()), + &managementv3.ListClientGrantsRequestParameters{ + Audience: auth0.String(audience), + ClientID: auth0.String(client.GetClientID()), + }, ) return err }); err != nil { @@ -114,7 +118,7 @@ func checkClientIsAuthorizedForAPI(ctx context.Context, cli *cli, client *manage ) } - if len(list.ClientGrants) < 1 { + if len(list.Results) < 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", @@ -124,8 +128,8 @@ func checkClientIsAuthorizedForAPI(ctx context.Context, cli *cli, client *manage ) } - grant := list.ClientGrants[0] - if grant.GetOrganizationUsage() == "require" && organization == "" { + grant := list.Results[0] + if grant.GetOrganizationUsage() == managementv3.ClientGrantOrganizationUsageEnumRequire && organization == "" { return fmt.Errorf( "the client grant for %s requires an organization.\n\n"+ "Use the --organization flag to specify one", diff --git a/internal/cli/utils_shared_test.go b/internal/cli/utils_shared_test.go index 597f66454..95d1852a1 100644 --- a/internal/cli/utils_shared_test.go +++ b/internal/cli/utils_shared_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/auth0/go-auth0/management" + managementv3 "github.com/auth0/go-auth0/v3/management" "github.com/golang/mock/gomock" "github.com/stretchr/testify/assert" @@ -62,14 +63,14 @@ func TestCheckClientIsAuthorizedForAPI(t *testing.T) { tests := []struct { name string organization string - grantList *management.ClientGrantList + grantList *auth0.ClientGrantPage apiError error expectedError string }{ { name: "no grant exists", organization: "", - grantList: &management.ClientGrantList{}, + grantList: &auth0.ClientGrantPage{}, expectedError: "the some-client-name application is not authorized to request access tokens for this API " + audience, }, @@ -83,27 +84,27 @@ func TestCheckClientIsAuthorizedForAPI(t *testing.T) { { name: "grant exists, no org required", organization: "", - grantList: &management.ClientGrantList{ - ClientGrants: []*management.ClientGrant{ - {OrganizationUsage: auth0.String("allow")}, + grantList: &auth0.ClientGrantPage{ + Results: []*managementv3.ClientGrantResponseContent{ + {OrganizationUsage: managementv3.ClientGrantOrganizationUsageEnumAllow.Ptr()}, }, }, }, { name: "grant requires org, org provided", organization: "org_abc123", - grantList: &management.ClientGrantList{ - ClientGrants: []*management.ClientGrant{ - {OrganizationUsage: auth0.String("require")}, + grantList: &auth0.ClientGrantPage{ + Results: []*managementv3.ClientGrantResponseContent{ + {OrganizationUsage: managementv3.ClientGrantOrganizationUsageEnumRequire.Ptr()}, }, }, }, { name: "grant requires org, no org provided", organization: "", - grantList: &management.ClientGrantList{ - ClientGrants: []*management.ClientGrant{ - {OrganizationUsage: auth0.String("require")}, + grantList: &auth0.ClientGrantPage{ + Results: []*managementv3.ClientGrantResponseContent{ + {OrganizationUsage: managementv3.ClientGrantOrganizationUsageEnumRequire.Ptr()}, }, }, expectedError: "the client grant for " + audience + " requires an organization.\n\n" + @@ -116,13 +117,13 @@ func TestCheckClientIsAuthorizedForAPI(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() - clientGrantAPI := mock.NewMockClientGrantAPI(ctrl) + clientGrantAPI := mock.NewMockClientGrantAPIV3(ctrl) clientGrantAPI.EXPECT(). List(gomock.Any(), gomock.Any()). Return(test.grantList, test.apiError) cli := &cli{ - api: &auth0.API{ClientGrant: clientGrantAPI}, + apiv3: &auth0.APIV3{ClientGrant: clientGrantAPI}, } err := checkClientIsAuthorizedForAPI(context.Background(), cli, client, audience, test.organization) diff --git a/internal/display/client_grant.go b/internal/display/client_grant.go new file mode 100644 index 000000000..9608c9970 --- /dev/null +++ b/internal/display/client_grant.go @@ -0,0 +1,254 @@ +package display + +import ( + "fmt" + "strings" + + managementv3 "github.com/auth0/go-auth0/v3/management" + "golang.org/x/term" + + "github.com/auth0/auth0-cli/internal/ansi" + "github.com/auth0/auth0-cli/internal/iostream" +) + +// clientGrantResponse is satisfied by every client-grant response content type +// returned by the v3 SDK (list, get, create and update), which all share the +// same getter surface. It lets a single view constructor serve all of them. +type clientGrantResponse interface { + GetID() string + GetClientID() string + GetAudience() string + GetScope() []string + GetAllowAllScopes() bool + GetOrganizationUsage() managementv3.ClientGrantOrganizationUsageEnum + GetAllowAnyOrganization() bool + GetDefaultFor() managementv3.ClientGrantDefaultForEnum + GetIsSystem() bool + GetSubjectType() managementv3.ClientGrantSubjectTypeEnum + GetAuthorizationDetailsTypes() []string +} + +// clientGrantView renders a single client grant as a key-value detail view +// (show, create and update). It carries the full scope list because the user +// asked for that one grant specifically. +type clientGrantView struct { + ID string + ClientID string + Audience string + Scopes string + SubjectType string + OrganizationUsage string + AllowAnyOrganization string + AuthorizationDetailsTypes string + + raw interface{} +} + +func (v *clientGrantView) AsTableHeader() []string { + return []string{} +} + +func (v *clientGrantView) AsTableRow() []string { + return []string{} +} + +func (v *clientGrantView) KeyValues() [][]string { + keyValues := [][]string{ + {"ID", ansi.Faint(v.ID)}, + {"CLIENT ID", v.ClientID}, + {"AUDIENCE", v.Audience}, + {"SCOPES", v.Scopes}, + {"SUBJECT TYPE", v.SubjectType}, + } + + // Only show the organization rows when the grant actually uses + // organizations. With no organization usage, allow_any_organization is + // always false, so both rows would just be noise. + if v.OrganizationUsage != "" { + keyValues = append(keyValues, + []string{"ORGANIZATION USAGE", v.OrganizationUsage}, + []string{"ALLOW ANY ORGANIZATION", v.AllowAnyOrganization}, + ) + } + + // Only show the authorization_details types when the grant carries any, + // since most grants do not use Rich Authorization Requests and an empty + // row would just be noise. + if v.AuthorizationDetailsTypes != "" { + keyValues = append(keyValues, + []string{"AUTHORIZATION DETAILS TYPES", v.AuthorizationDetailsTypes}, + ) + } + + return keyValues +} + +func (v *clientGrantView) Object() interface{} { + return v.raw +} + +// clientGrantTableView renders a client grant as a single row in the list +// table. It shows the scope count rather than the scope values, because +// dumping every scope inline pads the whole column to the widest grant and +// blows the table up (a single grant can carry hundreds of scopes). A grant +// that allows all scopes shows "all" instead of a count, since its scope list +// is empty and a bare 0 would misleadingly read as no access. +type clientGrantTableView struct { + ID string + ClientID string + Audience string + Scopes string + + raw interface{} +} + +func (v *clientGrantTableView) AsTableHeader() []string { + return []string{"ID", "Client ID", "Audience", "Scopes"} +} + +func (v *clientGrantTableView) AsTableRow() []string { + return []string{ansi.Faint(v.ID), v.ClientID, v.Audience, v.Scopes} +} + +func (v *clientGrantTableView) Object() interface{} { + return v.raw +} + +func (r *Renderer) ClientGrantList(grants []*managementv3.ClientGrantResponseContent) { + resource := "client grants" + + r.Heading(fmt.Sprintf("%s (%d)", resource, len(grants))) + + if len(grants) == 0 { + r.EmptyState(resource, "Use 'auth0 client-grants create' to add one") + return + } + + var results []View + for _, grant := range grants { + results = append(results, makeClientGrantTableView(grant)) + } + + r.Results(results) +} + +func (r *Renderer) ClientGrantShow(grant *managementv3.GetClientGrantResponseContent) { + r.Heading("client grant") + view, truncated := makeClientGrantView(grant) + r.Result(view) + r.hintClientGrantValuesTruncated(grant.GetID(), truncated) +} + +func (r *Renderer) ClientGrantCreate(grant *managementv3.CreateClientGrantResponseContent) { + r.Heading("client grant created") + view, truncated := makeClientGrantView(grant) + r.Result(view) + r.hintClientGrantValuesTruncated(grant.GetID(), truncated) +} + +func (r *Renderer) ClientGrantUpdate(grant *managementv3.UpdateClientGrantResponseContent) { + r.Heading("client grant updated") + view, truncated := makeClientGrantView(grant) + r.Result(view) + r.hintClientGrantValuesTruncated(grant.GetID(), truncated) +} + +func (r *Renderer) hintClientGrantValuesTruncated(id string, truncated bool) { + if !truncated || r.Format == OutputFormatJSON || r.Format == OutputFormatJSONCompact { + return + } + r.Newline() + r.Infof("Some values were truncated for display. To see the full list, run %s", ansi.Faint(fmt.Sprintf("client-grants show %s --json", id))) +} + +func makeClientGrantView(grant clientGrantResponse) (*clientGrantView, bool) { + scopes, scopesTruncated := clientGrantValuesForDisplay(grant.GetScope()) + + // A grant with allow_all_scopes carries no explicit scope list, so show + // that it authorizes everything rather than rendering a blank field. + if grant.GetAllowAllScopes() { + scopes, scopesTruncated = "(all scopes)", false + } + + // A grant with no explicit subject type is a client grant, so default the + // display to "client" rather than leaving the field blank. + subjectType := string(grant.GetSubjectType()) + if subjectType == "" { + subjectType = "client" + } + + // Authorization details types can be a long list too, so truncate them the + // same way as scopes rather than blowing the value column up. + authorizationDetailsTypes, authDetailsTruncated := clientGrantValuesForDisplay(grant.GetAuthorizationDetailsTypes()) + + view := &clientGrantView{ + ID: grant.GetID(), + ClientID: clientGrantIdentifier(grant), + Audience: grant.GetAudience(), + Scopes: scopes, + SubjectType: subjectType, + OrganizationUsage: string(grant.GetOrganizationUsage()), + AllowAnyOrganization: boolean(grant.GetAllowAnyOrganization()), + AuthorizationDetailsTypes: authorizationDetailsTypes, + raw: grant, + } + return view, scopesTruncated || authDetailsTruncated +} + +func makeClientGrantTableView(grant clientGrantResponse) *clientGrantTableView { + scopes := fmt.Sprint(len(grant.GetScope())) + if grant.GetAllowAllScopes() { + scopes = "all" + } + + return &clientGrantTableView{ + ID: grant.GetID(), + ClientID: clientGrantIdentifier(grant), + Audience: grant.GetAudience(), + Scopes: scopes, + raw: grant, + } +} + +// clientGrantIdentifier returns the client id of a grant, falling back to its +// default_for value for system grants that have no explicit client. +func clientGrantIdentifier(grant clientGrantResponse) string { + if clientID := grant.GetClientID(); clientID != "" { + return clientID + } + return string(grant.GetDefaultFor()) +} + +// clientGrantValuesForDisplay joins a list of values (scopes or authorization +// details types) into a single line for the detail view, truncating to the +// terminal width so a grant with hundreds of values does not blow the value +// column up. It returns the display string and whether truncation happened. +func clientGrantValuesForDisplay(values []string) (string, bool) { + const ( + ellipsis = "..." + separator = ", " + padding = 32 // The longest clientGrantView key plus surrounding spaces in the label column. + ) + + terminalWidth, _, err := term.GetSize(int(iostream.Input.Fd())) + if err != nil { + terminalWidth = 80 + } + + joined := strings.Join(values, separator) + maxCharacters := terminalWidth - padding + + if len(joined) <= maxCharacters { + return joined, false + } + + truncationIndex := maxCharacters - len(ellipsis) + if truncationIndex < 0 { + truncationIndex = 0 + } + if lastSeparator := strings.LastIndex(joined[:truncationIndex], separator); lastSeparator != -1 { + truncationIndex = lastSeparator + } + + return joined[:truncationIndex] + ellipsis, true +} diff --git a/internal/display/client_grant_test.go b/internal/display/client_grant_test.go new file mode 100644 index 000000000..fd18d716d --- /dev/null +++ b/internal/display/client_grant_test.go @@ -0,0 +1,210 @@ +package display + +import ( + "testing" + + managementv3 "github.com/auth0/go-auth0/v3/management" + "github.com/stretchr/testify/assert" + + "github.com/auth0/auth0-cli/internal/auth0" +) + +func TestMakeClientGrantView(t *testing.T) { + t.Run("maps the client-grant fields", func(t *testing.T) { + grant := &managementv3.ClientGrantResponseContent{ + ID: auth0.String("cgr_1"), + ClientID: auth0.String("client-id-1"), + Audience: auth0.String("https://travel0.com/api"), + Scope: []string{"read:users", "update:users"}, + OrganizationUsage: managementv3.ClientGrantOrganizationUsageEnumRequire.Ptr(), + AllowAnyOrganization: auth0.Bool(true), + } + + view, scopesTruncated := makeClientGrantView(grant) + + assert.Equal(t, "client-id-1", view.ClientID) + assert.Equal(t, "https://travel0.com/api", view.Audience) + assert.Equal(t, "read:users, update:users", view.Scopes) + assert.Equal(t, "require", view.OrganizationUsage) + assert.Equal(t, "✓", view.AllowAnyOrganization) + assert.False(t, scopesTruncated) + }) + + t.Run("falls back to default_for when client id is empty", func(t *testing.T) { + grant := &managementv3.ClientGrantResponseContent{ + ID: auth0.String("cgr_2"), + DefaultFor: managementv3.ClientGrantDefaultForEnumThirdPartyClients.Ptr(), + Audience: auth0.String("https://travel0.com/api"), + } + + view, _ := makeClientGrantView(grant) + + assert.Equal(t, "third_party_clients", view.ClientID) + }) + + t.Run("shows (all scopes) when the grant allows all scopes", func(t *testing.T) { + grant := &managementv3.ClientGrantResponseContent{ + ID: auth0.String("cgr_3"), + ClientID: auth0.String("client-id-3"), + Audience: auth0.String("https://travel0.com/api"), + AllowAllScopes: auth0.Bool(true), + } + + view, scopesTruncated := makeClientGrantView(grant) + + assert.Equal(t, "(all scopes)", view.Scopes) + assert.False(t, scopesTruncated) + }) +} + +func TestClientGrantView_KeyValues(t *testing.T) { + keys := func(keyValues [][]string) []string { + out := make([]string, 0, len(keyValues)) + for _, kv := range keyValues { + out = append(out, kv[0]) + } + return out + } + + t.Run("includes the organization rows when the grant uses organizations", func(t *testing.T) { + grant := &managementv3.ClientGrantResponseContent{ + ID: auth0.String("cgr_1"), + ClientID: auth0.String("client-id-1"), + Audience: auth0.String("https://travel0.com/api"), + Scope: []string{"read:users"}, + OrganizationUsage: managementv3.ClientGrantOrganizationUsageEnumRequire.Ptr(), + AllowAnyOrganization: auth0.Bool(true), + } + + view, _ := makeClientGrantView(grant) + + assert.Equal(t, + []string{"ID", "CLIENT ID", "AUDIENCE", "SCOPES", "SUBJECT TYPE", "ORGANIZATION USAGE", "ALLOW ANY ORGANIZATION"}, + keys(view.KeyValues()), + ) + }) + + t.Run("omits the organization rows when the grant has no organization usage", func(t *testing.T) { + grant := &managementv3.ClientGrantResponseContent{ + ID: auth0.String("cgr_2"), + ClientID: auth0.String("client-id-2"), + Audience: auth0.String("https://travel0.com/api"), + Scope: []string{"read:users"}, + } + + view, _ := makeClientGrantView(grant) + + assert.Equal(t, + []string{"ID", "CLIENT ID", "AUDIENCE", "SCOPES", "SUBJECT TYPE"}, + keys(view.KeyValues()), + ) + }) + + t.Run("includes the authorization details types row when the grant has any", func(t *testing.T) { + grant := &managementv3.ClientGrantResponseContent{ + ID: auth0.String("cgr_5"), + ClientID: auth0.String("client-id-5"), + Audience: auth0.String("https://travel0.com/api"), + Scope: []string{"read:users"}, + AuthorizationDetailsTypes: []string{"payment", "transfer"}, + } + + view, _ := makeClientGrantView(grant) + + assert.Equal(t, "payment, transfer", view.AuthorizationDetailsTypes) + assert.Equal(t, + []string{"ID", "CLIENT ID", "AUDIENCE", "SCOPES", "SUBJECT TYPE", "AUTHORIZATION DETAILS TYPES"}, + keys(view.KeyValues()), + ) + }) + + t.Run("omits the authorization details types row when the grant has none", func(t *testing.T) { + grant := &managementv3.ClientGrantResponseContent{ + ID: auth0.String("cgr_6"), + ClientID: auth0.String("client-id-6"), + Audience: auth0.String("https://travel0.com/api"), + Scope: []string{"read:users"}, + } + + view, _ := makeClientGrantView(grant) + + assert.NotContains(t, keys(view.KeyValues()), "AUTHORIZATION DETAILS TYPES") + }) + + t.Run("shows the subject type for a non-client subject type", func(t *testing.T) { + grant := &managementv3.ClientGrantResponseContent{ + ID: auth0.String("cgr_3"), + ClientID: auth0.String("client-id-3"), + Audience: auth0.String("https://travel0.com/api"), + Scope: []string{}, + SubjectType: managementv3.ClientGrantSubjectTypeEnumUser.Ptr(), + } + + view, _ := makeClientGrantView(grant) + + assert.Equal(t, "user", view.SubjectType) + }) + + t.Run("defaults the subject type to client when unset", func(t *testing.T) { + grant := &managementv3.ClientGrantResponseContent{ + ID: auth0.String("cgr_4"), + ClientID: auth0.String("client-id-4"), + Audience: auth0.String("https://travel0.com/api"), + Scope: []string{"read:users"}, + } + + view, _ := makeClientGrantView(grant) + + assert.Equal(t, "client", view.SubjectType) + }) +} + +func TestMakeClientGrantTableView(t *testing.T) { + t.Run("shows the scope count, not the scope values", func(t *testing.T) { + grant := &managementv3.ClientGrantResponseContent{ + ID: auth0.String("cgr_1"), + ClientID: auth0.String("client-id-1"), + Audience: auth0.String("https://travel0.com/api"), + Scope: []string{"read:users", "update:users", "delete:users"}, + } + + view := makeClientGrantTableView(grant) + + assert.Equal(t, "cgr_1", view.ID) + assert.Equal(t, "client-id-1", view.ClientID) + assert.Equal(t, "https://travel0.com/api", view.Audience) + assert.Equal(t, "3", view.Scopes) + assert.Equal(t, []string{"cgr_1", "client-id-1", "https://travel0.com/api", "3"}, view.AsTableRow()) + }) + + t.Run("shows all when the grant allows all scopes", func(t *testing.T) { + grant := &managementv3.ClientGrantResponseContent{ + ID: auth0.String("cgr_3"), + ClientID: auth0.String("client-id-3"), + Audience: auth0.String("https://travel0.com/api"), + AllowAllScopes: auth0.Bool(true), + } + + view := makeClientGrantTableView(grant) + + assert.Equal(t, "all", view.Scopes) + assert.Equal(t, []string{"cgr_3", "client-id-3", "https://travel0.com/api", "all"}, view.AsTableRow()) + }) + + t.Run("falls back to default_for when client id is empty", func(t *testing.T) { + grant := &managementv3.ClientGrantResponseContent{ + ID: auth0.String("cgr_2"), + DefaultFor: managementv3.ClientGrantDefaultForEnumThirdPartyClients.Ptr(), + Audience: auth0.String("https://travel0.com/api"), + } + + view := makeClientGrantTableView(grant) + + assert.Equal(t, "third_party_clients", view.ClientID) + }) +} + +func TestClientGrantTableView_AsTableHeader(t *testing.T) { + view := clientGrantTableView{} + assert.Equal(t, []string{"ID", "Client ID", "Audience", "Scopes"}, view.AsTableHeader()) +} diff --git a/test/integration/client-grants-test-cases.yaml b/test/integration/client-grants-test-cases.yaml new file mode 100644 index 000000000..e359547ef --- /dev/null +++ b/test/integration/client-grants-test-cases.yaml @@ -0,0 +1,145 @@ +config: + inherit-env: true + retries: 1 + interval: 1s + +tests: + 001 - list client grants: + command: auth0 client-grants list + exit-code: 0 + + 002 - list client grants with invalid number: + command: auth0 client-grants list --number 1001 + exit-code: 1 + stderr: + contains: + - Number flag invalid, please pass a number between 1 and 1000 + + 003 - create client grant with specific scopes and check json output: + command: auth0 client-grants create --client-id $(./test/integration/scripts/get-m2m-app-id.sh) --audience $(./test/integration/scripts/get-api-identifier.sh) --scopes read:todos --json --no-input + exit-code: 0 + stdout: + json: + audience: http://integration-test-api-client-grant + scope: "[read:todos]" + + 004 - create client grant that already exists should fail: + command: auth0 client-grants create --client-id $(./test/integration/scripts/get-m2m-app-id.sh) --audience $(./test/integration/scripts/get-api-identifier.sh) --scopes read:todos --no-input + exit-code: 1 + + 005 - create client grant with allow-any-organization on deny usage should fail: + command: auth0 client-grants create --client-id $(./test/integration/scripts/get-m2m-app-id.sh) --audience $(./test/integration/scripts/get-api-identifier.sh) --allow-all-scopes --organization-usage deny --allow-any-organization=true --no-input + exit-code: 1 + stderr: + contains: + - "--allow-any-organization can only be enabled when --organization-usage is 'allow' or 'require'" + + 006 - create client grant with user subject type and organization usage should fail: + command: auth0 client-grants create --client-id $(./test/integration/scripts/get-m2m-app-id.sh) --audience $(./test/integration/scripts/get-api-identifier.sh) --subject-type user --organization-usage allow --no-input + exit-code: 1 + stderr: + contains: + - "cannot be set when --subject-type is" + + 007 - show client grant json output: + command: auth0 client-grants show $(./test/integration/scripts/get-client-grant-id.sh) --json --no-input + exit-code: 0 + stdout: + json: + audience: http://integration-test-api-client-grant + scope: "[read:todos]" + + 008 - show client grant table output: + command: auth0 client-grants show $(./test/integration/scripts/get-client-grant-id.sh) --no-input + exit-code: 0 + stdout: + contains: + - AUDIENCE http://integration-test-api-client-grant + - SCOPES read:todos + + 009 - show client grant with invalid id: + command: auth0 client-grants show this-client-grant-id-does-not-exist --no-input + exit-code: 1 + stderr: + contains: + - "Failed to read client grant with ID \"this-client-grant-id-does-not-exist\"" + + 010 - update client grant scopes json output: + command: auth0 client-grants update $(./test/integration/scripts/get-client-grant-id.sh) --scopes read:todos,write:todos --json --no-input + exit-code: 0 + stdout: + json: + scope: "[read:todos write:todos]" + + 011 - update client grant to allow all scopes: + command: auth0 client-grants update $(./test/integration/scripts/get-client-grant-id.sh) --allow-all-scopes --json --no-input + exit-code: 0 + stdout: + json: + allow_all_scopes: "true" + + 012 - update client grant organization settings: + command: auth0 client-grants update $(./test/integration/scripts/get-client-grant-id.sh) --organization-usage allow --allow-any-organization=true --json --no-input + exit-code: 0 + stdout: + json: + organization_usage: allow + allow_any_organization: "true" + + 013 - update client grant back to specific scopes clears allow all scopes: + command: auth0 client-grants update $(./test/integration/scripts/get-client-grant-id.sh) --scopes read:todos --json --no-input + exit-code: 0 + stdout: + json: + scope: "[read:todos]" + + 014 - update client grant with allow-any-organization on deny usage should fail: + command: auth0 client-grants update $(./test/integration/scripts/get-client-grant-id.sh) --organization-usage deny --allow-any-organization=true --no-input + exit-code: 1 + stderr: + contains: + - "--allow-any-organization can only be enabled when --organization-usage is 'allow' or 'require'" + + 015 - delete client grant: + command: auth0 client-grants delete $(./test/integration/scripts/get-client-grant-id.sh) --force --no-input + exit-code: 0 + + 016 - delete client grant with invalid id: + command: auth0 client-grants delete this-client-grant-id-does-not-exist --force --no-input + exit-code: 1 + stderr: + contains: + - "Failed to delete client grant with ID \"this-client-grant-id-does-not-exist\"" + + 017 - create client grant with neither client-id nor default-for should fail: + command: auth0 client-grants create --audience $(./test/integration/scripts/get-api-identifier.sh) --no-input + exit-code: 1 + stderr: + contains: + - "of --client-id or --default-for must be set" + + 018 - create client grant with both client-id and default-for should fail: + command: auth0 client-grants create --client-id $(./test/integration/scripts/get-m2m-app-id.sh) --default-for third_party_clients --audience $(./test/integration/scripts/get-api-identifier.sh) --no-input + exit-code: 1 + stderr: + contains: + - "[client-id default-for]" + + 019 - create default client grant with organization usage should fail: + command: auth0 client-grants create --default-for third_party_clients --audience $(./test/integration/scripts/get-api-identifier.sh) --organization-usage allow --no-input + exit-code: 1 + stderr: + contains: + - "--organization-usage cannot be set with --default-for" + + 020 - create default client grant and check json output: + command: auth0 client-grants create --default-for third_party_clients --audience $(./test/integration/scripts/get-api-identifier.sh) --scopes read:todos --json --no-input + exit-code: 0 + stdout: + json: + audience: http://integration-test-api-client-grant + default_for: third_party_clients + + 021 - delete default client grant: + command: auth0 client-grants delete $(auth0 client-grants list --default-for third_party_clients --audience $(./test/integration/scripts/get-api-identifier.sh) --json --no-input | jq -r '.[0].id') --force --no-input + exit-code: 0 diff --git a/test/integration/scripts/create-client-grant.sh b/test/integration/scripts/create-client-grant.sh index a007d5f95..01f4b9514 100755 --- a/test/integration/scripts/create-client-grant.sh +++ b/test/integration/scripts/create-client-grant.sh @@ -3,4 +3,4 @@ management_api_audience=$(./test/integration/scripts/get-manage-api-audience.sh) m2m_client_id=$(./test/integration/scripts/get-m2m-app-id.sh) -auth0 api POST "client-grants" --data "{\"client_id\":\"$m2m_client_id\",\"audience\": \"$management_api_audience\",\"scope\": []}" \ No newline at end of file +auth0 client-grants create --client-id "$m2m_client_id" --audience "$management_api_audience" --no-input \ No newline at end of file diff --git a/test/integration/scripts/get-api-identifier.sh b/test/integration/scripts/get-api-identifier.sh new file mode 100755 index 000000000..3588d3199 --- /dev/null +++ b/test/integration/scripts/get-api-identifier.sh @@ -0,0 +1,14 @@ +#! /bin/bash + +FILE=./test/integration/identifiers/client-grant-api-identifier +if [ -f "$FILE" ]; then + cat $FILE + exit 0 +fi + +identifier="http://integration-test-api-client-grant" +auth0 apis create --name integration-test-api-client-grant --identifier "$identifier" --scopes read:todos,write:todos --json --no-input > /dev/null || true + +mkdir -p ./test/integration/identifiers +echo "$identifier" > $FILE +cat $FILE diff --git a/test/integration/scripts/get-client-grant-id.sh b/test/integration/scripts/get-client-grant-id.sh new file mode 100755 index 000000000..d3e92b88b --- /dev/null +++ b/test/integration/scripts/get-client-grant-id.sh @@ -0,0 +1,6 @@ +#! /bin/bash + +client_id=$(./test/integration/scripts/get-m2m-app-id.sh) +audience=$(./test/integration/scripts/get-api-identifier.sh) + +auth0 client-grants list --client-id "$client_id" --audience "$audience" --json --no-input | jq -r '.[0].id'