Skip to content

Commit ec598f5

Browse files
authored
Merge pull request #247 from 777GE90/add_obo_groups_lookup
Added ability to obtain groups from MS Graph when there are too many
2 parents 5067f8f + 1852042 commit ec598f5

File tree

10 files changed

+503
-33
lines changed

10 files changed

+503
-33
lines changed

django_auth_adfs/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,4 @@
44
Adding imports here will break setup.py
55
"""
66

7-
__version__ = '1.10.0'
7+
__version__ = '1.10.1'

django_auth_adfs/backend.py

Lines changed: 118 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,81 @@ def exchange_auth_code(self, authorization_code, request):
4242
adfs_response = response.json()
4343
return adfs_response
4444

45+
def get_obo_access_token(self, access_token):
46+
"""
47+
Gets an On Behalf Of (OBO) access token, which is required to make queries against MS Graph
48+
49+
Args:
50+
access_token (str): Original authorization access token from the user
51+
52+
Returns:
53+
obo_access_token (str): OBO access token that can be used with the MS Graph API
54+
"""
55+
logger.debug("Getting OBO access token: %s", provider_config.token_endpoint)
56+
data = {
57+
"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
58+
"client_id": settings.CLIENT_ID,
59+
"client_secret": settings.CLIENT_SECRET,
60+
"assertion": access_token,
61+
"requested_token_use": "on_behalf_of",
62+
}
63+
if provider_config.token_endpoint.endswith("/v2.0/token"):
64+
data["scope"] = 'GroupMember.Read.All'
65+
else:
66+
data["resource"] = 'https://graph.microsoft.com'
67+
68+
response = provider_config.session.get(provider_config.token_endpoint, data=data, timeout=settings.TIMEOUT)
69+
# 200 = valid token received
70+
# 400 = 'something' is wrong in our request
71+
if response.status_code == 400:
72+
logger.error("ADFS server returned an error: %s", response.json()["error_description"])
73+
raise PermissionDenied
74+
75+
if response.status_code != 200:
76+
logger.error("Unexpected ADFS response: %s", response.content.decode())
77+
raise PermissionDenied
78+
79+
obo_access_token = response.json()["access_token"]
80+
logger.debug("Received OBO access token: %s", obo_access_token)
81+
return obo_access_token
82+
83+
def get_group_memberships_from_ms_graph(self, obo_access_token):
84+
"""
85+
Looks up a users group membership from the MS Graph API
86+
87+
Args:
88+
obo_access_token (str): Access token obtained from the OBO authorization endpoint
89+
90+
Returns:
91+
claim_groups (list): List of the users group memberships
92+
"""
93+
graph_url = "https://{}/v1.0/me/transitiveMemberOf/microsoft.graph.group".format(
94+
provider_config.msgraph_endpoint
95+
)
96+
headers = {"Authorization": "Bearer {}".format(obo_access_token)}
97+
response = provider_config.session.get(graph_url, headers=headers, timeout=settings.TIMEOUT)
98+
# 200 = valid token received
99+
# 400 = 'something' is wrong in our request
100+
if response.status_code in [400, 401]:
101+
logger.error("MS Graph server returned an error: %s", response.json()["message"])
102+
raise PermissionDenied
103+
104+
if response.status_code != 200:
105+
logger.error("Unexpected MS Graph response: %s", response.content.decode())
106+
raise PermissionDenied
107+
108+
claim_groups = []
109+
for group_data in response.json()["value"]:
110+
if group_data["displayName"] is None:
111+
logger.error(
112+
"The application does not have the required permission to read user groups from "
113+
"MS Graph (GroupMember.Read.All)"
114+
)
115+
raise PermissionDenied
116+
117+
claim_groups.append(group_data["displayName"])
118+
return claim_groups
119+
45120
def validate_access_token(self, access_token):
46121
for idx, key in enumerate(provider_config.signing_keys):
47122
try:
@@ -100,10 +175,11 @@ def process_access_token(self, access_token, adfs_response=None):
100175
if not claims:
101176
raise PermissionDenied
102177

178+
groups = self.process_user_groups(claims, access_token)
103179
user = self.create_user(claims)
104180
self.update_user_attributes(user, claims)
105-
self.update_user_groups(user, claims)
106-
self.update_user_flags(user, claims)
181+
self.update_user_groups(user, groups)
182+
self.update_user_flags(user, claims, groups)
107183

108184
signals.post_authenticate.send(
109185
sender=self,
@@ -116,6 +192,41 @@ def process_access_token(self, access_token, adfs_response=None):
116192
user.save()
117193
return user
118194

195+
def process_user_groups(self, claims, access_token):
196+
"""
197+
Checks the user groups are in the claim or pulls them from MS Graph if
198+
applicable
199+
200+
Args:
201+
claims (dict): claims from the access token
202+
access_token (str): Used to make an OBO authentication request if
203+
groups must be obtained from Microsoft Graph
204+
205+
Returns:
206+
groups (list): Groups the user is a member of, taken from the access token or MS Graph
207+
"""
208+
groups = []
209+
if settings.GROUPS_CLAIM is None:
210+
logger.debug("No group claim has been configured")
211+
return groups
212+
213+
if settings.GROUPS_CLAIM in claims:
214+
groups = claims[settings.GROUPS_CLAIM]
215+
if not isinstance(groups, list):
216+
groups = [groups, ]
217+
elif (
218+
settings.TENANT_ID != "adfs"
219+
and "_claim_names" in claims
220+
and settings.GROUPS_CLAIM in claims["_claim_names"]
221+
):
222+
obo_access_token = self.get_obo_access_token(access_token)
223+
groups = self.get_group_memberships_from_ms_graph(obo_access_token)
224+
else:
225+
logger.debug("The configured groups claim %s was not found in the access token",
226+
settings.GROUPS_CLAIM)
227+
228+
return groups
229+
119230
def create_user(self, claims):
120231
"""
121232
Create the user if it doesn't exist yet
@@ -201,26 +312,18 @@ def update_user_attributes(self, user, claims, claim_mapping=None):
201312
msg = "Model '{}' has no field named '{}'. Check ADFS claims mapping."
202313
raise ImproperlyConfigured(msg.format(user._meta.model_name, field))
203314

204-
def update_user_groups(self, user, claims):
315+
def update_user_groups(self, user, claim_groups):
205316
"""
206317
Updates user group memberships based on the GROUPS_CLAIM setting.
207318
208319
Args:
209320
user (django.contrib.auth.models.User): User model instance
210-
claims (dict): Claims from the access token
321+
claim_groups (list): User groups from the access token / MS Graph
211322
"""
212323
if settings.GROUPS_CLAIM is not None:
213324
# Update the user's group memberships
214325
django_groups = [group.name for group in user.groups.all()]
215326

216-
if settings.GROUPS_CLAIM in claims:
217-
claim_groups = claims[settings.GROUPS_CLAIM]
218-
if not isinstance(claim_groups, list):
219-
claim_groups = [claim_groups, ]
220-
else:
221-
logger.debug("The configured groups claim '%s' was not found in the access token",
222-
settings.GROUPS_CLAIM)
223-
claim_groups = []
224327
if sorted(claim_groups) != sorted(django_groups):
225328
existing_groups = list(Group.objects.filter(name__in=claim_groups).iterator())
226329
existing_group_names = frozenset(group.name for group in existing_groups)
@@ -241,29 +344,22 @@ def update_user_groups(self, user, claims):
241344
pass
242345
user.groups.set(existing_groups + new_groups)
243346

244-
def update_user_flags(self, user, claims):
347+
def update_user_flags(self, user, claims, claim_groups):
245348
"""
246349
Updates user boolean attributes based on the BOOLEAN_CLAIM_MAPPING setting.
247350
248351
Args:
249352
user (django.contrib.auth.models.User): User model instance
250353
claims (dict): Claims from the access token
354+
claim_groups (list): User groups from the access token / MS Graph
251355
"""
252356
if settings.GROUPS_CLAIM is not None:
253-
if settings.GROUPS_CLAIM in claims:
254-
access_token_groups = claims[settings.GROUPS_CLAIM]
255-
if not isinstance(access_token_groups, list):
256-
access_token_groups = [access_token_groups, ]
257-
else:
258-
logger.debug("The configured group claim was not found in the access token")
259-
access_token_groups = []
260-
261357
for flag, group in settings.GROUP_TO_FLAG_MAPPING.items():
262358
if hasattr(user, flag):
263359
if not isinstance(group, list):
264360
group = [group]
265361

266-
if any(group_list_item in access_token_groups for group_list_item in group):
362+
if any(group_list_item in claim_groups for group_list_item in group):
267363
value = True
268364
else:
269365
value = False

django_auth_adfs/config.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,7 @@ def __init__(self):
180180
self.token_endpoint = None
181181
self.end_session_endpoint = None
182182
self.issuer = None
183+
self.msgraph_endpoint = None
183184

184185
allowed_methods = frozenset([
185186
'HEAD', 'GET', 'PUT', 'DELETE', 'OPTIONS', 'TRACE', 'POST'
@@ -229,6 +230,7 @@ def load_config(self):
229230
logger.info("token endpoint: %s", self.token_endpoint)
230231
logger.info("end session endpoint: %s", self.end_session_endpoint)
231232
logger.info("issuer: %s", self.issuer)
233+
logger.info("msgraph endpoint: %s", self.msgraph_endpoint)
232234

233235
def _load_openid_config(self):
234236
if settings.VERSION != 'v1.0':
@@ -262,8 +264,10 @@ def _load_openid_config(self):
262264
self.end_session_endpoint = openid_cfg["end_session_endpoint"]
263265
if settings.TENANT_ID != 'adfs':
264266
self.issuer = openid_cfg["issuer"]
267+
self.msgraph_endpoint = openid_cfg["msgraph_host"]
265268
else:
266269
self.issuer = openid_cfg["access_token_issuer"]
270+
self.msgraph_endpoint = "graph.microsoft.com"
267271
except KeyError:
268272
raise ConfigLoadError
269273
return True
@@ -299,6 +303,7 @@ def _load_federation_metadata(self):
299303
self.authorization_endpoint = base_url + "/oauth2/authorize"
300304
self.token_endpoint = base_url + "/oauth2/token"
301305
self.end_session_endpoint = base_url + "/ls/?wa=wsignout1.0"
306+
self.msgraph_endpoint = "graph.microsoft.com"
302307
return True
303308

304309
def _load_keys(self, certificates):
79.1 KB
Loading

docs/azure_ad_config_guide.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,3 +225,11 @@ Here we can give our frontend the permission scope we created earlier. Press **D
225225

226226
.. image:: _static/AzureAD/19_add-permission-2.PNG
227227
:scale: 50 %
228+
229+
------------
230+
231+
Finally, sometimes the plugin will need to obtain the user groups claim from MS Graph (for example when the user has too many groups to fit in the access token), to ensure the plugin can do this successfully add the GroupMember.Read.All permission.
232+
233+
234+
.. image:: _static/AzureAD/20_add-permission-3.PNG
235+
:scale: 50 %

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = 'django-auth-adfs'
3-
version = "1.10.0" # Remember to also change __init__.py version
3+
version = "1.10.1" # Remember to also change __init__.py version
44
description = 'A Django authentication backend for Microsoft ADFS and AzureAD'
55
authors = ['Joris Beckers <[email protected]>']
66
maintainers = ['Jonas Krüger Svensson <[email protected]>', 'Sondre Lillebø Gundersen <[email protected]>']
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
{
2+
"authorization_endpoint": "https://login.microsoftonline.com/01234567-89ab-cdef-0123-456789abcdef/oauth2/v2.0/authorize",
3+
"token_endpoint": "https://login.microsoftonline.com/01234567-89ab-cdef-0123-456789abcdef/oauth2/v2.0/token",
4+
"token_endpoint_auth_methods_supported": [
5+
"client_secret_post",
6+
"private_key_jwt",
7+
"client_secret_basic"
8+
],
9+
"jwks_uri": "https://login.microsoftonline.com/common/discovery/keys",
10+
"response_modes_supported": [
11+
"query",
12+
"fragment",
13+
"form_post"
14+
],
15+
"subject_types_supported": [
16+
"pairwise"
17+
],
18+
"id_token_signing_alg_values_supported": [
19+
"RS256"
20+
],
21+
"http_logout_supported": true,
22+
"frontchannel_logout_supported": true,
23+
"end_session_endpoint": "https://login.microsoftonline.com/01234567-89ab-cdef-0123-456789abcdef/oauth2/v2.0/logout",
24+
"response_types_supported": [
25+
"code",
26+
"id_token",
27+
"code id_token",
28+
"token id_token",
29+
"token"
30+
],
31+
"scopes_supported": [
32+
"openid"
33+
],
34+
"issuer": "https://sts.windows.net/01234567-89ab-cdef-0123-456789abcdef/",
35+
"claims_supported": [
36+
"sub",
37+
"iss",
38+
"cloud_instance_name",
39+
"cloud_instance_host_name",
40+
"cloud_graph_host_name",
41+
"msgraph_host",
42+
"aud",
43+
"exp",
44+
"iat",
45+
"auth_time",
46+
"acr",
47+
"amr",
48+
"nonce",
49+
"email",
50+
"given_name",
51+
"family_name",
52+
"nickname"
53+
],
54+
"microsoft_multi_refresh_token": true,
55+
"check_session_iframe": "https://login.microsoftonline.com/01234567-89ab-cdef-0123-456789abcdef/oauth2/v2.0/checksession",
56+
"userinfo_endpoint": "https://login.microsoftonline.com/01234567-89ab-cdef-0123-456789abcdef/openid/userinfo",
57+
"tenant_region_scope": "EU",
58+
"cloud_instance_name": "microsoftonline.com",
59+
"cloud_graph_host_name": "graph.windows.net",
60+
"msgraph_host": "graph.microsoft.com",
61+
"rbac_url": "https://pas.windows.net"
62+
}

tests/test_authentication.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,17 @@ def test_group_claim(self):
164164
self.assertEqual(user.email, "[email protected]")
165165
self.assertEqual(len(user.groups.all()), 0)
166166

167+
@mock_adfs("2016")
168+
def test_no_group_claim(self):
169+
backend = AdfsAuthCodeBackend()
170+
with patch("django_auth_adfs.backend.settings.GROUPS_CLAIM", None):
171+
user = backend.authenticate(self.request, authorization_code="dummycode")
172+
self.assertIsInstance(user, User)
173+
self.assertEqual(user.first_name, "John")
174+
self.assertEqual(user.last_name, "Doe")
175+
self.assertEqual(user.email, "[email protected]")
176+
self.assertEqual(len(user.groups.all()), 0)
177+
167178
@mock_adfs("2016", empty_keys=True)
168179
def test_empty_keys(self):
169180
backend = AdfsAuthCodeBackend()

0 commit comments

Comments
 (0)