diff --git a/tests/mock_files/azure-openid-configuration-v2.json b/tests/mock_files/azure-openid-configuration-v2.json new file mode 100644 index 00000000..533fdee9 --- /dev/null +++ b/tests/mock_files/azure-openid-configuration-v2.json @@ -0,0 +1,62 @@ +{ + "authorization_endpoint": "https://login.microsoftonline.com/01234567-89ab-cdef-0123-456789abcdef/oauth2/v2.0/authorize", + "token_endpoint": "https://login.microsoftonline.com/01234567-89ab-cdef-0123-456789abcdef/oauth2/v2.0/token", + "token_endpoint_auth_methods_supported": [ + "client_secret_post", + "private_key_jwt", + "client_secret_basic" + ], + "jwks_uri": "https://login.microsoftonline.com/common/discovery/keys", + "response_modes_supported": [ + "query", + "fragment", + "form_post" + ], + "subject_types_supported": [ + "pairwise" + ], + "id_token_signing_alg_values_supported": [ + "RS256" + ], + "http_logout_supported": true, + "frontchannel_logout_supported": true, + "end_session_endpoint": "https://login.microsoftonline.com/01234567-89ab-cdef-0123-456789abcdef/oauth2/v2.0/logout", + "response_types_supported": [ + "code", + "id_token", + "code id_token", + "token id_token", + "token" + ], + "scopes_supported": [ + "openid" + ], + "issuer": "https://sts.windows.net/01234567-89ab-cdef-0123-456789abcdef/", + "claims_supported": [ + "sub", + "iss", + "cloud_instance_name", + "cloud_instance_host_name", + "cloud_graph_host_name", + "msgraph_host", + "aud", + "exp", + "iat", + "auth_time", + "acr", + "amr", + "nonce", + "email", + "given_name", + "family_name", + "nickname" + ], + "microsoft_multi_refresh_token": true, + "check_session_iframe": "https://login.microsoftonline.com/01234567-89ab-cdef-0123-456789abcdef/oauth2/v2.0/checksession", + "userinfo_endpoint": "https://login.microsoftonline.com/01234567-89ab-cdef-0123-456789abcdef/openid/userinfo", + "tenant_region_scope": "EU", + "cloud_instance_name": "microsoftonline.com", + "cloud_graph_host_name": "graph.windows.net", + "msgraph_host": "graph.microsoft.com", + "rbac_url": "https://pas.windows.net" + } \ No newline at end of file diff --git a/tests/test_authentication.py b/tests/test_authentication.py index 0303d07b..9f38cbed 100644 --- a/tests/test_authentication.py +++ b/tests/test_authentication.py @@ -164,6 +164,17 @@ def test_group_claim(self): self.assertEqual(user.email, "john.doe@example.com") self.assertEqual(len(user.groups.all()), 0) + @mock_adfs("2016") + def test_no_group_claim(self): + backend = AdfsAuthCodeBackend() + with patch("django_auth_adfs.backend.settings.GROUPS_CLAIM", None): + user = backend.authenticate(self.request, authorization_code="dummycode") + self.assertIsInstance(user, User) + self.assertEqual(user.first_name, "John") + self.assertEqual(user.last_name, "Doe") + self.assertEqual(user.email, "john.doe@example.com") + self.assertEqual(len(user.groups.all()), 0) + @mock_adfs("2016", empty_keys=True) def test_empty_keys(self): backend = AdfsAuthCodeBackend() diff --git a/tests/test_drf_integration.py b/tests/test_drf_integration.py index 38d8f300..0a53e6c8 100644 --- a/tests/test_drf_integration.py +++ b/tests/test_drf_integration.py @@ -162,7 +162,7 @@ def test_access_token_azure_guest_but_no_upn_but_no_guest_username_claim(self): with self.assertRaises(exceptions.AuthenticationFailed): self.drf_auth_class.authenticate(request) - @mock_adfs("azure") + @mock_adfs("azure", requires_obo=True) def test_process_group_claim_from_ms_graph(self): access_token_header = "Bearer {}".format(self.access_token_azure_groups_in_claim_source) request = RequestFactory().get('/api', HTTP_AUTHORIZATION=access_token_header) @@ -175,18 +175,61 @@ def test_process_group_claim_from_ms_graph(self): with patch('django_auth_adfs.backend.settings', Settings()): with patch("django_auth_adfs.config.settings", Settings()): with patch("django_auth_adfs.backend.provider_config", ProviderConfig()): - with patch( - "django_auth_adfs.backend.AdfsBaseBackend.get_obo_access_token", - return_value="123456" - ): - with patch( - "django_auth_adfs.backend.AdfsBaseBackend.get_group_memberships_from_ms_graph", - return_value=["group1", "group2"] - ): - user, _ = self.drf_auth_class.authenticate(request) - self.assertEqual(user.username, "testuser") - self.assertEqual(user.groups.all()[0].name, "group1") - self.assertEqual(user.groups.all()[1].name, "group2") + user, _ = self.drf_auth_class.authenticate(request) + self.assertEqual(user.username, "testuser") + self.assertEqual(user.groups.all()[0].name, "group1") + self.assertEqual(user.groups.all()[1].name, "group2") + + @mock_adfs("azure", requires_obo=True, mfa_error=True) + def test_get_obo_access_token_mfa_error(self): + access_token_header = "Bearer {}".format(self.access_token_azure_groups_in_claim_source) + request = RequestFactory().get('/api', HTTP_AUTHORIZATION=access_token_header) + + from django_auth_adfs.config import django_settings + settings = deepcopy(django_settings) + del settings.AUTH_ADFS["SERVER"] + settings.AUTH_ADFS["TENANT_ID"] = "dummy_tenant_id" + with patch("django_auth_adfs.config.django_settings", settings): + with patch('django_auth_adfs.backend.settings', Settings()): + with patch("django_auth_adfs.config.settings", Settings()): + with patch("django_auth_adfs.backend.provider_config", ProviderConfig()): + with self.assertRaises(AuthenticationFailed): + self.drf_auth_class.authenticate(request) + + @mock_adfs("azure", requires_obo=True, version='v2.0') + def test_get_obo_access_token_version_2(self): + access_token_header = "Bearer {}".format(self.access_token_azure_groups_in_claim_source) + request = RequestFactory().get('/api', HTTP_AUTHORIZATION=access_token_header) + + from django_auth_adfs.config import django_settings + settings = deepcopy(django_settings) + del settings.AUTH_ADFS["SERVER"] + settings.AUTH_ADFS["TENANT_ID"] = "dummy_tenant_id" + settings.AUTH_ADFS["VERSION"] = 'v2.0' + with patch("django_auth_adfs.config.django_settings", settings): + with patch('django_auth_adfs.backend.settings', Settings()): + with patch("django_auth_adfs.config.settings", Settings()): + with patch("django_auth_adfs.backend.provider_config", ProviderConfig()): + user, _ = self.drf_auth_class.authenticate(request) + self.assertEqual(user.username, "testuser") + self.assertEqual(user.groups.all()[0].name, "group1") + self.assertEqual(user.groups.all()[1].name, "group2") + + @mock_adfs("azure", requires_obo=True, missing_graph_group_perm=True) + def test_missing_ms_graph_group_permission(self): + access_token_header = "Bearer {}".format(self.access_token_azure_groups_in_claim_source) + request = RequestFactory().get('/api', HTTP_AUTHORIZATION=access_token_header) + + from django_auth_adfs.config import django_settings + settings = deepcopy(django_settings) + del settings.AUTH_ADFS["SERVER"] + settings.AUTH_ADFS["TENANT_ID"] = "dummy_tenant_id" + with patch("django_auth_adfs.config.django_settings", settings): + with patch('django_auth_adfs.backend.settings', Settings()): + with patch("django_auth_adfs.config.settings", Settings()): + with patch("django_auth_adfs.backend.provider_config", ProviderConfig()): + with self.assertRaises(AuthenticationFailed): + self.drf_auth_class.authenticate(request) @mock_adfs("2012") def test_access_token_exceptions(self): diff --git a/tests/utils.py b/tests/utils.py index 9c86ae31..f6040d27 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -103,6 +103,14 @@ def do_build_mfa_error(request): return 400, [], json.dumps(response) +def do_build_graph_response(request): + return do_build_ms_graph_groups(request) + + +def do_build_graph_response_no_group_perm(request): + return do_build_ms_graph_groups(request, missing_group_names=True) + + def do_build_access_token(request, issuer, schema=None, no_upn=False, idp=None, groups_in_claim_names=False): issued_at = int(time.time()) expires = issued_at + 3600 @@ -163,6 +171,146 @@ def do_build_access_token(request, issuer, schema=None, no_upn=False, idp=None, return 200, [], json.dumps(response) +def do_build_obo_access_token(request): + obo_token = { + "aud": "https://graph.microsoft.com", + "iss": "https://sts.windows.net/01234567-89ab-cdef-0123-456789abcdef/", + "iat": 1660851337, + "nbf": 1660851337, + "exp": 1660856510, + "acct": 0, + "acr": "1", + "aio": ( + "AUQAu/8TBCDAcvfLrjwjR53Uci8V5KCONDvJXGEFM/gMeVSp6/LV338RTspRjxIhbmNLcAGa80KVXXglM7+ea1uqRKkRNCa9bQ==" + ), + "amr": [ + "wia", + "mfa" + ], + "app_displayname": "AppName", + "appid": "2345a5bc-123a-0a1b-0a12-a12345b6cd7e", + "appidacr": "1", + "family_name": "Doe", + "given_name": "John", + "idtyp": "user", + "ipaddr": "1.2.3.4", + "name": "Doe, John (Expert)", + "oid": "2345a5bc-123a-0a1b-0a12-a12345b6cd7e", + "onprem_sid": "S-1-5-21-456123456-1364589140-123456543-563809", + "platf": "5", + "puid": "10030000AD9D1530", + "rh": "0.AS8A1AA4aCjPK0uCpKTt25xSNwMAAAAAAAAAwAAAAAAAAAAvAEQ.", + "scp": "email GroupMember.Read.All openid profile User.Read", + "signin_state": [ + "inknownntwk" + ], + "sub": "PZBipRglYn2dgemAP_qDM3QzF1nosfdylWx8hsEwzYA", + "tenant_region_scope": "EU", + "tid": "01234567-89ab-cdef-0123-456789abcdef", + "unique_name": "john.doe@example.com", + "upn": "john.doe@example.com", + "uti": "D8NUc9MAwkutG-iBUnsBAA", + "ver": "1.0", + "wids": [ + "2345a5bc-123a-0a1b-0a12-a12345b6cd7e", + ], + "xms_tcdt": 1467198948 + } + token = jwt.encode(obo_token, signing_key_b, algorithm="RS256") + response = { + 'token_type': 'bearer', + 'scope': 'email GroupMember.Read.All openid profile User.Read', + 'expires_in': '4872', + 'ext_expires_in': '4872', + 'expires_on': '1660856510', + 'not_before': '1660851337', + 'resource': 'https://graph.microsoft.com', + 'refresh_token': 'not_used', + 'access_token': token.decode() if isinstance(token, bytes) else token # PyJWT>=2 returns a str instead of bytes + } + return 200, [], json.dumps(response) + + +def do_build_ms_graph_groups(request, missing_group_names=False): + response = { + "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#groups", + "value": [ + { + "id": "12ab345c-6abc-427f-85ca-93fc0cc7f00d", + "deletedDateTime": None, + "classification": None, + "createdDateTime": "2020-11-02T13:06:02Z", + "creationOptions": [], + "description": None, + "displayName": "group1", + "expirationDateTime": None, + "groupTypes": [], + "isAssignableToRole": None, + "mail": None, + "mailEnabled": False, + "mailNickname": "group1", + "membershipRule": None, + "membershipRuleProcessingState": None, + "onPremisesDomainName": "example.com", + "onPremisesLastSyncDateTime": "2022-08-18T19:32:43Z", + "onPremisesNetBiosName": "COMPANY", + "onPremisesSamAccountName": "group1", + "onPremisesSecurityIdentifier": "S-1-5-21-1234567891-1234567891-1234567891-123456", + "onPremisesSyncEnabled": True, + "preferredDataLocation": None, + "preferredLanguage": None, + "proxyAddresses": [], + "renewedDateTime": "2020-11-02T13:06:02Z", + "resourceBehaviorOptions": [], + "resourceProvisioningOptions": [], + "securityEnabled": False, + "securityIdentifier": "S-1-12-1-1234567891-1234567891-1234567891-1234567891", + "theme": None, + "visibility": None, + "onPremisesProvisioningErrors": [] + }, + { + "id": "23ab456c-7abc-427f-85ca-93fc0cc7f00d", + "deletedDateTime": None, + "classification": None, + "createdDateTime": "2020-11-02T13:06:02Z", + "creationOptions": [], + "description": None, + "displayName": "group2", + "expirationDateTime": None, + "groupTypes": [], + "isAssignableToRole": None, + "mail": None, + "mailEnabled": False, + "mailNickname": "group2", + "membershipRule": None, + "membershipRuleProcessingState": None, + "onPremisesDomainName": "example.com", + "onPremisesLastSyncDateTime": "2022-08-18T19:32:43Z", + "onPremisesNetBiosName": "COMPANY", + "onPremisesSamAccountName": "group2", + "onPremisesSecurityIdentifier": "S-1-5-21-1234567891-1234567891-1234567891-123456", + "onPremisesSyncEnabled": True, + "preferredDataLocation": None, + "preferredLanguage": None, + "proxyAddresses": [], + "renewedDateTime": "2020-11-02T13:06:02Z", + "resourceBehaviorOptions": [], + "resourceProvisioningOptions": [], + "securityEnabled": False, + "securityIdentifier": "S-1-12-1-1234567891-1234567891-1234567891-1234567891", + "theme": None, + "visibility": None, + "onPremisesProvisioningErrors": [] + }, + ] + } + if missing_group_names: + for group in response["value"]: + group["displayName"] = None + return 200, [], json.dumps(response) + + def build_openid_keys(request, empty_keys=False): if empty_keys: keys = {"keys": []} @@ -200,7 +348,15 @@ def build_adfs_meta(request): return 200, [], data -def mock_adfs(adfs_version, empty_keys=False, mfa_error=False, guest=False, version=None): +def mock_adfs( + adfs_version, + empty_keys=False, + mfa_error=False, + guest=False, + version=None, + requires_obo=False, + missing_graph_group_perm=False, +): if adfs_version not in ["2012", "2016", "azure"]: raise NotImplementedError("This version of ADFS is not implemented") @@ -212,13 +368,16 @@ def wrapper(*original_args, **original_kwargs): "azure": "https://login.microsoftonline.com", } prefix = prefix_table[adfs_version] - if version: + ms_graph_endpoint = "https://graph.microsoft.com/" + if version == "v2.0": openid_cfg = re.compile(prefix + r".*{}/\.well-known/openid-configuration".format(version)) + token_endpoint = re.compile(prefix + r".*/oauth2/{}/token".format(version)) else: openid_cfg = re.compile(prefix + r".*\.well-known/openid-configuration") + token_endpoint = re.compile(prefix + r".*/oauth2/token") openid_keys = re.compile(prefix + r".*/discovery/keys") adfs_meta = re.compile(prefix + r".*/FederationMetadata/2007-06/FederationMetadata\.xml") - token_endpoint = re.compile(prefix + r".*/oauth2/token") + ms_graph_groups = re.compile(ms_graph_endpoint + r".*/transitiveMemberOf/microsoft.graph.group") with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps: # https://github.com/getsentry/responses if adfs_version == "2016": @@ -232,10 +391,16 @@ def wrapper(*original_args, **original_kwargs): content_type='application/json', ) elif adfs_version == "azure": - rsps.add( - rsps.GET, openid_cfg, - json=load_json("mock_files/azure-openid-configuration.json") - ) + if version == "v2.0": + rsps.add( + rsps.GET, openid_cfg, + json=load_json("mock_files/azure-openid-configuration-v2.json") + ) + else: + rsps.add( + rsps.GET, openid_cfg, + json=load_json("mock_files/azure-openid-configuration.json") + ) rsps.add_callback( rsps.GET, openid_keys, callback=partial(build_openid_keys, empty_keys=empty_keys), @@ -268,6 +433,31 @@ def wrapper(*original_args, **original_kwargs): callback=build_access_token_azure, content_type='application/json', ) + if requires_obo: + if mfa_error: + rsps.add_callback( + rsps.GET, token_endpoint, + callback=do_build_mfa_error, + content_type='application/json', + ) + else: + rsps.add_callback( + rsps.GET, token_endpoint, + callback=do_build_obo_access_token, + content_type='application/json' + ) + if missing_graph_group_perm: + rsps.add_callback( + rsps.GET, ms_graph_groups, + callback=do_build_graph_response_no_group_perm, + content_type='application/json', + ) + else: + rsps.add_callback( + rsps.GET, ms_graph_groups, + callback=do_build_graph_response, + content_type='application/json', + ) else: if mfa_error: rsps.add_callback(