Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 3.11.x will end 14 Nov 2022 (12 months plus 6 months extension).
  • Bug fixes for security issues in 3.11.x will end 13 Nov 2023 (18 months plus 12 months extension).
  • PHP version: minimum PHP 7.3.0 Note: minimum PHP version has increased since Moodle 3.10. PHP 7.4.x is supported too.

Differences Between: [Versions 310 and 311] [Versions 311 and 400] [Versions 311 and 401] [Versions 311 and 402] [Versions 311 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      /** Indicates the token is to create a new registration */
  55      const REG_TOKEN_OP_NEW_REG = 'reg';
  56      /** Indicates the token is to update an existing registration */
  57      const REG_TOKEN_OP_UPDATE_REG = 'reg-update';
  58  
  59      /**
  60       * Get an instance of this helper
  61       *
  62       * @return object
  63       */
  64      public static function get() {
  65          return new registration_helper();
  66      }
  67  
  68      /**
  69       * Function used to validate parameters.
  70       *
  71       * This function is needed because the payload contains nested
  72       * objects, and optional_param() does not support arrays of arrays.
  73       *
  74       * @param array $payload that may contain the parameter key
  75       * @param string $key the key of the value to be looked for in the payload
  76       * @param bool $required if required, not finding a value will raise a registration_exception
  77       *
  78       * @return mixed
  79       */
  80      private function get_parameter(array $payload, string $key, bool $required) {
  81          if (!isset($payload[$key]) || empty($payload[$key])) {
  82              if ($required) {
  83                  throw new registration_exception('missing required attribute '.$key, 400);
  84              }
  85              return null;
  86          }
  87          $parameter = $payload[$key];
  88          // Cleans parameters to avoid XSS and other issues.
  89          if (is_array($parameter)) {
  90              return clean_param_array($parameter, PARAM_TEXT, true);
  91          }
  92          return clean_param($parameter, PARAM_TEXT);
  93      }
  94  
  95      /**
  96       * Transforms an LTI 1.3 Registration to a Moodle LTI Config.
  97       *
  98       * @param array $registrationpayload the registration data received from the tool.
  99       * @param string $clientid the clientid to be issued for that tool.
 100       *
 101       * @return object the Moodle LTI config.
 102       */
 103      public function registration_to_config(array $registrationpayload, string $clientid): object {
 104          $responsetypes = $this->get_parameter($registrationpayload, 'response_types', true);
 105          $initiateloginuri = $this->get_parameter($registrationpayload, 'initiate_login_uri', true);
 106          $redirecturis = $this->get_parameter($registrationpayload, 'redirect_uris', true);
 107          $clientname = $this->get_parameter($registrationpayload, 'client_name', true);
 108          $jwksuri = $this->get_parameter($registrationpayload, 'jwks_uri', true);
 109          $tokenendpointauthmethod = $this->get_parameter($registrationpayload, 'token_endpoint_auth_method', true);
 110  
 111          $applicationtype = $this->get_parameter($registrationpayload, 'application_type', false);
 112          $logouri = $this->get_parameter($registrationpayload, 'logo_uri', false);
 113  
 114          $ltitoolconfiguration = $this->get_parameter($registrationpayload,
 115              'https://purl.imsglobal.org/spec/lti-tool-configuration', true);
 116  
 117          $domain = $this->get_parameter($ltitoolconfiguration, 'domain', false);
 118          $targetlinkuri = $this->get_parameter($ltitoolconfiguration, 'target_link_uri', false);
 119          $customparameters = $this->get_parameter($ltitoolconfiguration, 'custom_parameters', false);
 120          $scopes = explode(" ", $this->get_parameter($registrationpayload, 'scope', false) ?? '');
 121          $claims = $this->get_parameter($ltitoolconfiguration, 'claims', false);
 122          $messages = $ltitoolconfiguration['messages'] ?? [];
 123          $description = $this->get_parameter($ltitoolconfiguration, 'description', false);
 124  
 125          // Validate domain and target link.
 126          if (empty($domain)) {
 127              throw new registration_exception('missing_domain', 400);
 128          }
 129  
 130          $targetlinkuri = $targetlinkuri ?: 'https://'.$domain;
 131          // Stripping www as this is ignored for domain matching.
 132          $domain = lti_get_domain_from_url($domain);
 133          if ($domain !== lti_get_domain_from_url($targetlinkuri)) {
 134              throw new registration_exception('domain_targetlinkuri_mismatch', 400);
 135          }
 136  
 137          // Validate response type.
 138          // According to specification, for this scenario, id_token must be explicitly set.
 139          if (!in_array('id_token', $responsetypes)) {
 140              throw new registration_exception('invalid_response_types', 400);
 141          }
 142  
 143          // According to specification, this parameter needs to be an array.
 144          if (!is_array($redirecturis)) {
 145              throw new registration_exception('invalid_redirect_uris', 400);
 146          }
 147  
 148          // According to specification, for this scenario private_key_jwt must be explicitly set.
 149          if ($tokenendpointauthmethod !== 'private_key_jwt') {
 150              throw new registration_exception('invalid_token_endpoint_auth_method', 400);
 151          }
 152  
 153          if (!empty($applicationtype) && $applicationtype !== 'web') {
 154              throw new registration_exception('invalid_application_type', 400);
 155          }
 156  
 157          $config = new stdClass();
 158          $config->lti_clientid = $clientid;
 159          $config->lti_toolurl = $targetlinkuri;
 160          $config->lti_tooldomain = $domain;
 161          $config->lti_typename = $clientname;
 162          $config->lti_description = $description;
 163          $config->lti_ltiversion = LTI_VERSION_1P3;
 164          $config->lti_organizationid_default = LTI_DEFAULT_ORGID_SITEID;
 165          $config->lti_icon = $logouri;
 166          $config->lti_coursevisible = LTI_COURSEVISIBLE_PRECONFIGURED;
 167          $config->lti_contentitem = 0;
 168          // Sets Content Item.
 169          if (!empty($messages)) {
 170              $messagesresponse = [];
 171              foreach ($messages as $value) {
 172                  if ($value['type'] === 'LtiDeepLinkingRequest') {
 173                      $config->lti_contentitem = 1;
 174                      $config->lti_toolurl_ContentItemSelectionRequest = $value['target_link_uri'] ?? '';
 175                      array_push($messagesresponse, $value);
 176                  }
 177              }
 178          }
 179  
 180          $config->lti_keytype = 'JWK_KEYSET';
 181          $config->lti_publickeyset = $jwksuri;
 182          $config->lti_initiatelogin = $initiateloginuri;
 183          $config->lti_redirectionuris = implode(PHP_EOL, $redirecturis);
 184          $config->lti_customparameters = '';
 185          // Sets custom parameters.
 186          if (isset($customparameters)) {
 187              $paramssarray = [];
 188              foreach ($customparameters as $key => $value) {
 189                  array_push($paramssarray, $key . '=' . $value);
 190              }
 191              $config->lti_customparameters = implode(PHP_EOL, $paramssarray);
 192          }
 193          // Sets launch container.
 194          $config->lti_launchcontainer = LTI_LAUNCH_CONTAINER_EMBED_NO_BLOCKS;
 195  
 196          // Sets Service info based on scopes.
 197          $config->lti_acceptgrades = LTI_SETTING_NEVER;
 198          $config->ltiservice_gradesynchronization = 0;
 199          $config->ltiservice_memberships = 0;
 200          $config->ltiservice_toolsettings = 0;
 201          if (isset($scopes)) {
 202              // Sets Assignment and Grade Services info.
 203  
 204              if (in_array(self::SCOPE_SCORE, $scopes)) {
 205                  $config->lti_acceptgrades = LTI_SETTING_DELEGATE;
 206                  $config->ltiservice_gradesynchronization = 1;
 207              }
 208              if (in_array(self::SCOPE_RESULT, $scopes)) {
 209                  $config->lti_acceptgrades = LTI_SETTING_DELEGATE;
 210                  $config->ltiservice_gradesynchronization = 1;
 211              }
 212              if (in_array(self::SCOPE_LINEITEM_RO, $scopes)) {
 213                  $config->lti_acceptgrades = LTI_SETTING_DELEGATE;
 214                  $config->ltiservice_gradesynchronization = 1;
 215              }
 216              if (in_array(self::SCOPE_LINEITEM, $scopes)) {
 217                  $config->lti_acceptgrades = LTI_SETTING_DELEGATE;
 218                  $config->ltiservice_gradesynchronization = 2;
 219              }
 220  
 221              // Sets Names and Role Provisioning info.
 222              if (in_array(self::SCOPE_NRPS, $scopes)) {
 223                  $config->ltiservice_memberships = 1;
 224              }
 225  
 226              // Sets Tool Settings info.
 227              if (in_array(self::SCOPE_TOOL_SETTING, $scopes)) {
 228                  $config->ltiservice_toolsettings = 1;
 229              }
 230          }
 231  
 232          // Sets privacy settings.
 233          $config->lti_sendname = LTI_SETTING_NEVER;
 234          $config->lti_sendemailaddr = LTI_SETTING_NEVER;
 235          if (isset($claims)) {
 236              // Sets name privacy settings.
 237  
 238              if (in_array('name', $claims)) {
 239                  $config->lti_sendname = LTI_SETTING_ALWAYS;
 240              }
 241              if (in_array('given_name', $claims)) {
 242                  $config->lti_sendname = LTI_SETTING_ALWAYS;
 243              }
 244              if (in_array('family_name', $claims)) {
 245                  $config->lti_sendname = LTI_SETTING_ALWAYS;
 246              }
 247  
 248              // Sets email privacy settings.
 249              if (in_array('email', $claims)) {
 250                  $config->lti_sendemailaddr = LTI_SETTING_ALWAYS;
 251              }
 252          }
 253          return $config;
 254      }
 255  
 256      /**
 257       * Adds to the config the LTI 1.1 key and sign it with the 1.1 secret.
 258       *
 259       * @param array $lticonfig reference to lticonfig to which to add the 1.1 OAuth info.
 260       * @param string $key - LTI 1.1 OAuth Key
 261       * @param string $secret - LTI 1.1 OAuth Secret
 262       *
 263       */
 264      private function add_previous_key_claim(array &$lticonfig, string $key, string $secret) {
 265          if ($key) {
 266              $oauthconsumer = [];
 267              $oauthconsumer['key'] = $key;
 268              $oauthconsumer['nonce'] = random_string(random_int(10, 20));
 269              $oauthconsumer['sign'] = hash('sha256', $key.$secret.$oauthconsumer['nonce']);
 270              $lticonfig['oauth_consumer'] = $oauthconsumer;
 271          }
 272      }
 273  
 274      /**
 275       * Transforms a moodle LTI 1.3 Config to an OAuth/LTI Client Registration.
 276       *
 277       * @param object $config Moodle LTI Config.
 278       * @param int $typeid which is the LTI deployment id.
 279       * @param object $type tool instance in case the tool already exists.
 280       *
 281       * @return array the Client Registration as an associative array.
 282       */
 283      public function config_to_registration(object $config, int $typeid, object $type = null): array {
 284          $configarray = [];
 285          foreach ((array)$config as $k => $v) {
 286              if (substr($k, 0, 4) == 'lti_') {
 287                  $k = substr($k, 4);
 288              }
 289              $configarray[$k] = $v;
 290          }
 291          $config = (object) $configarray;
 292          $registrationresponse = [];
 293          $lticonfigurationresponse = [];
 294          $ltiversion = $type ? $type->ltiversion : $config->ltiversion;
 295          $lticonfigurationresponse['version'] = $ltiversion;
 296          if ($ltiversion === LTI_VERSION_1P3) {
 297              $registrationresponse['client_id'] = $type ? $type->clientid : $config->clientid;
 298              $registrationresponse['response_types'] = ['id_token'];
 299              $registrationresponse['jwks_uri'] = $config->publickeyset;
 300              $registrationresponse['initiate_login_uri'] = $config->initiatelogin;
 301              $registrationresponse['grant_types'] = ['client_credentials', 'implicit'];
 302              $registrationresponse['redirect_uris'] = explode(PHP_EOL, $config->redirectionuris);
 303              $registrationresponse['application_type'] = 'web';
 304              $registrationresponse['token_endpoint_auth_method'] = 'private_key_jwt';
 305          } else if ($ltiversion === LTI_VERSION_1 && $type) {
 306              $this->add_previous_key_claim($lticonfigurationresponse, $config->resourcekey, $config->password);
 307          } else if ($ltiversion === LTI_VERSION_2 && $type) {
 308              $toolproxy = $this->get_tool_proxy($type->toolproxyid);
 309              $this->add_previous_key_claim($lticonfigurationresponse, $toolproxy['guid'], $toolproxy['secret']);
 310          }
 311          $registrationresponse['client_name'] = $type ? $type->name : $config->typename;
 312          $registrationresponse['logo_uri'] = $type ? ($type->secureicon ?? $type->icon ?? '') : $config->icon ?? '';
 313          $lticonfigurationresponse['deployment_id'] = strval($typeid);
 314          $lticonfigurationresponse['target_link_uri'] = $type ? $type->baseurl : $config->toolurl ?? '';
 315          $lticonfigurationresponse['domain'] = $type ? $type->tooldomain : $config->tooldomain ?? '';
 316          $lticonfigurationresponse['description'] = $type ? $type->description ?? '' : $config->description ?? '';
 317          if ($config->contentitem ?? 0 == 1) {
 318              $contentitemmessage = [];
 319              $contentitemmessage['type'] = 'LtiDeepLinkingRequest';
 320              if (isset($config->toolurl_ContentItemSelectionRequest)) {
 321                  $contentitemmessage['target_link_uri'] = $config->toolurl_ContentItemSelectionRequest;
 322              }
 323              $lticonfigurationresponse['messages'] = [$contentitemmessage];
 324          }
 325          if (isset($config->customparameters) && !empty($config->customparameters)) {
 326              $params = [];
 327              foreach (explode(PHP_EOL, $config->customparameters) as $param) {
 328                  $split = explode('=', $param);
 329                  $params[$split[0]] = $split[1];
 330              }
 331              $lticonfigurationresponse['custom_parameters'] = $params;
 332          }
 333          $scopesresponse = [];
 334          if ($config->ltiservice_gradesynchronization ?? 0 > 0) {
 335              $scopesresponse[] = self::SCOPE_SCORE;
 336              $scopesresponse[] = self::SCOPE_RESULT;
 337              $scopesresponse[] = self::SCOPE_LINEITEM_RO;
 338          }
 339          if ($config->ltiservice_gradesynchronization ?? 0 == 2) {
 340              $scopesresponse[] = self::SCOPE_LINEITEM;
 341          }
 342          if ($config->ltiservice_memberships ?? 0 == 1) {
 343              $scopesresponse[] = self::SCOPE_NRPS;
 344          }
 345          if ($config->ltiservice_toolsettings ?? 0 == 1) {
 346              $scopesresponse[] = self::SCOPE_TOOL_SETTING;
 347          }
 348          $registrationresponse['scope'] = implode(' ', $scopesresponse);
 349  
 350          $claimsresponse = ['sub', 'iss'];
 351          if ($config->sendname ?? '' == LTI_SETTING_ALWAYS) {
 352              $claimsresponse[] = 'name';
 353              $claimsresponse[] = 'family_name';
 354              $claimsresponse[] = 'given_name';
 355          }
 356          if ($config->sendemailaddr ?? '' == LTI_SETTING_ALWAYS) {
 357              $claimsresponse[] = 'email';
 358          }
 359          $lticonfigurationresponse['claims'] = $claimsresponse;
 360          $registrationresponse['https://purl.imsglobal.org/spec/lti-tool-configuration'] = $lticonfigurationresponse;
 361          return $registrationresponse;
 362      }
 363  
 364      /**
 365       * Validates the registration token is properly signed and not used yet.
 366       * Return the client id to use for this registration.
 367       *
 368       * @param string $registrationtokenjwt registration token
 369       *
 370       * @return array with 2 keys: clientid for the registration, type but only if it's an update
 371       */
 372      public function validate_registration_token(string $registrationtokenjwt): array {
 373          global $DB;
 374          $keys = JWK::parseKeySet(jwks_helper::get_jwks());
 375          $registrationtoken = JWT::decode($registrationtokenjwt, $keys, ['RS256']);
 376          $response = [];
 377          // Get clientid from registrationtoken.
 378          $clientid = $registrationtoken->sub;
 379          if ($registrationtoken->scope == self::REG_TOKEN_OP_NEW_REG) {
 380              // Checks if clientid is already registered.
 381              if (!empty($DB->get_record('lti_types', array('clientid' => $clientid)))) {
 382                  throw new registration_exception("token_already_used", 401);
 383              }
 384              $response['clientid'] = $clientid;
 385          } else if ($registrationtoken->scope == self::REG_TOKEN_OP_UPDATE_REG) {
 386              $tool = lti_get_type($registrationtoken->sub);
 387              if (!$tool) {
 388                  throw new registration_exception("Unknown client", 400);
 389              }
 390              $response['clientid'] = $tool->clientid ?? $this->new_clientid();
 391              $response['type'] = $tool;
 392          } else {
 393              throw new registration_exception("Incorrect scope", 403);
 394          }
 395          return $response;
 396      }
 397  
 398      /**
 399       * Initializes an array with the scopes for services supported by the LTI module
 400       *
 401       * @return array List of scopes
 402       */
 403      public function lti_get_service_scopes() {
 404  
 405          $services = lti_get_services();
 406          $scopes = array();
 407          foreach ($services as $service) {
 408              $servicescopes = $service->get_scopes();
 409              if (!empty($servicescopes)) {
 410                  $scopes = array_merge($scopes, $servicescopes);
 411              }
 412          }
 413          return $scopes;
 414      }
 415  
 416      /**
 417       * Generates a new client id string.
 418       *
 419       * @return string generated client id
 420       */
 421      public function new_clientid(): string {
 422          return random_string(15);
 423      }
 424  
 425      /**
 426       * Base64 encoded signature for LTI 1.1 migration.
 427       * @param string $key LTI 1.1 key
 428       * @param string $salt Salt value
 429       * @param string $secret LTI 1.1 secret
 430       *
 431       * @return string base64encoded hash
 432       */
 433      public function sign(string $key, string $salt, string $secret): string {
 434          return base64_encode(hash_hmac('sha-256', $key.$salt, $secret, true));
 435      }
 436  
 437      /**
 438       * Returns a tool proxy
 439       *
 440       * @param int $proxyid
 441       *
 442       * @return mixed Tool Proxy details
 443       */
 444      public function get_tool_proxy(int $proxyid) : array {
 445          return lti_get_tool_proxy($proxyid);
 446      }
 447  }