feat: Add scope user_id to JWT payload (#33455)

This commit is contained in:
Moeez Zahid
2023-10-30 07:56:58 +05:00
committed by GitHub
parent e6e124bf16
commit ba1f382471
5 changed files with 70 additions and 9 deletions

View File

@@ -1246,6 +1246,7 @@ OAUTH2_PROVIDER = {
'certificates:read': _('Retrieve your course certificates'),
'grades:read': _('Retrieve your grades for your enrolled courses'),
'tpa:read': _('Retrieve your third-party authentication username mapping'),
# user_id is added in code as a default scope for JWT cookies and all password grant_type JWTs
'user_id': _('Know your user identifier'),
}),
'DEFAULT_SCOPES': OAUTH2_DEFAULT_SCOPES,

View File

@@ -0,0 +1,36 @@
15. Add scope user_id for JWT token
###################################
Status
------
Accepted
Context
-------
In Feb 2018, to enable analytics (Segment) from Microfrontends (MFEs), a ``user_id`` claim was added to the JWT token `in this PR<https://github.com/openedx/edx-platform/pull/19765>`__.
The LMS API `to create authentication tokens`_ is used by external organizations to request a token on behalf of their users, mostly using grant_type ``client_credentials`` in the request. Since ``user_id`` is considered sensitive information, especially when combined with email and username which were already available in the JWT, it was decided to only add the ``user_id`` claim when a ``user_id`` scope was supplied. All MFE JWT cookies, which are known to only be used directly by the user, automatically used the ``user_id`` scope in order to get the required ``user_id`` claim.
No ADR could be found for the Feb 2018 decision detailed above.
In June 2019, an `ADR was captured in ecommerce`_ around the requirements to have the LMS user_id available for requests to ecommerce.
In 2022, the mobile apps switched to using JWTs for authentication. However, these JWTs were missing the ``user_id`` scope and claim required by the ecommerce service.
.. _to create authentication tokens: https://github.com/openedx/edx-platform/blob/caf8e456e28f9b9a1f5fa7186d3d155112fb75be/openedx/core/djangoapps/oauth_dispatch/urls.py#L14
.. _ADR was captured in ecommerce: https://github.com/openedx/ecommerce/blob/master/docs/decisions/0004-unique-identifier-for-users.rst
Decisions
---------
- The original decision to add the ``user_id`` claim to the JWT token using the ``user_id`` scope has been captured in the context of this ADR, because no ADR could be found.
- The scope ``user_id`` will be added to all requests having grant_type ``password`` in the API `/oauth2/access_token/`.
Consequences
------------
- The claim ``user_id`` will be present in the JWT token for all requesters who already have access to the login credentials of the user account.
- The ``user_id`` scope will continue to protect other JWT requests that don't require this sensitive information.
- This pattern could potentially be used to clean-up the manually added ``user_id`` scope for oauth clients involved in the social auth flow in the future.

View File

@@ -12,6 +12,7 @@ from edx_rbac.utils import create_role_auth_claim_for_user
from edx_toggles.toggles import SettingToggle
from jwt import PyJWK
from jwt.utils import base64url_encode
from oauth2_provider.models import Application
from common.djangoapps.student.models import UserProfile, anonymous_id_for_user
@@ -79,10 +80,11 @@ def create_jwt_token_dict(token_dict, oauth_adapter, use_asymmetric_key=None):
# .. custom_attribute_name: create_jwt_grant_type
# .. custom_attribute_description: The grant type of the newly created JWT.
set_custom_attribute('create_jwt_grant_type', grant_type)
scopes = _get_updated_scopes(token_dict['scope'].split(' '), grant_type)
jwt_access_token = _create_jwt(
access_token.user,
scopes=token_dict['scope'].split(' '),
scopes=scopes,
expires_in=jwt_expires_in,
use_asymmetric_key=use_asymmetric_key,
is_restricted=oauth_adapter.is_client_restricted(client),
@@ -91,11 +93,16 @@ def create_jwt_token_dict(token_dict, oauth_adapter, use_asymmetric_key=None):
)
jwt_token_dict = token_dict.copy()
# Note: only "scope" is not overwritten at this point.
# Note: only "refresh_token" is not overwritten at this point.
# At this time, the user_id scope added for grant type password is only added to the
# JWT, and is not added for the DOT access token or refresh token, so we must override
# here. If this inconsistency becomes an issue, then the user_id scope should be
# added earlier with the DOT tokens, and we would no longer need to override "scope".
jwt_token_dict.update({
"access_token": jwt_access_token,
"token_type": "JWT",
"expires_in": jwt_expires_in,
"scope": ' '.join(scopes),
})
return jwt_token_dict
@@ -167,9 +174,7 @@ def _create_jwt(
else:
increment('create_symmetric_jwt_count')
# Default scopes should only contain non-privileged data.
# Do not be misled by the fact that `email` and `profile` are default scopes. They
# were included for legacy compatibility, even though they contain privileged data.
# Scopes `email` and `profile` are included for legacy compatibility.
scopes = scopes or ['email', 'profile']
iat, exp = _compute_time_fields(expires_in)
@@ -285,3 +290,17 @@ def _encode_and_sign(payload, use_asymmetric_key, secret):
jwk = PyJWK(key, algorithm)
return jwt.encode(payload, jwk.key, algorithm=algorithm)
def _get_updated_scopes(scopes, grant_type):
"""
Default scopes should only contain non-privileged data.
Do not be misled by the fact that `email` and `profile` are default scopes.
They were included for legacy compatibility, even though they contain privileged
data. The scope `user_id` must be added for requests with grant_type password.
"""
scopes = scopes or ['email', 'profile']
if grant_type == Application.GRANT_PASSWORD and 'user_id' not in scopes:
scopes.append('user_id')
return scopes

View File

@@ -21,6 +21,7 @@ class TestCreateJWTs(AccessTokenMixin, TestCase):
super().setUp()
self.user = UserFactory()
self.default_scopes = ['email', 'profile']
self.default_scopes_password_grant_type = ['email', 'profile', 'user_id']
def _create_client(self, oauth_adapter, client_restricted, grant_type=None):
"""
@@ -176,7 +177,7 @@ class TestCreateJWTs(AccessTokenMixin, TestCase):
jwt_token_dict = jwt_api.create_jwt_token_dict(token_dict, oauth_adapter, use_asymmetric_key=False)
self.assert_valid_jwt_access_token(
jwt_token_dict["access_token"], self.user, self.default_scopes,
jwt_token_dict["access_token"], self.user, self.default_scopes_password_grant_type,
grant_type='password',
)

View File

@@ -315,17 +315,21 @@ class TestAccessTokenView(AccessTokenLoginMixin, mixins.AccessTokenMixin, _Dispa
scopes=['grades:read'],
filters=['test:filter'],
)
scopes = dot_app_access.scopes
requested_scopes = dot_app_access.scopes
filters = self.dot_adapter.get_authorization_filters(dot_app)
assert 'test:filter' in filters
response = self._post_request(self.user, dot_app, token_type='jwt', scope=scopes)
response = self._post_request(self.user, dot_app, token_type='jwt', scope=requested_scopes)
assert response.status_code == 200
data = json.loads(response.content.decode('utf-8'))
scopes_in_response = data['scope'].split(' ')
for requested_scope in requested_scopes:
assert requested_scope in scopes_in_response
self.assert_valid_jwt_access_token(
data['access_token'],
self.user,
scopes,
scopes_in_response,
filters=filters,
grant_type=grant_type,
)