diff --git a/cms/envs/common.py b/cms/envs/common.py
index fc4e314ec0..9a1b1f6f5c 100644
--- a/cms/envs/common.py
+++ b/cms/envs/common.py
@@ -94,6 +94,14 @@ from lms.envs.common import (
REDIRECT_CACHE_TIMEOUT,
REDIRECT_CACHE_KEY_PREFIX,
+ # This is required for the migrations in oauth_dispatch.models
+ # otherwise it fails saying this attribute is not present in Settings
+ # Although Studio does not enable OAuth2 Provider capability, the new approach
+ # to generating test databases will discover and try to create all tables
+ # and this setting needs to be present
+ OAUTH2_PROVIDER_APPLICATION_MODEL,
+ DEFAULT_JWT_ISSUER,
+ RESTRICTED_APPLICATION_JWT_ISSUER,
JWT_AUTH,
USERNAME_REGEX_PARTIAL,
@@ -1452,13 +1460,6 @@ HELP_TOKENS_LANGUAGE_CODE = lambda settings: settings.LANGUAGE_CODE
HELP_TOKENS_VERSION = lambda settings: doc_version()
derived('HELP_TOKENS_LANGUAGE_CODE', 'HELP_TOKENS_VERSION')
-# This is required for the migrations in oauth_dispatch.models
-# otherwise it fails saying this attribute is not present in Settings
-# Although Studio does not exable OAuth2 Provider capability, the new approach
-# to generating test databases will discover and try to create all tables
-# and this setting needs to be present
-OAUTH2_PROVIDER_APPLICATION_MODEL = 'oauth2_provider.Application'
-
# Used with Email sending
RETRY_ACTIVATION_EMAIL_MAX_ATTEMPTS = 5
RETRY_ACTIVATION_EMAIL_TIMEOUT = 0.5
diff --git a/lms/envs/common.py b/lms/envs/common.py
index a3f604a712..1fbe4134f1 100644
--- a/lms/envs/common.py
+++ b/lms/envs/common.py
@@ -496,18 +496,27 @@ OAUTH_EXPIRE_PUBLIC_CLIENT_DAYS = 30
################################## DJANGO OAUTH TOOLKIT #######################################
+# Scope description strings are presented to the user
+# on the application authorization page. See
+# lms/templates/oauth2_provider/authorize.html for details.
+OAUTH2_DEFAULT_SCOPES = {
+ 'read': _('Read access'),
+ 'write': _('Write access'),
+ 'email': _('Know your email address'),
+ 'profile': _('Know your name and username'),
+}
+
OAUTH2_PROVIDER = {
'OAUTH2_VALIDATOR_CLASS': 'openedx.core.djangoapps.oauth_dispatch.dot_overrides.validators.EdxOAuth2Validator',
'REFRESH_TOKEN_EXPIRE_SECONDS': 20160,
- 'SCOPES': {
- 'read': 'Read access',
- 'write': 'Write access',
- 'email': 'Know your email address',
- # conform profile scope message that is presented to end-user
- # to lms/templates/provider/authorize.html. This may be revised later.
- 'profile': 'Know your name and username',
- },
+ 'SCOPES_BACKEND_CLASS': 'openedx.core.djangoapps.oauth_dispatch.scopes.ApplicationModelScopes',
+ 'SCOPES': dict(OAUTH2_DEFAULT_SCOPES, **{
+ 'grades:read': _('Retrieve your grades for your enrolled courses'),
+ 'certificates:read': _('Retrieve your course certificates'),
+ }),
+ 'DEFAULT_SCOPES': OAUTH2_DEFAULT_SCOPES,
'REQUEST_APPROVAL_PROMPT': 'auto_even_if_expired',
+ 'ERROR_RESPONSE_WITH_SCOPES': True,
}
# This is required for the migrations in oauth_dispatch.models
# otherwise it fails saying this attribute is not present in Settings
@@ -2396,22 +2405,6 @@ SOCIAL_MEDIA_FOOTER_NAMES = [
"reddit",
]
-# JWT Settings
-JWT_AUTH = {
- # TODO Set JWT_SECRET_KEY to a secure value. By default, SECRET_KEY will be used.
- # 'JWT_SECRET_KEY': '',
- 'JWT_ALGORITHM': 'HS256',
- 'JWT_VERIFY_EXPIRATION': True,
- # TODO Set JWT_ISSUER and JWT_AUDIENCE to values specific to your service/organization.
- 'JWT_ISSUER': 'change-me',
- 'JWT_AUDIENCE': None,
- 'JWT_PAYLOAD_GET_USERNAME_HANDLER': lambda d: d.get('username'),
- 'JWT_LEEWAY': 1,
- 'JWT_DECODE_HANDLER': 'edx_rest_framework_extensions.utils.jwt_decode_handler',
- # Number of seconds before JWT tokens expire
- 'JWT_EXPIRATION': 30,
-}
-
# The footer URLs dictionary maps social footer names
# to URLs defined in configuration.
SOCIAL_MEDIA_FOOTER_URLS = {}
@@ -3184,6 +3177,41 @@ NOTIFICATION_EMAIL_CSS = "templates/credit_notifications/credit_notification.css
NOTIFICATION_EMAIL_EDX_LOGO = "templates/credit_notifications/edx-logo-header.png"
+################################ Settings for JWTs ################################
+
+DEFAULT_JWT_ISSUER = {
+ 'ISSUER': 'change-me',
+ 'SECRET_KEY': SECRET_KEY,
+ 'AUDIENCE': 'change-me',
+}
+
+RESTRICTED_APPLICATION_JWT_ISSUER = {
+ 'ISSUER': 'change-me',
+ 'SECRET_KEY': SECRET_KEY,
+ 'AUDIENCE': 'change-me',
+}
+
+JWT_AUTH = {
+ 'JWT_ALGORITHM': 'HS256',
+ 'JWT_VERIFY_EXPIRATION': True,
+
+ 'JWT_PAYLOAD_GET_USERNAME_HANDLER': lambda d: d.get('username'),
+ 'JWT_LEEWAY': 1,
+ 'JWT_DECODE_HANDLER': 'edx_rest_framework_extensions.utils.jwt_decode_handler',
+
+ # Number of seconds before JWT tokens expire
+ 'JWT_EXPIRATION': 30,
+ 'JWT_SUPPORTED_VERSION': '1.1.0',
+
+ 'JWT_SECRET_KEY': DEFAULT_JWT_ISSUER['SECRET_KEY'],
+ 'JWT_ISSUER': DEFAULT_JWT_ISSUER['ISSUER'],
+ 'JWT_AUDIENCE': DEFAULT_JWT_ISSUER['AUDIENCE'],
+ 'JWT_ISSUERS': [
+ DEFAULT_JWT_ISSUER,
+ RESTRICTED_APPLICATION_JWT_ISSUER,
+ ],
+}
+
################################ Settings for Microsites ################################
### Select an implementation for the microsite backend
diff --git a/lms/envs/devstack_docker.py b/lms/envs/devstack_docker.py
index 73b838058f..8ce464f8cb 100644
--- a/lms/envs/devstack_docker.py
+++ b/lms/envs/devstack_docker.py
@@ -27,10 +27,18 @@ CREDENTIALS_PUBLIC_SERVICE_URL = 'http://localhost:18150'
OAUTH_OIDC_ISSUER = '{}/oauth2'.format(LMS_ROOT_URL)
+DEFAULT_JWT_ISSUER = {
+ 'ISSUER': OAUTH_OIDC_ISSUER,
+ 'SECRET_KEY': 'lms-secret',
+ 'AUDIENCE': 'lms-key',
+}
JWT_AUTH.update({
- 'JWT_SECRET_KEY': 'lms-secret',
- 'JWT_ISSUER': OAUTH_OIDC_ISSUER,
- 'JWT_AUDIENCE': 'lms-key',
+ 'JWT_ISSUER': DEFAULT_JWT_ISSUER['ISSUER'],
+ 'JWT_AUDIENCE': DEFAULT_JWT_ISSUER['AUDIENCE'],
+ 'JWT_ISSUERS': [
+ DEFAULT_JWT_ISSUER,
+ RESTRICTED_APPLICATION_JWT_ISSUER,
+ ],
})
FEATURES.update({
diff --git a/lms/envs/test.py b/lms/envs/test.py
index 38d9a8bce8..db627a32b6 100644
--- a/lms/envs/test.py
+++ b/lms/envs/test.py
@@ -274,6 +274,19 @@ FEATURES['ENABLE_OAUTH2_PROVIDER'] = True
# don't cache courses for testing
OIDC_COURSE_HANDLER_CACHE_TIMEOUT = 0
+########################### Settings for JWTs ##################################
+RESTRICTED_APPLICATION_JWT_ISSUER = {
+ 'ISSUER': 'restricted-app',
+ 'SECRET_KEY': 'restricted-secret',
+ 'AUDIENCE': 'restricted-app',
+}
+JWT_AUTH.update({
+ 'JWT_ISSUERS': [
+ DEFAULT_JWT_ISSUER,
+ RESTRICTED_APPLICATION_JWT_ISSUER,
+ ],
+})
+
########################### External REST APIs #################################
FEATURES['ENABLE_MOBILE_REST_API'] = True
FEATURES['ENABLE_VIDEO_ABSTRACTION_LAYER_API'] = True
@@ -553,12 +566,6 @@ FEATURES['ORGANIZATIONS_APP'] = True
# Financial assistance page
FEATURES['ENABLE_FINANCIAL_ASSISTANCE_FORM'] = True
-JWT_AUTH.update({
- 'JWT_SECRET_KEY': 'test-secret',
- 'JWT_ISSUER': 'https://test-provider/oauth2',
- 'JWT_AUDIENCE': 'test-key',
-})
-
COURSE_CATALOG_API_URL = 'https://catalog.example.com/api/v1'
COMPREHENSIVE_THEME_DIRS = [REPO_ROOT / "themes", REPO_ROOT / "common/test"]
diff --git a/lms/templates/oauth2_provider/authorize.html b/lms/templates/oauth2_provider/authorize.html
index 75146eeaa4..6310a844c7 100644
--- a/lms/templates/oauth2_provider/authorize.html
+++ b/lms/templates/oauth2_provider/authorize.html
@@ -27,6 +27,16 @@
{{ scope }}
{% endfor %}
+
+ {% if content_orgs %}
+
{% trans "These permissions will be granted for data from your courses associated with the following content providers:" %}
+
+ {% for org_name in content_orgs %}
+
{{ org_name }}
+ {% endfor %}
+
+ {% endif %}
+
{% trans "Please click the 'Allow' button to grant these permissions to the above application. Otherwise, to withhold these permissions, please click the 'Cancel' button." %}
diff --git a/openedx/core/djangoapps/oauth_dispatch/adapters/dop.py b/openedx/core/djangoapps/oauth_dispatch/adapters/dop.py
index b12994b96f..8223f7610a 100644
--- a/openedx/core/djangoapps/oauth_dispatch/adapters/dop.py
+++ b/openedx/core/djangoapps/oauth_dispatch/adapters/dop.py
@@ -68,3 +68,15 @@ class DOPAdapter(object):
Given an access token object, return its scopes.
"""
return scope.to_names(token.scope)
+
+ def is_client_restricted(self, client_id): # pylint: disable=unused-argument
+ """
+ Returns true if the client is set up as a RestrictedApplication.
+ """
+ return False
+
+ def get_authorization_filters(self, client_id): # pylint: disable=unused-argument
+ """
+ Get the authorization filters for the given client application.
+ """
+ return []
diff --git a/openedx/core/djangoapps/oauth_dispatch/adapters/dot.py b/openedx/core/djangoapps/oauth_dispatch/adapters/dot.py
index e1ac9705db..2408731edd 100644
--- a/openedx/core/djangoapps/oauth_dispatch/adapters/dot.py
+++ b/openedx/core/djangoapps/oauth_dispatch/adapters/dot.py
@@ -4,6 +4,8 @@ Adapter to isolate django-oauth-toolkit dependencies
from oauth2_provider import models
+from openedx.core.djangoapps.oauth_dispatch.models import RestrictedApplication
+
class DOTAdapter(object):
"""
@@ -11,6 +13,7 @@ class DOTAdapter(object):
"""
backend = object()
+ FILTER_USER_ME = u'user:me'
def create_confidential_client(self,
name,
@@ -30,7 +33,8 @@ class DOTAdapter(object):
redirect_uris=redirect_uri,
)
- def create_public_client(self, name, user, redirect_uri, client_id=None):
+ def create_public_client(self, name, user, redirect_uri, client_id=None,
+ grant_type=models.Application.GRANT_PASSWORD):
"""
Create an oauth client application that is public.
"""
@@ -39,7 +43,7 @@ class DOTAdapter(object):
user=user,
client_id=client_id,
client_type=models.Application.CLIENT_PUBLIC,
- authorization_grant_type=models.Application.GRANT_PASSWORD,
+ authorization_grant_type=grant_type,
redirect_uris=redirect_uri,
)
@@ -76,3 +80,26 @@ class DOTAdapter(object):
Given an access token object, return its scopes.
"""
return list(token.scopes)
+
+ def is_client_restricted(self, client_id):
+ """
+ Returns true if the client is set up as a RestrictedApplication.
+ """
+ application = self.get_client(client_id=client_id)
+ return RestrictedApplication.objects.filter(application=application).exists()
+
+ def get_authorization_filters(self, client_id):
+ """
+ Get the authorization filters for the given client application.
+ """
+ application = self.get_client(client_id=client_id)
+ filters = [org_relation.to_jwt_filter_claim() for org_relation in application.organizations.all()]
+
+ # Allow applications configured with the client credentials grant type to access
+ # data for all users. This will enable these applications to fetch data in bulk.
+ # Applications configured with all other grant types should only have access
+ # to data for the request user.
+ if application.authorization_grant_type != application.GRANT_CLIENT_CREDENTIALS:
+ filters.append(self.FILTER_USER_ME)
+
+ return filters
diff --git a/openedx/core/djangoapps/oauth_dispatch/admin.py b/openedx/core/djangoapps/oauth_dispatch/admin.py
index 2c01b289d1..54689f2d8c 100644
--- a/openedx/core/djangoapps/oauth_dispatch/admin.py
+++ b/openedx/core/djangoapps/oauth_dispatch/admin.py
@@ -5,7 +5,7 @@ Override admin configuration for django-oauth-toolkit
from django.contrib.admin import ModelAdmin, site
from oauth2_provider import models
-from .models import RestrictedApplication
+from .models import RestrictedApplication, ApplicationAccess, ApplicationOrganization
def reregister(model_class):
@@ -52,17 +52,6 @@ class DOTRefreshTokenAdmin(ModelAdmin):
search_fields = [u'token', u'user__username', u'access_token__token']
-@reregister(models.Application)
-class DOTApplicationAdmin(ModelAdmin):
- """
- Custom Application Admin
- """
- list_display = [u'name', u'user', u'client_type', u'authorization_grant_type', u'client_id']
- list_filter = [u'client_type', u'authorization_grant_type']
- raw_id_fields = [u'user']
- search_fields = [u'name', u'user__username', u'client_id']
-
-
@reregister(models.Grant)
class DOTGrantAdmin(ModelAdmin):
"""
@@ -75,6 +64,31 @@ class DOTGrantAdmin(ModelAdmin):
search_fields = [u'code', u'user__username']
+@reregister(models.get_application_model())
+class DOTApplicationAdmin(ModelAdmin):
+ """
+ Custom Application Admin
+ """
+ list_display = [u'name', u'user', u'client_type', u'authorization_grant_type', u'client_id']
+ list_filter = [u'client_type', u'authorization_grant_type', u'skip_authorization']
+ raw_id_fields = [u'user']
+ search_fields = [u'name', u'user__username', u'client_id']
+
+
+class ApplicationAccessAdmin(ModelAdmin):
+ """
+ ModelAdmin for ApplicationAccess
+ """
+ list_display = [u'application', u'scopes']
+
+
+class ApplicationOrganizationAdmin(ModelAdmin):
+ """
+ ModelAdmin for ApplicationOrganization
+ """
+ list_display = [u'application', u'organization', u'relation_type']
+
+
class RestrictedApplicationAdmin(ModelAdmin):
"""
ModelAdmin for the Restricted Application
@@ -82,4 +96,6 @@ class RestrictedApplicationAdmin(ModelAdmin):
list_display = [u'application']
+site.register(ApplicationAccess, ApplicationAccessAdmin)
+site.register(ApplicationOrganization, ApplicationOrganizationAdmin)
site.register(RestrictedApplication, RestrictedApplicationAdmin)
diff --git a/openedx/core/djangoapps/oauth_dispatch/docs/decisions/0007-include-organizations-in-tokens.rst b/openedx/core/djangoapps/oauth_dispatch/docs/decisions/0007-include-organizations-in-tokens.rst
index 6cad2345cd..134bdc4509 100644
--- a/openedx/core/djangoapps/oauth_dispatch/docs/decisions/0007-include-organizations-in-tokens.rst
+++ b/openedx/core/djangoapps/oauth_dispatch/docs/decisions/0007-include-organizations-in-tokens.rst
@@ -100,7 +100,8 @@ organization information to the granting end-user.
"content_org:Microsoft"
-* For a token created via a *Client Credentials grant type*, the token
+* For a token created on behalf of a user (*not* created via a
+ *Client Credentials grant type*), the token
is further restricted specifically for the granting user. And so, a
"user" filter with the value "me" would be added for this grant type.
For example:
diff --git a/openedx/core/djangoapps/oauth_dispatch/dot_overrides/validators.py b/openedx/core/djangoapps/oauth_dispatch/dot_overrides/validators.py
index a44337a789..5214622995 100644
--- a/openedx/core/djangoapps/oauth_dispatch/dot_overrides/validators.py
+++ b/openedx/core/djangoapps/oauth_dispatch/dot_overrides/validators.py
@@ -11,6 +11,7 @@ from django.db.models.signals import pre_save
from django.dispatch import receiver
from oauth2_provider.models import AccessToken
from oauth2_provider.oauth2_validators import OAuth2Validator
+from oauth2_provider.scopes import get_scopes_backend
from pytz import utc
from ratelimitbackend.backends import RateLimitMixin
@@ -20,15 +21,10 @@ from ..models import RestrictedApplication
@receiver(pre_save, sender=AccessToken)
def on_access_token_presave(sender, instance, *args, **kwargs): # pylint: disable=unused-argument
"""
- A hook on the AccessToken. Since we do not have protected scopes, we must mark all
- AccessTokens as expired for 'restricted applications'.
-
- We do this as a pre-save hook on the ORM
+ Mark AccessTokens as expired for 'restricted applications' if required.
"""
-
- is_application_restricted = RestrictedApplication.objects.filter(application=instance.application).exists()
- if is_application_restricted:
- RestrictedApplication.set_access_token_as_expired(instance)
+ if RestrictedApplication.should_expire_access_token(instance.application):
+ instance.expires = datetime(1970, 1, 1, tzinfo=utc)
class EdxRateLimitedAllowAllUsersModelBackend(RateLimitMixin, UserModelBackend):
@@ -101,8 +97,7 @@ class EdxOAuth2Validator(OAuth2Validator):
super(EdxOAuth2Validator, self).save_bearer_token(token, request, *args, **kwargs)
- is_application_restricted = RestrictedApplication.objects.filter(application=request.client).exists()
- if is_application_restricted:
+ if RestrictedApplication.should_expire_access_token(request.client):
# Since RestrictedApplications will override the DOT defined expiry, so that access_tokens
# are always expired, we need to re-read the token from the database and then calculate the
# expires_in (in seconds) from what we stored in the database. This value should be a negative
@@ -121,3 +116,10 @@ class EdxOAuth2Validator(OAuth2Validator):
# Restore the original request attributes
request.grant_type = grant_type
request.user = user
+
+ def validate_scopes(self, client_id, scopes, client, request, *args, **kwargs):
+ """
+ Ensure required scopes are permitted (as specified in the settings file)
+ """
+ available_scopes = get_scopes_backend().get_available_scopes(application=client, request=request)
+ return set(scopes).issubset(set(available_scopes))
diff --git a/openedx/core/djangoapps/oauth_dispatch/dot_overrides/views.py b/openedx/core/djangoapps/oauth_dispatch/dot_overrides/views.py
index 363e6e86eb..0485f1c665 100644
--- a/openedx/core/djangoapps/oauth_dispatch/dot_overrides/views.py
+++ b/openedx/core/djangoapps/oauth_dispatch/dot_overrides/views.py
@@ -10,6 +10,8 @@ from oauth2_provider.scopes import get_scopes_backend
from oauth2_provider.settings import oauth2_settings
from oauth2_provider.views import AuthorizationView
+from openedx.core.djangoapps.oauth_dispatch.models import ApplicationOrganization
+
# TODO (ARCH-83) remove once we have full support of OAuth Scopes
class EdxOAuth2AuthorizationView(AuthorizationView):
@@ -43,7 +45,12 @@ class EdxOAuth2AuthorizationView(AuthorizationView):
# at this point we know an Application instance with such client_id exists in the database
application = get_application_model().objects.get(client_id=credentials['client_id'])
+ content_orgs = ApplicationOrganization.get_related_org_names(
+ application,
+ relation_type=ApplicationOrganization.RELATION_TYPE_CONTENT_ORG
+ )
kwargs['application'] = application
+ kwargs['content_orgs'] = content_orgs
kwargs['client_id'] = credentials['client_id']
kwargs['redirect_uri'] = credentials['redirect_uri']
kwargs['response_type'] = credentials['response_type']
diff --git a/openedx/core/djangoapps/oauth_dispatch/migrations/0004_auto_20180626_1349.py b/openedx/core/djangoapps/oauth_dispatch/migrations/0004_auto_20180626_1349.py
new file mode 100644
index 0000000000..63266b36c8
--- /dev/null
+++ b/openedx/core/djangoapps/oauth_dispatch/migrations/0004_auto_20180626_1349.py
@@ -0,0 +1,55 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.13 on 2018-06-26 17:49
+from __future__ import unicode_literals
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+import django_mysql.models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('organizations', '0006_auto_20171207_0259'),
+ migrations.swappable_dependency(settings.OAUTH2_PROVIDER_APPLICATION_MODEL),
+ ('oauth_dispatch', '0003_application_data'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='ApplicationAccess',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('scopes', django_mysql.models.ListCharField(models.CharField(max_length=32), help_text='Comma-separated list of scopes that this application will be allowed to request.', max_length=825, size=25)),
+ ('application', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='access', to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL, unique=True)),
+ ],
+ ),
+ migrations.CreateModel(
+ name='ApplicationOrganization',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('relation_type', models.CharField(choices=[(b'content_org', 'Content Provider')], default=b'content_org', max_length=32)),
+ ('application', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='organizations', to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL)),
+ ('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='organizations.Organization')),
+ ],
+ ),
+ migrations.RemoveField(
+ model_name='scopedapplication',
+ name='user',
+ ),
+ migrations.RemoveField(
+ model_name='scopedapplicationorganization',
+ name='application',
+ ),
+ migrations.DeleteModel(
+ name='ScopedApplication',
+ ),
+ migrations.DeleteModel(
+ name='ScopedApplicationOrganization',
+ ),
+ migrations.AlterUniqueTogether(
+ name='applicationorganization',
+ unique_together=set([('application', 'relation_type', 'organization')]),
+ ),
+ ]
diff --git a/openedx/core/djangoapps/oauth_dispatch/models.py b/openedx/core/djangoapps/oauth_dispatch/models.py
index 0faeb1f0f8..97e090cbcc 100644
--- a/openedx/core/djangoapps/oauth_dispatch/models.py
+++ b/openedx/core/djangoapps/oauth_dispatch/models.py
@@ -7,10 +7,13 @@ from datetime import datetime
from django.db import models
from django.utils.translation import ugettext_lazy as _
from django_mysql.models import ListCharField
-from oauth2_provider.models import AbstractApplication
from oauth2_provider.settings import oauth2_settings
+from organizations.models import Organization
from pytz import utc
+from openedx.core.djangoapps.oauth_dispatch.toggles import ENFORCE_JWT_SCOPES
+from openedx.core.djangoapps.request_cache import get_request_or_stub
+
class RestrictedApplication(models.Model):
"""
@@ -23,6 +26,9 @@ class RestrictedApplication(models.Model):
application = models.ForeignKey(oauth2_settings.APPLICATION_MODEL, null=False, on_delete=models.CASCADE)
+ class Meta:
+ app_label = 'oauth_dispatch'
+
def __unicode__(self):
"""
Return a unicode representation of this object
@@ -32,12 +38,11 @@ class RestrictedApplication(models.Model):
)
@classmethod
- def set_access_token_as_expired(cls, access_token):
- """
- For access_tokens for RestrictedApplications, put the expire timestamp into the beginning of the epoch
- which is Jan. 1, 1970
- """
- access_token.expires = datetime(1970, 1, 1, tzinfo=utc)
+ def should_expire_access_token(cls, application):
+ set_token_expired = not ENFORCE_JWT_SCOPES.is_enabled()
+ jwt_not_requested = get_request_or_stub().POST.get('token_type', '').lower() != 'jwt'
+ restricted_application = cls.objects.filter(application=application).exists()
+ return restricted_application and (jwt_not_requested or set_token_expired)
@classmethod
def verify_access_token_as_expired(cls, access_token):
@@ -48,19 +53,12 @@ class RestrictedApplication(models.Model):
return access_token.expires == datetime(1970, 1, 1, tzinfo=utc)
-class ScopedApplication(AbstractApplication):
+class ApplicationAccess(models.Model):
"""
- Custom Django OAuth Toolkit Application model that enables the definition
- of scopes that are authorized for the given Application.
+ Specifies access control information for the associated Application.
"""
- FILTER_USER_ME = 'user:me'
- # TODO: Remove the id field once we perform the inital migrations for this model.
- # We need to copy data over from the oauth2_provider.models.Application model to
- # this new model with the intial migration and the model IDs will need to match
- # so that existing AccessTokens will still work when switching over to the new model.
- # Once we have the data copied over we can move back to an auto-increment primary key.
- id = models.IntegerField(primary_key=True)
+ application = models.ForeignKey(oauth2_settings.APPLICATION_MODEL, unique=True, related_name='access')
scopes = ListCharField(
base_field=models.CharField(max_length=32),
size=25,
@@ -71,63 +69,68 @@ class ScopedApplication(AbstractApplication):
class Meta:
app_label = 'oauth_dispatch'
+ @classmethod
+ def get_scopes(cls, application):
+ return cls.objects.get(application=application).scopes
+
def __unicode__(self):
"""
Return a unicode representation of this object.
"""
- return u"".format(
- name=self.name
+ return u"{application_name}:{scopes}".format(
+ application_name=self.application.name,
+ scopes=self.scopes,
)
- @property
- def authorization_filters(self):
- """
- Return the list of authorization filters for this application.
- """
- filters = [':'.join([org.provider_type, org.short_name]) for org in self.organizations.all()]
- if self.authorization_grant_type == self.GRANT_CLIENT_CREDENTIALS:
- filters.append(self.FILTER_USER_ME)
- return filters
-
-class ScopedApplicationOrganization(models.Model):
+class ApplicationOrganization(models.Model):
"""
- Associates an organization to a given ScopedApplication including the
- provider type of the organization so that organization-based filters
- can be added to access tokens provided to the given Application.
+ Associates a DOT Application to an Organization.
See openedx/core/djangoapps/oauth_dispatch/docs/decisions/0007-include-organizations-in-tokens.rst
for the intended use of this model.
"""
- CONTENT_PROVIDER_TYPE = 'content_org'
- ORGANIZATION_PROVIDER_TYPES = (
- (CONTENT_PROVIDER_TYPE, _('Content Provider')),
+ RELATION_TYPE_CONTENT_ORG = 'content_org'
+ RELATION_TYPES = (
+ (RELATION_TYPE_CONTENT_ORG, _('Content Provider')),
)
- # In practice, short_name should match the short_name of an Organization model.
- # This is not a foreign key because the organizations app is not installed by default.
- short_name = models.CharField(
- max_length=255,
- help_text=_('The short_name of an existing Organization.'),
- )
- provider_type = models.CharField(
+ application = models.ForeignKey(oauth2_settings.APPLICATION_MODEL, related_name='organizations')
+ organization = models.ForeignKey(Organization)
+ relation_type = models.CharField(
max_length=32,
- choices=ORGANIZATION_PROVIDER_TYPES,
- default=CONTENT_PROVIDER_TYPE,
- )
- application = models.ForeignKey(
- oauth2_settings.APPLICATION_MODEL,
- related_name='organizations',
+ choices=RELATION_TYPES,
+ default=RELATION_TYPE_CONTENT_ORG,
)
class Meta:
app_label = 'oauth_dispatch'
+ unique_together = ('application', 'relation_type', 'organization')
+
+ @classmethod
+ def get_related_org_names(cls, application, relation_type=None):
+ """
+ Return the names of the Organizations related to the given DOT Application.
+
+ Filter by relation_type if provided.
+ """
+ queryset = application.organizations.all()
+ if relation_type:
+ queryset = queryset.filter(relation_type=relation_type)
+ return [r.organization.name for r in queryset]
def __unicode__(self):
"""
Return a unicode representation of this object.
"""
- return u"".format(
+ return u"{application_name}:{organization}:{relation_type}".format(
application_name=self.application.name,
- org=self.short_name,
+ organization=self.organization.short_name,
+ relation_type=self.relation_type,
)
+
+ def to_jwt_filter_claim(self):
+ """
+ Serialize for use in JWT filter claim.
+ """
+ return unicode(':'.join([self.relation_type, self.organization.short_name]))
diff --git a/openedx/core/djangoapps/oauth_dispatch/scopes.py b/openedx/core/djangoapps/oauth_dispatch/scopes.py
new file mode 100644
index 0000000000..ddce0bd3f3
--- /dev/null
+++ b/openedx/core/djangoapps/oauth_dispatch/scopes.py
@@ -0,0 +1,23 @@
+"""
+Custom Django OAuth Toolkit scopes backends.
+"""
+
+from oauth2_provider.scopes import SettingsScopes
+
+from openedx.core.djangoapps.oauth_dispatch.models import ApplicationAccess
+
+
+class ApplicationModelScopes(SettingsScopes):
+ """
+ Scopes backend that determines available scopes using the ApplicationAccess model.
+ """
+ def get_available_scopes(self, application=None, request=None, *args, **kwargs):
+ """ Returns valid scopes configured for the given application. """
+ try:
+ application_scopes = ApplicationAccess.get_scopes(application)
+ except ApplicationAccess.DoesNotExist:
+ application_scopes = []
+
+ default_scopes = self.get_default_scopes()
+ all_scopes = self.get_all_scopes().keys()
+ return set(application_scopes + default_scopes).intersection(all_scopes)
diff --git a/openedx/core/djangoapps/oauth_dispatch/tests/factories.py b/openedx/core/djangoapps/oauth_dispatch/tests/factories.py
index 100c166c27..07341e8ad5 100644
--- a/openedx/core/djangoapps/oauth_dispatch/tests/factories.py
+++ b/openedx/core/djangoapps/oauth_dispatch/tests/factories.py
@@ -8,7 +8,9 @@ from factory.fuzzy import FuzzyText
import pytz
from oauth2_provider.models import Application, AccessToken, RefreshToken
+from organizations.tests.factories import OrganizationFactory
+from openedx.core.djangoapps.oauth_dispatch.models import ApplicationAccess, ApplicationOrganization
from student.tests.factories import UserFactory
@@ -23,6 +25,23 @@ class ApplicationFactory(DjangoModelFactory):
authorization_grant_type = 'Client credentials'
+class ApplicationAccessFactory(DjangoModelFactory):
+ class Meta(object):
+ model = ApplicationAccess
+
+ application = factory.SubFactory(ApplicationFactory)
+ scopes = ['grades:read']
+
+
+class ApplicationOrganizationFactory(DjangoModelFactory):
+ class Meta(object):
+ model = ApplicationOrganization
+
+ application = factory.SubFactory(ApplicationFactory)
+ organization = factory.SubFactory(OrganizationFactory)
+ relation_type = ApplicationOrganization.RELATION_TYPE_CONTENT_ORG
+
+
class AccessTokenFactory(DjangoModelFactory):
class Meta(object):
model = AccessToken
diff --git a/openedx/core/djangoapps/oauth_dispatch/tests/mixins.py b/openedx/core/djangoapps/oauth_dispatch/tests/mixins.py
index cc2de10500..a92421e057 100644
--- a/openedx/core/djangoapps/oauth_dispatch/tests/mixins.py
+++ b/openedx/core/djangoapps/oauth_dispatch/tests/mixins.py
@@ -13,7 +13,8 @@ from student.models import UserProfile, anonymous_id_for_user
class AccessTokenMixin(object):
""" Mixin for tests dealing with OAuth 2 access tokens. """
- def assert_valid_jwt_access_token(self, access_token, user, scopes=None, should_be_expired=False):
+ def assert_valid_jwt_access_token(self, access_token, user, scopes=None, should_be_expired=False, filters=None,
+ jwt_issuer=settings.DEFAULT_JWT_ISSUER, should_be_restricted=None):
"""
Verify the specified JWT access token is valid, and belongs to the specified user.
@@ -26,8 +27,9 @@ class AccessTokenMixin(object):
dict: Decoded JWT payload
"""
scopes = scopes or []
- audience = settings.JWT_AUTH['JWT_AUDIENCE']
- issuer = settings.JWT_AUTH['JWT_ISSUER']
+ audience = jwt_issuer['AUDIENCE']
+ issuer = jwt_issuer['ISSUER']
+ secret_key = jwt_issuer['SECRET_KEY']
def _decode_jwt(verify_expiration):
"""
@@ -36,7 +38,7 @@ class AccessTokenMixin(object):
"""
return jwt.decode(
access_token,
- settings.JWT_AUTH['JWT_SECRET_KEY'],
+ secret_key,
algorithms=[settings.JWT_AUTH['JWT_ALGORITHM']],
audience=audience,
issuer=issuer,
@@ -55,6 +57,7 @@ class AccessTokenMixin(object):
'iss': issuer,
'preferred_username': user.username,
'scopes': scopes,
+ 'version': settings.JWT_AUTH['JWT_SUPPORTED_VERSION'],
'sub': anonymous_id_for_user(user, None),
}
@@ -74,6 +77,12 @@ class AccessTokenMixin(object):
'given_name': user.first_name,
})
+ if filters:
+ expected['filters'] = filters
+
+ if should_be_restricted is not None:
+ expected['is_restricted'] = should_be_restricted
+
self.assertDictContainsSubset(expected, payload)
# Since we suppressed checking of expiry
diff --git a/openedx/core/djangoapps/oauth_dispatch/tests/test_models.py b/openedx/core/djangoapps/oauth_dispatch/tests/test_models.py
new file mode 100644
index 0000000000..9045f1f995
--- /dev/null
+++ b/openedx/core/djangoapps/oauth_dispatch/tests/test_models.py
@@ -0,0 +1,20 @@
+"""
+Tests for oauth_dispatch models.
+"""
+from django.test import TestCase
+
+from openedx.core.djangoapps.oauth_dispatch.tests.factories import ApplicationOrganizationFactory
+from openedx.core.djangolib.testing.utils import skip_unless_lms
+
+
+@skip_unless_lms
+class ApplicationOrganizationTestCase(TestCase):
+ """
+ Tests for the ApplicationOrganization model.
+ """
+ def test_to_jwt_filter_claim(self):
+ """ Verify to_jwt_filter_claim returns the expected serialization of the model. """
+ org_relation = ApplicationOrganizationFactory()
+ organization = org_relation.organization
+ jwt_filter_claim = org_relation.to_jwt_filter_claim()
+ assert jwt_filter_claim == unicode(':'.join([org_relation.relation_type, organization.short_name]))
diff --git a/openedx/core/djangoapps/oauth_dispatch/tests/test_scopes.py b/openedx/core/djangoapps/oauth_dispatch/tests/test_scopes.py
new file mode 100644
index 0000000000..dab7d186fd
--- /dev/null
+++ b/openedx/core/djangoapps/oauth_dispatch/tests/test_scopes.py
@@ -0,0 +1,33 @@
+"""
+Tests for custom DOT scopes backend.
+"""
+import ddt
+from django.conf import settings
+from django.test import TestCase
+
+from openedx.core.djangoapps.oauth_dispatch.scopes import ApplicationModelScopes
+from openedx.core.djangoapps.oauth_dispatch.tests.factories import ApplicationAccessFactory
+from openedx.core.djangolib.testing.utils import skip_unless_lms
+
+
+@skip_unless_lms
+@ddt.ddt
+class ApplicationModelScopesTestCase(TestCase):
+ """
+ Tests for the ApplicationModelScopes custom DOT scopes backend.
+ """
+ @ddt.data(
+ ([], []),
+ (['unsupported_scope:read'], []),
+ (['grades:read'], ['grades:read']),
+ (['grades:read', 'certificates:read'], ['grades:read', 'certificates:read']),
+ )
+ @ddt.unpack
+ def test_get_available_scopes(self, application_scopes, expected_additional_scopes):
+ """ Verify the settings backend returns the expected available scopes. """
+ application_access = ApplicationAccessFactory(scopes=application_scopes)
+ scopes = ApplicationModelScopes()
+ self.assertEqual(
+ set(scopes.get_available_scopes(application_access.application)),
+ set(settings.OAUTH2_DEFAULT_SCOPES.keys() + expected_additional_scopes),
+ )
diff --git a/openedx/core/djangoapps/oauth_dispatch/tests/test_views.py b/openedx/core/djangoapps/oauth_dispatch/tests/test_views.py
index 347bae8684..125fc5b164 100644
--- a/openedx/core/djangoapps/oauth_dispatch/tests/test_views.py
+++ b/openedx/core/djangoapps/oauth_dispatch/tests/test_views.py
@@ -12,7 +12,9 @@ from django.conf import settings
from django.urls import reverse
from django.test import RequestFactory, TestCase, override_settings
from oauth2_provider import models as dot_models
+from organizations.tests.factories import OrganizationFactory
+from openedx.core.djangoapps.oauth_dispatch.toggles import ENFORCE_JWT_SCOPES
from provider import constants
from student.tests.factories import UserFactory
from third_party_auth.tests.utils import ThirdPartyOAuthTestMixin, ThirdPartyOAuthTestMixinGoogle
@@ -97,6 +99,15 @@ class _DispatchingViewTestCase(TestCase):
client_id='dop-app-client-id',
)
+ self.dot_app_access = models.ApplicationAccess.objects.create(
+ application=self.dot_app,
+ scopes=['grades:read'],
+ )
+ self.dot_app_org = models.ApplicationOrganization.objects.create(
+ application=self.dot_app,
+ organization=OrganizationFactory()
+ )
+
# Create a "restricted" DOT Application which means any AccessToken/JWT
# generated for this application will be immediately expired
self.restricted_dot_app = self.dot_adapter.create_public_client(
@@ -107,14 +118,14 @@ class _DispatchingViewTestCase(TestCase):
)
models.RestrictedApplication.objects.create(application=self.restricted_dot_app)
- def _post_request(self, user, client, token_type=None):
+ def _post_request(self, user, client, token_type=None, scope=None):
"""
- Call the view with a POST request objectwith the appropriate format,
+ Call the view with a POST request object with the appropriate format,
returning the response object.
"""
- return self.client.post(self.url, self._post_body(user, client, token_type)) # pylint: disable=no-member
+ return self.client.post(self.url, self._post_body(user, client, token_type, scope)) # pylint: disable=no-member
- def _post_body(self, user, client, token_type=None):
+ def _post_body(self, user, client, token_type=None, scope=None):
"""
Return a dictionary to be used as the body of the POST request
"""
@@ -132,20 +143,28 @@ class TestAccessTokenView(AccessTokenLoginMixin, mixins.AccessTokenMixin, _Dispa
self.url = reverse('access_token')
self.view_class = views.AccessTokenView
- def _post_body(self, user, client, token_type=None):
+ def _post_body(self, user, client, token_type=None, scope=None):
"""
Return a dictionary to be used as the body of the POST request
"""
+ grant_type = getattr(client, 'authorization_grant_type', dot_models.Application.GRANT_PASSWORD)
body = {
'client_id': client.client_id,
- 'grant_type': 'password',
- 'username': user.username,
- 'password': 'test',
+ 'grant_type': grant_type.replace('-', '_'),
}
+ if grant_type == dot_models.Application.GRANT_PASSWORD:
+ body['username'] = user.username
+ body['password'] = 'test'
+ elif grant_type == dot_models.Application.GRANT_CLIENT_CREDENTIALS:
+ body['client_secret'] = client.client_secret
+
if token_type:
body['token_type'] = token_type
+ if scope:
+ body['scope'] = scope
+
return body
@ddt.data('dop_app', 'dot_app')
@@ -159,21 +178,24 @@ class TestAccessTokenView(AccessTokenLoginMixin, mixins.AccessTokenMixin, _Dispa
self.assertIn('scope', data)
self.assertIn('token_type', data)
- def test_restricted_access_token_fields(self):
- response = self._post_request(self.user, self.restricted_dot_app)
- self.assertEqual(response.status_code, 200)
- data = json.loads(response.content)
- self.assertIn('access_token', data)
- self.assertIn('expires_in', data)
- self.assertIn('scope', data)
- self.assertIn('token_type', data)
+ @ddt.data(False, True)
+ def test_restricted_non_jwt_access_token_fields(self, enforce_jwt_scopes_enabled):
+ with ENFORCE_JWT_SCOPES.override(enforce_jwt_scopes_enabled):
+ response = self._post_request(self.user, self.restricted_dot_app)
+ self.assertEqual(response.status_code, 200)
+ data = json.loads(response.content)
+ self.assertIn('access_token', data)
+ self.assertIn('expires_in', data)
+ self.assertIn('scope', data)
+ self.assertIn('token_type', data)
- # Restricted applications have immediately expired tokens
- self.assertLess(data['expires_in'], 0)
-
- # double check that the token stored in the DB is marked as expired
- access_token = dot_models.AccessToken.objects.get(token=data['access_token'])
- self.assertTrue(models.RestrictedApplication.verify_access_token_as_expired(access_token))
+ # Verify token expiration.
+ self.assertEqual(data['expires_in'] < 0, True)
+ access_token = dot_models.AccessToken.objects.get(token=data['access_token'])
+ self.assertEqual(
+ models.RestrictedApplication.verify_access_token_as_expired(access_token),
+ True
+ )
@ddt.data('dop_app', 'dot_app')
def test_jwt_access_token(self, client_attr):
@@ -183,28 +205,41 @@ class TestAccessTokenView(AccessTokenLoginMixin, mixins.AccessTokenMixin, _Dispa
data = json.loads(response.content)
self.assertIn('expires_in', data)
self.assertEqual(data['token_type'], 'JWT')
- self.assert_valid_jwt_access_token(data['access_token'], self.user, data['scope'].split(' '))
+ self.assert_valid_jwt_access_token(
+ data['access_token'],
+ self.user,
+ data['scope'].split(' '),
+ should_be_restricted=False,
+ )
- def test_restricted_jwt_access_token(self):
+ @ddt.data(
+ (False, True, settings.DEFAULT_JWT_ISSUER),
+ (True, False, settings.RESTRICTED_APPLICATION_JWT_ISSUER),
+ )
+ @ddt.unpack
+ def test_restricted_jwt_access_token(self, enforce_jwt_scopes_enabled, expiration_expected,
+ jwt_issuer_expected):
"""
Verify that when requesting a JWT token from a restricted Application
within the DOT subsystem, that our claims is marked as already expired
(i.e. expiry set to Jan 1, 1970)
"""
- response = self._post_request(self.user, self.restricted_dot_app, token_type='jwt')
- self.assertEqual(response.status_code, 200)
- data = json.loads(response.content)
- self.assertIn('expires_in', data)
+ with ENFORCE_JWT_SCOPES.override(enforce_jwt_scopes_enabled):
+ response = self._post_request(self.user, self.restricted_dot_app, token_type='jwt')
+ self.assertEqual(response.status_code, 200)
+ data = json.loads(response.content)
- # jwt must indicate that it is already expired
- self.assertLess(data['expires_in'], 0)
- self.assertEqual(data['token_type'], 'JWT')
- self.assert_valid_jwt_access_token(
- data['access_token'],
- self.user,
- data['scope'].split(' '),
- should_be_expired=True
- )
+ self.assertIn('expires_in', data)
+ self.assertEqual(data['expires_in'] < 0, expiration_expected)
+ self.assertEqual(data['token_type'], 'JWT')
+ self.assert_valid_jwt_access_token(
+ data['access_token'],
+ self.user,
+ data['scope'].split(' '),
+ should_be_expired=expiration_expected,
+ jwt_issuer=jwt_issuer_expected,
+ should_be_restricted=True,
+ )
def test_restricted_access_token(self):
"""
@@ -238,6 +273,38 @@ class TestAccessTokenView(AccessTokenLoginMixin, mixins.AccessTokenMixin, _Dispa
data = json.loads(response.content)
self.assertNotIn('refresh_token', data)
+ @ddt.data(dot_models.Application.GRANT_CLIENT_CREDENTIALS, dot_models.Application.GRANT_PASSWORD)
+ def test_jwt_access_token_scopes_and_filters(self, grant_type):
+ """
+ Verify the JWT contains the expected scopes and filters.
+ """
+ dot_app = self.dot_adapter.create_public_client(
+ name='test dot application',
+ user=self.user,
+ redirect_uri=DUMMY_REDIRECT_URL,
+ client_id='dot-app-client-id-{grant_type}'.format(grant_type=grant_type),
+ grant_type=grant_type,
+ )
+ dot_app_access = models.ApplicationAccess.objects.create(
+ application=dot_app,
+ scopes=['grades:read'],
+ )
+ models.ApplicationOrganization.objects.create(
+ application=dot_app,
+ organization=OrganizationFactory()
+ )
+ scopes = dot_app_access.scopes
+ filters = self.dot_adapter.get_authorization_filters(dot_app.client_id)
+ response = self._post_request(self.user, dot_app, token_type='jwt', scope=scopes)
+ self.assertEqual(response.status_code, 200)
+ data = json.loads(response.content)
+ self.assert_valid_jwt_access_token(
+ data['access_token'],
+ self.user,
+ scopes,
+ filters=filters,
+ )
+
@ddt.ddt
@httpretty.activate
@@ -251,7 +318,7 @@ class TestAccessTokenExchangeView(ThirdPartyOAuthTestMixinGoogle, ThirdPartyOAut
self.view_class = views.AccessTokenExchangeView
super(TestAccessTokenExchangeView, self).setUp()
- def _post_body(self, user, client, token_type=None):
+ def _post_body(self, user, client, token_type=None, scope=None):
return {
'client_id': client.client_id,
'access_token': self.access_token,
@@ -283,6 +350,14 @@ class TestAuthorizationView(_DispatchingViewTestCase):
redirect_uri=DUMMY_REDIRECT_URL,
client_id='confidential-dot-app-client-id',
)
+ models.ApplicationAccess.objects.create(
+ application=self.dot_app,
+ scopes=['grades:read'],
+ )
+ self.dot_app_org = models.ApplicationOrganization.objects.create(
+ application=self.dot_app,
+ organization=OrganizationFactory()
+ )
self.dop_app = self.dop_adapter.create_confidential_client(
name='test dop client',
user=self.user,
@@ -327,7 +402,7 @@ class TestAuthorizationView(_DispatchingViewTestCase):
'response_type': 'code',
'state': 'random_state_string',
'redirect_uri': DUMMY_REDIRECT_URL,
- 'scope': 'profile'
+ 'scope': 'profile grades:read'
},
follow=True,
)
@@ -335,6 +410,7 @@ class TestAuthorizationView(_DispatchingViewTestCase):
# are the requested scopes on the page? We only requested 'profile', lets make
# sure the page only lists that one
self.assertContains(response, settings.OAUTH2_PROVIDER['SCOPES']['profile'])
+ self.assertContains(response, settings.OAUTH2_PROVIDER['SCOPES']['grades:read'])
self.assertNotContains(response, settings.OAUTH2_PROVIDER['SCOPES']['read'])
self.assertNotContains(response, settings.OAUTH2_PROVIDER['SCOPES']['write'])
self.assertNotContains(response, settings.OAUTH2_PROVIDER['SCOPES']['email'])
@@ -355,6 +431,12 @@ class TestAuthorizationView(_DispatchingViewTestCase):
'Allow'
)
+ # Are the content provider organizations listed on the page?
+ self.assertContains(
+ response,
+ '
{org}
'.format(org=self.dot_app_org.organization.name)
+ )
+
def _check_dot_response(self, response):
"""
Check that django-oauth-toolkit gives an appropriate authorization response.
diff --git a/openedx/core/djangoapps/oauth_dispatch/toggles.py b/openedx/core/djangoapps/oauth_dispatch/toggles.py
new file mode 100644
index 0000000000..fe1fd23909
--- /dev/null
+++ b/openedx/core/djangoapps/oauth_dispatch/toggles.py
@@ -0,0 +1,11 @@
+"""
+Feature toggle code for oauth_dispatch.
+"""
+
+from edx_rest_framework_extensions.config import SWITCH_ENFORCE_JWT_SCOPES
+
+from openedx.core.djangoapps.waffle_utils import WaffleSwitchNamespace, WaffleSwitch
+
+WAFFLE_NAMESPACE = 'oauth2'
+OAUTH2_SWITCHES = WaffleSwitchNamespace(name=WAFFLE_NAMESPACE)
+ENFORCE_JWT_SCOPES = WaffleSwitch(OAUTH2_SWITCHES, SWITCH_ENFORCE_JWT_SCOPES)
diff --git a/openedx/core/djangoapps/oauth_dispatch/views.py b/openedx/core/djangoapps/oauth_dispatch/views.py
index 8429fb333d..fb61caed20 100644
--- a/openedx/core/djangoapps/oauth_dispatch/views.py
+++ b/openedx/core/djangoapps/oauth_dispatch/views.py
@@ -25,6 +25,7 @@ from openedx.core.lib.token_utils import JwtBuilder
from . import adapters
from .dot_overrides import views as dot_overrides_views
+from .toggles import ENFORCE_JWT_SCOPES
class _DispatchingView(View):
@@ -101,10 +102,28 @@ class AccessTokenView(RatelimitMixin, _DispatchingView):
response = super(AccessTokenView, self).dispatch(request, *args, **kwargs)
if response.status_code == 200 and request.POST.get('token_type', '').lower() == 'jwt':
- expires_in, scopes, user = self._decompose_access_token_response(request, response)
+ client_id = self._get_client_id(request)
+ adapter = self.get_adapter(request)
+ expires_in, scopes, user = self._decompose_access_token_response(adapter, response)
+ issuer, secret, audience, filters, is_client_restricted = self._get_client_specific_claims(
+ client_id,
+ adapter
+ )
content = {
- 'access_token': JwtBuilder(user).build_token(scopes, expires_in),
+ 'access_token': JwtBuilder(
+ user,
+ secret=secret,
+ issuer=issuer,
+ ).build_token(
+ scopes,
+ expires_in,
+ aud=audience,
+ additional_claims={
+ 'filters': filters,
+ 'is_restricted': is_client_restricted,
+ },
+ ),
'expires_in': expires_in,
'token_type': 'JWT',
'scope': ' '.join(scopes),
@@ -113,17 +132,37 @@ class AccessTokenView(RatelimitMixin, _DispatchingView):
return response
- def _decompose_access_token_response(self, request, response):
+ def _decompose_access_token_response(self, adapter, response):
""" Decomposes the access token in the request to an expiration date, scopes, and User. """
content = json.loads(response.content)
access_token = content['access_token']
scope = content['scope']
- access_token_obj = self.get_adapter(request).get_access_token(access_token)
- user = access_token_obj.user
scopes = scope.split(' ')
+ user = adapter.get_access_token(access_token).user
expires_in = content['expires_in']
return expires_in, scopes, user
+ def _get_client_specific_claims(self, client_id, adapter):
+ """ Get claims that are specific to the client. """
+ # If JWT scope enforcement is enabled, we need to sign tokens
+ # given to restricted application with a separate secret which
+ # other IDAs do not have access to. This prevents restricted
+ # applications from getting access to API endpoints available
+ # on other IDAs which have not yet been protected with the
+ # scope-related DRF permission classes. Once all endpoints have
+ # been protected we can remove this if/else and go back to using
+ # a single secret.
+ # TODO: ARCH-162
+ is_client_restricted = adapter.is_client_restricted(client_id)
+ if ENFORCE_JWT_SCOPES.is_enabled() and is_client_restricted:
+ issuer_setting = 'RESTRICTED_APPLICATION_JWT_ISSUER'
+ else:
+ issuer_setting = 'DEFAULT_JWT_ISSUER'
+
+ jwt_issuer = getattr(settings, issuer_setting)
+ filters = adapter.get_authorization_filters(client_id)
+ return jwt_issuer['ISSUER'], jwt_issuer['SECRET_KEY'], jwt_issuer['AUDIENCE'], filters, is_client_restricted
+
class AuthorizationView(_DispatchingView):
"""
diff --git a/openedx/core/lib/tests/test_token_utils.py b/openedx/core/lib/tests/test_token_utils.py
index 59ba8cdab6..10ed16a641 100644
--- a/openedx/core/lib/tests/test_token_utils.py
+++ b/openedx/core/lib/tests/test_token_utils.py
@@ -47,14 +47,23 @@ class TestJwtBuilder(mixins.AccessTokenMixin, TestCase):
token = JwtBuilder(self.user).build_token(scopes, self.expires_in)
self.assert_valid_jwt_access_token(token, self.user, scopes)
- def test_override_secret_and_audience(self):
+ def test_override_secret_and_audience_and_issuer(self):
"""
- Verify that the signing key and audience can be overridden.
+ Verify that the signing key, audience, and issuer can be overridden.
"""
secret = 'avoid-this'
audience = 'avoid-this-too'
+ issuer = 'avoid-this-too'
scopes = []
- token = JwtBuilder(self.user, secret=secret).build_token(scopes, self.expires_in, aud=audience)
+ token = JwtBuilder(
+ self.user,
+ secret=secret,
+ issuer=issuer,
+ ).build_token(
+ scopes,
+ self.expires_in,
+ aud=audience,
+ )
- jwt.decode(token, secret, audience=audience)
+ jwt.decode(token, secret, audience=audience, issuer=issuer)
diff --git a/openedx/core/lib/token_utils.py b/openedx/core/lib/token_utils.py
index edbd52905e..9780912afc 100644
--- a/openedx/core/lib/token_utils.py
+++ b/openedx/core/lib/token_utils.py
@@ -8,7 +8,6 @@ from django.utils.functional import cached_property
from jwkest.jwk import KEYS, RSAKey
from jwkest.jws import JWS
-from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
from student.models import UserProfile, anonymous_id_for_user
@@ -27,13 +26,15 @@ class JwtBuilder(object):
Keyword Arguments:
asymmetric (Boolean): Whether the JWT should be signed with this app's private key.
secret (string): Overrides configured JWT secret (signing) key. Unused if an asymmetric signature is requested.
+ issuer (string): Overrides configured JWT issuer.
"""
- def __init__(self, user, asymmetric=False, secret=None):
+ def __init__(self, user, asymmetric=False, secret=None, issuer=None):
self.user = user
self.asymmetric = asymmetric
self.secret = secret
- self.jwt_auth = configuration_helpers.get_value('JWT_AUTH', settings.JWT_AUTH)
+ self.issuer = issuer
+ self.jwt_auth = settings.JWT_AUTH
def build_token(self, scopes, expires_in=None, aud=None, additional_claims=None):
"""Returns a JWT access token.
@@ -56,9 +57,10 @@ class JwtBuilder(object):
'aud': aud if aud else self.jwt_auth['JWT_AUDIENCE'],
'exp': now + expires_in,
'iat': now,
- 'iss': self.jwt_auth['JWT_ISSUER'],
+ 'iss': self.issuer if self.issuer else self.jwt_auth['JWT_ISSUER'],
'preferred_username': self.user.username,
'scopes': scopes,
+ 'version': self.jwt_auth['JWT_SUPPORTED_VERSION'],
'sub': anonymous_id_for_user(self.user, None),
}