Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 3.10.x will end 8 November 2021 (12 months).
  • Bug fixes for security issues in 3.10.x will end 9 May 2022 (18 months).
  • PHP version: minimum PHP 7.2.0 Note: minimum PHP version has increased since Moodle 3.8. PHP 7.3.x and 7.4.x are supported too.

Differences Between: [Versions 310 and 311] [Versions 310 and 400] [Versions 310 and 401] [Versions 310 and 402] [Versions 310 and 403]

   1  <?php
   2  // This file is part of Moodle - http://moodle.org/
   3  //
   4  // Moodle is free software: you can redistribute it and/or modify
   5  // it under the terms of the GNU General Public License as published by
   6  // the Free Software Foundation, either version 3 of the License, or
   7  // (at your option) any later version.
   8  //
   9  // Moodle is distributed in the hope that it will be useful,
  10  // but WITHOUT ANY WARRANTY; without even the implied warranty of
  11  // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
  12  // GNU General Public License for more details.
  13  //
  14  // You should have received a copy of the GNU General Public License
  15  // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
  16  
  17  /**
  18   * A Helper for LTI Dynamic Registration.
  19   *
  20   * @package    mod_lti
  21   * @copyright  2020 Claude Vervoort (Cengage), Carlos Costa, Adrian Hutchinson (Macgraw Hill)
  22   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  23   */
  24  namespace mod_lti\local\ltiopenid;
  25  
  26  defined('MOODLE_INTERNAL') || die;
  27  
  28  require_once($CFG->dirroot . '/mod/lti/locallib.php');
  29  use Firebase\JWT\JWK;
  30  use Firebase\JWT\JWT;
  31  use stdClass;
  32  
  33  /**
  34   * This class exposes functions for LTI Dynamic Registration.
  35   *
  36   * @package    mod_lti
  37   * @copyright  2020 Claude Vervoort (Cengage), Carlos Costa, Adrian Hutchinson (Macgraw Hill)
  38   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  39   */
  40  class registration_helper {
  41      /** score scope */
  42      const SCOPE_SCORE = 'https://purl.imsglobal.org/spec/lti-ags/scope/score';
  43      /** result scope */
  44      const SCOPE_RESULT = 'https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly';
  45      /** lineitem read-only scope */
  46      const SCOPE_LINEITEM_RO = 'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly';
  47      /** lineitem full access scope */
  48      const SCOPE_LINEITEM = 'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem';
  49      /** Names and Roles (membership) scope */
  50      const SCOPE_NRPS = 'https://purl.imsglobal.org/spec/lti-nrps/scope/contextmembership.readonly';
  51      /** Tool Settings scope */
  52      const SCOPE_TOOL_SETTING = 'https://purl.imsglobal.org/spec/lti-ts/scope/toolsetting';
  53  
  54  
  55      /**
  56       * Function used to validate parameters.
  57       *
  58       * This function is needed because the payload contains nested
  59       * objects, and optional_param() does not support arrays of arrays.
  60       *
  61       * @param array $payload that may contain the parameter key
  62       * @param string $key the key of the value to be looked for in the payload
  63       * @param bool $required if required, not finding a value will raise a registration_exception
  64       *
  65       * @return mixed
  66       */
  67      private static function get_parameter(array $payload, string $key, bool $required) {
  68          if (!isset($payload[$key]) || empty($payload[$key])) {
  69              if ($required) {
  70                  throw new registration_exception('missing required attribute '.$key, 400);
  71              }
  72              return null;
  73          }
  74          $parameter = $payload[$key];
  75          // Cleans parameters to avoid XSS and other issues.
  76          if (is_array($parameter)) {
  77              return clean_param_array($parameter, PARAM_TEXT, true);
  78          }
  79          return clean_param($parameter, PARAM_TEXT);
  80      }
  81  
  82      /**
  83       * Transforms an LTI 1.3 Registration to a Moodle LTI Config.
  84       *
  85       * @param array $registrationpayload the registration data received from the tool.
  86       * @param string $clientid the clientid to be issued for that tool.
  87       *
  88       * @return object the Moodle LTI config.
  89       */
  90      public static function registration_to_config(array $registrationpayload, string $clientid): object {
  91          $responsetypes = self::get_parameter($registrationpayload, 'response_types', true);
  92          $initiateloginuri = self::get_parameter($registrationpayload, 'initiate_login_uri', true);
  93          $redirecturis = self::get_parameter($registrationpayload, 'redirect_uris', true);
  94          $clientname = self::get_parameter($registrationpayload, 'client_name', true);
  95          $jwksuri = self::get_parameter($registrationpayload, 'jwks_uri', true);
  96          $tokenendpointauthmethod = self::get_parameter($registrationpayload, 'token_endpoint_auth_method', true);
  97  
  98          $applicationtype = self::get_parameter($registrationpayload, 'application_type', false);
  99          $logouri = self::get_parameter($registrationpayload, 'logo_uri', false);
 100  
 101          $ltitoolconfiguration = self::get_parameter($registrationpayload,
 102              'https://purl.imsglobal.org/spec/lti-tool-configuration', true);
 103  
 104          $domain = self::get_parameter($ltitoolconfiguration, 'domain', false);
 105          $targetlinkuri = self::get_parameter($ltitoolconfiguration, 'target_link_uri', false);
 106          $customparameters = self::get_parameter($ltitoolconfiguration, 'custom_parameters', false);
 107          $scopes = explode(" ", self::get_parameter($registrationpayload, 'scope', false) ?? '');
 108          $claims = self::get_parameter($ltitoolconfiguration, 'claims', false);
 109          $messages = $ltitoolconfiguration['messages'] ?? [];
 110          $description = self::get_parameter($ltitoolconfiguration, 'description', false);
 111  
 112          // Validate domain and target link.
 113          if (empty($domain)) {
 114              throw new registration_exception('missing_domain', 400);
 115          }
 116          $targetlinkuri = $targetlinkuri ?: 'https://'.$domain;
 117          if ($domain !== lti_get_domain_from_url($targetlinkuri)) {
 118              throw new registration_exception('domain_targetlinkuri_mismatch', 400);
 119          }
 120  
 121          // Validate response type.
 122          // According to specification, for this scenario, id_token must be explicitly set.
 123          if (!in_array('id_token', $responsetypes)) {
 124              throw new registration_exception('invalid_response_types', 400);
 125          }
 126  
 127          // According to specification, this parameter needs to be an array.
 128          if (!is_array($redirecturis)) {
 129              throw new registration_exception('invalid_redirect_uris', 400);
 130          }
 131  
 132          // According to specification, for this scenario private_key_jwt must be explicitly set.
 133          if ($tokenendpointauthmethod !== 'private_key_jwt') {
 134              throw new registration_exception('invalid_token_endpoint_auth_method', 400);
 135          }
 136  
 137          if (!empty($applicationtype) && $applicationtype !== 'web') {
 138              throw new registration_exception('invalid_application_type', 400);
 139          }
 140  
 141          $config = new stdClass();
 142          $config->lti_clientid = $clientid;
 143          $config->lti_toolurl = $targetlinkuri;
 144          $config->lti_tooldomain = $domain;
 145          $config->lti_typename = $clientname;
 146          $config->lti_description = $description;
 147          $config->lti_ltiversion = LTI_VERSION_1P3;
 148          $config->lti_organizationid_default = LTI_DEFAULT_ORGID_SITEID;
 149          $config->lti_icon = $logouri;
 150          $config->lti_coursevisible = LTI_COURSEVISIBLE_PRECONFIGURED;
 151          $config->lti_contentitem = 0;
 152          // Sets Content Item.
 153          if (!empty($messages)) {
 154              $messagesresponse = [];
 155              foreach ($messages as $value) {
 156                  if ($value['type'] === 'LtiDeepLinkingRequest') {
 157                      $config->lti_contentitem = 1;
 158                      $config->lti_toolurl_ContentItemSelectionRequest = $value['target_link_uri'] ?? '';
 159                      array_push($messagesresponse, $value);
 160                  }
 161              }
 162          }
 163  
 164          $config->lti_keytype = 'JWK_KEYSET';
 165          $config->lti_publickeyset = $jwksuri;
 166          $config->lti_initiatelogin = $initiateloginuri;
 167          $config->lti_redirectionuris = implode(PHP_EOL, $redirecturis);
 168          $config->lti_customparameters = '';
 169          // Sets custom parameters.
 170          if (isset($customparameters)) {
 171              $paramssarray = [];
 172              foreach ($customparameters as $key => $value) {
 173                  array_push($paramssarray, $key . '=' . $value);
 174              }
 175              $config->lti_customparameters = implode(PHP_EOL, $paramssarray);
 176          }
 177          // Sets launch container.
 178          $config->lti_launchcontainer = LTI_LAUNCH_CONTAINER_EMBED_NO_BLOCKS;
 179  
 180          // Sets Service info based on scopes.
 181          $config->lti_acceptgrades = LTI_SETTING_NEVER;
 182          $config->ltiservice_gradesynchronization = 0;
 183          $config->ltiservice_memberships = 0;
 184          $config->ltiservice_toolsettings = 0;
 185          if (isset($scopes)) {
 186              // Sets Assignment and Grade Services info.
 187  
 188              if (in_array(self::SCOPE_SCORE, $scopes)) {
 189                  $config->lti_acceptgrades = LTI_SETTING_DELEGATE;
 190                  $config->ltiservice_gradesynchronization = 1;
 191              }
 192              if (in_array(self::SCOPE_RESULT, $scopes)) {
 193                  $config->lti_acceptgrades = LTI_SETTING_DELEGATE;
 194                  $config->ltiservice_gradesynchronization = 1;
 195              }
 196              if (in_array(self::SCOPE_LINEITEM_RO, $scopes)) {
 197                  $config->lti_acceptgrades = LTI_SETTING_DELEGATE;
 198                  $config->ltiservice_gradesynchronization = 1;
 199              }
 200              if (in_array(self::SCOPE_LINEITEM, $scopes)) {
 201                  $config->lti_acceptgrades = LTI_SETTING_DELEGATE;
 202                  $config->ltiservice_gradesynchronization = 2;
 203              }
 204  
 205              // Sets Names and Role Provisioning info.
 206              if (in_array(self::SCOPE_NRPS, $scopes)) {
 207                  $config->ltiservice_memberships = 1;
 208              }
 209  
 210              // Sets Tool Settings info.
 211              if (in_array(self::SCOPE_TOOL_SETTING, $scopes)) {
 212                  $config->ltiservice_toolsettings = 1;
 213              }
 214          }
 215  
 216          // Sets privacy settings.
 217          $config->lti_sendname = LTI_SETTING_NEVER;
 218          $config->lti_sendemailaddr = LTI_SETTING_NEVER;
 219          if (isset($claims)) {
 220              // Sets name privacy settings.
 221  
 222              if (in_array('name', $claims)) {
 223                  $config->lti_sendname = LTI_SETTING_ALWAYS;
 224              }
 225              if (in_array('given_name', $claims)) {
 226                  $config->lti_sendname = LTI_SETTING_ALWAYS;
 227              }
 228              if (in_array('family_name', $claims)) {
 229                  $config->lti_sendname = LTI_SETTING_ALWAYS;
 230              }
 231  
 232              // Sets email privacy settings.
 233              if (in_array('email', $claims)) {
 234                  $config->lti_sendemailaddr = LTI_SETTING_ALWAYS;
 235              }
 236          }
 237          return $config;
 238      }
 239  
 240      /**
 241       * Transforms a moodle LTI 1.3 Config to an OAuth/LTI Client Registration.
 242       *
 243       * @param object $config Moodle LTI Config.
 244       * @param int $typeid which is the LTI deployment id.
 245       *
 246       * @return array the Client Registration as an associative array.
 247       */
 248      public static function config_to_registration(object $config, int $typeid): array {
 249          $registrationresponse = [];
 250          $registrationresponse['client_id'] = $config->lti_clientid;
 251          $registrationresponse['token_endpoint_auth_method'] = ['private_key_jwt'];
 252          $registrationresponse['response_types'] = ['id_token'];
 253          $registrationresponse['jwks_uri'] = $config->lti_publickeyset;
 254          $registrationresponse['initiate_login_uri'] = $config->lti_initiatelogin;
 255          $registrationresponse['grant_types'] = ['client_credentials', 'implicit'];
 256          $registrationresponse['redirect_uris'] = explode(PHP_EOL, $config->lti_redirectionuris);
 257          $registrationresponse['application_type'] = 'web';
 258          $registrationresponse['token_endpoint_auth_method'] = 'private_key_jwt';
 259          $registrationresponse['client_name'] = $config->lti_typename;
 260          $registrationresponse['logo_uri'] = $config->lti_icon ?? '';
 261          $lticonfigurationresponse = [];
 262          $lticonfigurationresponse['deployment_id'] = strval($typeid);
 263          $lticonfigurationresponse['target_link_uri'] = $config->lti_toolurl;
 264          $lticonfigurationresponse['domain'] = $config->lti_tooldomain ?? '';
 265          $lticonfigurationresponse['description'] = $config->lti_description ?? '';
 266          if ($config->lti_contentitem == 1) {
 267              $contentitemmessage = [];
 268              $contentitemmessage['type'] = 'LtiDeepLinkingRequest';
 269              if (isset($config->lti_toolurl_ContentItemSelectionRequest)) {
 270                  $contentitemmessage['target_link_uri'] = $config->lti_toolurl_ContentItemSelectionRequest;
 271              }
 272              $lticonfigurationresponse['messages'] = [$contentitemmessage];
 273          }
 274          if (isset($config->lti_customparameters) && !empty($config->lti_customparameters)) {
 275              $params = [];
 276              foreach (explode(PHP_EOL, $config->lti_customparameters) as $param) {
 277                  $split = explode('=', $param);
 278                  $params[$split[0]] = $split[1];
 279              }
 280              $lticonfigurationresponse['custom_parameters'] = $params;
 281          }
 282          $scopesresponse = [];
 283          if ($config->ltiservice_gradesynchronization > 0) {
 284              $scopesresponse[] = self::SCOPE_SCORE;
 285              $scopesresponse[] = self::SCOPE_RESULT;
 286              $scopesresponse[] = self::SCOPE_LINEITEM_RO;
 287          }
 288          if ($config->ltiservice_gradesynchronization == 2) {
 289              $scopesresponse[] = self::SCOPE_LINEITEM;
 290          }
 291          if ($config->ltiservice_memberships == 1) {
 292              $scopesresponse[] = self::SCOPE_NRPS;
 293          }
 294          if ($config->ltiservice_toolsettings == 1) {
 295              $scopesresponse[] = self::SCOPE_TOOL_SETTING;
 296          }
 297          $registrationresponse['scope'] = implode(' ', $scopesresponse);
 298  
 299          $claimsresponse = ['sub', 'iss'];
 300          if ($config->lti_sendname == LTI_SETTING_ALWAYS) {
 301              $claimsresponse[] = 'name';
 302              $claimsresponse[] = 'family_name';
 303              $claimsresponse[] = 'given_name';
 304          }
 305          if ($config->lti_sendemailaddr == LTI_SETTING_ALWAYS) {
 306              $claimsresponse[] = 'email';
 307          }
 308          $lticonfigurationresponse['claims'] = $claimsresponse;
 309          $registrationresponse['https://purl.imsglobal.org/spec/lti-tool-configuration'] = $lticonfigurationresponse;
 310          return $registrationresponse;
 311      }
 312  
 313      /**
 314       * Validates the registration token is properly signed and not used yet.
 315       * Return the client id to use for this registration.
 316       *
 317       * @param string $registrationtokenjwt registration token
 318       *
 319       * @return string client id for the registration
 320       */
 321      public static function validate_registration_token(string $registrationtokenjwt): string {
 322          global $DB;
 323          $keys = JWK::parseKeySet(jwks_helper::get_jwks());
 324          $registrationtoken = JWT::decode($registrationtokenjwt, $keys, ['RS256']);
 325  
 326          // Get clientid from registrationtoken.
 327          $clientid = $registrationtoken->sub;
 328  
 329          // Checks if clientid is already registered.
 330          if (!empty($DB->get_record('lti_types', array('clientid' => $clientid)))) {
 331              throw new registration_exception("token_already_used", 401);
 332          }
 333          return $clientid;
 334      }
 335  
 336      /**
 337       * Initializes an array with the scopes for services supported by the LTI module
 338       *
 339       * @return array List of scopes
 340       */
 341      public static function lti_get_service_scopes() {
 342  
 343          $services = lti_get_services();
 344          $scopes = array();
 345          foreach ($services as $service) {
 346              $servicescopes = $service->get_scopes();
 347              if (!empty($servicescopes)) {
 348                  $scopes = array_merge($scopes, $servicescopes);
 349              }
 350          }
 351          return $scopes;
 352      }
 353  
 354  }