Skip to content

Commit 1e2fa55

Browse files
authored
Add support for organizations feature (#258)
* Add support for organizations feature * README updates - fix typo, add info about where to get invitation ID
1 parent d0d5fe8 commit 1e2fa55

5 files changed

Lines changed: 150 additions & 9 deletions

File tree

README.rst

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,75 @@ The following example demonstrates the verification of an ID token signed with t
104104
105105
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.
106106
107+
===========================
108+
Organizations (Closed Beta)
109+
===========================
110+
111+
Organizations is a set of features that provide better support for developers who build and maintain SaaS and Business-to-Business (B2B) applications.
112+
113+
Using Organizations, you can:
114+
* Represent teams, business customers, partner companies, or any logical grouping of users that should have different ways of accessing your applications, as organizations.
115+
* Manage their membership in a variety of ways, including user invitation.
116+
* Configure branded, federated login flows for each organization.
117+
* Implement role-based access control, such that users can have different roles when authenticating in the context of different organizations.
118+
* Build administration capabilities into your products, using Organizations APIs, so that those businesses can manage their own organizations.
119+
120+
Note that Organizations is currently only available to customers on our Enterprise and Startup subscription plans.
121+
122+
-------------------------
123+
Log in to an organization
124+
-------------------------
125+
126+
Log in to an organization by specifying the ``organization`` property when calling ``authorize()``:
127+
128+
.. code-block:: python
129+
130+
from auth0.v3.authentication.authorize_client import AuthorizeClient
131+
132+
client = AuthorizeClient('my.domain.com')
133+
134+
client.authorize(client_id='client_id',
135+
redirect_uri='http://localhost',
136+
organization="org_abc")
137+
138+
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:
139+
140+
.. code-block:: python
141+
142+
from auth0.v3.authentication.token_verifier import TokenVerifier, AsymmetricSignatureVerifier
143+
144+
domain = 'myaccount.auth0.com'
145+
client_id = 'exampleid'
146+
147+
# After authenticating
148+
id_token = auth_result['id_token']
149+
150+
jwks_url = 'https://{}/.well-known/jwks.json'.format(domain)
151+
issuer = 'https://{}/'.format(domain)
152+
153+
sv = AsymmetricSignatureVerifier(jwks_url) # Reusable instance
154+
tv = TokenVerifier(signature_verifier=sv, issuer=issuer, audience=client_id)
155+
156+
# pass the expected organization the user logged in to:
157+
tv.verify(id_token, organization='org_abc')
158+
159+
-----------------------
160+
Accept user invitations
161+
-----------------------
162+
163+
Accept a user invitation by specifying the ``invitation`` property when calling ``authorize()``. Note that you must also specify the ``organization`` if providing an ``invitation``.
164+
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``
165+
166+
.. code-block:: python
167+
168+
from auth0.v3.authentication.authorize_client import AuthorizeClient
169+
170+
client = AuthorizeClient('my.domain.com')
171+
172+
client.authorize(client_id='client_id',
173+
redirect_uri='http://localhost',
174+
organization='org_abc',
175+
invitation="invitation_123")
107176
108177
====================
109178
Management SDK Usage

auth0/v3/authentication/authorize_client.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ class AuthorizeClient(AuthenticationBase):
1010
"""
1111

1212
def authorize(self, client_id, audience=None, state=None, redirect_uri=None,
13-
response_type='code', scope='openid'):
13+
response_type='code', scope='openid', organization=None, invitation=None):
1414
"""Authorization code grant
1515
1616
This is the OAuth 2.0 grant that regular web apps utilize in order to access an API.
@@ -21,7 +21,9 @@ def authorize(self, client_id, audience=None, state=None, redirect_uri=None,
2121
'response_type': response_type,
2222
'scope': scope,
2323
'state': state,
24-
'redirect_uri': redirect_uri
24+
'redirect_uri': redirect_uri,
25+
'organization': organization,
26+
'invitation': invitation
2527
}
2628

2729
return self.get(

auth0/v3/authentication/token_verifier.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -227,13 +227,15 @@ def __init__(self, signature_verifier, issuer, audience, leeway=0):
227227
token (str): The JWT to verify.
228228
nonce (str, optional): The nonce value sent during authentication.
229229
max_age (int, optional): The max_age value sent during authentication.
230+
organization (str, optional): The expected organization ID (org_id) claim value. This should be specified
231+
when logging in to an organization.
230232
231233
Raises:
232234
TokenValidationError: when the token cannot be decoded, the token signing algorithm is not the expected one,
233235
the token signature is invalid or the token has a claim missing or with unexpected value.
234236
"""
235237

236-
def verify(self, token, nonce=None, max_age=None):
238+
def verify(self, token, nonce=None, max_age=None, organization=None):
237239
# Verify token presence
238240
if not token or not isinstance(token, str):
239241
raise TokenValidationError("ID token is required but missing.")
@@ -242,9 +244,9 @@ def verify(self, token, nonce=None, max_age=None):
242244
payload = self._sv.verify_signature(token)
243245

244246
# Verify claims
245-
self._verify_payload(payload, nonce, max_age)
247+
self._verify_payload(payload, nonce, max_age, organization)
246248

247-
def _verify_payload(self, payload, nonce=None, max_age=None):
249+
def _verify_payload(self, payload, nonce=None, max_age=None, organization=None):
248250
try:
249251
# on Python 2.7, 'str' keys as parsed as 'unicode'
250252
# But 'unicode' was removed on Python 3.7
@@ -307,6 +309,15 @@ def _verify_payload(self, payload, nonce=None, max_age=None):
307309
'Nonce (nonce) claim mismatch in the ID token; expected "{}", '
308310
'found "{}"'.format(nonce, payload['nonce']))
309311

312+
# Organization
313+
if organization:
314+
if 'org_id' not in payload or not isinstance(payload['org_id'], (str, ustr)):
315+
raise TokenValidationError('Organization (org_id) claim must be a string present in the ID token')
316+
if payload['org_id'] != organization:
317+
raise TokenValidationError(
318+
'Organization (org_id) claim mismatch in the ID token; expected "{}", '
319+
'found "{}"'.format(organization, payload['org_id']))
320+
310321
# Authorized party
311322
if isinstance(payload['aud'], list) and len(payload['aud']) > 1:
312323
if 'azp' not in payload or not isinstance(payload['azp'], (str, ustr)):

auth0/v3/test/authentication/test_authorize_client.py

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@ def test_login(self, mock_get):
1515
state='st',
1616
redirect_uri='http://localhost',
1717
response_type='token',
18-
scope='openid profile')
18+
scope='openid profile',
19+
organization='org_123',
20+
invitation='invitation_abc')
1921

2022
args, kwargs = mock_get.call_args
2123

@@ -26,5 +28,28 @@ def test_login(self, mock_get):
2628
'state': 'st',
2729
'redirect_uri': 'http://localhost',
2830
'response_type': 'token',
29-
'scope': 'openid profile'
31+
'scope': 'openid profile',
32+
'organization': 'org_123',
33+
'invitation': 'invitation_abc'
34+
})
35+
36+
@mock.patch('auth0.v3.authentication.authorize_client.AuthorizeClient.get')
37+
def test_login_default_param_values(self, mock_get):
38+
39+
a = AuthorizeClient('my.domain.com')
40+
41+
a.authorize(client_id='cid')
42+
43+
args, kwargs = mock_get.call_args
44+
45+
self.assertEqual(args[0], 'https://my.domain.com/authorize')
46+
self.assertEqual(kwargs['params'], {
47+
'audience': None,
48+
'invitation': None,
49+
'organization': None,
50+
'redirect_uri': None,
51+
'state': None,
52+
'client_id': 'cid',
53+
'response_type': 'code',
54+
'scope': 'openid'
3055
})

auth0/v3/test/authentication/test_token_verifier.py

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,7 @@ def asymmetric_signature_verifier_mock():
221221
verifier._fetch_key.return_value = jwt.algorithms.RSAAlgorithm.from_jwk(json.dumps(RSA_PUB_KEY_1_JWK))
222222
return verifier
223223

224-
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):
224+
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):
225225
sv = signature_verifier or self.asymmetric_signature_verifier_mock()
226226
tv = TokenVerifier(
227227
signature_verifier=sv,
@@ -231,7 +231,7 @@ def assert_fails_with_error(self, token, error_message, signature_verifier=None,
231231
)
232232
tv._clock = clock
233233
with self.assertRaises(TokenValidationError) as err:
234-
tv.verify(token, nonce, max_age)
234+
tv.verify(token, nonce, max_age, organization)
235235
self.assertEqual(str(err.exception), error_message)
236236

237237
def test_fails_at_creation_with_invalid_signature_verifier(self):
@@ -369,3 +369,37 @@ def test_fails_when_max_age_sent_with_auth_time_invalid(self):
369369
mocked_clock = expected_auth_time + 1
370370

371371
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)
372+
373+
def test_passes_when_org_present_but_not_required(self):
374+
token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhdXRoMHxzZGs0NThma3MiLCJhdWQiOiJ0b2tlbnMtdGVzdC0xMjMiLCJvcmdfaWQiOiJvcmdfMTIzIiwiaXNzIjoiaHR0cHM6Ly90b2tlbnMtdGVzdC5hdXRoMC5jb20vIiwiZXhwIjoxNTg3NzY1MzYxLCJpYXQiOjE1ODc1OTI1NjF9.hjSPgJpg0Dn2z0giCdGqVLD5Kmqy_yMYlSkgwKD7ahQ"
375+
sv = SymmetricSignatureVerifier(HMAC_SHARED_SECRET)
376+
tv = TokenVerifier(
377+
signature_verifier=sv,
378+
issuer=expectations['issuer'],
379+
audience=expectations['audience']
380+
)
381+
tv._clock = MOCKED_CLOCK
382+
tv.verify(token)
383+
384+
def test_passes_when_org_present_and_matches(self):
385+
token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhdXRoMHxzZGs0NThma3MiLCJhdWQiOiJ0b2tlbnMtdGVzdC0xMjMiLCJvcmdfaWQiOiJvcmdfMTIzIiwiaXNzIjoiaHR0cHM6Ly90b2tlbnMtdGVzdC5hdXRoMC5jb20vIiwiZXhwIjoxNTg3NzY1MzYxLCJpYXQiOjE1ODc1OTI1NjF9.hjSPgJpg0Dn2z0giCdGqVLD5Kmqy_yMYlSkgwKD7ahQ"
386+
sv = SymmetricSignatureVerifier(HMAC_SHARED_SECRET)
387+
tv = TokenVerifier(
388+
signature_verifier=sv,
389+
issuer=expectations['issuer'],
390+
audience=expectations['audience']
391+
)
392+
tv._clock = MOCKED_CLOCK
393+
tv.verify(token, organization='org_123')
394+
395+
def test_fails_when_org_specified_but_not_present(self):
396+
token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhdXRoMHxzZGs0NThma3MiLCJhdWQiOiJ0b2tlbnMtdGVzdC0xMjMiLCJpc3MiOiJodHRwczovL3Rva2Vucy10ZXN0LmF1dGgwLmNvbS8iLCJleHAiOjE1ODc3NjUzNjEsImlhdCI6MTU4NzU5MjU2MX0.wotJnUdD5IfdZMewF_-BnHc0pI56uwzwr5qaSXvSu9w"
397+
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')
398+
399+
def test_fails_when_org_specified_but_not_(self):
400+
token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhdXRoMHxzZGs0NThma3MiLCJhdWQiOiJ0b2tlbnMtdGVzdC0xMjMiLCJvcmdfaWQiOjQyLCJpc3MiOiJodHRwczovL3Rva2Vucy10ZXN0LmF1dGgwLmNvbS8iLCJleHAiOjE1ODc3NjUzNjEsImlhdCI6MTU4NzU5MjU2MX0.fGL1_akaHikdovS7NRYla3flne1xdtCjP0ei_CRxO6k"
401+
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')
402+
403+
def test_fails_when_org_specified_but_does_not_match(self):
404+
token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhdXRoMHxzZGs0NThma3MiLCJhdWQiOiJ0b2tlbnMtdGVzdC0xMjMiLCJvcmdfaWQiOiJvcmdfMTIzIiwiaXNzIjoiaHR0cHM6Ly90b2tlbnMtdGVzdC5hdXRoMC5jb20vIiwiZXhwIjoxNTg3NzY1MzYxLCJpYXQiOjE1ODc1OTI1NjF9.hjSPgJpg0Dn2z0giCdGqVLD5Kmqy_yMYlSkgwKD7ahQ"
405+
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')

0 commit comments

Comments
 (0)