From f0ecc938efa0f78020ff5ca77fc0f20399b16572 Mon Sep 17 00:00:00 2001 From: Thomas Tracy Date: Fri, 7 Jun 2019 09:38:46 -0400 Subject: [PATCH] Add IdentityServer3 Backend (#20275) * Add IdentityServer3 Backend This adds a backend for users who want to use IdentityServer3 as their SSO provider. It can be used with the OAuth2ProviderConfig in django admin to point to an external provider. --- .../third_party_auth/identityserver3.py | 69 +++++++++++++++++++ .../tests/test_identityserver3.py | 50 ++++++++++++++ .../third_party_auth/tests/testutil.py | 6 ++ lms/envs/production.py | 1 + 4 files changed, 126 insertions(+) create mode 100644 common/djangoapps/third_party_auth/identityserver3.py create mode 100644 common/djangoapps/third_party_auth/tests/test_identityserver3.py diff --git a/common/djangoapps/third_party_auth/identityserver3.py b/common/djangoapps/third_party_auth/identityserver3.py new file mode 100644 index 0000000000..ebec0ebb0e --- /dev/null +++ b/common/djangoapps/third_party_auth/identityserver3.py @@ -0,0 +1,69 @@ +#pylint: disable=W0223 +""" + python-social-auth backend for use with IdentityServer3 + docs: https://identityserver.github.io/Documentation/docsv2/endpoints/authorization.html + docs for adding a new backend to python-social-auth: + https://python-social-auth.readthedocs.io/en/latest/backends/implementation.html#oauth +""" +from social_core.backends.oauth import BaseOAuth2 +from django.utils.functional import cached_property + + +class IdentityServer3(BaseOAuth2): + """ + An extension of the BaseOAuth2 for use with an IdentityServer3 service. + """ + + name = "identityServer3" + REDIRECT_STATE = False + ACCESS_TOKEN_METHOD = 'POST' + DEFAULT_SCOPE = ['openid'] + ID_KEY = "sub" + + def authorization_url(self): + return self.get_config().get_setting('auth_url') + + def access_token_url(self): + return self.get_config().get_setting('token_url') + + def get_redirect_uri(self, state=None): + return self.get_config().get_setting('redirect_url') + + def user_data(self, access_token, *args, **kwargs): + """ + consumes the access_token to get data about the user logged + into the service. + """ + url = self.get_config().get_setting('user_info_url') + # The access token returned from the service's token route. + header = {"Authorization": u"Bearer %s" % access_token} + return self.get_json(url, headers=header) + + def get_user_details(self, response): + """ + Return details about the user account from the service + """ + details = {"fullname": response["name"], "email": response["email"]} + return details + + def get_user_id(self, details, response): + """ + Gets the unique identifier from the user. this is + how edx knows who's logging in, and if they have an account + already through edx. IdentityServer emits standard claim type of sub + to identify the user according to these docs: + https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims + """ + try: + user_id = response.get(self.ID_KEY) + except KeyError: + user_id = None + return user_id + + def get_config(self): + return self._id3_config + + @cached_property + def _id3_config(self): + from .models import OAuth2ProviderConfig + return OAuth2ProviderConfig.current("identityServer3") diff --git a/common/djangoapps/third_party_auth/tests/test_identityserver3.py b/common/djangoapps/third_party_auth/tests/test_identityserver3.py new file mode 100644 index 0000000000..189c16666e --- /dev/null +++ b/common/djangoapps/third_party_auth/tests/test_identityserver3.py @@ -0,0 +1,50 @@ +""" +Unit tests for the IdentityServer3 OAuth2 Backend +""" +import unittest +from third_party_auth.identityserver3 import IdentityServer3 +from third_party_auth.tests import testutil + + +@unittest.skipUnless(testutil.AUTH_FEATURE_ENABLED, testutil.AUTH_FEATURES_KEY + ' not enabled') +class IdentityServer3Test(testutil.TestCase): + """ + Unit tests for the IdentityServer3 OAuth2 Backend + """ + + def setUp(self): + super(IdentityServer3Test, self).setUp() + self.id3_instance = IdentityServer3() + + def test_proper_get_of_user_id(self): + """ + make sure the "sub" claim works properly to grab user Id + """ + response = {"sub": 1, "email": "example@example.com"} + self.assertEqual(self.id3_instance.get_user_id({}, response), 1) + + def test_key_error_thrown_with_no_sub(self): + """ + test that a KeyError is thrown if the "sub" claim does not exist + """ + response = {"id": 1} + self.assertRaises(KeyError, self.id3_instance.get_user_id({}, response)) + + def test_proper_config_access(self): + """ + test that the IdentityServer3 model properly grabs OAuth2Configs + """ + provider_config = self.configure_identityServer3_provider(backend_name="identityServer3") + self.assertEqual(self.id3_instance.get_config(), provider_config) + + def test_config_after_updating(self): + """ + Make sure when the OAuth2Config for this backend is updated, the new config is properly grabbed + """ + original_provider_config = self.configure_identityServer3_provider(enabled=True, slug="original") + updated_provider_config = self.configure_identityServer3_provider( + slug="updated", + backend_name="identityServer3" + ) + self.assertEqual(self.id3_instance.get_config(), updated_provider_config) + self.assertNotEqual(self.id3_instance.get_config(), original_provider_config) diff --git a/common/djangoapps/third_party_auth/tests/testutil.py b/common/djangoapps/third_party_auth/tests/testutil.py index b6dbabcc37..3ae3cc23df 100644 --- a/common/djangoapps/third_party_auth/tests/testutil.py +++ b/common/djangoapps/third_party_auth/tests/testutil.py @@ -156,6 +156,12 @@ class ThirdPartyAuthTestMixin(object): kwargs.setdefault("backend_name", "dummy") return cls.configure_oauth_provider(**kwargs) + @classmethod + def configure_identityServer3_provider(cls, **kwargs): + kwargs.setdefault("name", "identityServer3TestConfig") + kwargs.setdefault("backend_name", "identityServer3") + return cls.configure_oauth_provider(**kwargs) + @classmethod def verify_user_email(cls, email): """ Mark the user with the given email as verified """ diff --git a/lms/envs/production.py b/lms/envs/production.py index 0cce47a12e..ff8c05062f 100644 --- a/lms/envs/production.py +++ b/lms/envs/production.py @@ -678,6 +678,7 @@ if FEATURES.get('ENABLE_THIRD_PARTY_AUTH'): 'social_core.backends.linkedin.LinkedinOAuth2', 'social_core.backends.facebook.FacebookOAuth2', 'social_core.backends.azuread.AzureADOAuth2', + 'third_party_auth.identityserver3.IdentityServer3', 'third_party_auth.saml.SAMLAuthBackend', 'third_party_auth.lti.LTIAuthBackend', ])