Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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)]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How is EXTRA_DATA used?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@douglashall: I added the comment above to address your question. I also adjusted the commit comment, which you are welcome to review.


# 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': '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
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': '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)])

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FYI: I didn't want to do an integration test, and I want to ensure this doesn't get lost, so I added a silly simple test.