<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* A Helper for LTI Dynamic Registration.
*
* @package mod_lti
* @copyright 2020 Claude Vervoort (Cengage), Carlos Costa, Adrian Hutchinson (Macgraw Hill)
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace mod_lti\local\ltiopenid;
defined('MOODLE_INTERNAL') || die;
require_once($CFG->dirroot . '/mod/lti/locallib.php');
use Firebase\JWT\JWK;
use Firebase\JWT\JWT;
> use Firebase\JWT\Key;
use stdClass;
/**
* This class exposes functions for LTI Dynamic Registration.
*
* @package mod_lti
* @copyright 2020 Claude Vervoort (Cengage), Carlos Costa, Adrian Hutchinson (Macgraw Hill)
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class registration_helper {
/** score scope */
const SCOPE_SCORE = 'https://purl.imsglobal.org/spec/lti-ags/scope/score';
/** result scope */
const SCOPE_RESULT = 'https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly';
/** lineitem read-only scope */
const SCOPE_LINEITEM_RO = 'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly';
/** lineitem full access scope */
const SCOPE_LINEITEM = 'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem';
/** Names and Roles (membership) scope */
const SCOPE_NRPS = 'https://purl.imsglobal.org/spec/lti-nrps/scope/contextmembership.readonly';
/** Tool Settings scope */
const SCOPE_TOOL_SETTING = 'https://purl.imsglobal.org/spec/lti-ts/scope/toolsetting';
> /** Indicates the token is to create a new registration */
> const REG_TOKEN_OP_NEW_REG = 'reg';
/**
> /** Indicates the token is to update an existing registration */
* Function used to validate parameters.
> const REG_TOKEN_OP_UPDATE_REG = 'reg-update';
*
>
* This function is needed because the payload contains nested
> /**
* objects, and optional_param() does not support arrays of arrays.
> * Get an instance of this helper
*
> *
* @param array $payload that may contain the parameter key
> * @return object
* @param string $key the key of the value to be looked for in the payload
> */
* @param bool $required if required, not finding a value will raise a registration_exception
> public static function get() {
*
> return new registration_helper();
* @return mixed
> }
*/
< private static function get_parameter(array $payload, string $key, bool $required) {
> private function get_parameter(array $payload, string $key, bool $required) {
if (!isset($payload[$key]) || empty($payload[$key])) {
if ($required) {
throw new registration_exception('missing required attribute '.$key, 400);
}
return null;
}
$parameter = $payload[$key];
// Cleans parameters to avoid XSS and other issues.
if (is_array($parameter)) {
return clean_param_array($parameter, PARAM_TEXT, true);
}
return clean_param($parameter, PARAM_TEXT);
}
/**
* Transforms an LTI 1.3 Registration to a Moodle LTI Config.
*
* @param array $registrationpayload the registration data received from the tool.
* @param string $clientid the clientid to be issued for that tool.
*
* @return object the Moodle LTI config.
*/
< public static function registration_to_config(array $registrationpayload, string $clientid): object {
< $responsetypes = self::get_parameter($registrationpayload, 'response_types', true);
< $initiateloginuri = self::get_parameter($registrationpayload, 'initiate_login_uri', true);
< $redirecturis = self::get_parameter($registrationpayload, 'redirect_uris', true);
< $clientname = self::get_parameter($registrationpayload, 'client_name', true);
< $jwksuri = self::get_parameter($registrationpayload, 'jwks_uri', true);
< $tokenendpointauthmethod = self::get_parameter($registrationpayload, 'token_endpoint_auth_method', true);
> public function registration_to_config(array $registrationpayload, string $clientid): object {
> $responsetypes = $this->get_parameter($registrationpayload, 'response_types', true);
> $initiateloginuri = $this->get_parameter($registrationpayload, 'initiate_login_uri', true);
> $redirecturis = $this->get_parameter($registrationpayload, 'redirect_uris', true);
> $clientname = $this->get_parameter($registrationpayload, 'client_name', true);
> $jwksuri = $this->get_parameter($registrationpayload, 'jwks_uri', true);
> $tokenendpointauthmethod = $this->get_parameter($registrationpayload, 'token_endpoint_auth_method', true);
< $applicationtype = self::get_parameter($registrationpayload, 'application_type', false);
< $logouri = self::get_parameter($registrationpayload, 'logo_uri', false);
> $applicationtype = $this->get_parameter($registrationpayload, 'application_type', false);
> $logouri = $this->get_parameter($registrationpayload, 'logo_uri', false);
< $ltitoolconfiguration = self::get_parameter($registrationpayload,
> $ltitoolconfiguration = $this->get_parameter($registrationpayload,
'https://purl.imsglobal.org/spec/lti-tool-configuration', true);
< $domain = self::get_parameter($ltitoolconfiguration, 'domain', false);
< $targetlinkuri = self::get_parameter($ltitoolconfiguration, 'target_link_uri', false);
< $customparameters = self::get_parameter($ltitoolconfiguration, 'custom_parameters', false);
< $scopes = explode(" ", self::get_parameter($registrationpayload, 'scope', false) ?? '');
< $claims = self::get_parameter($ltitoolconfiguration, 'claims', false);
> $domain = $this->get_parameter($ltitoolconfiguration, 'domain', false);
> $targetlinkuri = $this->get_parameter($ltitoolconfiguration, 'target_link_uri', false);
> $customparameters = $this->get_parameter($ltitoolconfiguration, 'custom_parameters', false);
> $scopes = explode(" ", $this->get_parameter($registrationpayload, 'scope', false) ?? '');
> $claims = $this->get_parameter($ltitoolconfiguration, 'claims', false);
$messages = $ltitoolconfiguration['messages'] ?? [];
< $description = self::get_parameter($ltitoolconfiguration, 'description', false);
> $description = $this->get_parameter($ltitoolconfiguration, 'description', false);
// Validate domain and target link.
if (empty($domain)) {
throw new registration_exception('missing_domain', 400);
}
>
$targetlinkuri = $targetlinkuri ?: 'https://'.$domain;
> // Stripping www as this is ignored for domain matching.
if ($domain !== lti_get_domain_from_url($targetlinkuri)) {
> $domain = lti_get_domain_from_url($domain);
throw new registration_exception('domain_targetlinkuri_mismatch', 400);
}
// Validate response type.
// According to specification, for this scenario, id_token must be explicitly set.
if (!in_array('id_token', $responsetypes)) {
throw new registration_exception('invalid_response_types', 400);
}
// According to specification, this parameter needs to be an array.
if (!is_array($redirecturis)) {
throw new registration_exception('invalid_redirect_uris', 400);
}
// According to specification, for this scenario private_key_jwt must be explicitly set.
if ($tokenendpointauthmethod !== 'private_key_jwt') {
throw new registration_exception('invalid_token_endpoint_auth_method', 400);
}
if (!empty($applicationtype) && $applicationtype !== 'web') {
throw new registration_exception('invalid_application_type', 400);
}
$config = new stdClass();
$config->lti_clientid = $clientid;
$config->lti_toolurl = $targetlinkuri;
$config->lti_tooldomain = $domain;
$config->lti_typename = $clientname;
$config->lti_description = $description;
$config->lti_ltiversion = LTI_VERSION_1P3;
$config->lti_organizationid_default = LTI_DEFAULT_ORGID_SITEID;
$config->lti_icon = $logouri;
$config->lti_coursevisible = LTI_COURSEVISIBLE_PRECONFIGURED;
$config->lti_contentitem = 0;
// Sets Content Item.
if (!empty($messages)) {
$messagesresponse = [];
foreach ($messages as $value) {
if ($value['type'] === 'LtiDeepLinkingRequest') {
$config->lti_contentitem = 1;
$config->lti_toolurl_ContentItemSelectionRequest = $value['target_link_uri'] ?? '';
array_push($messagesresponse, $value);
}
}
}
$config->lti_keytype = 'JWK_KEYSET';
$config->lti_publickeyset = $jwksuri;
$config->lti_initiatelogin = $initiateloginuri;
$config->lti_redirectionuris = implode(PHP_EOL, $redirecturis);
$config->lti_customparameters = '';
// Sets custom parameters.
if (isset($customparameters)) {
$paramssarray = [];
foreach ($customparameters as $key => $value) {
array_push($paramssarray, $key . '=' . $value);
}
$config->lti_customparameters = implode(PHP_EOL, $paramssarray);
}
// Sets launch container.
$config->lti_launchcontainer = LTI_LAUNCH_CONTAINER_EMBED_NO_BLOCKS;
// Sets Service info based on scopes.
$config->lti_acceptgrades = LTI_SETTING_NEVER;
$config->ltiservice_gradesynchronization = 0;
$config->ltiservice_memberships = 0;
$config->ltiservice_toolsettings = 0;
if (isset($scopes)) {
// Sets Assignment and Grade Services info.
if (in_array(self::SCOPE_SCORE, $scopes)) {
$config->lti_acceptgrades = LTI_SETTING_DELEGATE;
$config->ltiservice_gradesynchronization = 1;
}
if (in_array(self::SCOPE_RESULT, $scopes)) {
$config->lti_acceptgrades = LTI_SETTING_DELEGATE;
$config->ltiservice_gradesynchronization = 1;
}
if (in_array(self::SCOPE_LINEITEM_RO, $scopes)) {
$config->lti_acceptgrades = LTI_SETTING_DELEGATE;
$config->ltiservice_gradesynchronization = 1;
}
if (in_array(self::SCOPE_LINEITEM, $scopes)) {
$config->lti_acceptgrades = LTI_SETTING_DELEGATE;
$config->ltiservice_gradesynchronization = 2;
}
// Sets Names and Role Provisioning info.
if (in_array(self::SCOPE_NRPS, $scopes)) {
$config->ltiservice_memberships = 1;
}
// Sets Tool Settings info.
if (in_array(self::SCOPE_TOOL_SETTING, $scopes)) {
$config->ltiservice_toolsettings = 1;
}
}
// Sets privacy settings.
$config->lti_sendname = LTI_SETTING_NEVER;
$config->lti_sendemailaddr = LTI_SETTING_NEVER;
if (isset($claims)) {
// Sets name privacy settings.
if (in_array('name', $claims)) {
$config->lti_sendname = LTI_SETTING_ALWAYS;
}
if (in_array('given_name', $claims)) {
$config->lti_sendname = LTI_SETTING_ALWAYS;
}
if (in_array('family_name', $claims)) {
$config->lti_sendname = LTI_SETTING_ALWAYS;
}
// Sets email privacy settings.
if (in_array('email', $claims)) {
$config->lti_sendemailaddr = LTI_SETTING_ALWAYS;
}
}
return $config;
}
/**
> * Adds to the config the LTI 1.1 key and sign it with the 1.1 secret.
* Transforms a moodle LTI 1.3 Config to an OAuth/LTI Client Registration.
> *
*
> * @param array $lticonfig reference to lticonfig to which to add the 1.1 OAuth info.
* @param object $config Moodle LTI Config.
> * @param string $key - LTI 1.1 OAuth Key
* @param int $typeid which is the LTI deployment id.
> * @param string $secret - LTI 1.1 OAuth Secret
*
> *
* @return array the Client Registration as an associative array.
> */
*/
> private function add_previous_key_claim(array &$lticonfig, string $key, string $secret) {
public static function config_to_registration(object $config, int $typeid): array {
> if ($key) {
$registrationresponse = [];
> $oauthconsumer = [];
$registrationresponse['client_id'] = $config->lti_clientid;
> $oauthconsumer['key'] = $key;
$registrationresponse['token_endpoint_auth_method'] = ['private_key_jwt'];
> $oauthconsumer['nonce'] = random_string(random_int(10, 20));
$registrationresponse['response_types'] = ['id_token'];
> $oauthconsumer['sign'] = hash('sha256', $key.$secret.$oauthconsumer['nonce']);
$registrationresponse['jwks_uri'] = $config->lti_publickeyset;
> $lticonfig['oauth_consumer'] = $oauthconsumer;
$registrationresponse['initiate_login_uri'] = $config->lti_initiatelogin;
> }
$registrationresponse['grant_types'] = ['client_credentials', 'implicit'];
> }
$registrationresponse['redirect_uris'] = explode(PHP_EOL, $config->lti_redirectionuris);
>
$registrationresponse['application_type'] = 'web';
> /**
$registrationresponse['token_endpoint_auth_method'] = 'private_key_jwt';
> * @param object $type tool instance in case the tool already exists.
< public static function config_to_registration(object $config, int $typeid): array {
> public function config_to_registration(object $config, int $typeid, object $type = null): array {
> $configarray = [];
> foreach ((array)$config as $k => $v) {
> if (substr($k, 0, 4) == 'lti_') {
> $k = substr($k, 4);
> }
> $configarray[$k] = $v;
> }
> $config = (object) $configarray;
< $registrationresponse['client_id'] = $config->lti_clientid;
< $registrationresponse['token_endpoint_auth_method'] = ['private_key_jwt'];
> $lticonfigurationresponse = [];
> $ltiversion = $type ? $type->ltiversion : $config->ltiversion;
> $lticonfigurationresponse['version'] = $ltiversion;
> if ($ltiversion === LTI_VERSION_1P3) {
> $registrationresponse['client_id'] = $type ? $type->clientid : $config->clientid;
< $registrationresponse['jwks_uri'] = $config->lti_publickeyset;
< $registrationresponse['initiate_login_uri'] = $config->lti_initiatelogin;
> $registrationresponse['jwks_uri'] = $config->publickeyset;
> $registrationresponse['initiate_login_uri'] = $config->initiatelogin;
< $registrationresponse['redirect_uris'] = explode(PHP_EOL, $config->lti_redirectionuris);
> $registrationresponse['redirect_uris'] = explode(PHP_EOL, $config->redirectionuris);
< $registrationresponse['client_name'] = $config->lti_typename;
< $registrationresponse['logo_uri'] = $config->lti_icon ?? '';
< $lticonfigurationresponse = [];
> } else if ($ltiversion === LTI_VERSION_1 && $type) {
> $this->add_previous_key_claim($lticonfigurationresponse, $config->resourcekey, $config->password);
> } else if ($ltiversion === LTI_VERSION_2 && $type) {
> $toolproxy = $this->get_tool_proxy($type->toolproxyid);
> $this->add_previous_key_claim($lticonfigurationresponse, $toolproxy['guid'], $toolproxy['secret']);
> }
> $registrationresponse['client_name'] = $type ? $type->name : $config->typename;
> $registrationresponse['logo_uri'] = $type ? ($type->secureicon ?? $type->icon ?? '') : $config->icon ?? '';
< $lticonfigurationresponse['target_link_uri'] = $config->lti_toolurl;
< $lticonfigurationresponse['domain'] = $config->lti_tooldomain ?? '';
< $lticonfigurationresponse['description'] = $config->lti_description ?? '';
< if ($config->lti_contentitem == 1) {
> $lticonfigurationresponse['target_link_uri'] = $type ? $type->baseurl : $config->toolurl ?? '';
> $lticonfigurationresponse['domain'] = $type ? $type->tooldomain : $config->tooldomain ?? '';
> $lticonfigurationresponse['description'] = $type ? $type->description ?? '' : $config->description ?? '';
> if ($config->contentitem ?? 0 == 1) {
< if (isset($config->lti_toolurl_ContentItemSelectionRequest)) {
< $contentitemmessage['target_link_uri'] = $config->lti_toolurl_ContentItemSelectionRequest;
> if (isset($config->toolurl_ContentItemSelectionRequest)) {
> $contentitemmessage['target_link_uri'] = $config->toolurl_ContentItemSelectionRequest;
< if (isset($config->lti_customparameters) && !empty($config->lti_customparameters)) {
> if (isset($config->customparameters) && !empty($config->customparameters)) {
$params = [];
< foreach (explode(PHP_EOL, $config->lti_customparameters) as $param) {
> foreach (explode(PHP_EOL, $config->customparameters) as $param) {
$split = explode('=', $param);
$params[$split[0]] = $split[1];
}
$lticonfigurationresponse['custom_parameters'] = $params;
}
$scopesresponse = [];
< if ($config->ltiservice_gradesynchronization > 0) {
> if ($config->ltiservice_gradesynchronization ?? 0 > 0) {
$scopesresponse[] = self::SCOPE_SCORE;
$scopesresponse[] = self::SCOPE_RESULT;
$scopesresponse[] = self::SCOPE_LINEITEM_RO;
}
< if ($config->ltiservice_gradesynchronization == 2) {
> if ($config->ltiservice_gradesynchronization ?? 0 == 2) {
$scopesresponse[] = self::SCOPE_LINEITEM;
}
< if ($config->ltiservice_memberships == 1) {
> if ($config->ltiservice_memberships ?? 0 == 1) {
$scopesresponse[] = self::SCOPE_NRPS;
}
< if ($config->ltiservice_toolsettings == 1) {
> if ($config->ltiservice_toolsettings ?? 0 == 1) {
$scopesresponse[] = self::SCOPE_TOOL_SETTING;
}
$registrationresponse['scope'] = implode(' ', $scopesresponse);
$claimsresponse = ['sub', 'iss'];
< if ($config->lti_sendname == LTI_SETTING_ALWAYS) {
> if ($config->sendname ?? '' == LTI_SETTING_ALWAYS) {
$claimsresponse[] = 'name';
$claimsresponse[] = 'family_name';
$claimsresponse[] = 'given_name';
}
< if ($config->lti_sendemailaddr == LTI_SETTING_ALWAYS) {
> if ($config->sendemailaddr ?? '' == LTI_SETTING_ALWAYS) {
$claimsresponse[] = 'email';
}
$lticonfigurationresponse['claims'] = $claimsresponse;
$registrationresponse['https://purl.imsglobal.org/spec/lti-tool-configuration'] = $lticonfigurationresponse;
return $registrationresponse;
}
/**
* Validates the registration token is properly signed and not used yet.
* Return the client id to use for this registration.
*
* @param string $registrationtokenjwt registration token
*
< * @return string client id for the registration
> * @return array with 2 keys: clientid for the registration, type but only if it's an update
*/
< public static function validate_registration_token(string $registrationtokenjwt): string {
> public function validate_registration_token(string $registrationtokenjwt): array {
global $DB;
> // JWK::parseKeySet uses RS256 algorithm by default.
$keys = JWK::parseKeySet(jwks_helper::get_jwks());
< $registrationtoken = JWT::decode($registrationtokenjwt, $keys, ['RS256']);
<
> $registrationtoken = JWT::decode($registrationtokenjwt, $keys);
> $response = [];
// Get clientid from registrationtoken.
$clientid = $registrationtoken->sub;
<
> if ($registrationtoken->scope == self::REG_TOKEN_OP_NEW_REG) {
// Checks if clientid is already registered.
if (!empty($DB->get_record('lti_types', array('clientid' => $clientid)))) {
throw new registration_exception("token_already_used", 401);
}
< return $clientid;
> $response['clientid'] = $clientid;
> } else if ($registrationtoken->scope == self::REG_TOKEN_OP_UPDATE_REG) {
> $tool = lti_get_type($registrationtoken->sub);
> if (!$tool) {
> throw new registration_exception("Unknown client", 400);
> }
> $response['clientid'] = $tool->clientid ?? $this->new_clientid();
> $response['type'] = $tool;
> } else {
> throw new registration_exception("Incorrect scope", 403);
> }
> return $response;
}
/**
* Initializes an array with the scopes for services supported by the LTI module
*
* @return array List of scopes
*/
< public static function lti_get_service_scopes() {
> public function lti_get_service_scopes() {
$services = lti_get_services();
$scopes = array();
foreach ($services as $service) {
$servicescopes = $service->get_scopes();
if (!empty($servicescopes)) {
$scopes = array_merge($scopes, $servicescopes);
}
}
return $scopes;
}
> /**
}
> * Generates a new client id string.
> *
> * @return string generated client id
> */
> public function new_clientid(): string {
> return random_string(15);
> }
>
> /**
> * Base64 encoded signature for LTI 1.1 migration.
> * @param string $key LTI 1.1 key
> * @param string $salt Salt value
> * @param string $secret LTI 1.1 secret
> *
> * @return string base64encoded hash
> */
> public function sign(string $key, string $salt, string $secret): string {
> return base64_encode(hash_hmac('sha-256', $key.$salt, $secret, true));
> }
>
> /**
> * Returns a tool proxy
> *
> * @param int $proxyid
> *
> * @return mixed Tool Proxy details
> */
> public function get_tool_proxy(int $proxyid) : array {
> return lti_get_tool_proxy($proxyid);
> }