Developer Platform (May 2017)

Remote Plugins

«  Integrating with the Integrated Learning Platform UI   ·  [   home  ·   reference  ·   community   ·  search   ·  index   ·  routing table   ·  scopes table   ]   ·  Client libraries and tools to simplify integration  »

Contents

The Remote Plugin Service lets third parties register web applications to provide a variety of services for LMS users. Remote Plugins build on top of the LTI standard connection framework (with your third-party service in the role of Tool provider), and improve the ease of creation and depth of integration that you can achieve by creating the relationship between the LMS and an External Learning Tool manually.

Because Remote Plugins build on top of LTI, you have access to the same authenticated contextual data that you would get if you built the LTI connection through to your application as an External Learning Tool. Additionally, you can make calls to the Brightspace API immediately after receiving an LTI launch (including a call to retrieve user tokens for the already logged-in Learning Service user).

Service configuration

Use the Remote Plugins management tool in the Learning Environment to create and manage Remote Plugin integrations. Currently, the Remote Plugin Service supports these types of integrations:

Course Builder. Create a provider of course content elements: in the course builder list of sources, your service will appear, using an LTI launch out to your service to let a course designer select content for inclusion in a course.

ISF. Create a provider of insert-stuff elements: in the list of Insert Stuff sources, your service will appear, using an LTI launch out to your service

Navbar. Create a navbar link object that can be incorporated into an organization’s navbars on its various homepages (course homepage, department homepage, and so forth).

Quicklink. Create a quicklink object containing an LTI launch URL: users can put this object anywhere they could put a quicklink.

Widget. Create a widget object that can be incorporated into the body of a homepage: essentially an iframe, the contents of which will get provided by your web service or application.

Common configuration details

All the different Remote Plugin types share a common set of configuration details.

Launch point. The launch point provides the back-end service with the entry point on your service to which LTI launches should get made. Your launch point should be capable of handling the LTI launch request, and the data it provides.

Secret and key. You will most likely want to provide a key/secret token pair as part of the Remote Plugin registration. You can use this key/secret to authenticate the origin of every LTI Launch from this Remote Plugin registration. You may, therefore, want to make the key/secret pair unique to the tool consumer and perhaps even to the individual Remote Plugin registration itself (so that you can separately authenticate launches to different services from the same tool consumer).

System test. Here you can optionally specify an entry point on your service the back-end service can use to test the Remote Plugin registration. The back-end service sends a simple LTI launch to this URL, using the data configured for the registration, and expects to receive back a 200 OK with a RP.TestResult JSON block.

Note that, if you specify a system test URL, then the LMS will send a test launch to this URL whenever you:

  • Specifically choose to run the test
  • Save any updates you make to the Remote Plugin configuration

Org unit availability. When you register a Remote Plugin it becomes available, by default, to the root organization. If you want the service available to additional org units, then you must add them to the configuration. You can choose to add an org unit by itself, or add an org unit and descendants (either all descendants or descendants of a particular org unit type).

If, for example, you want the plugin available to the entire organization, add an org unit, choose the root org, and choose to add inclusion for all its descendants.

Configuration for specific types

The Widget and Course Builder types of Remote Plugin explicitly get granted a fixed portion of the browser window’s UI – these types let you specify the dimensions of the portion you’d like:

  • Course Builder types let you provide height and width.
  • Widget types only let you specify the height because the granted width depends on the widget’s position within the layout flow of the housing homepage.

The Navbar type of Remote Plugin lets you control where the results of the launch will get shown: you can have the results of the launch get shown to the user in a frame below the Learning Environment’s title bar, take over the window that was showing the Learning Environment, or in a newly opened window.

Permissions

Remote Plugins act as a functional convenience wrapper around External Learning Tools; as such, the general permissions controlling the creation and modification of Remote Plugins come from the permissions controlling External Learning Tools.

  • Manage External Learning Tools Links and Manage External Learning Tool Providers together let a user role create, modify, or delete a Remote Plugin configuration.
  • See Remote Plugins lets a user make use of the Remote Plugin tool to browse and select the existing Remote Plugins.

Remote Plugins often provide an integration between an external service and a specific ILP toolset; as such, the permissions controlling access to the appropriate tools’ functionality applies to particular types of Remote Plugins:

  • See Course Builder Tool lets a user role make use of course builder Remote Plugins.
  • Manage Widgets lets a user role place an existing Remote Plugin created as a widget plugin onto an appropriate org unit’s home page.
  • Share Widgets and Delete Shared Widgets lets a user role share a widget Remote Plugin to org units that are descendants from the org unit where they’re created.
  • Once you register a navbar, quicklink, or ISF Remote Plugin, the ILP governs access to these items using the same sets of role permissions it would consider for manually created, or pre-installed, versions of those objects (navbars, quicklinks, and ISF types).

Service implementation

Implementing your application to register as a Remote Plugin service comes down to two fundamental matters:

This section provides some simple examples to demonstrate how you could handle these matters. Your actual application will need to be more robust and complex than any of these examples: they’re only meant to be illustrative of the general principles in play.

Reading the examples. To understand these examples, it helps to first understand these points (each particular example might have further points of context that we will point out):

Handling a generic LTI launch

All Remote Plugins must be able to handle an LTI launch: this is the fundamental first step of all communications with the back-end service (where the LMS plays the role of tool consumer). Here is an extremely simplistic, generic web service handler example.

Reading the example. To understand this example, it helps to know that

  • request.session is a dictionary of session state (provided by beaker) that you can read from and freely add to.
  • request.forms is a dictionary (provided by bottle) of all the LTI properties posted to this route; of particular interest there is the LTI field launch_presentation_return_url which, if present, is the URL callback that the tool consumer expects your service to redirect the traffic flow back to when your service finishes doing what it needs to do from this particular launch.
## d2ltoolptest.py -- simple tool provider example (excerpt)

import urllib.parse
import beaker.middleware
import bottle
from bottle import request, hook, route

# Hook request to make the beaker session easier to get to
@hook('before_request')
def _setup_request():
    request.session = request.environ['beaker.session']

def _verify_lti_launch(request):
    # body of function to do OAuth signature checking here
    # should return: - true for a verified sig (see below for example)
    #                - false for a failed authentication

# LTI launch handler
@route('/service/<plugin_type>', method='POST')
def handle_lti_launch(plugin_type):
    # Plugin_type must be one of our supported services (i.e. widget, navbar, etc)
    assert plugin_type in _CFG['services']

    # LTI launch's OAuth signature must pass verification
    if not _verify_lti_launch(request):
        abort(401, 'Failed authentication')

    # The tool consumer may expect to give us a callback, so let's store it here.
    request.session['lti_context_return_url'] = None
    request.session['lti_context_return_ou'] = None
    request.session['lti_context_return_parent_node'] = None

    if 'launch_presentation_return_url' in request.forms:
        ret_host_url_parts = urllib.parse.urlsplit(
             request.forms['launch_presentation_return_url'] )
        r_parsed_query = urllib.parse.parse_qs(ret_host_url_parts.query)

        # We won't use the entire presentation return URL for ISF and
        # quicklinks; only the domain and path.
        request.session['lti_context_return_url'] = '{0}://{1}{2}'.format(
            ret_host_url_parts.scheme.lower(),
            ret_host_url_parts.netloc,
            ret_host_url_parts.path)
        if 'ou' in r_parsed_query:
            request.session['lti_context_return_ou'] = r_parsed_query['ou']
        if 'parentNode' in r_parsed_query:
            request.session['lti_context_return_parent_node'] = r_parsed_query['parentNode']

    return template(plugin_type,
                    page_title=plugin_type,
                    ret_url=request.session['lti_context_return_url'],
                    ou=request.session['lti_context_return_ou'],
                    parent_node=request.session['lti_context_return_parent_node'])

LTI launch verification

All LTI launches that get signed should authenticated by the tool provider receiving the launch. Launches out to registered Remote Plugins use the standard LTI signing methodology, so you can build a tool provider that can verify against the standard LTI reference implementations (or you can build your service to verify test requests from a test LMS instance).

Reading the example. We highly recommend that you use a commonly accepted third party library to handle the Remote Plugin signature checking for you. Our test example does this; to understand it, it helps to note that

  • The verify_oauth_sig() function takes in two groups of parameters: the key and secret that the tool consumer and tool provider share (the ones that are used to make the signature); and the request URL, request HTTP method, request HTTP headers, and HTTP post content body (the post form).
  • The verify_oauth_sig() function that we import (that wraps around the third party library we’re using) has two kinds of results: a true or false result if the parameters we provide are well-formed (the message is either authenticated, or not authenticated), or a thrown ValueError.
  • We’re using a single key/secret in this simple example (stored in our _CFG state dictionary): in a real application, we’d need a storage system to manage the known key/secrets, and verify_oauth_sig() could then query it to find the secret for the key advertised within the LTI launch body’s form.
## d2ltoolptest.py -- simple tool provider example (excerpt)

# We import a function from a separate module to verify signatures
from d2loauth import verify_oauth_sig

# Here we wrap around 'verify_oauth_sig' so we can handle the results in a
# webby fashion consistent with being a web service.
def _verify_lti_launch(request):
    verified = False
    try:
        verified = verify_oauth_sig(_CFG['app_oauth_key'],
                                    _CFG['app_oauth_secret'],
                                    uri=request.url,
                                    http_method=request.method,
                                    body=request.forms,
                                    headers=request.headers )
    except ValueError:
        abort(400, 'Badly formed LTI launch paramters')
    return verified

In a companion, sample d2loauth module, we implement our exported function (which here composes an implementation of the Server abstract base class from the third-party oauthlib package). In your case, you may want to hide whatever thing you’re actually doing the OAuth signature verification with from your web service layer in a similar fashion.

## d2loauth.py -- simple wrapper module around OAuth signature verification (excerpt)


def verify_oauth_sig(key=None, secret=None,
                     uri=None,
                     http_method=None,
                     body=None,
                     headers=None):
    s = D2LOauthServer(k=key, s=secret)
    return s.verify_signature(uri = uri,
                              http_method = http_method,
                              body = body,
                              headers = headers)


from oauthlib.oauth1 import RequestValidator
from oauthlib.oauth1.rfc5849.endpoints import BaseEndpoint

# shell validator to house our known key and secret
class _D2LValidator(RequestValidator):

    def __init__(self, k, s,  *args, **kwargs):
        RequestValidator.__init__(self,*args,**kwargs)
        self.key = k
        self.secret = s

    # This is not a robust Validator. As all we're doing is signature
    # checking, and we trust the other end to have valid data, all we're
    # doing here is providing a way to get the shared secret back.
    def get_client_secret(self, k,r):
        if k == self.key:
            return self.secret
        else:
            return 'dummy'

    def get_request_token_secret(self,k,t,r):
        if k == self.key:
            return self.secret
        else:
            return 'dummy'

    def get_access_token_secret(self,k,t,r):
        if k == self.key:
            return self.secret
        else:
            return 'dummy'

# shell endpoint that uses our validator for validation
class _D2LEndpoint(BaseEndpoint):
    def __init__(self,v,*args,**kwargs):
        BaseEndpoint.__init__(self,v)


# Here, we need to build a D2LOauthServer object that composes together a
# validator and a base endpoint, and which implements a verify_signature method
class _D2LOauthServer(object):

    def __init__(self, k=None, s=None, *args, **kwargs):
        self.validator= _D2LValidator(k,s)
        self.endpoint = _D2LEndpoint(self.validator)
        pass

    # provide verify_signature method
    def verify_signature(self, uri=None, http_method=None, body=None,
                         headers=None):
        try:
            # use the endpoint to fashion an oauthlib request object
            r = self.endpoint._create_request(
                uri,
                http_method,
                body,
                headers)
        except errors.OAuth1Error:
            # we can't even build the request for some reason
            return False

        # pass our request directly down into the endpoint's internal
        # signature checking method as that's all we need it for: this is
        # NOT a great idea to use long-term or in production
        return self.endpoint._check_signature(r)

Handling a system test request

Because the system test for a Remote Plugin registration is optional, your service does not need to provide one. However, should you chose to handle it, you should treat it as a regular LTI launch (verify the launch with key/secret, and so forth). Your service should respond with a 200 OK to indicate that it has understood the test request, and send back a reply that contains JSON like this:

RP.TestResult
{
    "result_code": <string>,   // Should be either 'OK' or 'FAILURE'
    "result_description": <string>|null
}

If your service is prepared to respond to the registered Remote Plugin from that tool consumer going forward, then you should respond with an OK result code. If your service is not prepared to respond to the registered Remote Plugin from that tool consumer going forward, then you should respond with a FAILURE result code.

You can, in either case, send back a result description string providing more information about the result of the test. Here’s a simple example of a request handler for the system test request; to understand it, it helps to note that

  • It uses the _verify_lti_launch() function in exactly the same way as the actual LTI launch handler, to receive an LTI launch post request, and then verify it the request as an authenticated launch.

  • The test handler should always return with an 200 OK, if the hosting web server can handle the request: the contents of the JSON returned indicate the status of the test being performed.

    Because we’re using the same verification code here, our server will actually send back a 400 Bad Request if the LTI launch request is somehow badly formed.

## d2ltoolptest.py -- simple tool provider example (excerpt)

# System test handler
@route('/test/<plugin_type>',method='POST')
def test_service(plugin_type):
    r = {'result_code': 'FAILURE','result_description':'Test Failure'}
    if plugin_type not in _CFG['services']:
        r['result_description'] = 'No such service'
    else:
        if not _verify_lti_launch(request):
            r['result_description'] = 'Authentication failure'
        else:
            r['result_code']='OK'
            r['result_description']='Authentication success'
    return r

Launch presentation return

Three of the Remote Plugin types (ISF, Quicklink, Coursebuilder) must direct information about a user’s choice back to the tool consumer:

  • With an ISF Remote Plugin, users seek to choose a media item to insert into some content they are writing (for example, an image, or video). The tool provider service after the launch lets users pick the media, and then must send back to the LMS an iframe to render this choice going forward.
  • With a Quicklink Remote Plugin, users seek to choose a specific target for an LTI launch URL to insert into some content they are writing (like a link in a discussion post that leads off to a specific video or image). When a user adds a qucklink to their content, the tool provider service lets them select some item, and them must send back a link to the LMS for this item.
  • With a Coursebuilder Remote Plugin, instructor users seek to choose a specific piece of course content from a library of content. When instructors use the coursebuilder tool to construct a course, they can add a course component provided by your registered service; this causes a launch letting them choose the specific piece of content from your library. Your service will be using Brightspace API calls to insert content into the course context from the launch.

In all three cases, the LTI launch passes a special lti_presentation_return_url property. Our generic LTI launch handler sample above captures that URL if it’s present, and passes it (or None) into the template. Some of our sample templates will use this value (the quicklink template, for example), and some won’t (the widget template, for example):

## d2ltoolptest.py -- simple tool provider example (excerpt)

# ... LTI launch handler function declaration and context snipped

    # The tool consumer may expect to give us a callback, so let's store it here.
    request.session['lti_context_return_url'] = None
    request.session['lti_context_return_ou'] = None
    request.session['lti_context_return_parent_node'] = None

    if 'launch_presentation_return_url' in request.forms:
        ret_host_url_parts = urllib.parse.urlsplit(
             request.forms['launch_presentation_return_url'] )
        r_parsed_query = urllib.parse.parse_qs(ret_host_url_parts.query)

        # We won't use the entire presentation return URL for ISF and quicklinks;
        # only the domain and path; course builder needs some of the query parm values
        request.session['lti_context_return_url'] = '{0}://{1}{2}'.format(
            ret_host_url_parts.scheme.lower(),
            ret_host_url_parts.netloc,
            ret_host_url_parts.path)
        if 'ou' in r_parsed_query:
            request.session['lti_context_return_ou'] = r_parsed_query['ou']
        if 'parentNode' in r_parsed_query:
            request.session['lti_context_return_parent_node'] = r_parsed_query['parentNode']

    return template(plugin_type,
                    page_title=plugin_type,
                    ret_url=request.session['lti_context_return_url'],
                    ou=request.session['lti_context_return_ou'],
                    parent_node=request.session['lti_context_return_parent_node'])

We use three bits of information from the launch_presentation_return_url: we use the base URL as the entry point on the LMS to return to; we use the ou and parentNode query parameter values as context information that Coursebuilder Remote Plugins will need.

What the LMS expects back. What exactly the LMS expects back from the handling of the LTI launch once again depends upon the Remote Plugin type in question.

Note

The ret_url parameter that gets passed into the templating engine (building the page to send to the user’s browser) contains the parts of the original LTI launch_presentation_return_url that you will actually be using: the scheme, net-location, and path parts. The templating engine uses that ret_url as a base: what it _adds_ to that base depends on what _type_ of remote plugin you’re dealing with.

  • With an ISF Remote Plugin, your web service needs to redirect to a URL that looks something like this, sending back some HTML content that will go where the user wants to “insert the stuff”; in this case, we end back an iframe that will render a remote image:

    var returnUrl = '{{ ret_url }}';
    returnUrl += '?content='
               + encodeURIComponent(
                  '<iframe height=256 width=300 src="http://some.mediaserver.com/sample/image.png"></iframe>'
                );
    
    content

    Content component for in-line insertion (most typically, an iframe to render your service’s remotely hosted content).

  • With a Quicklink Remote Plugin, your web service needs to redirect to a URL that looks something like this, sending back the URL and title to form a new link that will go where the user wants to embed the quicklink; in this case, we send back an URL to a remote image, the title for the link (to render in the text), and a target for the link.

    var returnUrl = '{{ ret_url }}';
        returnUrl += '?quickLink='
                 + encodeURIComponent('http://some.mediaserver.com/sample/image.png')
                 + '&title=The Treachery of Images (Magritte:1829)'
                 + '&target=NewWindow';
    
    quicklink

    The encoded URI of the item.

    target

    Valid values for the target query parameter here are one of SameFrame (take over the frame under the Navbar), WholeWindow (take over the current browser window), or NewWindow (open a new tab/window to render in).

    tile

    Title that will be shown inline for the link; if you leave it blank, the full URI will get used.

  • With a Coursebuilder Remote Plugin, the LMS expects you to do all the actual course content insertion out of band using Brightspace API calls; therefore, your web service only needs to redirect back to the returnUrl.

    You can use the ou and parent_node values pulled out of the launch presentation return URL as the org unit ID and parent node (module) ID for all the Brightspace API calls you need to make to insert content into the course.

Using Brightspace APIs with Remote Plugins

For complex circumstances, you can use the Brightspace APIs as a complement to the LTI foundation for Remote Plugins. Recall that all Brightspace API calls require a user context: because you know that an LTI launch is most likely to be initiated by a real user logged into the back-end service, you can very easily insert the Brightspace authentication process into your Remote Plugin implementation.

After you receive and handle the LTI launch, but before you move the flow onto your view template, you can request user tokens from the back-end service. So, let’s replace and expand our return template(plugin_type... block, as in the following simple example.

Reading the example. To understand this example, it helps to note that _AUTH_CB and _AUTH_ROUTE internal globals just stand for the form of the authentication callback URL for your service to which the back-end LMS will send the generated user ID-key pair for your application.

Note also that we have to add in the imports for the D2LValence library modules, and build a global application context we can use for building user contexts to authenticate our API calls.

## d2ltoolptest.py -- simple tool provider example (excerpt)

# The d2lvalence.auth module provides app and user contexts
import d2lvalence.auth as d2lauth

# Application context built from the App ID-key pair we have stored in our
# global configuration data
_ac = d2lauth.fashion_app_context(app_id=_CFG['app_id'], app_key=_CFG['app_key'])

# ... LTI launch handler function declaration and context snipped

    # ... Snip this return out of the bottom of the LTI launch handler
    # ... and put in the new route handlers to do the auth process and
    # ... the eventual view template for the service
    #
    # return template(plugin_type,
    #                page_title=plugin_type,
    #                ret_url=request.session['lti_context_return_url'],
    #                ou=request.session['lti_context_return_ou'],
    #                parent_node=request.session['lti_context_return_parent_node'])

    # Wipe any user context for this launch, assume a new launch
    request.session['valence_user_context'] = None

    # Grab some details about the tool consumer
    tc_host_parts = urllib.parse.urlsplit(request.forms['lis_outcome_service_url'])
    request.session['lti_tc_host'] = tc_host_parts.netloc
    request.session['lti_tc_encrypt'] = False
    if tc_host_parts.scheme.lower()[-1:] is 's':
        request.session['lti_tc_encrypt'] = True

    # Start the auth process from scratch
    aurl = _ac.create_url_for_authentication(
        host=request.session['lti_tc_host'],
        # provide the callback where the tool consumer should send the user tokens;
        # _AUTH_CB is the base URL for our service to receive user tokens,
        # and we append the plugin type to the base route so that the callback
        # knows what kind of plugin is initiating the auth request
        client_app_url=_AUTH_CB+'/{0}'.format(plugin_type),
        encrypt_request=request.session['lti_tc_encrypt']
    )

    # Redirect to the Brightspace auth entry point on the tool consumer
    redirect(aurl,302)

# Callback handler to gather user tokens
# _AUTH_ROUTE is the path portion of our _AUTH_CB URL
@route(_AUTH_ROUTE+'/<plugin_type>',method='GET')
def auth_token_handler(plugin_type):
    assert plugin_type in _CFG['services']

    # Use the passed back user tokens in the request URL query parameters
    # to build a user context
    uc = _ac.create_user_context(
              result_uri = request.url,
              host = request.session['lti_tc_host'],
              encrypt_requests = request.session['lti_tc_encrypt'] )

    # Store the context's properties, so we can rebuilt it from these later
    request.session['valence_user_context'] = uc.get_context_properties()

    # Redirect to the GET entry point for the service identified in the original launch
    redirect('/service/{0}'.format(plugin_type),302)

# Route to pass back the result for each Remote Plugin launch
@route('/service/<plugin_type>',method='GET')
def service_result(plugin_type):
    assert plugin_type.isalnum()
    assert plugin_type in _CFG['services']
    assert (('lti_service_request_type' in request.session) and
           (request.session['lti_service_request_type'] == plugin_type) )

    # Build a user context and make a whoami call so we know who's launched
    uc = _ac.create_user_context(d2l_user_context_props_dict=request.session['valence_user_context'])
    whoami_route = '{}://{}/d2l/api/lp/1.10/users/whoami'.format(uc.scheme, uc.host)
    resp = requests.get(whoami_route, auth=uc)
    user = resp.json()

    # Send back a view based on the Remote Plugin type
    return template(plugin_type,
                    page_title=plugin_type,
                    ret_url=request.session['lti_context_return_url'],
                    ou=request.session['lti_context_return_ou'],
                    parent_node=request.session['lti_context_return_parent_node'],
                    first_name=user.get('FirstName', 'Anonymous'),
                    last_name=user.get('LastName', 'Anonymous'))

«  Integrating with the Integrated Learning Platform UI   ·  [   home  ·   reference  ·   community   ·  search   ·  index   ·  routing table   ·  scopes table   ]   ·  Client libraries and tools to simplify integration  »