Repository: auth0/auth0-python
Pull request #549 (2023-11-08 17:36:48)

[SDK-4544] Add orgs in client credentials support
### Changes

- Add optional organization parameter to client credentials grant
- Add Management API endpoints to /client-grants and /organizations



Patch:
diff --git a/auth0/authentication/get_token.py b/auth0/authentication/get_token.py
index a7321b8..8fea32c 100644
--- a/auth0/authentication/get_token.py
+++ b/auth0/authentication/get_token.py
@@ -91,6 +91,7 @@ class GetToken(AuthenticationBase):
         self,
         audience: str,
         grant_type: str = "client_credentials",
+        organization: str | None = None,
     ) -> Any:
         """Client credentials grant
 
@@ -104,6 +105,9 @@ class GetToken(AuthenticationBase):
 
             grant_type (str, optional): Denotes the flow you're using. For client credentials use "client_credentials"
 
+            organization (str, optional): Optional Organization name or ID. When included, the access token returned
+            will include the org_id and org_name claims
+
         Returns:
             access_token
         """
@@ -114,6 +118,7 @@ class GetToken(AuthenticationBase):
                 "client_id": self.client_id,
                 "audience": audience,
                 "grant_type": grant_type,
+                "organization": organization,
             },
         )
 
diff --git a/auth0/management/client_grants.py b/auth0/management/client_grants.py
index 88cbcf0..46b2d9d 100644
--- a/auth0/management/client_grants.py
+++ b/auth0/management/client_grants.py
@@ -59,6 +59,7 @@ class ClientGrants:
         per_page: int | None = None,
         include_totals: bool = False,
         client_id: str | None = None,
+        allow_any_organization: bool | None = None,
     ):
         """Retrieves all client grants.
 
@@ -77,6 +78,8 @@ class ClientGrants:
 
             client_id (string, optional): The id of a client to filter.
 
+            allow_any_organization (bool, optional): Optional filter on allow_any_organization.
+
         See: https://auth0.com/docs/api/management/v2#!/Client_Grants/get_client_grants
         """
 
@@ -86,6 +89,7 @@ class ClientGrants:
             "per_page": per_page,
             "include_totals": str(include_totals).lower(),
             "client_id": client_id,
+            "allow_any_organization": allow_any_organization,
         }
 
         return self.client.get(self._url(), params=params)
@@ -124,3 +128,43 @@ class ClientGrants:
         """
 
         return self.client.patch(self._url(id), data=body)
+
+    def get_organizations(
+        self,
+        id: str,
+        page: int | None = None,
+        per_page: int | None = None,
+        include_totals: bool = False,
+        from_param: str | None = None,
+        take: int | None = None,
+    ):
+        """Get the organizations associated to a client grant.
+
+        Args:
+            id (str): Id of client grant.
+
+            page (int, optional): The result's page number (zero based). When not set,
+                the default value is up to the server.
+
+            per_page (int, optional): The amount of entries per page. When not set,
+                the default value is up to the server.
+
+            include_totals (bool, optional): True if the query summary is
+                to be included in the result, False otherwise. Defaults to False.
+
+            from_param (str, optional): Id to start retrieving entries. You can
+                limit the amount of entries using the take parameter.
+
+            take (int, optional): The total amount of entries to retrieve when
+                using the from parameter. When not set, the default value is up to the server.
+        """
+
+        params = {
+            "per_page": per_page,
+            "page": page,
+            "include_totals": str(include_totals).lower(),
+            "from": from_param,
+            "take": take,
+        }
+
+        return self.client.get(self._url(f"{id}/organizations"), params=params)
diff --git a/auth0/management/organizations.py b/auth0/management/organizations.py
index dabaf6c..8e0473a 100644
--- a/auth0/management/organizations.py
+++ b/auth0/management/organizations.py
@@ -460,3 +460,65 @@ class Organizations:
         """
 
         return self.client.delete(self._url(id, "invitations", invitation_id))
+
+    def get_client_grants(
+        self,
+        id: str,
+        audience: str | None = None,
+        client_id: str | None = None,
+        page: int | None = None,
+        per_page: int | None = None,
+        include_totals: bool = False,
+    ):
+        """Get client grants associated to an organization.
+
+        Args:
+            id (str): Id of organization.
+
+            audience (str, optional): URL encoded audience of a Resource Server
+                to filter.
+
+            client_id (string, optional): The id of a client to filter.
+
+            page (int, optional): The result's page number (zero based). When not set,
+                the default value is up to the server.
+
+            per_page (int, optional): The amount of entries per page. When not set,
+                the default value is up to the server.
+
+            include_totals (bool, optional): True if the query summary is
+                to be included in the result, False otherwise. Defaults to False.
+        """
+        params = {
+            "audience": audience,
+            "client_id": client_id,
+            "page": page,
+            "per_page": per_page,
+            "include_totals": str(include_totals).lower(),
+        }
+
+        return self.client.get(self._url(id, "client-grants"), params=params)
+
+    def add_client_grant(self, id: str, grant_id: str) -> dict[str, Any]:
+        """Associate a client grant with an organization.
+
+        Args:
+           id (str): the ID of the organization.
+
+           grant_id (string) A Client Grant ID to add to the organization.
+        """
+
+        return self.client.post(
+            self._url(id, "client-grants"), data={"grant_id": grant_id}
+        )
+
+    def delete_client_grant(self, id: str, grant_id: str) -> dict[str, Any]:
+        """Remove a client grant from an organization.
+
+        Args:
+           id (str): the ID of the organization.
+
+           grant_id (string) A Client Grant ID to remove from the organization.
+        """
+
+        return self.client.delete(self._url(id, "client-grants", grant_id))


Test patch:
diff --git a/auth0/test/authentication/test_get_token.py b/auth0/test/authentication/test_get_token.py
index 5a8e3e0..21dfc94 100644
--- a/auth0/test/authentication/test_get_token.py
+++ b/auth0/test/authentication/test_get_token.py
@@ -111,6 +111,7 @@ class TestGetToken(unittest.TestCase):
                 "client_secret": "clsec",
                 "audience": "aud",
                 "grant_type": "gt",
+                "organization": None,
             },
         )
 
@@ -133,11 +134,32 @@ class TestGetToken(unittest.TestCase):
                 "client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
                 "audience": "aud",
                 "grant_type": "gt",
+                "organization": None,
             },
         )
 
         self.assertTrue(fnmatch(kwargs["data"]["client_assertion"], "*.*.*"))
 
+    @mock.patch("auth0.rest.RestClient.post")
+    def test_client_credentials_with_organization(self, mock_post):
+        g = GetToken("my.domain.com", "cid", client_secret="clsec")
+
+        g.client_credentials("aud", organization="my-org")
+
+        args, kwargs = mock_post.call_args
+
+        self.assertEqual(args[0], "https://my.domain.com/oauth/token")
+        self.assertEqual(
+            kwargs["data"],
+            {
+                "client_id": "cid",
+                "grant_type": "client_credentials",
+                "client_secret": "clsec",
+                "audience": "aud",
+                "organization": "my-org",
+            },
+        )
+
     @mock.patch("auth0.rest.RestClient.post")
     def test_login(self, mock_post):
         g = GetToken("my.domain.com", "cid", client_secret="clsec")
diff --git a/auth0/test/management/test_client_grants.py b/auth0/test/management/test_client_grants.py
index 583c90e..eeef4f7 100644
--- a/auth0/test/management/test_client_grants.py
+++ b/auth0/test/management/test_client_grants.py
@@ -33,6 +33,7 @@ class TestClientGrants(unittest.TestCase):
                 "per_page": None,
                 "include_totals": "false",
                 "client_id": None,
+                "allow_any_organization": None,
             },
         )
 
@@ -50,6 +51,7 @@ class TestClientGrants(unittest.TestCase):
                 "per_page": None,
                 "include_totals": "false",
                 "client_id": None,
+                "allow_any_organization": None,
             },
         )
 
@@ -67,6 +69,7 @@ class TestClientGrants(unittest.TestCase):
                 "per_page": 23,
                 "include_totals": "true",
                 "client_id": None,
+                "allow_any_organization": None,
             },
         )
 
@@ -84,6 +87,25 @@ class TestClientGrants(unittest.TestCase):
                 "per_page": None,
                 "include_totals": "false",
                 "client_id": "exampleid",
+                "allow_any_organization": None,
+            },
+        )
+
+        # With allow any organization
+        c.all(allow_any_organization=True)
+
+        args, kwargs = mock_instance.get.call_args
+
+        self.assertEqual("https://domain/api/v2/client-grants", args[0])
+        self.assertEqual(
+            kwargs["params"],
+            {
+                "audience": None,
+                "page": None,
+                "per_page": None,
+                "include_totals": "false",
+                "client_id": None,
+                "allow_any_organization": True,
             },
         )
 
@@ -120,3 +142,26 @@ class TestClientGrants(unittest.TestCase):
 
         self.assertEqual("https://domain/api/v2/client-grants/this-id", args[0])
         self.assertEqual(kwargs["data"], {"a": "b", "c": "d"})
+
+    @mock.patch("auth0.management.client_grants.RestClient")
+    def test_get_organizations(self, mock_rc):
+        mock_instance = mock_rc.return_value
+
+        c = ClientGrants(domain="domain", token="jwttoken")
+        c.get_organizations("cgid")
+
+        args, kwargs = mock_instance.get.call_args
+
+        self.assertEqual(
+            "https://domain/api/v2/client-grants/cgid/organizations", args[0]
+        )
+        self.assertEqual(
+            kwargs["params"],
+            {
+                "page": None,
+                "per_page": None,
+                "include_totals": "false",
+                "from": None,
+                "take": None,
+            },
+        )
diff --git a/auth0/test/management/test_organizations.py b/auth0/test/management/test_organizations.py
index ec1fc84..c4b9235 100644
--- a/auth0/test/management/test_organizations.py
+++ b/auth0/test/management/test_organizations.py
@@ -479,3 +479,44 @@ class TestOrganizations(unittest.TestCase):
         mock_instance.delete.assert_called_with(
             "https://domain/api/v2/organizations/test-org/invitations/test-inv"
         )
+
+    @mock.patch("auth0.management.organizations.RestClient")
+    def test_get_client_grants(self, mock_rc):
+        mock_instance = mock_rc.return_value
+
+        c = Organizations(domain="domain", token="jwttoken")
+        c.get_client_grants("test-org")
+
+        mock_instance.get.assert_called_with(
+            "https://domain/api/v2/organizations/test-org/client-grants",
+            params={
+                "audience": None,
+                "client_id": None,
+                "page": None,
+                "per_page": None,
+                "include_totals": "false",
+            },
+        )
+
+    @mock.patch("auth0.management.organizations.RestClient")
+    def test_add_client_grant(self, mock_rc):
+        mock_instance = mock_rc.return_value
+
+        c = Organizations(domain="domain", token="jwttoken")
+        c.add_client_grant("test-org", "test-cg")
+
+        mock_instance.post.assert_called_with(
+            "https://domain/api/v2/organizations/test-org/client-grants",
+            data={"grant_id": "test-cg"},
+        )
+
+    @mock.patch("auth0.management.organizations.RestClient")
+    def test_delete_client_grant(self, mock_rc):
+        mock_instance = mock_rc.return_value
+
+        c = Organizations(domain="domain", token="jwttoken")
+        c.delete_client_grant("test-org", "test-cg")
+
+        mock_instance.delete.assert_called_with(
+            "https://domain/api/v2/organizations/test-org/client-grants/test-cg",
+        )