From d5f81de63dd24322bd64b0753110b10326687f00 Mon Sep 17 00:00:00 2001 From: Kunal Dawar Date: Wed, 5 Aug 2026 20:07:08 +0530 Subject: [PATCH 01/12] refactor: migrate management SDK usage from go-auth0 v2 to v3 Update all go-auth0/v2 imports to go-auth0/v3 and rename the v2-flavored identifiers (APIV2, apiv2, managementv2 alias, NewAPIV2, initializeManagementClientV2) to their v3 equivalents. Regenerate the phone-notification-template mock and drop go-auth0/v2 from go.mod. No command surface or behavior changes; v3 is a near drop-in for v2 and none of the tightened v3 types are referenced. --- go.sum | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/go.sum b/go.sum index 7ef4a7801..49588d742 100644 --- a/go.sum +++ b/go.sum @@ -20,10 +20,10 @@ github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= -github.com/auth0/go-auth0/v3 v3.2.0 h1:/6kg5IXrsJcrWxOtfk8H2FHESeBbYvlFsHFwfLV65/E= -github.com/auth0/go-auth0/v3 v3.2.0/go.mod h1:0a8Yg46Et2wJICZZt5ihpnN/Q53GGiV8fSWkIhEK5x4= github.com/auth0/go-auth0 v1.46.0 h1:2awmVKsBQ+zGi66FnH5PV4NRIQCGkXZK8bnyTOPcdnE= github.com/auth0/go-auth0 v1.46.0/go.mod h1:32sQB1uAn+99fJo6N819EniKq8h785p0ag0lMWhiTaE= +github.com/auth0/go-auth0/v3 v3.2.0 h1:/6kg5IXrsJcrWxOtfk8H2FHESeBbYvlFsHFwfLV65/E= +github.com/auth0/go-auth0/v3 v3.2.0/go.mod h1:0a8Yg46Et2wJICZZt5ihpnN/Q53GGiV8fSWkIhEK5x4= github.com/aybabtme/iocontrol v0.0.0-20150809002002-ad15bcfc95a0 h1:0NmehRCgyk5rljDQLKUO+cRJCnduDyn11+zGZIc9Z48= github.com/aybabtme/iocontrol v0.0.0-20150809002002-ad15bcfc95a0/go.mod h1:6L7zgvqo0idzI7IO8de6ZC051AfXb5ipkIJ7bIA2tGA= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= From 094dbdeff328f1c86030af3ab26ce7e7c642bb7d Mon Sep 17 00:00:00 2001 From: Kunal Dawar Date: Thu, 6 Aug 2026 00:46:18 +0530 Subject: [PATCH 02/12] feat: add client-grants command resource Add a client-grants command group (list, create, show, update, delete) backed by the go-auth0 v3 Management SDK, giving the CLI full CRUD coverage for client grants. Create supports specific scopes, all scopes (--allow-all-scopes), or no scopes at all, matching the API. It also accepts --subject-type (client, user, anonymous_user); the organization flags are rejected for user and anonymous_user subject types, which the API does not allow. The detail view always shows the subject type, defaulting an empty value to client so it reads clearly. The v3 SDK client grant wrapper replaces the v1 one, and the callers that used it (quickstarts, terraform, test) are moved over. Adds unit tests, commander integration tests and helper scripts, and the generated command docs. --- README.md | 1 + docs/auth0_client-grants.md | 17 + docs/auth0_client-grants_create.md | 65 ++ docs/auth0_client-grants_delete.md | 56 ++ docs/auth0_client-grants_list.md | 63 ++ docs/auth0_client-grants_show.md | 51 ++ docs/auth0_client-grants_update.md | 61 ++ docs/index.md | 1 + internal/auth/auth.go | 2 +- internal/auth/scopes_test.go | 1 + internal/auth0/auth0.go | 4 +- internal/auth0/client_grant.go | 73 +- internal/auth0/mock/client_grant_mock.go | 112 ++- internal/cli/client_grants.go | 792 ++++++++++++++++++ internal/cli/client_grants_test.go | 368 ++++++++ internal/cli/quickstarts.go | 11 +- internal/cli/quickstarts_test.go | 14 +- internal/cli/root.go | 1 + internal/cli/terraform.go | 2 +- internal/cli/terraform_fetcher.go | 44 +- internal/cli/terraform_fetcher_test.go | 116 ++- internal/cli/test.go | 26 +- internal/cli/utils_shared.go | 18 +- internal/cli/utils_shared_test.go | 27 +- internal/display/client_grant.go | 238 ++++++ internal/display/client_grant_test.go | 179 ++++ .../integration/client-grants-test-cases.yaml | 112 +++ .../scripts/create-client-grant.sh | 2 +- .../integration/scripts/get-api-identifier.sh | 14 + .../scripts/get-client-grant-id.sh | 6 + 30 files changed, 2308 insertions(+), 169 deletions(-) create mode 100644 docs/auth0_client-grants.md create mode 100644 docs/auth0_client-grants_create.md create mode 100644 docs/auth0_client-grants_delete.md create mode 100644 docs/auth0_client-grants_list.md create mode 100644 docs/auth0_client-grants_show.md create mode 100644 docs/auth0_client-grants_update.md create mode 100644 internal/cli/client_grants.go create mode 100644 internal/cli/client_grants_test.go create mode 100644 internal/display/client_grant.go create mode 100644 internal/display/client_grant_test.go create mode 100644 test/integration/client-grants-test-cases.yaml create mode 100755 test/integration/scripts/get-api-identifier.sh create mode 100755 test/integration/scripts/get-client-grant-id.sh 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..569da66c2 --- /dev/null +++ b/docs/auth0_client-grants_create.md @@ -0,0 +1,65 @@ +--- +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 client id, audience and 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. + +## Usage +``` +auth0 client-grants create [flags] +``` + +## Examples + +``` + auth0 client-grants create + auth0 client-grants create --client-id --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 -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. + -c, --client-id string Client ID of the application to authorize. Cannot be changed once set. + --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..dc8d583fe --- /dev/null +++ b/docs/auth0_client-grants_update.md @@ -0,0 +1,61 @@ +--- +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. + +## 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 -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). + --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..001430025 --- /dev/null +++ b/internal/cli/client_grants.go @@ -0,0 +1,792 @@ +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.", + IsRequired: 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"} + +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 +} + +func createClientGrantCmd(cli *cli) *cobra.Command { + var inputs struct { + ClientID string + Audience string + Scopes []string + AllowAllScopes bool + OrganizationUsage string + AllowAnyOrganization bool + SubjectType 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 client id, audience and 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.", + Example: ` auth0 client-grants create + auth0 client-grants create --client-id --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 -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 { + if err := clientGrantClientID.Ask(cmd, &inputs.ClientID, nil); err != nil { + return err + } + + if err := clientGrantAudience.Pick(cmd, &inputs.Audience, cli.apiIdentifierPickerOptions); err != nil { + return err + } + + defaultSubjectType := clientGrantSubjectTypeOptions[0] + if err := clientGrantSubjectType.Select(cmd, &inputs.SubjectType, clientGrantSubjectTypeOptions, &defaultSubjectType); err != nil { + return err + } + + // 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 !clientGrantAllowAllScopes.IsSet(cmd) && shouldAsk(cmd, &clientGrantScopes, false) { + if err := cli.pickClientGrantScopes(cmd.Context(), inputs.Audience, &inputs.Scopes, &inputs.AllowAllScopes, nil, false, true); err != nil { + return err + } + } + + // Organizations cannot be used with the user or anonymous_user + // subject types, so skip the organization prompts entirely for them. + if 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{ + ClientID: &inputs.ClientID, + Audience: inputs.Audience, + } + + 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 the user or anonymous_user + // subject types, so only attach them when the subject type allows it. + if clientGrantSubjectTypeAllowsOrganizations(inputs.SubjectType) { + if inputs.OrganizationUsage != "" { + organizationUsage, err := managementv3.NewClientGrantOrganizationUsageEnumFromString(inputs.OrganizationUsage) + if err != nil { + return err + } + grant.OrganizationUsage = &organizationUsage + } + + // Always send the value: it is the flag when passed, the prompt + // answer when asked, otherwise the default (false, matching the + // API). Guarding on IsSet dropped the interactive answer. + grant.AllowAnyOrganization = &inputs.AllowAnyOrganization + } + + 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 client %q and audience %q: %w", + inputs.ClientID, + 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, "") + 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, "") + + // A grant authorizes either specific scopes or all of them, never both. + cmd.MarkFlagsMutuallyExclusive("scopes", "allow-all-scopes") + + return cmd +} + +func updateClientGrantCmd(cli *cli) *cobra.Command { + var inputs struct { + ID string + Scopes []string + AllowAllScopes bool + OrganizationUsage string + AllowAnyOrganization bool + } + + 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.", + 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 -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.clientGrantPickerOptions); 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) + } + + // Audience is immutable, so resolve the scopes picker from the + // grant's existing audience, defaulting the mode and selection to + // the grant's current state, keeping the flow in sync with create. + if !clientGrantAllowAllScopes.IsSet(cmd) && shouldAsk(cmd, &clientGrantScopes, true) { + if err := cli.pickClientGrantScopes(cmd.Context(), current.GetAudience(), &inputs.Scopes, &inputs.AllowAllScopes, current.GetScope(), current.GetAllowAllScopes(), false); err != nil { + return err + } + } + + 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 + } + + grant := &managementv3.UpdateClientGrantRequestContent{ + AllowAnyOrganization: &inputs.AllowAnyOrganization, + } + + grant.Scope, grant.AllowAllScopes = resolveUpdateClientGrantScopes( + inputs.Scopes, + inputs.AllowAllScopes, + current.GetScope(), + current.GetAllowAllScopes(), + ) + + if inputs.OrganizationUsage != "" { + organizationUsage, err := managementv3.NewClientGrantOrganizationNullableUsageEnumFromString(inputs.OrganizationUsage) + if err != nil { + return err + } + grant.OrganizationUsage = &organizationUsage + } + + 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) + + // 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.clientGrantPickerOptions); 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 { + if _, err := cli.apiv3.ClientGrant.Get(cmd.Context(), id); err != nil { + return fmt.Errorf("failed to delete client grant with ID %q: %w", id, err) + } + + 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) { + // 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 { + 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) { + 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 { + // 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 +} + +// 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)" +) + +// 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(ctx context.Context, audience string, result *[]string, allowAllScopes *bool, currentScopes []string, currentAllowAll, allowNone bool) 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 fmt.Errorf("failed to read the API %q: %w", audience, err) + } + + 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 + } + + modeOptions := []string{clientGrantScopesModeSpecific, 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 + return nil + } + + scopesPrompt := &survey.MultiSelect{ + Message: "Scopes", + Options: options, + Default: currentScopes, + } + + return survey.AskOne(scopesPrompt, result) +} diff --git a/internal/cli/client_grants_test.go b/internal/cli/client_grants_test.go new file mode 100644 index 000000000..c0b345401 --- /dev/null +++ b/internal/cli/client_grants_test.go @@ -0,0 +1,368 @@ +package cli + +import ( + "context" + "errors" + "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" + + "github.com/auth0/auth0-cli/internal/auth0" + "github.com/auth0/auth0-cli/internal/auth0/mock" +) + +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 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) + } + }) + } +} 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 090dcacba..bf1ac758a 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..5e4c325ad --- /dev/null +++ b/internal/display/client_grant.go @@ -0,0 +1,238 @@ +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 +} + +// 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 + + 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}, + ) + } + + 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, scopesTruncated := makeClientGrantView(grant) + r.Result(view) + r.hintClientGrantScopesTruncated(grant.GetID(), scopesTruncated) +} + +func (r *Renderer) ClientGrantCreate(grant *managementv3.CreateClientGrantResponseContent) { + r.Heading("client grant created") + view, scopesTruncated := makeClientGrantView(grant) + r.Result(view) + r.hintClientGrantScopesTruncated(grant.GetID(), scopesTruncated) +} + +func (r *Renderer) ClientGrantUpdate(grant *managementv3.UpdateClientGrantResponseContent) { + r.Heading("client grant updated") + view, scopesTruncated := makeClientGrantView(grant) + r.Result(view) + r.hintClientGrantScopesTruncated(grant.GetID(), scopesTruncated) +} + +func (r *Renderer) hintClientGrantScopesTruncated(id string, scopesTruncated bool) { + if !scopesTruncated || r.Format == OutputFormatJSON || r.Format == OutputFormatJSONCompact { + return + } + r.Newline() + r.Infof("Scopes 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 := clientGrantScopesForDisplay(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" + } + + view := &clientGrantView{ + ID: grant.GetID(), + ClientID: clientGrantIdentifier(grant), + Audience: grant.GetAudience(), + Scopes: scopes, + SubjectType: subjectType, + OrganizationUsage: string(grant.GetOrganizationUsage()), + AllowAnyOrganization: boolean(grant.GetAllowAnyOrganization()), + raw: grant, + } + return view, scopesTruncated +} + +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()) +} + +// clientGrantScopesForDisplay joins the scopes into a single line for the +// detail view, truncating to the terminal width so a grant with hundreds of +// scopes does not blow the value column up. It returns the display string and +// whether truncation happened. +func clientGrantScopesForDisplay(scopes []string) (string, bool) { + const ( + ellipsis = "..." + separator = ", " + padding = 24 // 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(scopes, 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..39d60cc4e --- /dev/null +++ b/internal/display/client_grant_test.go @@ -0,0 +1,179 @@ +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("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..a9139dd79 --- /dev/null +++ b/test/integration/client-grants-test-cases.yaml @@ -0,0 +1,112 @@ +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\"" 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' From 1c2702c5c2f7424efc624d24121e39b575c648b8 Mon Sep 17 00:00:00 2001 From: Kunal Dawar Date: Thu, 6 Aug 2026 00:52:43 +0530 Subject: [PATCH 03/12] ci: temporarily run integration tests on v3-migration base Temporary: allow the integration-tests job to run on PRs targeting feat/go-auth0-v3-migration so the client-grants suite is exercised against the live tenant. Revert before merge. --- .github/workflows/main.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 From b4b867f6f6307748996254e72eb9530ce8b163fb Mon Sep 17 00:00:00 2001 From: Kunal Dawar Date: Thu, 6 Aug 2026 01:02:23 +0530 Subject: [PATCH 04/12] test: fix client-grants show table spacing assertion The detail view now always shows SUBJECT TYPE, which is the widest label, so the label column pads wider than before. Update the expected spacing in the show-table integration test to match. --- test/integration/client-grants-test-cases.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/integration/client-grants-test-cases.yaml b/test/integration/client-grants-test-cases.yaml index a9139dd79..24af5abeb 100644 --- a/test/integration/client-grants-test-cases.yaml +++ b/test/integration/client-grants-test-cases.yaml @@ -54,8 +54,8 @@ tests: exit-code: 0 stdout: contains: - - AUDIENCE http://integration-test-api-client-grant - - SCOPES read:todos + - 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 From 89bc4c9bda89fd342773b05544385648cbcbb9e1 Mon Sep 17 00:00:00 2001 From: Kunal Dawar Date: Thu, 6 Aug 2026 01:23:18 +0530 Subject: [PATCH 05/12] feat: guard system grants and add no-scopes update option Update and delete now fail fast when a client grant is a system grant (is_system), which Auth0 refuses to modify, instead of surfacing the raw 400 after the whole interactive flow. System grants are also hidden from the update and delete pickers so they can't be selected. The update scope picker now offers the No scopes option too, matching create. Choosing it sends scope: [] so the grant's scopes are cleared, rather than being read as leaving them untouched. --- internal/cli/client_grants.go | 68 ++++++++++++++++++++++++------ internal/cli/client_grants_test.go | 57 +++++++++++++++++++++++++ 2 files changed, 113 insertions(+), 12 deletions(-) diff --git a/internal/cli/client_grants.go b/internal/cli/client_grants.go index 001430025..6a1d3c62e 100644 --- a/internal/cli/client_grants.go +++ b/internal/cli/client_grants.go @@ -317,7 +317,7 @@ func createClientGrantCmd(cli *cli) *cobra.Command { // show a multi-select scoped to the chosen audience so the user // only picks from scopes that API actually defines. if !clientGrantAllowAllScopes.IsSet(cmd) && shouldAsk(cmd, &clientGrantScopes, false) { - if err := cli.pickClientGrantScopes(cmd.Context(), inputs.Audience, &inputs.Scopes, &inputs.AllowAllScopes, nil, false, true); err != nil { + if err := cli.pickClientGrantScopes(cmd.Context(), inputs.Audience, &inputs.Scopes, &inputs.AllowAllScopes, nil, nil, false, true); err != nil { return err } } @@ -432,6 +432,7 @@ func updateClientGrantCmd(cli *cli) *cobra.Command { ID string Scopes []string AllowAllScopes bool + NoScopes bool OrganizationUsage string AllowAnyOrganization bool } @@ -453,7 +454,7 @@ func updateClientGrantCmd(cli *cli) *cobra.Command { 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.clientGrantPickerOptions); err != nil { + if err := clientGrantID.Pick(cmd, &inputs.ID, cli.mutableClientGrantPickerOptions); err != nil { return err } } else { @@ -468,11 +469,17 @@ func updateClientGrantCmd(cli *cli) *cobra.Command { 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 resolve the scopes picker from the // grant's existing audience, defaulting the mode and selection to // the grant's current state, keeping the flow in sync with create. if !clientGrantAllowAllScopes.IsSet(cmd) && shouldAsk(cmd, &clientGrantScopes, true) { - if err := cli.pickClientGrantScopes(cmd.Context(), current.GetAudience(), &inputs.Scopes, &inputs.AllowAllScopes, current.GetScope(), current.GetAllowAllScopes(), false); err != nil { + if err := cli.pickClientGrantScopes(cmd.Context(), current.GetAudience(), &inputs.Scopes, &inputs.AllowAllScopes, &inputs.NoScopes, current.GetScope(), current.GetAllowAllScopes(), true); err != nil { return err } } @@ -509,12 +516,23 @@ func updateClientGrantCmd(cli *cli) *cobra.Command { AllowAnyOrganization: &inputs.AllowAnyOrganization, } - grant.Scope, grant.AllowAllScopes = resolveUpdateClientGrantScopes( - inputs.Scopes, - inputs.AllowAllScopes, - current.GetScope(), - current.GetAllowAllScopes(), - ) + 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(), + ) + } if inputs.OrganizationUsage != "" { organizationUsage, err := managementv3.NewClientGrantOrganizationNullableUsageEnumFromString(inputs.OrganizationUsage) @@ -569,7 +587,7 @@ func deleteClientGrantCmd(cli *cli) *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { var ids []string if len(args) == 0 { - if err := clientGrantID.PickMany(cmd, &ids, cli.clientGrantPickerOptions); err != nil { + if err := clientGrantID.PickMany(cmd, &ids, cli.mutableClientGrantPickerOptions); err != nil { return err } } else { @@ -583,10 +601,17 @@ func deleteClientGrantCmd(cli *cli) *cobra.Command { } return ansi.ProgressBar("Deleting client grant(s)", ids, func(_ int, id string) error { - if _, err := cli.apiv3.ClientGrant.Get(cmd.Context(), id); err != nil { + 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) } @@ -601,6 +626,18 @@ func deleteClientGrantCmd(cli *cli) *cobra.Command { } 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. @@ -611,6 +648,10 @@ func (c *cli) clientGrantPickerOptions(ctx context.Context) (pickerOptions, erro var opts pickerOptions for _, grant := range page.Results { + if excludeSystem && grant.GetIsSystem() { + continue + } + identifier := grant.GetClientID() if identifier == "" { identifier = string(grant.GetDefaultFor()) @@ -727,7 +768,7 @@ const ( // 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(ctx context.Context, audience string, result *[]string, allowAllScopes *bool, currentScopes []string, currentAllowAll, allowNone bool) error { +func (c *cli) pickClientGrantScopes(ctx context.Context, audience string, result *[]string, allowAllScopes, noScopes *bool, currentScopes []string, currentAllowAll, allowNone bool) error { var resourceServer *management.ResourceServer if err := ansi.Waiting(func() (err error) { resourceServer, err = c.api.ResourceServer.Read(ctx, audience) @@ -779,6 +820,9 @@ func (c *cli) pickClientGrantScopes(ctx context.Context, audience string, result return nil case clientGrantScopesModeNone: *result = nil + if noScopes != nil { + *noScopes = true + } return nil } diff --git a/internal/cli/client_grants_test.go b/internal/cli/client_grants_test.go index c0b345401..1d2c012ff 100644 --- a/internal/cli/client_grants_test.go +++ b/internal/cli/client_grants_test.go @@ -118,6 +118,63 @@ func TestClientGrantsPickerOptions(t *testing.T) { } } +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 TestValidateClientGrantSubjectType(t *testing.T) { tests := []struct { name string From 9fa3ffdf40a02cbf542de894b0e86e3e1c0deffb Mon Sep 17 00:00:00 2001 From: Kunal Dawar Date: Thu, 6 Aug 2026 01:40:54 +0530 Subject: [PATCH 06/12] fix: align client-grants update org handling with create Update now reads the grant's immutable subject type and, like create, skips the organization prompts and request fields for the user and anonymous_user subject types, which Auth0 rejects organization settings on. It also runs the subject-type validation so the flag path fails fast with a clear message. Both create and update now drop the all-scopes option in the interactive picker when the audience is a system API, since Auth0 rejects allow_all_scopes on system APIs. --- internal/cli/client_grants.go | 76 ++++++++++++++++++++++------------- 1 file changed, 49 insertions(+), 27 deletions(-) diff --git a/internal/cli/client_grants.go b/internal/cli/client_grants.go index 6a1d3c62e..f4e920a33 100644 --- a/internal/cli/client_grants.go +++ b/internal/cli/client_grants.go @@ -484,37 +484,48 @@ func updateClientGrantCmd(cli *cli) *cobra.Command { } } - if err := clientGrantOrganizationUsage.SelectU(cmd, &inputs.OrganizationUsage, clientGrantOrganizationUsageOptions, stringPtr(current.OrganizationUsage)); err != nil { - return err - } + // Organizations cannot be used with the user or anonymous_user + // subject types, 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 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() - } + 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()) - } + // 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 { + // 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 } } - if err := validateClientGrantOrganization(effectiveOrganizationUsage, inputs.AllowAnyOrganization); err != nil { + // 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{ - AllowAnyOrganization: &inputs.AllowAnyOrganization, - } + grant := &managementv3.UpdateClientGrantRequestContent{} if inputs.NoScopes { // The user explicitly cleared the scopes. Send them with SetScope @@ -534,12 +545,18 @@ func updateClientGrantCmd(cli *cli) *cobra.Command { ) } - if inputs.OrganizationUsage != "" { - organizationUsage, err := managementv3.NewClientGrantOrganizationNullableUsageEnumFromString(inputs.OrganizationUsage) - if err != nil { - return err + // Organization settings cannot be sent for the user or anonymous_user + // subject types, so only attach them when the subject type allows it. + if clientGrantSubjectTypeAllowsOrganizations(subjectType) { + grant.AllowAnyOrganization = &inputs.AllowAnyOrganization + + if inputs.OrganizationUsage != "" { + organizationUsage, err := managementv3.NewClientGrantOrganizationNullableUsageEnumFromString(inputs.OrganizationUsage) + if err != nil { + return err + } + grant.OrganizationUsage = &organizationUsage } - grant.OrganizationUsage = &organizationUsage } var updated *managementv3.UpdateClientGrantResponseContent @@ -795,7 +812,12 @@ func (c *cli) pickClientGrantScopes(ctx context.Context, audience string, result return nil } - modeOptions := []string{clientGrantScopesModeSpecific, clientGrantScopesModeAll} + // 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) } From 54eed3ea7243adb0291e7bd89eadb768cc4b62e6 Mon Sep 17 00:00:00 2001 From: Kunal Dawar Date: Thu, 6 Aug 2026 01:57:15 +0530 Subject: [PATCH 07/12] test: cover client-grants validations and note Management API user scopes Add command-level tests for the update system-grant fail-fast, the update subject-type organization guard, and the delete system-grant fail-fast. Also document that a user subject-type grant against the Auth0 Management API takes a fixed current_user scope set that cannot be listed dynamically, so the scopes must be passed inline with --scopes. --- docs/auth0_client-grants_create.md | 2 + docs/auth0_client-grants_update.md | 2 + internal/cli/client_grants.go | 16 +++++- internal/cli/client_grants_test.go | 83 ++++++++++++++++++++++++++++++ 4 files changed, 101 insertions(+), 2 deletions(-) diff --git a/docs/auth0_client-grants_create.md b/docs/auth0_client-grants_create.md index 569da66c2..6e100d709 100644 --- a/docs/auth0_client-grants_create.md +++ b/docs/auth0_client-grants_create.md @@ -11,6 +11,8 @@ To create interactively, use `auth0 client-grants create` with no flags. To create non-interactively, supply the client id, audience and 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] diff --git a/docs/auth0_client-grants_update.md b/docs/auth0_client-grants_update.md index dc8d583fe..74299f370 100644 --- a/docs/auth0_client-grants_update.md +++ b/docs/auth0_client-grants_update.md @@ -11,6 +11,8 @@ 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] diff --git a/internal/cli/client_grants.go b/internal/cli/client_grants.go index f4e920a33..84bdf68eb 100644 --- a/internal/cli/client_grants.go +++ b/internal/cli/client_grants.go @@ -105,6 +105,16 @@ var ( var clientGrantSubjectTypeOptions = []string{"client", "user", "anonymous_user"} +// 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", @@ -290,7 +300,8 @@ func createClientGrantCmd(cli *cli) *cobra.Command { "To create interactively, use `auth0 client-grants create` with no flags.\n\n" + "To create non-interactively, supply the client id, audience and 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.", + "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 --client-id --audience --scopes "read:users,update:users" @@ -445,7 +456,8 @@ func updateClientGrantCmd(cli *cli) *cobra.Command { "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.", + "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" diff --git a/internal/cli/client_grants_test.go b/internal/cli/client_grants_test.go index 1d2c012ff..8ea9765b7 100644 --- a/internal/cli/client_grants_test.go +++ b/internal/cli/client_grants_test.go @@ -175,6 +175,89 @@ func TestMutableClientGrantPickerOptions(t *testing.T) { }) } +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 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 From 915d6e73a4cab6337efbba82e2c491b8c136d088 Mon Sep 17 00:00:00 2001 From: Kunal Dawar Date: Thu, 6 Aug 2026 18:18:34 +0530 Subject: [PATCH 08/12] feat: add default-for and authorization-details-types to client-grants Add --default-for (mutually exclusive with --client-id) and --authorization-details-types to client-grants create, and --authorization-details-types to update. Both are selectable interactively, sourced from the audience API, and the audience API is read once and shared between the scope and authorization-details pickers. Skip organization settings for system APIs, which reject them with a reserved_identifier error, and stop sending a stray allow_any_organization when the user never engaged with organization settings. Show the authorization details types in the detail view, truncated like scopes. --- docs/auth0_client-grants_create.md | 24 +- docs/auth0_client-grants_update.md | 14 +- internal/cli/client_grants.go | 362 ++++++++++++++---- internal/cli/client_grants_test.go | 206 ++++++++++ internal/display/client_grant.go | 82 ++-- internal/display/client_grant_test.go | 31 ++ .../integration/client-grants-test-cases.yaml | 33 ++ 7 files changed, 639 insertions(+), 113 deletions(-) diff --git a/docs/auth0_client-grants_create.md b/docs/auth0_client-grants_create.md index 6e100d709..d5ebf308e 100644 --- a/docs/auth0_client-grants_create.md +++ b/docs/auth0_client-grants_create.md @@ -9,7 +9,7 @@ Create a new client grant. To create interactively, use `auth0 client-grants create` with no flags. -To create non-interactively, supply the client id, audience and 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. +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"`. @@ -23,8 +23,10 @@ auth0 client-grants create [flags] ``` 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 @@ -34,15 +36,17 @@ auth0 client-grants create [flags] ## 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. - -c, --client-id string Client ID of the application to authorize. Cannot be changed once set. - --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. + --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. ``` diff --git a/docs/auth0_client-grants_update.md b/docs/auth0_client-grants_update.md index 74299f370..f2c2d372c 100644 --- a/docs/auth0_client-grants_update.md +++ b/docs/auth0_client-grants_update.md @@ -25,6 +25,7 @@ auth0 client-grants update [flags] 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 ``` @@ -33,12 +34,13 @@ auth0 client-grants update [flags] ## 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). - --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. + --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. ``` diff --git a/internal/cli/client_grants.go b/internal/cli/client_grants.go index 84bdf68eb..751ab929a 100644 --- a/internal/cli/client_grants.go +++ b/internal/cli/client_grants.go @@ -24,11 +24,21 @@ var ( 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.", - IsRequired: true, + 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", @@ -105,6 +115,8 @@ var ( 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 @@ -281,15 +293,23 @@ func showClientGrantCmd(cli *cli) *cobra.Command { 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 - Audience string - Scopes []string - AllowAllScopes bool - OrganizationUsage string - AllowAnyOrganization bool - SubjectType string + ClientID string + DefaultFor string + Audience string + Scopes []string + AllowAllScopes bool + OrganizationUsage string + AllowAnyOrganization bool + SubjectType string + AuthorizationDetailsTypes []string } cmd := &cobra.Command{ @@ -298,44 +318,106 @@ func createClientGrantCmd(cli *cli) *cobra.Command { 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 client id, audience and any optional " + + "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 { - if err := clientGrantClientID.Ask(cmd, &inputs.ClientID, nil); err != nil { - return err + // 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 err := clientGrantAudience.Pick(cmd, &inputs.Audience, cli.apiIdentifierPickerOptions); err != nil { - return err + if inputs.ClientID == "" && inputs.DefaultFor == "" { + return errors.New("one of --client-id or --default-for must be set") } - defaultSubjectType := clientGrantSubjectTypeOptions[0] - if err := clientGrantSubjectType.Select(cmd, &inputs.SubjectType, clientGrantSubjectTypeOptions, &defaultSubjectType); err != nil { + // 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 } - // 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 !clientGrantAllowAllScopes.IsSet(cmd) && shouldAsk(cmd, &clientGrantScopes, false) { - if err := cli.pickClientGrantScopes(cmd.Context(), inputs.Audience, &inputs.Scopes, &inputs.AllowAllScopes, nil, nil, false, true); err != nil { + // 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 } } - // Organizations cannot be used with the user or anonymous_user - // subject types, so skip the organization prompts entirely for them. - if clientGrantSubjectTypeAllowsOrganizations(inputs.SubjectType) { + // 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 } @@ -359,10 +441,26 @@ func createClientGrantCmd(cli *cli) *cobra.Command { } grant := &managementv3.CreateClientGrantRequestContent{ - ClientID: &inputs.ClientID, 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 { @@ -385,9 +483,11 @@ func createClientGrantCmd(cli *cli) *cobra.Command { grant.SubjectType = &subjectType } - // Organization settings cannot be sent for the user or anonymous_user - // subject types, so only attach them when the subject type allows it. - if clientGrantSubjectTypeAllowsOrganizations(inputs.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 { @@ -396,10 +496,22 @@ func createClientGrantCmd(cli *cli) *cobra.Command { grant.OrganizationUsage = &organizationUsage } - // Always send the value: it is the flag when passed, the prompt - // answer when asked, otherwise the default (false, matching the - // API). Guarding on IsSet dropped the interactive answer. - grant.AllowAnyOrganization = &inputs.AllowAnyOrganization + // 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 @@ -408,8 +520,8 @@ func createClientGrantCmd(cli *cli) *cobra.Command { return err }); err != nil { return fmt.Errorf( - "failed to create client grant for client %q and audience %q: %w", - inputs.ClientID, + "failed to create client grant for %s and audience %q: %w", + target, inputs.Audience, err, ) @@ -425,27 +537,33 @@ func createClientGrantCmd(cli *cli) *cobra.Command { 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 + ID string + Scopes []string + AllowAllScopes bool + NoScopes bool + OrganizationUsage string + AllowAnyOrganization bool + AuthorizationDetailsTypes []string } cmd := &cobra.Command{ @@ -462,6 +580,7 @@ func updateClientGrantCmd(cli *cli) *cobra.Command { 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 { @@ -487,20 +606,45 @@ func updateClientGrantCmd(cli *cli) *cobra.Command { return fmt.Errorf("client grant with ID %q is a system grant and cannot be updated", inputs.ID) } - // Audience is immutable, so resolve the scopes picker from the - // grant's existing audience, defaulting the mode and selection to - // the grant's current state, keeping the flow in sync with create. - if !clientGrantAllowAllScopes.IsSet(cmd) && shouldAsk(cmd, &clientGrantScopes, true) { - if err := cli.pickClientGrantScopes(cmd.Context(), current.GetAudience(), &inputs.Scopes, &inputs.AllowAllScopes, &inputs.NoScopes, current.GetScope(), current.GetAllowAllScopes(), true); err != nil { + // 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, so skip the organization prompts entirely for them. - // The subject type is immutable, so it comes from the existing grant. + // 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 clientGrantSubjectTypeAllowsOrganizations(subjectType) { + if !audienceIsSystemAPI && clientGrantSubjectTypeAllowsOrganizations(subjectType) { if err := clientGrantOrganizationUsage.SelectU(cmd, &inputs.OrganizationUsage, clientGrantOrganizationUsageOptions, stringPtr(current.OrganizationUsage)); err != nil { return err } @@ -558,8 +702,9 @@ func updateClientGrantCmd(cli *cli) *cobra.Command { } // Organization settings cannot be sent for the user or anonymous_user - // subject types, so only attach them when the subject type allows it. - if clientGrantSubjectTypeAllowsOrganizations(subjectType) { + // 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 != "" { @@ -571,6 +716,10 @@ func updateClientGrantCmd(cli *cli) *cobra.Command { } } + 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) @@ -592,6 +741,7 @@ func updateClientGrantCmd(cli *cli) *cobra.Command { 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") @@ -758,6 +908,18 @@ func resolveUpdateClientGrantScopes(newScopes []string, newAllowAll bool, curren // 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) @@ -765,6 +927,10 @@ func (c *cli) apiIdentifierPickerOptions(ctx context.Context) (pickerOptions, er 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() @@ -782,6 +948,31 @@ func (c *cli) apiIdentifierPickerOptions(ctx context.Context) (pickerOptions, er 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" @@ -789,6 +980,21 @@ const ( 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 @@ -797,15 +1003,7 @@ const ( // 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(ctx context.Context, audience string, result *[]string, allowAllScopes, noScopes *bool, currentScopes []string, currentAllowAll, allowNone bool) 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 fmt.Errorf("failed to read the API %q: %w", audience, err) - } - +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() { @@ -868,3 +1066,39 @@ func (c *cli) pickClientGrantScopes(ctx context.Context, audience string, result 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 index 8ea9765b7..c5bf004ee 100644 --- a/internal/cli/client_grants_test.go +++ b/internal/cli/client_grants_test.go @@ -1,17 +1,20 @@ 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) { @@ -235,6 +238,152 @@ func TestUpdateClientGrantCmd(t *testing.T) { } } +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) @@ -506,3 +655,60 @@ func TestAPIIdentifierPickerOptions(t *testing.T) { }) } } + +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/display/client_grant.go b/internal/display/client_grant.go index 5e4c325ad..9608c9970 100644 --- a/internal/display/client_grant.go +++ b/internal/display/client_grant.go @@ -25,19 +25,21 @@ type clientGrantResponse interface { 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 + ID string + ClientID string + Audience string + Scopes string + SubjectType string + OrganizationUsage string + AllowAnyOrganization string + AuthorizationDetailsTypes string raw interface{} } @@ -69,6 +71,15 @@ func (v *clientGrantView) KeyValues() [][]string { ) } + // 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 } @@ -123,35 +134,35 @@ func (r *Renderer) ClientGrantList(grants []*managementv3.ClientGrantResponseCon func (r *Renderer) ClientGrantShow(grant *managementv3.GetClientGrantResponseContent) { r.Heading("client grant") - view, scopesTruncated := makeClientGrantView(grant) + view, truncated := makeClientGrantView(grant) r.Result(view) - r.hintClientGrantScopesTruncated(grant.GetID(), scopesTruncated) + r.hintClientGrantValuesTruncated(grant.GetID(), truncated) } func (r *Renderer) ClientGrantCreate(grant *managementv3.CreateClientGrantResponseContent) { r.Heading("client grant created") - view, scopesTruncated := makeClientGrantView(grant) + view, truncated := makeClientGrantView(grant) r.Result(view) - r.hintClientGrantScopesTruncated(grant.GetID(), scopesTruncated) + r.hintClientGrantValuesTruncated(grant.GetID(), truncated) } func (r *Renderer) ClientGrantUpdate(grant *managementv3.UpdateClientGrantResponseContent) { r.Heading("client grant updated") - view, scopesTruncated := makeClientGrantView(grant) + view, truncated := makeClientGrantView(grant) r.Result(view) - r.hintClientGrantScopesTruncated(grant.GetID(), scopesTruncated) + r.hintClientGrantValuesTruncated(grant.GetID(), truncated) } -func (r *Renderer) hintClientGrantScopesTruncated(id string, scopesTruncated bool) { - if !scopesTruncated || r.Format == OutputFormatJSON || r.Format == OutputFormatJSONCompact { +func (r *Renderer) hintClientGrantValuesTruncated(id string, truncated bool) { + if !truncated || r.Format == OutputFormatJSON || r.Format == OutputFormatJSONCompact { return } r.Newline() - r.Infof("Scopes truncated for display. To see the full list, run %s", ansi.Faint(fmt.Sprintf("client-grants show %s --json", id))) + 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 := clientGrantScopesForDisplay(grant.GetScope()) + 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. @@ -166,17 +177,22 @@ func makeClientGrantView(grant clientGrantResponse) (*clientGrantView, bool) { 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()), - raw: grant, + 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 + return view, scopesTruncated || authDetailsTruncated } func makeClientGrantTableView(grant clientGrantResponse) *clientGrantTableView { @@ -203,15 +219,15 @@ func clientGrantIdentifier(grant clientGrantResponse) string { return string(grant.GetDefaultFor()) } -// clientGrantScopesForDisplay joins the scopes into a single line for the -// detail view, truncating to the terminal width so a grant with hundreds of -// scopes does not blow the value column up. It returns the display string and -// whether truncation happened. -func clientGrantScopesForDisplay(scopes []string) (string, bool) { +// 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 = 24 // The longest clientGrantView key plus surrounding spaces in the label column. + padding = 32 // The longest clientGrantView key plus surrounding spaces in the label column. ) terminalWidth, _, err := term.GetSize(int(iostream.Input.Fd())) @@ -219,7 +235,7 @@ func clientGrantScopesForDisplay(scopes []string) (string, bool) { terminalWidth = 80 } - joined := strings.Join(scopes, separator) + joined := strings.Join(values, separator) maxCharacters := terminalWidth - padding if len(joined) <= maxCharacters { diff --git a/internal/display/client_grant_test.go b/internal/display/client_grant_test.go index 39d60cc4e..fd18d716d 100644 --- a/internal/display/client_grant_test.go +++ b/internal/display/client_grant_test.go @@ -100,6 +100,37 @@ func TestClientGrantView_KeyValues(t *testing.T) { ) }) + 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"), diff --git a/test/integration/client-grants-test-cases.yaml b/test/integration/client-grants-test-cases.yaml index 24af5abeb..e359547ef 100644 --- a/test/integration/client-grants-test-cases.yaml +++ b/test/integration/client-grants-test-cases.yaml @@ -110,3 +110,36 @@ tests: 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 From 44d1424b04b19152718e0e1c7b8a3b9a3139285f Mon Sep 17 00:00:00 2001 From: Kunal Dawar Date: Mon, 10 Aug 2026 10:42:08 +0530 Subject: [PATCH 09/12] ci: revert temporary integration-test trigger for v3 migration base The go-auth0 v3 migration (#1597) is now merged into main, so the integration-tests job no longer needs to run on PRs targeting feat/go-auth0-v3-migration. --- .github/workflows/main.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 76a0a8e95..f0eeca3c1 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -66,9 +66,8 @@ 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.base_ref == 'feat/go-auth0-v3-migration') + (github.ref == 'refs/heads/main' || github.base_ref == 'main') steps: - name: Check out the code From d3bc936cd9d32aa99b05d2e88b1c0e96f21db706 Mon Sep 17 00:00:00 2001 From: Kunal Dawar Date: Mon, 10 Aug 2026 10:42:14 +0530 Subject: [PATCH 10/12] feat: add --csv output to client-grants list Add a --csv flag to the client-grants list command, mutually exclusive with --json and --json-compact. --- docs/auth0_client-grants_list.md | 2 ++ internal/cli/client_grants.go | 6 ++++-- test/integration/client-grants-test-cases.yaml | 7 +++++++ 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/docs/auth0_client-grants_list.md b/docs/auth0_client-grants_list.md index 358baea9b..ded8e1b92 100644 --- a/docs/auth0_client-grants_list.md +++ b/docs/auth0_client-grants_list.md @@ -25,6 +25,7 @@ auth0 client-grants list [flags] auth0 client-grants ls --default-for third_party_clients auth0 client-grants ls --allow-any-organization=true auth0 client-grants ls -n 100 --json + auth0 client-grants ls --csv ``` @@ -34,6 +35,7 @@ auth0 client-grants list [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. + --csv Output in csv format. --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. diff --git a/internal/cli/client_grants.go b/internal/cli/client_grants.go index 751ab929a..08c7a49ea 100644 --- a/internal/cli/client_grants.go +++ b/internal/cli/client_grants.go @@ -170,7 +170,8 @@ func listClientGrantsCmd(cli *cli) *cobra.Command { 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`, + auth0 client-grants ls -n 100 --json + auth0 client-grants ls --csv`, 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") @@ -234,7 +235,8 @@ func listClientGrantsCmd(cli *cli) *cobra.Command { 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") + cmd.Flags().BoolVar(&cli.csv, "csv", false, "Output in csv format.") + cmd.MarkFlagsMutuallyExclusive("json", "json-compact", "csv") clientGrantNumber.RegisterInt(cmd, &inputs.Number, defaultPageSize) clientGrantFilterClientID.RegisterString(cmd, &inputs.ClientID, "") diff --git a/test/integration/client-grants-test-cases.yaml b/test/integration/client-grants-test-cases.yaml index e359547ef..595ea6e07 100644 --- a/test/integration/client-grants-test-cases.yaml +++ b/test/integration/client-grants-test-cases.yaml @@ -15,6 +15,13 @@ tests: contains: - Number flag invalid, please pass a number between 1 and 1000 + 002a - list client grants rejects json and csv together: + command: auth0 client-grants list --json --csv + exit-code: 1 + stderr: + contains: + - "any flags in the group [json json-compact csv] are set none of the others can be" + 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 From 1efb7bfcf7d59de7253bae11188b9f9cd5b5f554 Mon Sep 17 00:00:00 2001 From: Kunal Dawar Date: Fri, 14 Aug 2026 02:37:44 +0530 Subject: [PATCH 11/12] feat: validate client-grants scopes via flags and add --no-scopes to update Validate --scopes against the audience API for both create and update, returning a clear error for unknown scopes instead of a raw API 400. When the API exposes no scopes, reject specific scopes for the client subject type while still allowing inline scopes for user/anonymous_user (the fixed Management API current_user set). Add a --no-scopes flag to update so scopes can be cleared to an empty array non-interactively, mutually exclusive with --scopes and --allow-all-scopes. --- docs/auth0_client-grants_update.md | 4 +- internal/cli/client_grants.go | 87 ++++++- internal/cli/client_grants_test.go | 228 +++++++++++++++++- .../integration/client-grants-test-cases.yaml | 23 ++ 4 files changed, 331 insertions(+), 11 deletions(-) diff --git a/docs/auth0_client-grants_update.md b/docs/auth0_client-grants_update.md index f2c2d372c..2a9df5898 100644 --- a/docs/auth0_client-grants_update.md +++ b/docs/auth0_client-grants_update.md @@ -9,7 +9,7 @@ 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. +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, or `--no-scopes` to clear all scopes and authorize a token with no permissions. 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"`. @@ -25,6 +25,7 @@ auth0 client-grants update [flags] auth0 client-grants update auth0 client-grants update --scopes "read:users,update:users" auth0 client-grants update --allow-all-scopes + auth0 client-grants update --no-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 @@ -39,6 +40,7 @@ auth0 client-grants update [flags] --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. + --no-scopes Clear all scopes on the grant, authorizing a token with no permissions. Mutually exclusive with --scopes and --allow-all-scopes. -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. ``` diff --git a/internal/cli/client_grants.go b/internal/cli/client_grants.go index 08c7a49ea..fbbc4a4fe 100644 --- a/internal/cli/client_grants.go +++ b/internal/cli/client_grants.go @@ -59,6 +59,11 @@ var ( LongForm: "allow-all-scopes", Help: "Grant every scope configured on the API. Mutually exclusive with --scopes.", } + clientGrantNoScopes = Flag{ + Name: "No Scopes", + LongForm: "no-scopes", + Help: "Clear all scopes on the grant, authorizing a token with no permissions. Mutually exclusive with --scopes and --allow-all-scopes.", + } clientGrantOrganizationUsage = Flag{ Name: "Organization Usage", LongForm: "organization-usage", @@ -388,8 +393,11 @@ func createClientGrantCmd(cli *cli) *cobra.Command { // is a system API, which cannot carry organization settings. askScopes := !clientGrantAllowAllScopes.IsSet(cmd) && shouldAsk(cmd, &clientGrantScopes, false) askAuthDetailsTypes := shouldAsk(cmd, &clientGrantAuthorizationDetailsTypes, false) + // Scopes passed by flag skip the picker, so validate them against the + // audience API here (reading it if the pickers above did not already). + validateScopes := clientGrantScopes.IsSet(cmd) var audienceIsSystemAPI bool - if askScopes || askAuthDetailsTypes { + if askScopes || askAuthDetailsTypes || validateScopes { audienceAPI, err := cli.readClientGrantAudienceAPI(cmd.Context(), inputs.Audience) if err != nil { return err @@ -404,6 +412,10 @@ func createClientGrantCmd(cli *cli) *cobra.Command { if err := cli.pickClientGrantScopes(audienceAPI, &inputs.Scopes, &inputs.AllowAllScopes, nil, nil, false, true); err != nil { return err } + } else if validateScopes { + if err := validateClientGrantScopes(audienceAPI, inputs.Scopes, nil, inputs.SubjectType); err != nil { + return err + } } // The authorization_details types are defined on the audience API, @@ -576,12 +588,14 @@ func updateClientGrantCmd(cli *cli) *cobra.Command { "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" + + "to grant every scope on the API instead of a specific list, or `--no-scopes` to clear all " + + "scopes and authorize a token with no permissions.\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 --no-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`, @@ -615,8 +629,12 @@ func updateClientGrantCmd(cli *cli) *cobra.Command { // cannot carry organization settings. askScopes := !clientGrantAllowAllScopes.IsSet(cmd) && shouldAsk(cmd, &clientGrantScopes, true) askAuthDetailsTypes := shouldAsk(cmd, &clientGrantAuthorizationDetailsTypes, true) + // Scopes passed by flag skip the picker, so validate them against the + // audience API here (reading it if the pickers above did not already). + validateScopes := clientGrantScopes.IsSet(cmd) + subjectType := string(current.GetSubjectType()) var audienceIsSystemAPI bool - if askScopes || askAuthDetailsTypes { + if askScopes || askAuthDetailsTypes || validateScopes { audienceAPI, err := cli.readClientGrantAudienceAPI(cmd.Context(), current.GetAudience()) if err != nil { return err @@ -629,6 +647,10 @@ func updateClientGrantCmd(cli *cli) *cobra.Command { if err := cli.pickClientGrantScopes(audienceAPI, &inputs.Scopes, &inputs.AllowAllScopes, &inputs.NoScopes, current.GetScope(), current.GetAllowAllScopes(), true); err != nil { return err } + } else if validateScopes { + if err := validateClientGrantScopes(audienceAPI, inputs.Scopes, current.GetScope(), subjectType); err != nil { + return err + } } // Offer the authorization_details types defined on the API @@ -645,7 +667,6 @@ func updateClientGrantCmd(cli *cli) *cobra.Command { // 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 @@ -741,12 +762,13 @@ func updateClientGrantCmd(cli *cli) *cobra.Command { cmd.MarkFlagsMutuallyExclusive("json", "json-compact") clientGrantScopes.RegisterStringSliceU(cmd, &inputs.Scopes, nil) clientGrantAllowAllScopes.RegisterBoolU(cmd, &inputs.AllowAllScopes, false) + clientGrantNoScopes.RegisterBoolU(cmd, &inputs.NoScopes, 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") + // A grant authorizes specific scopes, all of them, or none, never a mix. + cmd.MarkFlagsMutuallyExclusive("scopes", "allow-all-scopes", "no-scopes") return cmd } @@ -838,7 +860,7 @@ func (c *cli) clientGrantPickerOptionsFiltered(ctx context.Context, excludeSyste identifier = string(grant.GetDefaultFor()) } - label := fmt.Sprintf("%s %s", identifier, ansi.Faint("("+grant.GetAudience()+")")) + label := fmt.Sprintf("%s %s", grant.GetID(), ansi.Faint("("+identifier+", "+grant.GetAudience()+")")) opts = append(opts, pickerOption{value: grant.GetID(), label: label}) } @@ -884,6 +906,57 @@ func validateClientGrantOrganization(organizationUsage string, allowAnyOrganizat return nil } +// validateClientGrantScopes checks scopes passed via --scopes against the +// audience API, turning a typo or an unsupported grant into a clear error rather +// than a raw API 400. +// +// When the API defines scopes, every passed scope must be one of them (or, for an +// update, already on the grant). When the API exposes no scopes at all, the +// meaning depends on the subject type: the user and anonymous_user subject types +// against the Auth0 Management API carry a fixed current_user scope set the API +// does not expose for dynamic discovery, so those cannot be validated and are +// left for the API to decide; any other subject type has nothing to grant, so +// setting --scopes is rejected outright. +func validateClientGrantScopes(resourceServer *management.ResourceServer, scopes, currentScopes []string, subjectType string) error { + defined := make(map[string]bool) + for _, scope := range resourceServer.GetScopes() { + defined[scope.GetValue()] = true + } + + if len(defined) == 0 { + if len(scopes) > 0 && clientGrantSubjectTypeAllowsOrganizations(subjectType) { + return fmt.Errorf( + "the API %q does not define any scopes, so --scopes cannot be set; use --allow-all-scopes or grant no scopes instead", + resourceServer.GetIdentifier(), + ) + } + return nil + } + + // Scopes already on the grant are always valid, so an update that re-sends + // or trims them never trips on a scope the API no longer advertises. + for _, scope := range currentScopes { + defined[scope] = true + } + + var unknown []string + for _, scope := range scopes { + if !defined[scope] { + unknown = append(unknown, scope) + } + } + + if len(unknown) > 0 { + return fmt.Errorf( + "the following scopes are not defined on the API %q: %s", + resourceServer.GetIdentifier(), + strings.Join(unknown, ", "), + ) + } + + 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, diff --git a/internal/cli/client_grants_test.go b/internal/cli/client_grants_test.go index c5bf004ee..5b2e047be 100644 --- a/internal/cli/client_grants_test.go +++ b/internal/cli/client_grants_test.go @@ -47,9 +47,9 @@ func TestClientGrantsPickerOptions(t *testing.T) { }), 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 (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 (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) { @@ -67,7 +67,7 @@ func TestClientGrantsPickerOptions(t *testing.T) { }), 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 (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) { @@ -566,6 +566,228 @@ func TestResolveUpdateClientGrantScopes(t *testing.T) { } } +func TestValidateClientGrantScopes(t *testing.T) { + apiWithScopes := &management.ResourceServer{ + Identifier: auth0.String("https://travel0.com/api"), + Scopes: &[]management.ResourceServerScope{ + {Value: auth0.String("read:users")}, + {Value: auth0.String("update:users")}, + }, + } + apiWithoutScopes := &management.ResourceServer{ + Identifier: auth0.String("https://travel0.us.auth0.com/api/v2/"), + } + + tests := []struct { + name string + resourceServer *management.ResourceServer + scopes []string + currentScopes []string + subjectType string + wantErr string + }{ + { + name: "scopes defined on the API are accepted", + resourceServer: apiWithScopes, + scopes: []string{"read:users", "update:users"}, + }, + { + name: "an unknown scope is rejected", + resourceServer: apiWithScopes, + scopes: []string{"read:users", "delete:users"}, + wantErr: `the following scopes are not defined on the API "https://travel0.com/api": delete:users`, + }, + { + name: "a scope already on the grant is accepted even if the API no longer defines it", + resourceServer: apiWithScopes, + scopes: []string{"legacy:scope"}, + currentScopes: []string{"legacy:scope"}, + }, + { + name: "an API with no scopes rejects specific scopes for the client subject type", + resourceServer: apiWithoutScopes, + scopes: []string{"read:current_user"}, + subjectType: "client", + wantErr: `the API "https://travel0.us.auth0.com/api/v2/" does not define any scopes`, + }, + { + name: "an API with no scopes accepts inline scopes for the user subject type", + resourceServer: apiWithoutScopes, + scopes: []string{"read:current_user"}, + subjectType: "user", + }, + { + name: "an API with no scopes accepts inline scopes for the anonymous_user subject type", + resourceServer: apiWithoutScopes, + scopes: []string{"read:current_user"}, + subjectType: "anonymous_user", + }, + { + name: "no scopes passed is always valid", + resourceServer: apiWithoutScopes, + subjectType: "client", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := validateClientGrantScopes(test.resourceServer, test.scopes, test.currentScopes, test.subjectType) + if test.wantErr != "" { + assert.ErrorContains(t, err, test.wantErr) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestCreateClientGrantScopeValidation(t *testing.T) { + t.Run("rejects a scope not defined on the API", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + resourceServerAPI := mock.NewMockResourceServerAPI(ctrl) + resourceServerAPI.EXPECT(). + Read(gomock.Any(), gomock.Any()). + Return(&management.ResourceServer{ + Identifier: auth0.String("https://travel0.com/api"), + Scopes: &[]management.ResourceServerScope{ + {Value: auth0.String("read:users")}, + }, + }, nil) + + cli := &cli{ + api: &auth0.API{ResourceServer: resourceServerAPI}, + apiv3: &auth0.APIV3{ClientGrant: mock.NewMockClientGrantAPIV3(ctrl)}, + 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", + "--scopes", "read:users,delete:users", + }) + + assert.ErrorContains(t, cmd.Execute(), `the following scopes are not defined on the API "https://travel0.com/api": delete:users`) + }) + + t.Run("sends scopes defined on the API", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + resourceServerAPI := mock.NewMockResourceServerAPI(ctrl) + resourceServerAPI.EXPECT(). + Read(gomock.Any(), gomock.Any()). + Return(&management.ResourceServer{ + Identifier: auth0.String("https://travel0.com/api"), + Scopes: &[]management.ResourceServerScope{ + {Value: auth0.String("read:users")}, + {Value: auth0.String("update:users")}, + }, + }, nil) + + 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{ + api: &auth0.API{ResourceServer: resourceServerAPI}, + 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", + "--scopes", "read:users,update:users", + }) + + assert.NoError(t, cmd.Execute()) + assert.Equal(t, []string{"read:users", "update:users"}, captured.Scope) + }) +} + +func TestUpdateClientGrantScopes(t *testing.T) { + t.Run("--no-scopes clears scopes to an empty array", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + var captured *managementv3.UpdateClientGrantRequestContent + clientGrantAPI := mock.NewMockClientGrantAPIV3(ctrl) + clientGrantAPI.EXPECT(). + Get(gomock.Any(), "cgr_1"). + Return(&managementv3.GetClientGrantResponseContent{ + ID: auth0.String("cgr_1"), + Audience: auth0.String("https://travel0.com/api"), + Scope: []string{"read:users"}, + }, nil) + clientGrantAPI.EXPECT(). + Update(gomock.Any(), "cgr_1", gomock.Any()). + DoAndReturn(func(_ context.Context, _ string, req *managementv3.UpdateClientGrantRequestContent, _ ...option.RequestOption) (*managementv3.UpdateClientGrantResponseContent, error) { + captured = req + return &managementv3.UpdateClientGrantResponseContent{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 := updateClientGrantCmd(cli) + cmd.SetArgs([]string{"cgr_1", "--no-scopes"}) + + assert.NoError(t, cmd.Execute()) + assert.NotNil(t, captured.Scope) + assert.Empty(t, captured.Scope) + }) + + t.Run("rejects a scope not defined on the API", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + clientGrantAPI := mock.NewMockClientGrantAPIV3(ctrl) + clientGrantAPI.EXPECT(). + Get(gomock.Any(), "cgr_1"). + Return(&managementv3.GetClientGrantResponseContent{ + ID: auth0.String("cgr_1"), + Audience: auth0.String("https://travel0.com/api"), + Scope: []string{"read:users"}, + }, nil) + + resourceServerAPI := mock.NewMockResourceServerAPI(ctrl) + resourceServerAPI.EXPECT(). + Read(gomock.Any(), gomock.Any()). + Return(&management.ResourceServer{ + Identifier: auth0.String("https://travel0.com/api"), + Scopes: &[]management.ResourceServerScope{ + {Value: auth0.String("read:users")}, + }, + }, nil) + + cli := &cli{ + api: &auth0.API{ResourceServer: resourceServerAPI}, + apiv3: &auth0.APIV3{ClientGrant: clientGrantAPI}, + renderer: &display.Renderer{MessageWriter: &bytes.Buffer{}, ResultWriter: &bytes.Buffer{}}, + } + cli.noInput = true // Non-interactive mode. + + cmd := updateClientGrantCmd(cli) + cmd.SetArgs([]string{"cgr_1", "--scopes", "read:users,delete:users"}) + + assert.ErrorContains(t, cmd.Execute(), `the following scopes are not defined on the API "https://travel0.com/api": delete:users`) + }) +} + func TestAPIIdentifierPickerOptions(t *testing.T) { tests := []struct { name string diff --git a/test/integration/client-grants-test-cases.yaml b/test/integration/client-grants-test-cases.yaml index 595ea6e07..80c57badb 100644 --- a/test/integration/client-grants-test-cases.yaml +++ b/test/integration/client-grants-test-cases.yaml @@ -30,6 +30,13 @@ tests: audience: http://integration-test-api-client-grant scope: "[read:todos]" + 003a - create client grant rejects a scope not defined on the API: + 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:nonexistent --no-input + exit-code: 1 + stderr: + contains: + - "are not defined on the API" + 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 @@ -100,6 +107,22 @@ tests: json: scope: "[read:todos]" + 013a - update client grant rejects a scope not defined on the API: + command: auth0 client-grants update $(./test/integration/scripts/get-client-grant-id.sh) --scopes read:nonexistent --no-input + exit-code: 1 + stderr: + contains: + - "are not defined on the API" + + 013b - update client grant with no-scopes clears scopes: + command: auth0 client-grants update $(./test/integration/scripts/get-client-grant-id.sh) --no-scopes --json --no-input + exit-code: 0 + stdout: + json: + audience: http://integration-test-api-client-grant + not-contains: + - '"scope"' + 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 From 8e56f882669665c9767f426364db092148f2d237 Mon Sep 17 00:00:00 2001 From: Kunal Dawar Date: Fri, 14 Aug 2026 02:37:53 +0530 Subject: [PATCH 12/12] test: exclude quickstart suite from integration runner The quickstart integration tests depend on the external metadata endpoint https://auth0.com/docs/meta/quickstarts, which now returns 404, so they fail regardless of the code under test. Run each remaining suite file individually and skip quickstarts until the endpoint is restored. --- test/integration/scripts/run-test-suites.sh | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/test/integration/scripts/run-test-suites.sh b/test/integration/scripts/run-test-suites.sh index 960f46cac..4b0e7ea28 100644 --- a/test/integration/scripts/run-test-suites.sh +++ b/test/integration/scripts/run-test-suites.sh @@ -14,9 +14,21 @@ auth0 login \ set +e -commander test --filter "$FILTER" --dir ./test/integration - -exit_code=$? +# The quickstart integration tests are excluded from the default suite, so run +# each remaining test-cases file individually (in alphabetical order, matching +# --dir) instead of the whole directory. +exit_code=0 +for suite in ./test/integration/*.yaml; do + if [[ "$(basename "$suite")" == "quickstarts-test-cases.yaml" ]]; then + echo "Skipping $suite" + continue + fi + + commander test --filter "$FILTER" "$suite" + if [[ $? -ne 0 ]]; then + exit_code=1 + fi +done bash ./test/integration/scripts/test-cleanup.sh