diff --git a/README.rst b/README.rst index 856842db..daf44b11 100644 --- a/README.rst +++ b/README.rst @@ -67,6 +67,10 @@ Note that the OAuth 2.0 provider uses ``SOCIAL_AUTH_EDX_OAUTH2_ENDPOINT`` to rea data returned from this endpoint provides the URLs necessary for authentication as well as the public keys used to verify the signed JWT (JWS) access token. +As of auth-backends 2.0.0, oAuth2 Applications require access to the ``user_id`` scope in order for the ``EdXOAuth2`` backend to work. The backend will write the ``user_id`` into the social-auth extra_data, and can be accessed within the User model as follows:: + + self.social_auth.first().extra_data[u'user_id'] # pylint: disable=no-member + OIDC Settings (deprecated) ~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/auth_backends/__init__.py b/auth_backends/__init__.py index efac4aab..9a6f0d42 100644 --- a/auth_backends/__init__.py +++ b/auth_backends/__init__.py @@ -3,4 +3,4 @@ These package is designed to be used primarily with Open edX Django projects, but should be compatible with non-edX projects as well. """ -__version__ = '1.2.2' # pragma: no cover +__version__ = '2.0.0' # pragma: no cover diff --git a/auth_backends/backends.py b/auth_backends/backends.py index 15d167c1..19a8a4bb 100644 --- a/auth_backends/backends.py +++ b/auth_backends/backends.py @@ -16,21 +16,36 @@ from social_core.backends.open_id_connect import OpenIdConnectAuth from social_core.exceptions import AuthTokenError +PROFILE_CLAIMS_TO_DETAILS_KEY_MAP = { + 'preferred_username': 'username', + 'email': 'email', + 'name': 'full_name', + 'given_name': 'first_name', + 'family_name': 'last_name', + 'locale': 'language', +} + + +def _merge_two_dicts(x, y): + """ + Given two dicts, merge them into a new dict as a shallow copy. + + Once Python 3.6+ only is supported, replace method with ``z = {**x, **y}`` + """ + z = x.copy() + z.update(y) + return z + # pylint: disable=abstract-method,missing-docstring class EdXBackendMixin(object): + # used by social-auth ACCESS_TOKEN_METHOD = 'POST' DEFAULT_SCOPE = ['profile', 'email'] ID_KEY = 'preferred_username' - PROFILE_TO_DETAILS_KEY_MAP = { - 'preferred_username': 'username', - 'email': 'email', - 'name': 'full_name', - 'given_name': 'first_name', - 'family_name': 'last_name', - 'locale': 'language', - 'user_tracking_id': 'user_tracking_id', - } + + # local only (not part of social-auth) + CLAIMS_TO_DETAILS_KEY_MAP = PROFILE_CLAIMS_TO_DETAILS_KEY_MAP def get_user_details(self, response): details = self._map_user_details(response) @@ -54,7 +69,7 @@ def _map_user_details(self, response): Does not transfer any key/value that is empty or not present in the response. """ dest = {} - for source_key, dest_key in self.PROFILE_TO_DETAILS_KEY_MAP.items(): + for source_key, dest_key in self.CLAIMS_TO_DETAILS_KEY_MAP.items(): value = response.get(source_key) if value is not None: dest[dest_key] = value @@ -75,15 +90,9 @@ class EdXOpenIdConnect(EdXBackendMixin, OpenIdConnectAuth): DEFAULT_SCOPE = ['openid', 'profile', 'email'] + getattr(settings, 'EXTRA_SCOPE', []) - PROFILE_TO_DETAILS_KEY_MAP = { - 'preferred_username': 'username', - 'email': 'email', - 'name': 'full_name', - 'given_name': 'first_name', - 'family_name': 'last_name', - 'locale': 'language', + CLAIMS_TO_DETAILS_KEY_MAP = _merge_two_dicts(PROFILE_CLAIMS_TO_DETAILS_KEY_MAP, { 'user_tracking_id': 'user_tracking_id', - } + }) auth_complete_signal = Signal(providing_args=['user', 'id_token']) @@ -234,8 +243,25 @@ def _to_language(locale): class EdXOAuth2(EdXBackendMixin, BaseOAuth2): + """ + IMPORTANT: The oauth2 application must have access to the ``user_id`` scope in order + to use this backend. + """ + # used by social-auth + name = 'edx-oauth2' + DEFAULT_SCOPE = ['user_id', 'profile', 'email'] + discard_missing_values = True + # EXTRA_DATA is used to store the `user_id` from the details in the UserSocialAuth.extra_data field. + # See https://python-social-auth.readthedocs.io/en/latest/backends/oauth.html?highlight=extra_data + EXTRA_DATA = [('user_id', 'user_id', discard_missing_values)] + + # local only (not part of social-auth) + CLAIMS_TO_DETAILS_KEY_MAP = _merge_two_dicts(PROFILE_CLAIMS_TO_DETAILS_KEY_MAP, { + 'user_id': 'user_id', + }) + @property def logout_url(self): if self.setting('LOGOUT_REDIRECT_URL'): @@ -267,6 +293,6 @@ def auth_complete_params(self, state=None): def user_data(self, access_token, *args, **kwargs): decoded_access_token = jwt.decode(access_token, verify=False) - keys = list(self.PROFILE_TO_DETAILS_KEY_MAP.keys()) + ['administrator'] + keys = list(self.CLAIMS_TO_DETAILS_KEY_MAP.keys()) + ['administrator'] user_data = {key: decoded_access_token[key] for key in keys if key in decoded_access_token} return user_data diff --git a/auth_backends/tests/test_backends.py b/auth_backends/tests/test_backends.py index 989d3cd7..e525a7c1 100644 --- a/auth_backends/tests/test_backends.py +++ b/auth_backends/tests/test_backends.py @@ -210,7 +210,7 @@ def access_token_body(self, request, _url, headers): expires_in = 3600 access_token = self.create_jws_access_token(expires_in) body = json.dumps({ - 'scope': 'read write profile email', + 'scope': 'read write profile email user_id', 'token_type': 'JWT', 'expires_in': expires_in, 'access_token': access_token @@ -242,11 +242,12 @@ def create_jws_access_token(self, expires_in=3600, issuer=None, key=None, alg='R 'sub': 'e3bfe0e4e7c6693efba9c3a93ee7f31b', 'preferred_username': self.expected_username, 'aud': 'InkocujLikyucsEdwiWatdebrEackmevLakDuifKooshkakWow', - 'scopes': ['read', 'write', 'profile', 'email'], + 'scopes': ['read', 'write', 'profile', 'email', 'user_id'], 'email': 'jsmith@example.com', 'exp': timegm(expiration_datetime.utctimetuple()), 'name': 'Joe Smith', - 'family_name': 'Smith' + 'family_name': 'Smith', + 'user_id': '1', } access_token = JWS(payload, jwk=key, alg=alg).sign_compact() return access_token @@ -310,3 +311,21 @@ def test_end_session_url(self): # Now, add the public url root to the settings. self.set_social_auth_setting('PUBLIC_URL_ROOT', self.public_url_root) self.assertEqual(self.backend.end_session_url(), self.public_url_root + logout_location) + + def test_user_data(self): + user_data = self.backend.user_data(self.create_jws_access_token()) + self.assertDictEqual(user_data, { + 'name': 'Joe Smith', + 'preferred_username': 'jsmith', + 'email': 'jsmith@example.com', + 'given_name': 'Joe', + 'user_id': '1', + 'family_name': 'Smith', + 'administrator': False + }) + + def test_extra_data(self): + """ + Ensure that `user_id` stays in EXTRA_DATA. + """ + self.assertEqual(self.backend.EXTRA_DATA, [('user_id', 'user_id', True)])