Skip to content

Commit

Permalink
retrieve and store user_id claim
Browse files Browse the repository at this point in the history
BREAKING CHANGE: The user_id scope is now required when using the
EdXOAuth2 backend for oAuth+SSO. This means that the oauth
application must first be configured to have access to the user_id
scope, which is not available by default.

The backend will then pull the user_id from the JWT and store it
in the UserSocialAuth.extra_data field.

ARCH-603
  • Loading branch information
robrap committed Mar 28, 2019
1 parent ef97603 commit 493f93e
Show file tree
Hide file tree
Showing 4 changed files with 72 additions and 23 deletions.
4 changes: 4 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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)
~~~~~~~~~~~~~~~~~~~~~~~~~~
Expand Down
2 changes: 1 addition & 1 deletion auth_backends/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
64 changes: 45 additions & 19 deletions auth_backends/backends.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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'])

Expand Down Expand Up @@ -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'):
Expand Down Expand Up @@ -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
25 changes: 22 additions & 3 deletions auth_backends/tests/test_backends.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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': '[email protected]',
'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
Expand Down Expand Up @@ -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': '[email protected]',
'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)])

0 comments on commit 493f93e

Please sign in to comment.