diff --git a/.coveragerc b/.coveragerc
index 3a33749e6757bbeab1059cf1bb9b35095f98f5b2..970aebfdd95a456c7f8f63564afa81f908011e25 100644
--- a/.coveragerc
+++ b/.coveragerc
@@ -2,3 +2,4 @@
 [run]
 data_file = .coverage
 source = lti_consumer
+omit = */urls.py
diff --git a/lti_consumer/__init__.py b/lti_consumer/__init__.py
index bdd1ff4c530841cdc7da9767f819899da181c0ca..8bc16deb250f2eef5aa7cb35a80084be3fc7c9ab 100644
--- a/lti_consumer/__init__.py
+++ b/lti_consumer/__init__.py
@@ -2,3 +2,4 @@
 Runtime will load the XBlock class from here.
 """
 from .lti_consumer import LtiConsumerXBlock
+from .apps import LTIConsumerApp
diff --git a/lti_consumer/apps.py b/lti_consumer/apps.py
new file mode 100644
index 0000000000000000000000000000000000000000..f06d6d84b584eb07875c1d9f3ebfa0cd63389569
--- /dev/null
+++ b/lti_consumer/apps.py
@@ -0,0 +1,27 @@
+# -*- coding: utf-8 -*-
+"""
+lti_consumer Django application initialization.
+"""
+from __future__ import absolute_import, unicode_literals
+
+from django.apps import AppConfig
+
+
+class LTIConsumerApp(AppConfig):
+    """
+    Configuration for the lti_consumer Django application.
+    """
+
+    name = 'lti_consumer'
+
+    # Set LMS urls for LTI endpoints
+    # Urls are under /api/lti_consumer/
+    plugin_app = {
+        'url_config': {
+            'lms.djangoapp': {
+                'namespace': 'lti_consumer',
+                'regex': '^api/',
+                'relative_path': 'plugin.urls',
+            }
+        }
+    }
diff --git a/lti_consumer/lti_1p3/__init__.py b/lti_consumer/lti_1p3/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/lti_consumer/lti_1p3/constants.py b/lti_consumer/lti_1p3/constants.py
new file mode 100644
index 0000000000000000000000000000000000000000..c0d93a0644a987a389242e5ad44dfcbcfb4fff97
--- /dev/null
+++ b/lti_consumer/lti_1p3/constants.py
@@ -0,0 +1,35 @@
+"""
+LTI 1.3 Constants definition file
+
+This includes the LTI Base message, OAuth2 scopes, and
+lists of required and optional parameters required for
+LTI message generation and validation.
+"""
+
+LTI_BASE_MESSAGE = {
+    # Claim type: fixed key with value `LtiResourceLinkRequest`
+    # http://www.imsglobal.org/spec/lti/v1p3/#message-type-claim
+    "https://purl.imsglobal.org/spec/lti/claim/message_type": "LtiResourceLinkRequest",
+
+    # LTI Claim version
+    # http://www.imsglobal.org/spec/lti/v1p3/#lti-version-claim
+    "https://purl.imsglobal.org/spec/lti/claim/version": "1.3.0",
+}
+
+LTI_1P3_ROLE_MAP = {
+    'staff': [
+        'http://purl.imsglobal.org/vocab/lis/v2/system/person#Administrator',
+        'http://purl.imsglobal.org/vocab/lis/v2/institution/person#Instructor',
+        'http://purl.imsglobal.org/vocab/lis/v2/institution/person#Student',
+    ],
+    'instructor': [
+        'http://purl.imsglobal.org/vocab/lis/v2/institution/person#Instructor',
+        'http://purl.imsglobal.org/vocab/lis/v2/institution/person#Student'
+    ],
+    'student': [
+        'http://purl.imsglobal.org/vocab/lis/v2/institution/person#Student'
+    ],
+    'guest': [
+        'http://purl.imsglobal.org/vocab/lis/v2/institution/person#Student'
+    ],
+}
diff --git a/lti_consumer/lti_1p3/consumer.py b/lti_consumer/lti_1p3/consumer.py
new file mode 100644
index 0000000000000000000000000000000000000000..3ea978441ca205d5ca3ddfe9021df03992df33ae
--- /dev/null
+++ b/lti_consumer/lti_1p3/consumer.py
@@ -0,0 +1,275 @@
+"""
+LTI 1.3 Consumer implementation
+"""
+import json
+import time
+
+# Quality checks failing due to know pylint bug
+# pylint: disable=relative-import
+from six.moves.urllib.parse import urlencode
+
+from Crypto.PublicKey import RSA
+from jwkest.jwk import RSAKey
+from jwkest.jws import JWS
+from jwkest import jwk
+
+from .constants import LTI_1P3_ROLE_MAP, LTI_BASE_MESSAGE
+
+
+class LtiConsumer1p3(object):
+    """
+    LTI 1.3 Consumer Implementation
+    """
+    def __init__(
+            self,
+            iss,
+            lti_oidc_url,
+            lti_launch_url,
+            client_id,
+            deployment_id,
+            rsa_key,
+            rsa_key_id
+    ):
+        """
+        Initialize LTI 1.3 Consumer class
+        """
+        self.iss = iss
+        self.oidc_url = lti_oidc_url
+        self.launch_url = lti_launch_url
+        self.client_id = client_id
+        self.deployment_id = deployment_id
+
+        # Generate JWK from RSA key
+        self.jwk = RSAKey(
+            # Using the same key ID as client id
+            # This way we can easily serve multiple public
+            # keys on teh same endpoint and keep all
+            # LTI 1.3 blocks working
+            kid=rsa_key_id,
+            key=RSA.import_key(rsa_key)
+        )
+
+        # IMS LTI Claim data
+        self.lti_claim_user_data = None
+        self.lti_claim_launch_presentation = None
+        self.lti_claim_custom_parameters = None
+
+    def _encode_and_sign(self, message):
+        """
+        Encode and sign JSON with RSA key
+        """
+        # The class instance that sets up the signing operation
+        # An RS 256 key is required for LTI 1.3
+        _jws = JWS(message, alg="RS256", cty="JWT")
+
+        # Encode and sign LTI message
+        return _jws.sign_compact([self.jwk])
+
+    @staticmethod
+    def _get_user_roles(role):
+        """
+        Converts platform role into LTI compliant roles
+
+        Used in roles claim: should return array of URI values
+        for roles that the user has within the message's context.
+
+        Supported roles:
+        * Core - Administrator
+        * Institution - Instructor (non-core role)
+        * Institution - Student
+
+        Reference: http://www.imsglobal.org/spec/lti/v1p3/#roles-claim
+        Role vocabularies: http://www.imsglobal.org/spec/lti/v1p3/#role-vocabularies
+        """
+        lti_user_roles = set()
+
+        if role:
+            # Raise value error if value doesn't exist in map
+            if role not in LTI_1P3_ROLE_MAP:
+                raise ValueError("Invalid role list provided.")
+
+            # Add roles to list
+            lti_user_roles.update(LTI_1P3_ROLE_MAP[role])
+
+        return list(lti_user_roles)
+
+    def prepare_preflight_url(
+            self,
+            callback_url,
+            hint="hint",
+            lti_hint="lti_hint"
+    ):
+        """
+        Generates OIDC url with parameters
+        """
+        oidc_url = self.oidc_url + "?"
+        parameters = {
+            "iss": self.iss,
+            "client_id": self.client_id,
+            "lti_deployment_id": self.deployment_id,
+            "target_link_uri": callback_url,
+            "login_hint": hint,
+            "lti_message_hint": lti_hint
+        }
+
+        return {
+            "oidc_url": oidc_url + urlencode(parameters),
+        }
+
+    def set_user_data(
+            self,
+            user_id,
+            role,
+            full_name=None,
+            email_address=None
+    ):
+        """
+        Set user data/roles and convert to IMS Specification
+
+        User Claim doc: http://www.imsglobal.org/spec/lti/v1p3/#user-identity-claims
+        Roles Claim doc: http://www.imsglobal.org/spec/lti/v1p3/#roles-claim
+        """
+        self.lti_claim_user_data = {
+            # User identity claims
+            # sub: locally stable identifier for user that initiated the launch
+            "sub": user_id,
+
+            # Roles claim
+            # Array of URI values for roles that the user has within the message's context
+            "https://purl.imsglobal.org/spec/lti/claim/roles": self._get_user_roles(role)
+        }
+
+        # Additonal user identity claims
+        # Optional user data that can be sent to the tool, if the block is configured to do so
+        if full_name:
+            self.lti_claim_user_data.update({
+                "name": full_name,
+            })
+
+        if email_address:
+            self.lti_claim_user_data.update({
+                "email": email_address,
+            })
+
+    def set_launch_presentation_claim(
+            self,
+            document_target="iframe"
+    ):
+        """
+        Optional: Set launch presentation claims
+
+        http://www.imsglobal.org/spec/lti/v1p3/#launch-presentation-claim
+        """
+        if document_target not in ['iframe', 'frame', 'window']:
+            raise ValueError("Invalid launch presentation format.")
+
+        self.lti_claim_launch_presentation = {
+            # Launch presentation claim
+            "https://purl.imsglobal.org/spec/lti/claim/launch_presentation": {
+                # Can be one of: iframe, frame, window
+                "document_target": document_target,
+                # TODO: Add support for `return_url` handler to allow the tool
+                # to return error messages back to the lms.
+                # See the spec referenced above for more information.
+            },
+        }
+
+    def set_custom_parameters(
+            self,
+            custom_parameters
+    ):
+        """
+        Stores custom parameters configured for LTI launch
+        """
+        if not isinstance(custom_parameters, dict):
+            raise ValueError("Custom parameters must be a key/value dictionary.")
+
+        self.lti_claim_custom_parameters = {
+            "https://purl.imsglobal.org/spec/lti/claim/custom": custom_parameters
+        }
+
+    def generate_launch_request(
+            self,
+            preflight_response,
+            resource_link
+    ):
+        """
+        Build LTI message from class parameters
+
+        This will add all required parameters from the LTI 1.3 spec and any additional ones set in
+        the configuration and JTW encode the message using the provided key.
+        """
+        # Start from base message
+        lti_message = LTI_BASE_MESSAGE.copy()
+
+        # TODO: Validate preflight response
+        # Add base parameters
+        lti_message.update({
+            # Issuer
+            "iss": self.iss,
+
+            # Nonce from OIDC preflight launch request
+            "nonce": preflight_response.get("nonce"),
+
+            # JWT aud and azp
+            "aud": [
+                self.client_id
+            ],
+            "azp": self.client_id,
+
+            # LTI Deployment ID Claim:
+            # String that identifies the platform-tool integration governing the message
+            # http://www.imsglobal.org/spec/lti/v1p3/#lti-deployment-id-claim
+            "https://purl.imsglobal.org/spec/lti/claim/deployment_id": self.deployment_id,
+
+            # Target Link URI: actual endpoint for the LTI resource to display
+            # MUST be the same value as the target_link_uri passed by the platform in the OIDC login request
+            # http://www.imsglobal.org/spec/lti/v1p3/#target-link-uri
+            "https://purl.imsglobal.org/spec/lti/claim/target_link_uri": self.launch_url,
+
+            # Resource link: stable and unique to each deployment_id
+            # This value MUST change if the link is copied or exported from one system or
+            # context and imported into another system or context
+            # http://www.imsglobal.org/spec/lti/v1p3/#resource-link-claim
+            "https://purl.imsglobal.org/spec/lti/claim/resource_link": {
+                "id": resource_link,
+                # Optional claims
+                # "title": "Introduction Assignment"
+                # "description": "Assignment to introduce who you are",
+            },
+        })
+
+        # Check if user data is set, then append it to lti message
+        # Raise if isn't set, since some user data is required for the launch
+        if self.lti_claim_user_data:
+            lti_message.update(self.lti_claim_user_data)
+        else:
+            raise ValueError("Required user data isn't set.")
+
+        # Set optional claims
+        # Launch presentation claim
+        if self.lti_claim_launch_presentation:
+            lti_message.update(self.lti_claim_launch_presentation)
+
+        # Custom variables claim
+        if self.lti_claim_custom_parameters:
+            lti_message.update(self.lti_claim_custom_parameters)
+
+        # Add `exp` and `iat` JWT attributes
+        lti_message.update({
+            "iat": int(round(time.time())),
+            "exp": int(round(time.time()) + 3600)
+        })
+
+        return {
+            "state": preflight_response.get("state"),
+            "id_token": self._encode_and_sign(lti_message)
+        }
+
+    def get_public_keyset(self):
+        """
+        Export Public JWK
+        """
+        public_keys = jwk.KEYS()
+        public_keys.append(self.jwk)
+        return json.loads(public_keys.dump_jwks())
diff --git a/lti_consumer/lti_consumer.py b/lti_consumer/lti_consumer.py
index 9a1d9108199fef3a28a50f1a1c475aa404bea37b..02f2640e2e947790391543e63fa34b69a6766c79 100644
--- a/lti_consumer/lti_consumer.py
+++ b/lti_consumer/lti_consumer.py
@@ -54,6 +54,7 @@ from __future__ import absolute_import, unicode_literals
 
 import logging
 import re
+import uuid
 from collections import namedtuple
 from importlib import import_module
 
@@ -61,6 +62,7 @@ import six.moves.urllib.error
 import six.moves.urllib.parse
 import six
 import bleach
+from Crypto.PublicKey import RSA
 from django.utils import timezone
 from webob import Response
 from xblock.core import List, Scope, String, XBlock
@@ -72,9 +74,16 @@ from xblockutils.studio_editable import StudioEditableXBlockMixin
 
 from .exceptions import LtiError
 from .lti import LtiConsumer
+from .lti_1p3.consumer import LtiConsumer1p3
 from .oauth import log_authorization_header
 from .outcomes import OutcomeService
-from .utils import _
+from .utils import (
+    _,
+    get_lms_base,
+    get_lms_lti_keyset_link,
+    get_lms_lti_launch_link,
+)
+
 
 log = logging.getLogger(__name__)
 
@@ -165,6 +174,7 @@ class LaunchTarget(object):  # pylint: disable=bad-option-value, useless-object-
 
 
 @XBlock.needs('i18n')
+@XBlock.wants('user')
 @XBlock.wants('settings')
 @XBlock.wants('lti-configuration')
 class LtiConsumerXBlock(StudioEditableXBlockMixin, XBlock):
@@ -270,6 +280,48 @@ class LtiConsumerXBlock(StudioEditableXBlockMixin, XBlock):
         default="",
         scope=Scope.settings
     )
+
+    # LTI 1.3 fields
+    lti_version = String(
+        display_name=_("LTI Version"),
+        scope=Scope.settings,
+        values=[
+            {"display_name": "LTI 1.1/1.2", "value": "lti_1p1"},
+            {"display_name": "LTI 1.3", "value": "lti_1p3"},
+        ],
+        default="lti_1p1",
+    )
+    lti_1p3_launch_url = String(
+        display_name=_("LTI 1.3 Launch URL"),
+        default='',
+        scope=Scope.settings
+    )
+    lti_1p3_oidc_url = String(
+        display_name=_("LTI 1.3 OIDC URL"),
+        default='',
+        scope=Scope.settings
+    )
+    lti_1p3_tool_public_key = String(
+        display_name=_("LTI 1.3 Tool Public Key"),
+        default='',
+        scope=Scope.settings
+    )
+    # Client ID and block key
+    lti_1p3_client_id = String(
+        display_name=_("LTI 1.3 Block Client ID"),
+        default=str(uuid.uuid4()),
+        scope=Scope.settings
+    )
+    # This key isn't editable, and should be regenerated
+    # for every block created (and not be carried over)
+    # This isn't what happens right now though
+    lti_1p3_block_key = String(
+        display_name=_("LTI 1.3 Block Key"),
+        default=RSA.generate(2048).export_key('PEM'),
+        scope=Scope.settings
+    )
+
+    # LTI 1.1 fields
     lti_id = String(
         display_name=_("LTI ID"),
         help=_(
@@ -297,6 +349,8 @@ class LtiConsumerXBlock(StudioEditableXBlockMixin, XBlock):
         default='',
         scope=Scope.settings
     )
+
+    # Misc
     custom_parameters = List(
         display_name=_("Custom Parameters"),
         help=_(
@@ -435,12 +489,23 @@ class LtiConsumerXBlock(StudioEditableXBlockMixin, XBlock):
 
     # Possible editable fields
     editable_field_names = (
-        'display_name', 'description', 'lti_id', 'launch_url', 'custom_parameters',
-        'launch_target', 'button_text', 'inline_height', 'modal_height', 'modal_width',
-        'has_score', 'weight', 'hide_launch', 'accept_grades_past_due', 'ask_to_send_username',
-        'ask_to_send_email', 'enable_processors',
+        'display_name', 'description',
+        # LTI 1.3 variables
+        'lti_version', 'lti_1p3_launch_url', 'lti_1p3_oidc_url', 'lti_1p3_tool_public_key',
+        # TODO: implement a proper default setter method on XBlock Fields API.
+        # This is just a workaround the issue.
+        'lti_1p3_client_id', 'lti_1p3_block_key',
+        # LTI 1.1 variables
+        'lti_id', 'launch_url',
+        # Other parameters
+        'custom_parameters', 'launch_target', 'button_text', 'inline_height', 'modal_height',
+        'modal_width', 'has_score', 'weight', 'hide_launch', 'accept_grades_past_due',
+        'ask_to_send_username', 'ask_to_send_email', 'enable_processors',
     )
 
+    # Author view
+    has_author_view = True
+
     @staticmethod
     def workbench_scenarios():
         """
@@ -723,6 +788,52 @@ class LtiConsumerXBlock(StudioEditableXBlockMixin, XBlock):
             close_date = due_date
         return close_date is not None and timezone.now() > close_date
 
+    def _get_lti1p3_consumer(self):
+        """
+        Returns a preconfigured LTI 1.3 consumer.
+
+        If the block is configured to use LTI 1.3, set up a
+        base LTI 1.3 consumer class with all block related
+        configuration services.
+
+        This class does NOT store state between calls.
+        """
+        return LtiConsumer1p3(
+            iss=get_lms_base(),
+            lti_oidc_url=self.lti_1p3_oidc_url,
+            lti_launch_url=self.lti_1p3_launch_url,
+            client_id=self.lti_1p3_client_id,
+            deployment_id="1",
+            rsa_key=self.lti_1p3_block_key,
+            rsa_key_id=self.lti_1p3_client_id
+        )
+
+    def author_view(self, context):
+        """
+        XBlock author view of this component.
+
+        If using LTI 1.1 it shows a launch preview of the XBlock.
+        If using LTI 1.3 it displays a fragment with parameters that
+        need to be set on the LTI Tool to make the integration work.
+        """
+        if self.lti_version == "lti_1p1":
+            return self.student_view(context)
+
+        fragment = Fragment()
+        loader = ResourceLoader(__name__)
+        context = {
+            "client": self.lti_1p3_client_id,
+            "deployment_id": "1",
+            "keyset_url": get_lms_lti_keyset_link(self.location),  # pylint: disable=no-member
+            "oidc_callback": get_lms_lti_launch_link(),
+            "launch_url": self.lti_1p3_launch_url,
+        }
+        fragment.add_content(loader.render_mako_template('/templates/html/lti_1p3_studio.html', context))
+        fragment.add_css(loader.load_unicode('static/css/student.css'))
+        fragment.add_javascript(loader.load_unicode('static/js/xblock_lti_consumer.js'))
+        fragment.initialize_js('LtiConsumerXBlock')
+        return fragment
+
     def student_view(self, context):
         """
         XBlock student view of this component.
@@ -749,7 +860,7 @@ class LtiConsumerXBlock(StudioEditableXBlockMixin, XBlock):
     @XBlock.handler
     def lti_launch_handler(self, request, suffix=''):  # pylint: disable=unused-argument
         """
-        XBlock handler for launching the LTI provider.
+        XBlock handler for launching LTI 1.1 tools.
 
         Displays a form which is submitted via Javascript
         to send the LTI launch POST request to the LTI
@@ -770,6 +881,91 @@ class LtiConsumerXBlock(StudioEditableXBlockMixin, XBlock):
         template = loader.render_mako_template('/templates/html/lti_launch.html', context)
         return Response(template, content_type='text/html')
 
+    @XBlock.handler
+    def lti_1p3_launch_handler(self, request, suffix=''):  # pylint: disable=unused-argument
+        """
+        XBlock handler for launching the LTI 1.3 tools.
+
+        Displays a form with the OIDC preflight request and
+        submits it to the tool.
+
+        Arguments:
+            request (xblock.django.request.DjangoWebobRequest): Request object for current HTTP request
+
+        Returns:
+            webob.response: HTML LTI launch form
+        """
+        lti_consumer = self._get_lti1p3_consumer()
+        context = lti_consumer.prepare_preflight_url(
+            callback_url=get_lms_lti_launch_link(),
+            hint=six.text_type(self.location),  # pylint: disable=no-member
+            lti_hint=six.text_type(self.location)  # pylint: disable=no-member
+        )
+
+        loader = ResourceLoader(__name__)
+        template = loader.render_mako_template('/templates/html/lti_1p3_oidc.html', context)
+        return Response(template, content_type='text/html')
+
+    @XBlock.handler
+    def lti_1p3_launch_callback(self, request, suffix=''):  # pylint: disable=unused-argument
+        """
+        XBlock handler for launching the LTI 1.3 tool.
+
+        This endpoint is only valid when a LTI 1.3 tool is being used.
+
+        Returns:
+            webob.response: HTML LTI launch form or error page if misconfigured
+        """
+        if self.lti_version != "lti_1p3":
+            return Response(status=404)
+
+        loader = ResourceLoader(__name__)
+        context = {}
+
+        lti_consumer = self._get_lti1p3_consumer()
+
+        # Pass user data
+        lti_consumer.set_user_data(
+            user_id=self.runtime.user_id,
+            # Pass django user role to library
+            role=self.runtime.get_user_role()
+        )
+
+        # Set launch context
+        # Hardcoded for now, but we need to translate from
+        # self.launch_target to one of the LTI compliant names,
+        # either `iframe`, `frame` or `window`
+        # This is optional though
+        lti_consumer.set_launch_presentation_claim('iframe')
+
+        context.update({
+            "preflight_response": dict(request.GET),
+            "launch_request": lti_consumer.generate_launch_request(
+                resource_link=self.resource_link_id,
+                preflight_response=request.GET
+            )
+        })
+
+        context.update({
+            'launch_url': self.lti_1p3_launch_url,
+            'user': self.runtime.user_id
+        })
+        template = loader.render_mako_template('/templates/html/lti_1p3_launch.html', context)
+        return Response(template, content_type='text/html')
+
+    @XBlock.handler
+    def public_keyset_endpoint(self, request, suffix=''):  # pylint: disable=unused-argument
+        """
+        XBlock handler for launching the LTI provider.
+        """
+        if self.lti_version == "lti_1p3":
+            return Response(
+                json_body=self._get_lti1p3_consumer().get_public_keyset(),
+                content_type='application/json',
+                content_disposition='attachment; filename=keyset.json'
+            )
+        return Response(status=404)
+
     @XBlock.handler
     def outcome_service_handler(self, request, suffix=''):  # pylint: disable=unused-argument
         """
@@ -935,13 +1131,19 @@ class LtiConsumerXBlock(StudioEditableXBlockMixin, XBlock):
         allowed_attributes = dict(bleach.sanitizer.ALLOWED_ATTRIBUTES, **{'img': ['src', 'alt']})
         sanitized_comment = bleach.clean(self.score_comment, tags=allowed_tags, attributes=allowed_attributes)
 
+        # Set launch handler depending on LTI version
+        lti_block_launch_handler = self.runtime.handler_url(self, 'lti_launch_handler').rstrip('/?')
+        if self.lti_version == 'lti_1p3':
+            lti_block_launch_handler = self.runtime.handler_url(self, 'lti_1p3_launch_handler').rstrip('/?')
+
         return {
             'launch_url': self.launch_url.strip(),
+            'lti_1p3_launch_url': self.lti_1p3_launch_url.strip(),
             'element_id': self.location.html_id(),  # pylint: disable=no-member
             'element_class': self.category,  # pylint: disable=no-member
             'launch_target': self.launch_target,
             'display_name': self.display_name,
-            'form_url': self.runtime.handler_url(self, 'lti_launch_handler').rstrip('/?'),
+            'form_url': lti_block_launch_handler,
             'hide_launch': self.hide_launch,
             'has_score': self.has_score,
             'weight': self.weight,
diff --git a/lti_consumer/plugin/__init__.py b/lti_consumer/plugin/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/lti_consumer/plugin/urls.py b/lti_consumer/plugin/urls.py
new file mode 100644
index 0000000000000000000000000000000000000000..8bfc8789e861e63d09aaec1d5ba4560ca5f972b4
--- /dev/null
+++ b/lti_consumer/plugin/urls.py
@@ -0,0 +1,27 @@
+"""
+URL mappings for LTI Consumer plugin.
+"""
+
+from __future__ import absolute_import
+
+from django.conf import settings
+from django.conf.urls import url
+
+from .views import (
+    public_keyset_endpoint,
+    launch_gate_endpoint,
+)
+
+
+urlpatterns = [
+    url(
+        'lti_consumer/v1/public_keysets/{}$'.format(settings.USAGE_ID_PATTERN),
+        public_keyset_endpoint,
+        name='lti_consumer.public_keyset_endpoint'
+    ),
+    url(
+        'lti_consumer/v1/launch/(?:/(?P<suffix>.*))?$',
+        launch_gate_endpoint,
+        name='lti_consumer.launch_gate'
+    )
+]
diff --git a/lti_consumer/plugin/views.py b/lti_consumer/plugin/views.py
new file mode 100644
index 0000000000000000000000000000000000000000..14aa4c977cd1cd3d01087a880a28011c9fde1458
--- /dev/null
+++ b/lti_consumer/plugin/views.py
@@ -0,0 +1,56 @@
+"""
+LTI consumer plugin passthrough views
+"""
+
+from django.http import HttpResponse
+
+from opaque_keys.edx.keys import UsageKey  # pylint: disable=import-error
+from lms.djangoapps.courseware.module_render import (  # pylint: disable=import-error
+    handle_xblock_callback,
+    handle_xblock_callback_noauth,
+)
+
+
+def public_keyset_endpoint(request, usage_id=None):
+    """
+    Gate endpoint to fetch public keysets from a problem
+
+    This is basically a passthrough function that uses the
+    OIDC response parameter `login_hint` to locate the block
+    and run the proper handler.
+    """
+    try:
+        usage_key = UsageKey.from_string(usage_id)
+
+        return handle_xblock_callback_noauth(
+            request=request,
+            course_id=str(usage_key.course_key),
+            usage_id=str(usage_key),
+            handler='public_keyset_endpoint'
+        )
+    except:  # pylint: disable=bare-except
+        return HttpResponse(status=404)
+
+
+def launch_gate_endpoint(request, suffix):
+    """
+    Gate endpoint that triggers LTI launch endpoint XBlock handler
+
+    This is basically a passthrough function that uses the
+    OIDC response parameter `login_hint` to locate the block
+    and run the proper handler.
+    """
+    try:
+        usage_key = UsageKey.from_string(
+            request.GET.get('login_hint')
+        )
+
+        return handle_xblock_callback(
+            request=request,
+            course_id=str(usage_key.course_key),
+            usage_id=str(usage_key),
+            handler='lti_1p3_launch_callback',
+            suffix=suffix
+        )
+    except:  # pylint: disable=bare-except
+        return HttpResponse(status=404)
diff --git a/lti_consumer/templates/html/lti_1p3_launch.html b/lti_consumer/templates/html/lti_1p3_launch.html
new file mode 100644
index 0000000000000000000000000000000000000000..11356c1e9c121282931566c48b315824bad645c7
--- /dev/null
+++ b/lti_consumer/templates/html/lti_1p3_launch.html
@@ -0,0 +1,30 @@
+<!DOCTYPE HTML>
+<html>
+    <head>
+        <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
+        <title>LTI</title>
+    </head>
+    <body>
+        <form
+            id="lti-launch"
+            action="${launch_url}"
+            method="post"
+            style="display: none;"
+            encType="application/x-www-form-urlencoded"
+        >
+            % for param_name, param_value in launch_request.items():
+                <input name="${param_name}" value="${param_value}" />
+            % endfor
+
+            <input type="submit" value="Press to Launch" />
+        </form>
+        <script type="text/javascript">
+            (function (d) {
+                var element = d.getElementById("lti-launch");
+                if (element) {
+                    element.submit();
+                }
+            }(document));
+        </script>
+    </body>
+</html>
diff --git a/lti_consumer/templates/html/lti_1p3_oidc.html b/lti_consumer/templates/html/lti_1p3_oidc.html
new file mode 100644
index 0000000000000000000000000000000000000000..861b4f587e5f72fb9c9a303b9c25207c8372c645
--- /dev/null
+++ b/lti_consumer/templates/html/lti_1p3_oidc.html
@@ -0,0 +1,20 @@
+<!DOCTYPE HTML>
+<html>
+    <head>
+        <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
+        <title>LTI</title>
+    </head>
+    <body>
+        <a id="launch-link" href="${oidc_url}">
+            Click here to launch activity.
+        </a>
+        <script type="text/javascript">
+            (function (d) {
+                var element = d.getElementById("launch-link");
+                if (element) {
+                    element.click();
+                }
+            }(document));
+        </script>
+    </body>
+</html>
diff --git a/lti_consumer/templates/html/lti_1p3_studio.html b/lti_consumer/templates/html/lti_1p3_studio.html
new file mode 100644
index 0000000000000000000000000000000000000000..e16c28ad63d6f19080d048282f4ba4d1a37a2729
--- /dev/null
+++ b/lti_consumer/templates/html/lti_1p3_studio.html
@@ -0,0 +1,41 @@
+<!DOCTYPE HTML>
+<html>
+    <head>
+        <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
+        <title>LTI</title>
+    </head>
+    <body>
+        <p>
+            <b>LTI 1.3 Launches can only be performed from the LMS.</b>
+        </p>
+
+        <p>
+            To set up the LTI integration, you need to register the LMS in the tool with the information provided below.
+        </p>
+
+        <p>
+            <b>Client: </b>
+            ${client}
+        </p>
+
+        <p>
+            <b>Deployment ID: </b>
+            ${deployment_id}
+        </p>
+
+        <p>
+            <b>Keyset URL: </b>
+            ${keyset_url}
+        </p>
+
+        <p>
+            <b>OAuth URL: </b>
+            N/A
+        </p>
+
+        <p>
+            <b>OIDC Callback URL: </b>
+            ${oidc_callback}
+        </p>
+    </body>
+</html>
diff --git a/lti_consumer/templates/html/student.html b/lti_consumer/templates/html/student.html
index 59f85a629c8b53a16665e3f3e9241c7c2bedb3e4..a49632b72e3c3bd484ae58bc233684fedd94f8ed 100644
--- a/lti_consumer/templates/html/student.html
+++ b/lti_consumer/templates/html/student.html
@@ -20,7 +20,7 @@
     data-ask-to-send-email="${ask_to_send_email}"
 >
 
-% if launch_url and not hide_launch:
+% if (launch_url or lti_1p3_launch_url) and not hide_launch:
     % if launch_target in ['modal', 'new_window']:
         <section class="wrapper-lti-link">
             % if description:
diff --git a/lti_consumer/tests/unit/test_lti_1p3_consumer.py b/lti_consumer/tests/unit/test_lti_1p3_consumer.py
new file mode 100644
index 0000000000000000000000000000000000000000..d0d89bb221481bce6787c1582ab18e3448e2ca98
--- /dev/null
+++ b/lti_consumer/tests/unit/test_lti_1p3_consumer.py
@@ -0,0 +1,298 @@
+"""
+Unit tests for LTI 1.3 consumer implementation
+"""
+from __future__ import absolute_import, unicode_literals
+
+import json
+import ddt
+
+from mock import Mock, patch
+from django.test.testcases import TestCase
+from six.moves.urllib.parse import urlparse, parse_qs
+
+from Crypto.PublicKey import RSA
+from jwkest.jwk import load_jwks
+from jwkest.jws import JWS
+
+from lti_consumer.lti_1p3.consumer import LtiConsumer1p3
+
+
+# Variables required for testing and verification
+ISS = "http://test-platform.example/"
+OIDC_URL = "http://test-platform/oidc"
+LAUNCH_URL = "http://test-platform/launch"
+CLIENT_ID = "1"
+DEPLOYMENT_ID = "1"
+# Consider storing a fixed key
+RSA_KEY_ID = "1"
+RSA_KEY = RSA.generate(2048).export_key('PEM')
+
+
+# Test classes
+@ddt.ddt
+class TestLti1p3Consumer(TestCase):
+    """
+    Unit tests for LtiConsumer1p3
+    """
+    def setUp(self):
+        super(TestLti1p3Consumer, self).setUp()
+
+        # Set up consumer
+        self.lti_consumer = LtiConsumer1p3(
+            iss=ISS,
+            lti_oidc_url=OIDC_URL,
+            lti_launch_url=LAUNCH_URL,
+            client_id=CLIENT_ID,
+            deployment_id=DEPLOYMENT_ID,
+            rsa_key=RSA_KEY,
+            rsa_key_id=RSA_KEY_ID
+        )
+
+    def _setup_lti_user(self):
+        """
+        Set up a minimal LTI message with only required parameters.
+
+        Currently, the only required parameters are the user data,
+        but using a helper function to keep the usage consistent accross
+        all tests.
+        """
+        self.lti_consumer.set_user_data(
+            user_id="1",
+            role="student",
+        )
+
+    def _get_lti_message(
+            self,
+            preflight_response=None,
+            resource_link="link"
+    ):
+        """
+        Retrieves a base LTI message with fixed test parameters.
+
+        This function has valid default values, so it can be used to test custom
+        parameters, but allows overriding them.
+        """
+        if preflight_response is None:
+            preflight_response = {"nonce": "", "state": ""}
+
+        return self.lti_consumer.generate_launch_request(
+            preflight_response,
+            resource_link
+        )
+
+    def _decode_token(self, token):
+        """
+        Checks for a valid signarute and decodes JWT signed LTI message
+
+        This also tests the public keyset function.
+        """
+        public_keyset = self.lti_consumer.get_public_keyset()
+        key_set = load_jwks(json.dumps(public_keyset))
+
+        return JWS().verify_compact(token, keys=key_set)
+
+    @ddt.data(
+        (
+            'student',
+            ['http://purl.imsglobal.org/vocab/lis/v2/institution/person#Student']
+        ),
+        (
+            'staff',
+            [
+                'http://purl.imsglobal.org/vocab/lis/v2/system/person#Administrator',
+                'http://purl.imsglobal.org/vocab/lis/v2/institution/person#Instructor',
+                'http://purl.imsglobal.org/vocab/lis/v2/institution/person#Student',
+            ]
+        )
+    )
+    @ddt.unpack
+    def test_get_user_roles(self, role, expected_output):
+        """
+        Check that user roles are correctly translated to LTI 1.3 compliant rolenames.
+        """
+        roles = self.lti_consumer._get_user_roles(role)  # pylint: disable=protected-access
+        self.assertItemsEqual(roles, expected_output)
+
+    def test_get_user_roles_invalid(self):
+        """
+        Check that invalid user roles are throw a ValueError.
+        """
+        with self.assertRaises(ValueError):
+            self.lti_consumer._get_user_roles('invalid')  # pylint: disable=protected-access
+
+    def test_prepare_preflight_url(self):
+        """
+        Check if preflight request is properly formed and has all required keys.
+        """
+        preflight_request_data = self.lti_consumer.prepare_preflight_url(
+            callback_url=LAUNCH_URL,
+            hint="test-hint",
+            lti_hint="test-lti-hint"
+        )
+
+        # Extract and check parameters from OIDC launch request url
+        parameters = parse_qs(urlparse(preflight_request_data['oidc_url']).query)
+        self.assertItemsEqual(
+            parameters.keys(),
+            [
+                'iss',
+                'login_hint',
+                'lti_message_hint',
+                'client_id',
+                'target_link_uri',
+                'lti_deployment_id'
+            ]
+        )
+        self.assertEqual(parameters['iss'][0], ISS)
+        self.assertEqual(parameters['client_id'][0], CLIENT_ID)
+        self.assertEqual(parameters['login_hint'][0], "test-hint")
+        self.assertEqual(parameters['lti_message_hint'][0], "test-lti-hint")
+        self.assertEqual(parameters['lti_deployment_id'][0], DEPLOYMENT_ID)
+        self.assertEqual(parameters['target_link_uri'][0], LAUNCH_URL)
+
+    @ddt.data(
+        # User with no roles
+        (
+            {"user_id": "1", "role": ''},
+            {
+                "sub": "1",
+                "https://purl.imsglobal.org/spec/lti/claim/roles": []
+            }
+        ),
+        # Student user, no optional data
+        (
+            {"user_id": "1", "role": 'student'},
+            {
+                "sub": "1",
+                "https://purl.imsglobal.org/spec/lti/claim/roles": [
+                    "http://purl.imsglobal.org/vocab/lis/v2/institution/person#Student"
+                ]
+            }
+        ),
+        # User with extra data
+        (
+            {"user_id": "1", "role": '', "full_name": "Jonh", "email_address": "jonh@example.com"},
+            {
+                "sub": "1",
+                "https://purl.imsglobal.org/spec/lti/claim/roles": [],
+                "name": "Jonh",
+                "email": "jonh@example.com"
+            }
+        ),
+
+    )
+    @ddt.unpack
+    def test_set_user_data(self, data, expected_output):
+        """
+        Check if setting user data works
+        """
+        self.lti_consumer.set_user_data(**data)
+        self.assertEqual(
+            self.lti_consumer.lti_claim_user_data,
+            expected_output
+        )
+
+    @ddt.data(
+        "iframe",
+        "frame",
+        "window"
+    )
+    def test_set_valid_presentation_claim(self, target):
+        """
+        Check if setting presentation claim data works
+        """
+        self._setup_lti_user()
+        self.lti_consumer.set_launch_presentation_claim(document_target=target)
+        self.assertEqual(
+            self.lti_consumer.lti_claim_launch_presentation,
+            {
+                "https://purl.imsglobal.org/spec/lti/claim/launch_presentation": {
+                    "document_target": target
+                }
+            }
+        )
+
+        # Prepare LTI message
+        launch_request = self._get_lti_message()
+
+        # Decode and verify message
+        decoded = self._decode_token(launch_request['id_token'])
+        self.assertIn(
+            "https://purl.imsglobal.org/spec/lti/claim/launch_presentation",
+            decoded.keys()
+        )
+        self.assertEqual(
+            decoded["https://purl.imsglobal.org/spec/lti/claim/launch_presentation"],
+            {
+                "document_target": target
+            }
+        )
+
+    def test_set_invalid_presentation_claim(self):
+        """
+        Check if setting invalid presentation claim data raises
+        """
+        with self.assertRaises(ValueError):
+            self.lti_consumer.set_launch_presentation_claim(document_target="invalid")
+
+    def test_check_no_user_data_error(self):
+        """
+        Check if the launch request fails if no user data is set.
+        """
+        with self.assertRaises(ValueError):
+            self.lti_consumer.generate_launch_request(
+                preflight_response=Mock(),
+                resource_link=Mock()
+            )
+
+    @patch('time.time', return_value=1000)
+    def test_launch_request(self, mock_time):
+        """
+        Check if the launch request works if user data is set.
+        """
+        self._setup_lti_user()
+        launch_request = self._get_lti_message(
+            preflight_response={
+                "nonce": "test",
+                "state": "state"
+            },
+            resource_link="link"
+        )
+
+        self.assertEqual(mock_time.call_count, 2)
+
+        # Check launch request contents
+        self.assertItemsEqual(launch_request.keys(), ['state', 'id_token'])
+        self.assertEqual(launch_request['state'], 'state')
+        # TODO: Decode and check token
+
+    def test_custom_parameters(self):
+        """
+        Check if custom parameters are properly set.
+        """
+        custom_parameters = {
+            "custom": "parameter",
+        }
+
+        self._setup_lti_user()
+        self.lti_consumer.set_custom_parameters(custom_parameters)
+
+        launch_request = self._get_lti_message()
+
+        # Decode and verify message
+        decoded = self._decode_token(launch_request['id_token'])
+        self.assertIn(
+            'https://purl.imsglobal.org/spec/lti/claim/custom',
+            decoded.keys()
+        )
+        self.assertEqual(
+            decoded["https://purl.imsglobal.org/spec/lti/claim/custom"],
+            custom_parameters
+        )
+
+    def test_invalid_custom_parameters(self):
+        """
+        Check if invalid custom parameters raise exceptions.
+        """
+        with self.assertRaises(ValueError):
+            self.lti_consumer.set_custom_parameters("invalid")
diff --git a/lti_consumer/tests/unit/test_lti_consumer.py b/lti_consumer/tests/unit/test_lti_consumer.py
index 5767ee66a5d0bdb6215079d967c80790e0d64f44..a56613fe405891dccbe91956907a08fa38e8d4f5 100644
--- a/lti_consumer/tests/unit/test_lti_consumer.py
+++ b/lti_consumer/tests/unit/test_lti_consumer.py
@@ -812,14 +812,17 @@ class TestGetContext(TestLtiConsumerXBlock):
     Unit tests for LtiConsumerXBlock._get_context_for_template()
     """
 
-    def test_context_keys(self):
+    @ddt.data('lti_1p1', 'lti_1p3')
+    def test_context_keys(self, lti_version):
         """
         Test `_get_context_for_template` returns dict with correct keys
         """
+        self.xblock.lti_version = lti_version
         context_keys = (
-            'launch_url', 'element_id', 'element_class', 'launch_target', 'display_name', 'form_url', 'hide_launch',
-            'has_score', 'weight', 'module_score', 'comment', 'description', 'ask_to_send_username',
-            'ask_to_send_email', 'button_text', 'modal_vertical_offset', 'modal_horizontal_offset', 'modal_width',
+            'launch_url', 'lti_1p3_launch_url', 'element_id', 'element_class', 'launch_target',
+            'display_name', 'form_url', 'hide_launch', 'has_score', 'weight', 'module_score',
+            'comment', 'description', 'ask_to_send_username', 'ask_to_send_email', 'button_text',
+            'modal_vertical_offset', 'modal_horizontal_offset', 'modal_width',
             'accept_grades_past_due'
         )
         context = self.xblock._get_context_for_template()  # pylint: disable=protected-access
@@ -912,3 +915,84 @@ class TestGetModalPositionOffset(TestLtiConsumerXBlock):
 
         # modal_height defaults to 80, so offset should equal 10
         self.assertEqual(offset, 10)
+
+
+class TestLtiConsumer1p3XBlock(TestCase):
+    """
+    Unit tests for LtiConsumerXBlock when using an LTI 1.3 tool.
+    """
+    def setUp(self):
+        super(TestLtiConsumer1p3XBlock, self).setUp()
+
+        self.xblock_attributes = {
+            'lti_version': 'lti_1p3',
+            'lti_1p3_launch_url': 'http://tool.example/launch',
+            'lti_1p3_oidc_url': 'http://tool.example/oidc',
+            'lti_1p3_tool_public_key': ''
+        }
+        self.xblock = make_xblock('lti_consumer', LtiConsumerXBlock, self.xblock_attributes)
+
+    # pylint: disable=unused-argument
+    @patch('lti_consumer.utils.get_lms_base', return_value="https://example.com")
+    @patch('lti_consumer.lti_consumer.get_lms_base', return_value="https://example.com")
+    def test_launch_request(self, mock_url, mock_url_2):
+        """
+        Test LTI 1.3 launch request
+        """
+        response = self.xblock.lti_1p3_launch_handler(make_request('', 'GET'))
+        self.assertEqual(response.status_code, 200)
+
+        # Check if tool OIDC url is on page
+        self.assertIn(self.xblock_attributes['lti_1p3_oidc_url'], response.body)
+
+    # pylint: disable=unused-argument
+    @patch('lti_consumer.utils.get_lms_base', return_value="https://example.com")
+    @patch('lti_consumer.lti_consumer.get_lms_base', return_value="https://example.com")
+    def test_launch_callback_endpoint(self, mock_url, mock_url_2):
+        """
+        Test that the LTI 1.3 callback endpoind.
+        """
+        self.xblock.runtime.get_user_role.return_value = 'student'
+        self.xblock.runtime.user_id = 2
+
+        # Craft request sent back by LTI tool
+        request = make_request('', 'GET')
+        request.query_string = "state=state_test_123&nonce=nonce&login_hint=oidchint&lti_message_hint=ltihint"
+
+        response = self.xblock.lti_1p3_launch_callback(request)
+
+        # Check response and assert that state was inserted
+        self.assertEqual(response.status_code, 200)
+        self.assertIn("state", response.body)
+        self.assertIn("state_test_123", response.body)
+
+    def test_launch_callback_endpoint_when_using_lti_1p1(self):
+        """
+        Test that the LTI 1.3 callback endpoind is unavailable when using 1.1.
+        """
+        self.xblock.lti_version = 'lti_1p1'
+        self.xblock.save()
+        response = self.xblock.lti_1p3_launch_callback(make_request('', 'GET'))
+        self.assertEqual(response.status_code, 404)
+
+    # pylint: disable=unused-argument
+    @patch('lti_consumer.utils.get_lms_base', return_value="https://example.com")
+    @patch('lti_consumer.lti_consumer.get_lms_base', return_value="https://example.com")
+    def test_keyset_endpoint(self, mock_url, mock_url_2):
+        """
+        Test that the LTI 1.3 keyset endpoind.
+        """
+        response = self.xblock.public_keyset_endpoint(make_request('', 'GET'))
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.content_type, 'application/json')
+        self.assertEqual(response.content_disposition, 'attachment; filename=keyset.json')
+
+    def test_keyset_endpoint_when_using_lti_1p1(self):
+        """
+        Test that the LTI 1.3 keyset endpoind is unavailable when using 1.1.
+        """
+        self.xblock.lti_version = 'lti_1p1'
+        self.xblock.save()
+
+        response = self.xblock.public_keyset_endpoint(make_request('', 'GET'))
+        self.assertEqual(response.status_code, 404)
diff --git a/lti_consumer/utils.py b/lti_consumer/utils.py
index 75cc9cd2bc138a211158d2f593654ceb19a2fd04..9e61613e58d4a56efd67d238ab3e2f0f65878630 100644
--- a/lti_consumer/utils.py
+++ b/lti_consumer/utils.py
@@ -1,11 +1,48 @@
 # -*- coding: utf-8 -*-
 """
-Make '_' a no-op so we can scrape strings
+Utility functions for LTI Consumer block
 """
+from six import text_type
+from django.conf import settings
 
 
 def _(text):
     """
-    :return text
+    Make '_' a no-op so we can scrape strings
     """
     return text
+
+
+def get_lms_base():
+    """
+    Returns LMS base url to be used as issuer on OAuth2 flows
+
+    TODO: This needs to be improved and account for Open edX sites and
+    organizations.
+    One possible improvement is to use `contentstore.get_lms_link_for_item`
+    and strip the base domain name.
+    """
+    return settings.LMS_ROOT_URL
+
+
+def get_lms_lti_keyset_link(location):
+    """
+    Returns an LMS link to LTI public keyset endpoint
+
+    :param location: the location of the block
+    """
+    return u"{lms_base}/api/lti_consumer/v1/public_keysets/{location}".format(
+        lms_base=get_lms_base(),
+        location=text_type(location),
+    )
+
+
+def get_lms_lti_launch_link():
+    """
+    Returns an LMS link to LTI Launch endpoint
+
+    :param location: the location of the block
+    """
+    return u"{lms_base}/api/lti_consumer/v1/launch/".format(
+        lms_base=get_lms_base(),
+    )
diff --git a/setup.py b/setup.py
index db3d15c90a126f6dd15963b6fb0c36e5b5f7b4ce..01a6a99ea412757885b8dced6325fd03e395e5e3 100644
--- a/setup.py
+++ b/setup.py
@@ -50,7 +50,7 @@ with open('README.rst') as _f:
 
 setup(
     name='lti-consumer-xblock',
-    version='1.4.2',
+    version='2.0.0',
     description='This XBlock implements the consumer side of the LTI specification.',
     long_description=long_description,
     long_description_content_type='text/markdown',
@@ -64,6 +64,9 @@ setup(
     entry_points={
         'xblock.v1': [
             'lti_consumer = lti_consumer:LtiConsumerXBlock',
+        ],
+        'lms.djangoapp': [
+            "lti_consumer = lti_consumer:LTIConsumerApp",
         ]
     },
     package_data=package_data("lti_consumer", ["static", "templates", "public", "translations"]),