From db22939193999dee2989580d7d72a12e6ca940b4 Mon Sep 17 00:00:00 2001
From: Douglas Hall <dhall@edx.org>
Date: Tue, 5 Jun 2018 17:12:00 -0400
Subject: [PATCH] Add OAuth2 Scopes and Filters to JWTs.

---
 cms/envs/common.py                            |  15 +-
 lms/envs/common.py                            |  76 +++++---
 lms/envs/devstack_docker.py                   |  14 +-
 lms/envs/test.py                              |  19 +-
 lms/templates/oauth2_provider/authorize.html  |  10 ++
 .../djangoapps/oauth_dispatch/adapters/dop.py |  12 ++
 .../djangoapps/oauth_dispatch/adapters/dot.py |  31 +++-
 .../core/djangoapps/oauth_dispatch/admin.py   |  38 ++--
 .../0007-include-organizations-in-tokens.rst  |   3 +-
 .../dot_overrides/validators.py               |  22 +--
 .../oauth_dispatch/dot_overrides/views.py     |   7 +
 .../migrations/0004_auto_20180626_1349.py     |  55 ++++++
 .../core/djangoapps/oauth_dispatch/models.py  | 107 ++++++------
 .../core/djangoapps/oauth_dispatch/scopes.py  |  23 +++
 .../oauth_dispatch/tests/factories.py         |  19 ++
 .../djangoapps/oauth_dispatch/tests/mixins.py |  17 +-
 .../oauth_dispatch/tests/test_models.py       |  20 +++
 .../oauth_dispatch/tests/test_scopes.py       |  33 ++++
 .../oauth_dispatch/tests/test_views.py        | 164 +++++++++++++-----
 .../core/djangoapps/oauth_dispatch/toggles.py |  11 ++
 .../core/djangoapps/oauth_dispatch/views.py   |  49 +++++-
 openedx/core/lib/tests/test_token_utils.py    |  17 +-
 openedx/core/lib/token_utils.py               |  10 +-
 23 files changed, 598 insertions(+), 174 deletions(-)
 create mode 100644 openedx/core/djangoapps/oauth_dispatch/migrations/0004_auto_20180626_1349.py
 create mode 100644 openedx/core/djangoapps/oauth_dispatch/scopes.py
 create mode 100644 openedx/core/djangoapps/oauth_dispatch/tests/test_models.py
 create mode 100644 openedx/core/djangoapps/oauth_dispatch/tests/test_scopes.py
 create mode 100644 openedx/core/djangoapps/oauth_dispatch/toggles.py

diff --git a/cms/envs/common.py b/cms/envs/common.py
index fc4e314ec09..9a1b1f6f5cb 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 a3f604a7121..1fbe4134f1e 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 73b838058ff..8ce464f8cb2 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 38d9a8bce8d..db627a32b60 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 75146eeaa47..6310a844c71 100644
--- a/lms/templates/oauth2_provider/authorize.html
+++ b/lms/templates/oauth2_provider/authorize.html
@@ -27,6 +27,16 @@
                                 <li>{{ scope }}</li>
                             {% endfor %}
                         </ul>
+
+                        {% if content_orgs %}
+                            <p>{% trans "These permissions will be granted for data from your courses associated with the following content providers:" %}</p>
+                            <ul>
+                                {% for org_name in content_orgs %}
+                                    <li>{{ org_name }}</li>
+                                {% endfor %}
+                            </ul>
+                        {% endif %}
+
                         <p>{% trans "Please click the 'Allow' button to grant these permissions to the above application. Otherwise, to withhold these permissions, please click the 'Cancel' button." %}
                         </p>
 
diff --git a/openedx/core/djangoapps/oauth_dispatch/adapters/dop.py b/openedx/core/djangoapps/oauth_dispatch/adapters/dop.py
index b12994b96f6..8223f7610aa 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 e1ac9705dbc..2408731edd8 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 2c01b289d15..54689f2d8ca 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,27 +52,41 @@ class DOTRefreshTokenAdmin(ModelAdmin):
     search_fields = [u'token', u'user__username', u'access_token__token']
 
 
-@reregister(models.Application)
+@reregister(models.Grant)
+class DOTGrantAdmin(ModelAdmin):
+    """
+    Custom Grant Admin
+    """
+    date_hierarchy = u'expires'
+    list_display = [u'code', u'user', u'application', u'expires']
+    list_filter = [u'application']
+    raw_id_fields = [u'user']
+    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']
+    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']
 
 
-@reregister(models.Grant)
-class DOTGrantAdmin(ModelAdmin):
+class ApplicationAccessAdmin(ModelAdmin):
     """
-    Custom Grant Admin
+    ModelAdmin for ApplicationAccess
     """
-    date_hierarchy = u'expires'
-    list_display = [u'code', u'user', u'application', u'expires']
-    list_filter = [u'application']
-    raw_id_fields = [u'user']
-    search_fields = [u'code', u'user__username']
+    list_display = [u'application', u'scopes']
+
+
+class ApplicationOrganizationAdmin(ModelAdmin):
+    """
+    ModelAdmin for ApplicationOrganization
+    """
+    list_display = [u'application', u'organization', u'relation_type']
 
 
 class RestrictedApplicationAdmin(ModelAdmin):
@@ -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 6cad2345cdc..134bdc45098 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 a44337a789b..5214622995f 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 363e6e86ebd..0485f1c6654 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 00000000000..63266b36c8f
--- /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 0faeb1f0f8a..97e090cbcc9 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"<ScopedApplication '{name}'>".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"<ScopedApplicationOrganization '{application_name}':'{org}'>".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 00000000000..ddce0bd3f30
--- /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 100c166c273..07341e8ad52 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 cc2de105008..a92421e057a 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 00000000000..9045f1f995c
--- /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 00000000000..dab7d186fde
--- /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 347bae8684c..125fc5b164e 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)
-
-        # 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))
+    @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)
+
+            # 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)
-
-        # 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
-        )
+        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)
+
+            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):
             '<button type="submit" class="btn btn-authorization-allow" name="allow" value="Authorize"/>Allow</button>'
         )
 
+        # Are the content provider organizations listed on the page?
+        self.assertContains(
+            response,
+            '<li>{org}</li>'.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 00000000000..fe1fd23909f
--- /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 8429fb333d1..fb61caed208 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 59ba8cdab64..10ed16a6413 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 edbd52905e6..9780912afc0 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),
         }
 
-- 
GitLab