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