Repository: auth0/auth0-python
Pull request #258 (2021-03-24 20:59:41)

Add support for organizations feature
### Changes

This PR adds support for the (beta) Organizations and User Invitations features. Changes included:

- Supports specifying the organization and invitation query parameters when using the `AuthorizeClient`.
- Supports verification of the `org_id` claim in `TokenVerifier`
- Adds documentation for using the Organizations and User Invitations features

### Testing

Please describe how this can be tested by reviewers. Be specific about anything not tested and reasons why. If this library has unit and/or integration testing, tests should be added for new functionality and existing tests should complete without errors. 

- [X] This change adds unit test coverage
- [ ] This change adds integration test coverage
- [ ] This change has been tested on the latest version of the platform/language or why not

### Checklist

- [X] I have read the [Auth0 general contribution guidelines](https://github.com/auth0/open-source-template/blob/master/GENERAL-CONTRIBUTING.md)
- [X] I have read the [Auth0 Code of Conduct](https://github.com/auth0/open-source-template/blob/master/CODE-OF-CONDUCT.md)
- [X] All existing and new tests complete without errors



Patch:
diff --git a/README.rst b/README.rst
index be31bb6..a9e053d 100644
--- a/README.rst
+++ b/README.rst
@@ -104,6 +104,75 @@ The following example demonstrates the verification of an ID token signed with t
     
 If the token verification fails, a ``TokenValidationError`` will be raised. In that scenario, the ID token should be deemed invalid and its contents should not be trusted.
 
+===========================
+Organizations (Closed Beta)
+===========================
+
+Organizations is a set of features that provide better support for developers who build and maintain SaaS and Business-to-Business (B2B) applications.
+
+Using Organizations, you can:
+* Represent teams, business customers, partner companies, or any logical grouping of users that should have different ways of accessing your applications, as organizations.
+* Manage their membership in a variety of ways, including user invitation.
+* Configure branded, federated login flows for each organization.
+* Implement role-based access control, such that users can have different roles when authenticating in the context of different organizations.
+* Build administration capabilities into your products, using Organizations APIs, so that those businesses can manage their own organizations.
+
+Note that Organizations is currently only available to customers on our Enterprise and Startup subscription plans.
+
+-------------------------
+Log in to an organization
+-------------------------
+
+Log in to an organization by specifying the ``organization`` property when calling ``authorize()``:
+
+.. code-block:: python
+
+    from auth0.v3.authentication.authorize_client import AuthorizeClient
+    
+    client = AuthorizeClient('my.domain.com')
+
+    client.authorize(client_id='client_id',
+                redirect_uri='http://localhost',
+                organization="org_abc")
+
+When logging into an organization, it is important to ensure the ``org_id`` claim of the ID Token matches the expected organization value. The ``TokenVerifier`` can be be used to ensure the ID Token contains the expected ``org_id`` claim value:
+
+.. code-block:: python
+    
+    from auth0.v3.authentication.token_verifier import TokenVerifier, AsymmetricSignatureVerifier
+    
+    domain = 'myaccount.auth0.com'
+    client_id = 'exampleid'
+    
+    # After authenticating
+    id_token = auth_result['id_token']
+    
+    jwks_url = 'https://{}/.well-known/jwks.json'.format(domain)
+    issuer = 'https://{}/'.format(domain)
+    
+    sv = AsymmetricSignatureVerifier(jwks_url)  # Reusable instance
+    tv = TokenVerifier(signature_verifier=sv, issuer=issuer, audience=client_id)
+
+    # pass the expected organization the user logged in to:
+    tv.verify(id_token, organization='org_abc')
+
+-----------------------
+Accept user invitations
+-----------------------
+
+Accept a user invitation by specifying the ``invitation`` property when calling ``authorize()``. Note that you must also specify the ``organization`` if providing an ``invitation``.
+The ID of the invitation and organization are available as query parameters on the invitation URL, e.g., ``https://your-domain.auth0.com/login?invitation=invitation_id&organization=org_id&organization_name=org_name``
+
+.. code-block:: python
+
+    from auth0.v3.authentication.authorize_client import AuthorizeClient
+
+    client = AuthorizeClient('my.domain.com')
+
+    client.authorize(client_id='client_id',
+            redirect_uri='http://localhost',
+            organization='org_abc',
+            invitation="invitation_123")
 
 ====================
 Management SDK Usage
diff --git a/auth0/v3/authentication/authorize_client.py b/auth0/v3/authentication/authorize_client.py
index db5060c..bc9621f 100644
--- a/auth0/v3/authentication/authorize_client.py
+++ b/auth0/v3/authentication/authorize_client.py
@@ -10,7 +10,7 @@ class AuthorizeClient(AuthenticationBase):
     """
 
     def authorize(self, client_id, audience=None, state=None, redirect_uri=None,
-                  response_type='code', scope='openid'):
+                  response_type='code', scope='openid', organization=None, invitation=None):
         """Authorization code grant
 
         This is the OAuth 2.0 grant that regular web apps utilize in order to access an API.
@@ -21,7 +21,9 @@ class AuthorizeClient(AuthenticationBase):
             'response_type': response_type,
             'scope': scope,
             'state': state,
-            'redirect_uri': redirect_uri
+            'redirect_uri': redirect_uri,
+            'organization': organization,
+            'invitation': invitation
         }
 
         return self.get(
diff --git a/auth0/v3/authentication/token_verifier.py b/auth0/v3/authentication/token_verifier.py
index e86938a..a6a11cd 100644
--- a/auth0/v3/authentication/token_verifier.py
+++ b/auth0/v3/authentication/token_verifier.py
@@ -227,13 +227,15 @@ class TokenVerifier():
         token (str): The JWT to verify.
         nonce (str, optional): The nonce value sent during authentication.
         max_age (int, optional): The max_age value sent during authentication.
+        organization (str, optional): The expected organization ID (org_id) claim value. This should be specified
+            when logging in to an organization.
         
     Raises:
         TokenValidationError: when the token cannot be decoded, the token signing algorithm is not the expected one, 
         the token signature is invalid or the token has a claim missing or with unexpected value.
     """
 
-    def verify(self, token, nonce=None, max_age=None):
+    def verify(self, token, nonce=None, max_age=None, organization=None):
         # Verify token presence
         if not token or not isinstance(token, str):
             raise TokenValidationError("ID token is required but missing.")
@@ -242,9 +244,9 @@ class TokenVerifier():
         payload = self._sv.verify_signature(token)
 
         # Verify claims
-        self._verify_payload(payload, nonce, max_age)
+        self._verify_payload(payload, nonce, max_age, organization)
 
-    def _verify_payload(self, payload, nonce=None, max_age=None):
+    def _verify_payload(self, payload, nonce=None, max_age=None, organization=None):
         try:
             # on Python 2.7, 'str' keys as parsed as 'unicode'
             # But 'unicode' was removed on Python 3.7
@@ -307,6 +309,15 @@ class TokenVerifier():
                     'Nonce (nonce) claim mismatch in the ID token; expected "{}", '
                     'found "{}"'.format(nonce, payload['nonce']))
 
+        # Organization
+        if organization:
+            if 'org_id' not in payload or not isinstance(payload['org_id'], (str, ustr)):
+                raise TokenValidationError('Organization (org_id) claim must be a string present in the ID token')
+            if payload['org_id'] != organization:
+                raise TokenValidationError(
+                    'Organization (org_id) claim mismatch in the ID token; expected "{}", '
+                    'found "{}"'.format(organization, payload['org_id']))
+
         # Authorized party
         if isinstance(payload['aud'], list) and len(payload['aud']) > 1:
             if 'azp' not in payload or not isinstance(payload['azp'], (str, ustr)):


Test patch:
diff --git a/auth0/v3/test/authentication/test_authorize_client.py b/auth0/v3/test/authentication/test_authorize_client.py
index 1bc54ec..59febf3 100644
--- a/auth0/v3/test/authentication/test_authorize_client.py
+++ b/auth0/v3/test/authentication/test_authorize_client.py
@@ -15,7 +15,9 @@ class TestAuthorizeClient(unittest.TestCase):
                     state='st',
                     redirect_uri='http://localhost',
                     response_type='token',
-                    scope='openid profile')
+                    scope='openid profile',
+                    organization='org_123',
+                    invitation='invitation_abc')
 
         args, kwargs = mock_get.call_args
 
@@ -26,5 +28,28 @@ class TestAuthorizeClient(unittest.TestCase):
             'state': 'st',
             'redirect_uri': 'http://localhost',
             'response_type': 'token',
-            'scope': 'openid profile'
+            'scope': 'openid profile',
+            'organization': 'org_123',
+            'invitation': 'invitation_abc'
+        })
+
+    @mock.patch('auth0.v3.authentication.authorize_client.AuthorizeClient.get')
+    def test_login_default_param_values(self, mock_get):
+
+        a = AuthorizeClient('my.domain.com')
+
+        a.authorize(client_id='cid')
+
+        args, kwargs = mock_get.call_args
+
+        self.assertEqual(args[0], 'https://my.domain.com/authorize')
+        self.assertEqual(kwargs['params'], {
+            'audience': None,
+            'invitation': None,
+            'organization':  None,
+            'redirect_uri': None,
+            'state': None,
+            'client_id': 'cid',
+            'response_type':  'code',
+            'scope': 'openid'
         })
diff --git a/auth0/v3/test/authentication/test_token_verifier.py b/auth0/v3/test/authentication/test_token_verifier.py
index 293f260..befe878 100644
--- a/auth0/v3/test/authentication/test_token_verifier.py
+++ b/auth0/v3/test/authentication/test_token_verifier.py
@@ -221,7 +221,7 @@ class TestTokenVerifier(unittest.TestCase):
         verifier._fetch_key.return_value = jwt.algorithms.RSAAlgorithm.from_jwk(json.dumps(RSA_PUB_KEY_1_JWK))
         return verifier
 
-    def assert_fails_with_error(self, token, error_message, signature_verifier=None, audience=expectations['audience'], issuer=expectations['issuer'], nonce=None, max_age=None, clock=MOCKED_CLOCK):
+    def assert_fails_with_error(self, token, error_message, signature_verifier=None, audience=expectations['audience'], issuer=expectations['issuer'], nonce=None, max_age=None, clock=MOCKED_CLOCK, organization=None):
         sv = signature_verifier or self.asymmetric_signature_verifier_mock()
         tv = TokenVerifier(
             signature_verifier=sv,
@@ -231,7 +231,7 @@ class TestTokenVerifier(unittest.TestCase):
         )
         tv._clock = clock
         with self.assertRaises(TokenValidationError) as err:
-            tv.verify(token, nonce, max_age)
+            tv.verify(token, nonce, max_age, organization)
         self.assertEqual(str(err.exception), error_message)
 
     def test_fails_at_creation_with_invalid_signature_verifier(self):
@@ -369,3 +369,37 @@ class TestTokenVerifier(unittest.TestCase):
         mocked_clock = expected_auth_time + 1
 
         self.assert_fails_with_error(token, "Authentication Time (auth_time) claim in the ID token indicates that too much time has passed since the last end-user authentication. Current time ({}) is after last auth at ({})".format(mocked_clock, expected_auth_time), max_age=max_age, clock=mocked_clock)
+
+    def test_passes_when_org_present_but_not_required(self):
+        token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhdXRoMHxzZGs0NThma3MiLCJhdWQiOiJ0b2tlbnMtdGVzdC0xMjMiLCJvcmdfaWQiOiJvcmdfMTIzIiwiaXNzIjoiaHR0cHM6Ly90b2tlbnMtdGVzdC5hdXRoMC5jb20vIiwiZXhwIjoxNTg3NzY1MzYxLCJpYXQiOjE1ODc1OTI1NjF9.hjSPgJpg0Dn2z0giCdGqVLD5Kmqy_yMYlSkgwKD7ahQ"
+        sv = SymmetricSignatureVerifier(HMAC_SHARED_SECRET)
+        tv = TokenVerifier(
+            signature_verifier=sv,
+            issuer=expectations['issuer'],
+            audience=expectations['audience']
+        )
+        tv._clock = MOCKED_CLOCK
+        tv.verify(token)
+
+    def test_passes_when_org_present_and_matches(self):
+        token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhdXRoMHxzZGs0NThma3MiLCJhdWQiOiJ0b2tlbnMtdGVzdC0xMjMiLCJvcmdfaWQiOiJvcmdfMTIzIiwiaXNzIjoiaHR0cHM6Ly90b2tlbnMtdGVzdC5hdXRoMC5jb20vIiwiZXhwIjoxNTg3NzY1MzYxLCJpYXQiOjE1ODc1OTI1NjF9.hjSPgJpg0Dn2z0giCdGqVLD5Kmqy_yMYlSkgwKD7ahQ"
+        sv = SymmetricSignatureVerifier(HMAC_SHARED_SECRET)
+        tv = TokenVerifier(
+            signature_verifier=sv,
+            issuer=expectations['issuer'],
+            audience=expectations['audience']
+        )
+        tv._clock = MOCKED_CLOCK
+        tv.verify(token, organization='org_123')    
+
+    def test_fails_when_org_specified_but_not_present(self):
+        token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhdXRoMHxzZGs0NThma3MiLCJhdWQiOiJ0b2tlbnMtdGVzdC0xMjMiLCJpc3MiOiJodHRwczovL3Rva2Vucy10ZXN0LmF1dGgwLmNvbS8iLCJleHAiOjE1ODc3NjUzNjEsImlhdCI6MTU4NzU5MjU2MX0.wotJnUdD5IfdZMewF_-BnHc0pI56uwzwr5qaSXvSu9w"
+        self.assert_fails_with_error(token, "Organization (org_id) claim must be a string present in the ID token", signature_verifier=SymmetricSignatureVerifier(HMAC_SHARED_SECRET), organization='org_123')
+
+    def test_fails_when_org_specified_but_not_(self):
+        token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhdXRoMHxzZGs0NThma3MiLCJhdWQiOiJ0b2tlbnMtdGVzdC0xMjMiLCJvcmdfaWQiOjQyLCJpc3MiOiJodHRwczovL3Rva2Vucy10ZXN0LmF1dGgwLmNvbS8iLCJleHAiOjE1ODc3NjUzNjEsImlhdCI6MTU4NzU5MjU2MX0.fGL1_akaHikdovS7NRYla3flne1xdtCjP0ei_CRxO6k"
+        self.assert_fails_with_error(token, "Organization (org_id) claim must be a string present in the ID token", signature_verifier=SymmetricSignatureVerifier(HMAC_SHARED_SECRET), organization='org_123')
+
+    def test_fails_when_org_specified_but_does_not_match(self):
+        token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhdXRoMHxzZGs0NThma3MiLCJhdWQiOiJ0b2tlbnMtdGVzdC0xMjMiLCJvcmdfaWQiOiJvcmdfMTIzIiwiaXNzIjoiaHR0cHM6Ly90b2tlbnMtdGVzdC5hdXRoMC5jb20vIiwiZXhwIjoxNTg3NzY1MzYxLCJpYXQiOjE1ODc1OTI1NjF9.hjSPgJpg0Dn2z0giCdGqVLD5Kmqy_yMYlSkgwKD7ahQ"
+        self.assert_fails_with_error(token, 'Organization (org_id) claim mismatch in the ID token; expected "org_abc", found "org_123"', signature_verifier=SymmetricSignatureVerifier(HMAC_SHARED_SECRET), organization='org_abc')
\ No newline at end of file