From 9a01566582b57cb7b1e9a667bc47b8dd1b03b9f7 Mon Sep 17 00:00:00 2001 From: Evan Sims Date: Mon, 13 Nov 2023 04:49:02 -0500 Subject: [PATCH] feat(SDK-4543): Support Organizations with Client Grants (#736) ### Changes This pull request adds functionality associated with Organizations and Client Grants. It introduces support for: - Retrieving Organizations associated with a Client Grant. - Retrieving Client Grants associated with an Organization. - Associating or disassociating client grants from organizations. ### References Please review internal ticket SDK-4543. ### Testing Tests have been added and updated to cover these changes. Coverage remains at 100%. ### Contributor Checklist - [x] I agree to adhere to the [Auth0 General Contribution Guidelines](https://github.com/auth0/open-source-template/blob/master/GENERAL-CONTRIBUTING.md). - [x] I agree to uphold the [Auth0 Code of Conduct](https://github.com/auth0/open-source-template/blob/master/CODE-OF-CONDUCT.md). --- src/API/Management/ClientGrants.php | 66 ++++++++++++++---- src/API/Management/Organizations.php | 67 +++++++++++++++++++ .../API/Management/ClientGrantsInterface.php | 38 +++++++++-- .../API/Management/OrganizationsInterface.php | 50 ++++++++++++++ tests/Unit/API/AuthenticationTest.php | 26 +++++++ .../Unit/API/Management/ClientGrantsTest.php | 59 ++++++++++++++++ .../Unit/API/Management/OrganizationsTest.php | 40 +++++++++++ 7 files changed, 327 insertions(+), 19 deletions(-) diff --git a/src/API/Management/ClientGrants.php b/src/API/Management/ClientGrants.php index 225aeaf1..13786145 100644 --- a/src/API/Management/ClientGrants.php +++ b/src/API/Management/ClientGrants.php @@ -21,6 +21,8 @@ public function create( string $audience, ?array $scope = null, ?RequestOptions $options = null, + ?string $organizationUsage = null, + ?bool $allowAnyOrganization = null, ): ResponseInterface { [$clientId, $audience] = Toolkit::filter([$clientId, $audience])->string()->trim(); [$scope] = Toolkit::filter([$scope])->array()->trim(); @@ -30,16 +32,24 @@ public function create( [$audience, \Auth0\SDK\Exception\ArgumentException::missing('audience')], ])->isString(); + $body = [ + 'client_id' => $clientId, + 'audience' => $audience, + 'scope' => $scope, + ]; + + if (null !== $organizationUsage) { + $body['organization_usage'] = $organizationUsage; + } + + if (null !== $allowAnyOrganization) { + $body['allow_any_organization'] = $allowAnyOrganization; + } + return $this->getHttpClient() ->method('post') ->addPath(['client-grants']) - ->withBody( - (object) [ - 'client_id' => $clientId, - 'audience' => $audience, - 'scope' => $scope, - ], - ) + ->withBody((object) $body) ->withOptions($options) ->call(); } @@ -120,10 +130,34 @@ public function getAllByClientId( return $this->getAll($params, $options); } + public function getOrganizations( + string $grantId, + ?array $parameters = null, + ?RequestOptions $options = null, + ): ResponseInterface { + [$grantId] = Toolkit::filter([$grantId])->string()->trim(); + [$parameters] = Toolkit::filter([$parameters])->array()->trim(); + + Toolkit::assert([ + [$grantId, \Auth0\SDK\Exception\ArgumentException::missing('grantId')], + ])->isString(); + + /** @var array $parameters */ + + return $this->getHttpClient() + ->method('get') + ->addPath(['client-grants', $grantId, 'organizations']) + ->withParams($parameters) + ->withOptions($options) + ->call(); + } + public function update( string $grantId, ?array $scope = null, ?RequestOptions $options = null, + ?string $organizationUsage = null, + ?bool $allowAnyOrganization = null, ): ResponseInterface { [$grantId] = Toolkit::filter([$grantId])->string()->trim(); [$scope] = Toolkit::filter([$scope])->array()->trim(); @@ -132,13 +166,21 @@ public function update( [$grantId, \Auth0\SDK\Exception\ArgumentException::missing('grantId')], ])->isString(); + $body = [ + 'scope' => $scope, + ]; + + if (null !== $organizationUsage) { + $body['organization_usage'] = $organizationUsage; + } + + if (null !== $allowAnyOrganization) { + $body['allow_any_organization'] = $allowAnyOrganization; + } + return $this->getHttpClient() ->method('patch')->addPath(['client-grants', $grantId]) - ->withBody( - (object) [ - 'scope' => $scope, - ], - ) + ->withBody((object) $body) ->withOptions($options) ->call(); } diff --git a/src/API/Management/Organizations.php b/src/API/Management/Organizations.php index b170a888..3346cd6c 100644 --- a/src/API/Management/Organizations.php +++ b/src/API/Management/Organizations.php @@ -16,6 +16,34 @@ */ final class Organizations extends ManagementEndpoint implements OrganizationsInterface { + public function addClientGrant( + string $id, + string $grantId, + ?array $parameters = null, + ?RequestOptions $options = null, + ): ResponseInterface { + [$id, $grantId] = Toolkit::filter([$id, $grantId])->string()->trim(); + [$parameters] = Toolkit::filter([$parameters])->array()->trim(); + + Toolkit::assert([ + [$id, \Auth0\SDK\Exception\ArgumentException::missing('id')], + [$grantId, \Auth0\SDK\Exception\ArgumentException::missing('grantId')], + ])->isString(); + + $body = [ + 'grant_id' => $grantId, + ]; + + /** @var array $parameters */ + + return $this->getHttpClient() + ->method('post')->addPath(['organizations', $id, 'client-grants']) + ->withBody((object) $body) + ->withParams($parameters) + ->withOptions($options) + ->call(); + } + public function addEnabledConnection( string $id, string $connectionId, @@ -259,6 +287,22 @@ public function getByName( ->call(); } + public function getClientGrants( + string $id, + ?RequestOptions $options = null, + ): ResponseInterface { + [$id] = Toolkit::filter([$id])->string()->trim(); + + Toolkit::assert([ + [$id, \Auth0\SDK\Exception\ArgumentException::missing('id')], + ])->isString(); + + return $this->getHttpClient() + ->method('get')->addPath(['organizations', $id, 'client-grants']) + ->withOptions($options) + ->call(); + } + public function getEnabledConnection( string $id, string $connectionId, @@ -361,6 +405,29 @@ public function getMembers( ->call(); } + public function removeClientGrant( + string $id, + string $grantId, + ?array $parameters = null, + ?RequestOptions $options = null, + ): ResponseInterface { + [$id, $grantId] = Toolkit::filter([$id, $grantId])->string()->trim(); + [$parameters] = Toolkit::filter([$parameters])->array()->trim(); + + Toolkit::assert([ + [$id, \Auth0\SDK\Exception\ArgumentException::missing('id')], + [$grantId, \Auth0\SDK\Exception\ArgumentException::missing('grantId')], + ])->isString(); + + /** @var array $parameters */ + + return $this->getHttpClient() + ->method('delete')->addPath(['organizations', $id, 'client-grants', $grantId]) + ->withParams($parameters) + ->withOptions($options) + ->call(); + } + public function removeEnabledConnection( string $id, string $connectionId, diff --git a/src/Contract/API/Management/ClientGrantsInterface.php b/src/Contract/API/Management/ClientGrantsInterface.php index 0f8d0b89..afe2c721 100644 --- a/src/Contract/API/Management/ClientGrantsInterface.php +++ b/src/Contract/API/Management/ClientGrantsInterface.php @@ -13,10 +13,12 @@ interface ClientGrantsInterface * Create a new Client Grant. * Required scope: `create:client_grants`. * - * @param string $clientId client ID to receive the grant - * @param string $audience audience identifier for the API being granted - * @param null|array $scope Optional. Scopes allowed for this client grant. - * @param null|RequestOptions $options Optional. Additional request options to use, such as a field filtering or pagination. (Not all endpoints support these. See @see for supported options.) + * @param string $clientId client ID to receive the grant + * @param string $audience audience identifier for the API being granted + * @param null|array $scope Optional. Scopes allowed for this client grant. + * @param null|RequestOptions $options Optional. Additional request options to use, such as a field filtering or pagination. (Not all endpoints support these. See @see for supported options.) + * @param null|string $organizationUsage Optional. Defines whether organizations can be used with client credentials exchanges for this grant. Possible values are `deny`, `allow` or `require`. + * @param null|bool $allowAnyOrganization Optional. If enabled, any organization can be used with this grant. If disabled (default), the grant must be explicitly assigned to the desired organizations. * * @throws \Auth0\SDK\Exception\ArgumentException when an invalid `clientId` or `audience` are provided * @throws \Auth0\SDK\Exception\NetworkException when the API request fails due to a network error @@ -28,6 +30,8 @@ public function create( string $audience, ?array $scope = null, ?RequestOptions $options = null, + ?string $organizationUsage = null, + ?bool $allowAnyOrganization = null, ): ResponseInterface; /** @@ -101,13 +105,31 @@ public function getAllByClientId( ?RequestOptions $options = null, ): ResponseInterface; + /** + * Retrieve a client grant's organizations. + * Required scope: `read:organization_client_grants`. + * + * @param string $grantId Grant (by it's ID) to update + * @param null|int[]|null[]|string[] $parameters Optional. Additional query parameters to pass with the API request. See @see for supported options. + * @param null|RequestOptions $options Optional. Additional request options to use, such as a field filtering or pagination. (Not all endpoints support these. See @see for supported options.) + * + * @throws \Auth0\SDK\Exception\NetworkException when the API request fails due to a network error + */ + public function getOrganizations( + string $grantId, + ?array $parameters = null, + ?RequestOptions $options = null, + ): ResponseInterface; + /** * Update an existing Client Grant. * Required scope: `update:client_grants`. * - * @param string $grantId grant (by it's ID) to update - * @param null|array $scope Optional. Array of scopes to update; will replace existing scopes, not merge. - * @param null|RequestOptions $options Optional. Additional request options to use, such as a field filtering or pagination. (Not all endpoints support these. See @see for supported options.) + * @param string $grantId Grant (by it's ID) to update + * @param null|array $scope Optional. Array of scopes to update; will replace existing scopes, not merge. + * @param null|RequestOptions $options Optional. Additional request options to use, such as a field filtering or pagination. (Not all endpoints support these. See @see for supported options.) + * @param null|string $organizationUsage Optional. Defines whether organizations can be used with client credentials exchanges for this grant. Possible values are `deny`, `allow` or `require`. + * @param null|bool $allowAnyOrganization Optional. If enabled, any organization can be used with this grant. If disabled (default), the grant must be explicitly assigned to the desired organizations. * * @throws \Auth0\SDK\Exception\ArgumentException when an invalid `grantId` is provided * @throws \Auth0\SDK\Exception\NetworkException when the API request fails due to a network error @@ -118,5 +140,7 @@ public function update( string $grantId, ?array $scope = null, ?RequestOptions $options = null, + ?string $organizationUsage = null, + ?bool $allowAnyOrganization = null, ): ResponseInterface; } diff --git a/src/Contract/API/Management/OrganizationsInterface.php b/src/Contract/API/Management/OrganizationsInterface.php index f513c5e2..519ff834 100644 --- a/src/Contract/API/Management/OrganizationsInterface.php +++ b/src/Contract/API/Management/OrganizationsInterface.php @@ -9,6 +9,25 @@ interface OrganizationsInterface { + /** + * Associate a client grant to an organization. + * Required scope: `create:organization_client_grants`. + * + * @param string $id Organization (by ID) to associate the client grant with. + * @param string $grantId Client Grant (by ID) to associate with the organization. + * @param null|array $parameters Optional. Additional body content to send with the API request. + * @param null|RequestOptions $options Optional. Additional request options to use, such as a field filtering or pagination. (Not all endpoints support these. + * + * @throws \Auth0\SDK\Exception\ArgumentException When an invalid `id` or `connectionId` are provided + * @throws \Auth0\SDK\Exception\NetworkException When the API request fails due to a network error + */ + public function addClientGrant( + string $id, + string $grantId, + ?array $parameters = null, + ?RequestOptions $options = null, + ): ResponseInterface; + /** * Add a connection to an organization. * Required scope: `create:organization_connections`. @@ -206,6 +225,21 @@ public function getByName( ?RequestOptions $options = null, ): ResponseInterface; + /** + * Get client grants associated to an organization. + * Required scope: `read:organization_client_grants`. + * + * @param string $id Organization (by ID) that the connection is associated with + * @param null|RequestOptions $options Optional. Additional request options to use, such as a field filtering or pagination. (Not all endpoints support these.) + * + * @throws \Auth0\SDK\Exception\ArgumentException when an invalid `id` or `connectionId` are provided + * @throws \Auth0\SDK\Exception\NetworkException when the API request fails due to a network error + */ + public function getClientGrants( + string $id, + ?RequestOptions $options = null, + ): ResponseInterface; + /** * Get a connection (by ID) associated with an organization. * Required scope: `read:organization_connections`. @@ -314,6 +348,22 @@ public function getMembers( ?RequestOptions $options = null, ): ResponseInterface; + /** + * Remove a client grant from an organization. + * Required scope: `delete:organization_client_grants`. + * + * @param string $id Organization (by ID) to remove client grant from. + * @param string $grantId Client Grant (by ID) to remove from the organization. + * @param null|array $parameters Optional. Additional body content to send with the API request. + * @param null|RequestOptions $options Optional. Additional request options to use, such as a field filtering or pagination. (Not all endpoints support these.) + */ + public function removeClientGrant( + string $id, + string $grantId, + ?array $parameters = null, + ?RequestOptions $options = null, + ): ResponseInterface; + /** * Remove a connection from an organization. * Required scope: `delete:organization_connections`. diff --git a/tests/Unit/API/AuthenticationTest.php b/tests/Unit/API/AuthenticationTest.php index f6eef99b..b518826b 100644 --- a/tests/Unit/API/AuthenticationTest.php +++ b/tests/Unit/API/AuthenticationTest.php @@ -465,6 +465,32 @@ expect($requestHeaders['header_testing'][0])->toEqual(123); }); +test('clientCredentials() includes organization in request when configured', function(): void { + $clientSecret = uniqid(); + + $this->configuration->setClientSecret($clientSecret); + $authentication = $this->sdk->authentication(); + $authentication->getHttpClient()->mockResponses([HttpResponseGenerator::create()]); + $authentication->clientCredentials(['organization' => 'org_xyz'], ['header_testing' => 123]); + + $request = $authentication->getHttpClient()->getLastRequest()->getLastRequest(); + $requestUri = $request->getUri(); + $requestBody = explode('&', $request->getBody()->__toString()); + $requestHeaders = $request->getHeaders(); + + expect($requestUri->getHost())->toEqual($this->configuration->getDomain()); + expect($requestUri->getPath())->toEqual('/oauth/token'); + + expect($requestBody)->toContain('grant_type=client_credentials'); + expect($requestBody)->toContain('client_id=__test_client_id__'); + expect($requestBody)->toContain('client_secret=' . $clientSecret); + expect($requestBody)->toContain('audience=aud1'); + expect($requestBody)->toContain('organization=org_xyz'); + + $this->assertArrayHasKey('header_testing', $requestHeaders); + expect($requestHeaders['header_testing'][0])->toEqual(123); +}); + test('refreshToken() is properly formatted', function(): void { $clientSecret = uniqid(); $refreshToken = uniqid(); diff --git a/tests/Unit/API/Management/ClientGrantsTest.php b/tests/Unit/API/Management/ClientGrantsTest.php index 8b21ddfc..fc4cf0c1 100644 --- a/tests/Unit/API/Management/ClientGrantsTest.php +++ b/tests/Unit/API/Management/ClientGrantsTest.php @@ -125,3 +125,62 @@ expect($this->api->getRequestMethod())->toEqual('DELETE'); expect($this->api->getRequestUrl())->toEndWith('/api/v2/client-grants/' . $grantId); }); + +test('getOrganizations() issues an appropriate request', function(): void { + $grantId = uniqid(); + + $this->endpoint->getOrganizations($grantId); + + expect($this->api->getRequestMethod())->toEqual('GET'); + expect($this->api->getRequestUrl())->toStartWith('https://' . $this->api->mock()->getConfiguration()->getDomain() . '/api/v2/client-grants/' . $grantId . '/organizations'); +}); + +test('getAll() issues an appropriate request with organization queries', function(): void { + $this->endpoint->getAll(['allow_any_organization' => true]); + + expect($this->api->getRequestMethod())->toEqual('GET'); + expect($this->api->getRequestUrl())->toStartWith('https://' . $this->api->mock()->getConfiguration()->getDomain() . '/api/v2/client-grants'); + + expect($this->api->getRequestQuery(null))->toEqual('allow_any_organization=true'); +}); + +test('create() issues an appropriate request with organization queries', function(): void { + $clientId = uniqid(); + $audience = uniqid(); + $scope = uniqid(); + + $this->endpoint->create($clientId, $audience, [$scope], null, 'require', true); + + expect($this->api->getRequestMethod())->toEqual('POST'); + expect($this->api->getRequestUrl())->toEndWith('/api/v2/client-grants'); + + $body = $this->api->getRequestBody(); + $this->assertArrayHasKey('client_id', $body); + $this->assertArrayHasKey('audience', $body); + $this->assertArrayHasKey('scope', $body); + expect($body['client_id'])->toEqual($clientId); + expect($body['audience'])->toEqual($audience); + expect($body['scope'])->toContain($scope); + expect($body['organization_usage'])->toEqual('require'); + expect($body['allow_any_organization'])->toEqual(true); + + $body = $this->api->getRequestBodyAsString(); + expect($body)->toEqual(json_encode(['client_id' => $clientId, 'audience' => $audience, 'scope' => [$scope], 'organization_usage' => 'require', 'allow_any_organization' => true])); +}); + +test('update() issues an appropriate request with organization queries', function(): void { + $grantId = uniqid(); + $scope = uniqid(); + + $this->endpoint->update($grantId, [$scope], null, 'require', true); + + expect($this->api->getRequestMethod())->toEqual('PATCH'); + expect($this->api->getRequestUrl())->toEndWith('/api/v2/client-grants/' . $grantId); + + $body = $this->api->getRequestBody(); + $this->assertArrayHasKey('scope', $body); + expect($body['scope'])->toContain($scope); + + $body = $this->api->getRequestBodyAsString(); + expect($body)->toEqual(json_encode(['scope' => [$scope], 'organization_usage' => 'require', 'allow_any_organization' => true])); +}); diff --git a/tests/Unit/API/Management/OrganizationsTest.php b/tests/Unit/API/Management/OrganizationsTest.php index e00c3882..722266a5 100644 --- a/tests/Unit/API/Management/OrganizationsTest.php +++ b/tests/Unit/API/Management/OrganizationsTest.php @@ -468,3 +468,43 @@ test('deleteInvitation() throws an exception when an invalid `invitationId` is used', function(): void { $this->endpoint->deleteInvitation('test-organization', ''); })->throws(ArgumentException::class, sprintf(ArgumentException::MSG_VALUE_CANNOT_BE_EMPTY, 'invitationId')); + +test('addClientGrant() issues an appropriate request', function(): void { + $organization = 'org_' . uniqid(); + $grant = uniqid(); + + $this->endpoint->addClientGrant( + $organization, + $grant, + ); + + expect($this->api->getRequestMethod())->toEqual('POST'); + expect($this->api->getRequestUrl())->toEndWith('/api/v2/organizations/' . $organization . '/client-grants'); + + $headers = $this->api->getRequestHeaders(); + expect($headers['Content-Type'][0])->toEqual('application/json'); + + $body = $this->api->getRequestBody(); + + $this->assertArrayHasKey('grant_id', $body); + expect($body['grant_id'])->toEqual($grant); +}); + +test('getClientGrants() issues an appropriate request', function(): void { + $organization = 'org_' . uniqid(); + + $this->endpoint->getClientGrants($organization); + + expect($this->api->getRequestMethod())->toEqual('GET'); + expect($this->api->getRequestUrl())->toStartWith('https://' . $this->api->mock()->getConfiguration()->getDomain() . '/api/v2/organizations/' . $organization . '/client-grants'); +}); + +test('removeClientGrant() issues an appropriate request', function(): void { + $organization = 'org_' . uniqid(); + $grant = uniqid(); + + $this->endpoint->removeClientGrant($organization, $grant); + + expect($this->api->getRequestMethod())->toEqual('DELETE'); + expect($this->api->getRequestUrl())->toStartWith('https://' . $this->api->mock()->getConfiguration()->getDomain() . '/api/v2/organizations/' . $organization . '/client-grants/' . $grant); +});