From e0c2a6343bd26a3760ae8442bd5032e9cf782d20 Mon Sep 17 00:00:00 2001 From: Stephen Vickers Date: Thu, 26 Nov 2020 21:33:08 +0000 Subject: [PATCH] Dynamic registration support Added support for the LTI 1.3 dynamic registration process available in Moodle 3.10+ Avoid use of $_REQUEST --- src/Jwt/ClientInterface.php | 7 + src/Jwt/FirebaseClient.php | 14 +- src/Jwt/SpomkyLabsClient.php | 14 +- src/Jwt/WebTokenClient.php | 12 ++ src/Platform.php | 2 +- src/Tool.php | 374 ++++++++++++++++++++++++++++++++++- src/Util.php | 21 ++ 7 files changed, 431 insertions(+), 13 deletions(-) diff --git a/src/Jwt/ClientInterface.php b/src/Jwt/ClientInterface.php index 3ee8cad..8c79289 100644 --- a/src/Jwt/ClientInterface.php +++ b/src/Jwt/ClientInterface.php @@ -12,6 +12,13 @@ interface ClientInterface { + /** + * Return an array of supported signature algorithms. + * + * @return string[] Array of algorithm names + */ + public static function getSupportedAlgorithms(); + /** * Check if a JWT is defined. * diff --git a/src/Jwt/FirebaseClient.php b/src/Jwt/FirebaseClient.php index 68b8ca4..ecb7212 100644 --- a/src/Jwt/FirebaseClient.php +++ b/src/Jwt/FirebaseClient.php @@ -17,12 +17,24 @@ class FirebaseClient implements ClientInterface { + const SUPPORTED_ALGORITHMS = array('RS256', 'RS384', 'RS512'); + private $jwtString = null; private $jwtHeaders = null; private $jwtPayload = null; private static $lastHeaders = null; private static $lastPayload = null; + /** + * Return an array of supported signature algorithms. + * + * @return string[] Array of algorithm names + */ + public static function getSupportedAlgorithms() + { + return self::SUPPORTED_ALGORITHMS; + } + /** * Check if a JWT is defined. * @@ -219,7 +231,7 @@ public function verify($publicKey, $jku = null) $retry = false; do { try { - JWT::decode($this->jwtString, $publicKey, array('RS256', 'RS384', 'RS512')); + JWT::decode($this->jwtString, $publicKey, self::SUPPORTED_ALGORITHMS); $ok = true; } catch (\Exception $e) { Util::logError($e->getMessage()); diff --git a/src/Jwt/SpomkyLabsClient.php b/src/Jwt/SpomkyLabsClient.php index a689cb5..51904be 100644 --- a/src/Jwt/SpomkyLabsClient.php +++ b/src/Jwt/SpomkyLabsClient.php @@ -25,11 +25,23 @@ class SpomkyLabsClient implements ClientInterface { + const SUPPORTED_ALGORITHMS = array('RS256', 'RS384', 'RS512'); + private $jwe = null; private $jwt = null; private static $lastHeaders = null; private static $lastPayload = null; + /** + * Return an array of supported signature algorithms. + * + * @return string[] Array of algorithm names + */ + public static function getSupportedAlgorithms() + { + return self::SUPPORTED_ALGORITHMS; + } + /** * Check if a JWT is defined. * @@ -377,7 +389,7 @@ public static function getPublicKey($privateKey) /** * Get the public JWKS from a key in PEM or JWK format. * - * @param string $Key Private or public key in PEM or JWK format + * @param string $key Private or public key in PEM or JWK format * @param string $signatureMethod Signature method * @param string $kid Key ID (optional) * diff --git a/src/Jwt/WebTokenClient.php b/src/Jwt/WebTokenClient.php index f17cb2b..ca9fc48 100644 --- a/src/Jwt/WebTokenClient.php +++ b/src/Jwt/WebTokenClient.php @@ -20,12 +20,24 @@ class WebTokenClient implements ClientInterface { + const SUPPORTED_ALGORITHMS = array('RS256', 'RS384', 'RS512'); + private $jwe = null; private $jwt = null; private $claims = array(); private static $lastHeaders = null; private static $lastPayload = null; + /** + * Return an array of supported signature algorithms. + * + * @return string[] Array of algorithm names + */ + public static function getSupportedAlgorithms() + { + return self::SUPPORTED_ALGORITHMS; + } + /** * Check if a JWT is defined. * diff --git a/src/Platform.php b/src/Platform.php index 2f70ceb..dedf5a2 100644 --- a/src/Platform.php +++ b/src/Platform.php @@ -97,7 +97,7 @@ class Platform /** * The tool proxy. * - * @var object|null $toolPrixy + * @var object|null $toolProxy */ public $toolProxy = null; diff --git a/src/Tool.php b/src/Tool.php index 622331a..f121a00 100644 --- a/src/Tool.php +++ b/src/Tool.php @@ -6,6 +6,7 @@ use ceLTIc\LTI\MediaType; use ceLTIc\LTI\Profile; use ceLTIc\LTI\Content\Item; +use ceLTIc\LTI\Jwt\Jwt; use ceLTIc\LTI\Http\HttpMessage; use ceLTIc\LTI\OAuth; use ceLTIc\LTI\ApiHook\ApiHook; @@ -352,23 +353,27 @@ public function getMessageParameters() */ public function handleRequest($strictMode = false) { + $parameters = Util::getRequestParameters(); if ($this->debugMode) { Util::$logLevel = Util::LOGLEVEL_DEBUG; } if ($_SERVER['REQUEST_METHOD'] === 'HEAD') { // Ignore HEAD requests Util::logRequest(true); - } elseif (!empty($_REQUEST['iss'])) { // Initiate login request + } elseif (!empty($parameters['iss'])) { // Initiate login request Util::logRequest(); - if (empty($_REQUEST['login_hint'])) { + if (empty($parameters['login_hint'])) { $this->ok = false; $this->reason = 'Missing login_hint parameter'; - } elseif (empty($_REQUEST['target_link_uri'])) { + } elseif (empty($parameters['target_link_uri'])) { $this->ok = false; $this->reason = 'Missing target_link_uri parameter'; } else { - $hint = isset($_REQUEST['lti_message_hint']) ? $_REQUEST['lti_message_hint'] : null; + $hint = isset($parameters['lti_message_hint']) ? $parameters['lti_message_hint'] : null; $this->ok = $this->sendAuthenticationRequest($hint); } + } elseif (!empty($parameters['openid_configuration'])) { // Dynamic registration request + Util::logRequest(); + $this->onRegistration(); } else { // LTI message $this->getMessageParameters(); Util::logRequest(); @@ -548,6 +553,23 @@ protected function onRegister() $this->onError(); } + /** + * Process a dynamic registration request + */ + protected function onRegistration() + { + $platformConfig = $this->getPlatformConfiguration(); + if ($this->ok) { + $toolConfig = $this->getConfiguration($platformConfig); + $registrationConfig = $this->sendRegistration($platformConfig, $toolConfig); + if ($this->ok) { + $platform = $this->getPlatformToRegister($platformConfig, $registrationConfig); + } + } + $this->getRegistrationResponsePage($toolConfig); + $this->ok = true; + } + /** * Process a response to an invalid request */ @@ -556,6 +578,337 @@ protected function onError() $this->ok = false; } + /** + * Fetch a platform's configuration data + * + * @return array|null Platform configuration data + */ + protected function getPlatformConfiguration() + { + if ($this->ok) { + $parameters = Util::getRequestParameters(); + $this->ok = !empty($parameters['openid_configuration']); + if ($this->ok) { + $http = new HttpMessage($parameters['openid_configuration']); + $this->ok = $http->send(); + if ($this->ok) { + $platformConfig = json_decode($http->response, true); + $this->ok = !empty($platformConfig); + } + if (!$this->ok) { + $this->reason = 'Unable to access platform configuration details.'; + } + } else { + $this->reason = 'Invalid registration request: missing openid_configuration parameter.'; + } + if ($this->ok) { + $this->ok = !empty($platformConfig['registration_endpoint']) && !empty($platformConfig['jwks_uri']) && !empty($platformConfig['authorization_endpoint']) && + !empty($platformConfig['token_endpoint']) && !empty($platformConfig['https://purl.imsglobal.org/spec/lti-platform-configuration']) && + !empty($platformConfig['claims_supported']) && !empty($platformConfig['scopes_supported']) && + !empty($platformConfig['id_token_signing_alg_values_supported']) && + !empty($platformConfig['https://purl.imsglobal.org/spec/lti-platform-configuration']['product_family_code']) && + !empty($platformConfig['https://purl.imsglobal.org/spec/lti-platform-configuration']['version']) && + !empty($platformConfig['https://purl.imsglobal.org/spec/lti-platform-configuration']['messages_supported']); + if (!$this->ok) { + $this->reason = 'Invalid platform configuration details.'; + } + } + if ($this->ok) { + $jwtClient = Jwt::getJwtClient(); + $algorithms = \array_intersect($jwtClient::getSupportedAlgorithms(), + $platformConfig['id_token_signing_alg_values_supported']); + $this->ok = !empty($algorithms); + if ($this->ok) { + rsort($platformConfig['id_token_signing_alg_values_supported']); + } else { + $this->reason = 'None of the signature algorithms offered by the platform is supported.'; + } + } + } + if (!$this->ok) { + $platformConfig = null; + } + + return $platformConfig; + } + + /** + * Prepare the tool's configuration data + * + * @param array $platformConfig Platform configuration data + * + * @return array Tool configuration data + */ + protected function getConfiguration($platformConfig) + { + $claimsMapping = array( + 'User.id' => 'sub', + 'Person.name.full' => 'name', + 'Person.name.given' => 'given_name', + 'Person.name.family' => 'family_name', + 'Person.email.primary' => 'email' + ); + $toolName = (!empty($this->product->name)) ? $this->product->name : 'Unnamed tool'; + $toolDescription = (!empty($this->product->description)) ? $this->product->description : ''; + $oauthRequest = OAuth\OAuthRequest::from_request(); + $toolUrl = $oauthRequest->get_normalized_http_url(); + $pos = strpos($toolUrl, '//'); + $domain = substr($toolUrl, $pos + 2); + $domain = substr($domain, 0, strpos($domain, '/')); + $claimsSupported = $platformConfig['claims_supported']; + $messagesSupported = $platformConfig['https://purl.imsglobal.org/spec/lti-platform-configuration']['messages_supported']; + $scopesSupported = $platformConfig['scopes_supported']; + $iconUrl = null; + $messages = array(); + $claims = array('iss'); + $variables = array(); + $constants = array(); + $redirectUris = array(); + foreach ($this->resourceHandlers as $resourceHandler) { + if (empty($iconUrl)) { + $iconUrl = $resourceHandler->icon; + } + foreach (array_merge($resourceHandler->optionalMessages, $resourceHandler->requiredMessages) as $message) { + $type = $message->type; + if (array_key_exists($type, Util::MESSAGE_TYPE_MAPPING)) { + $type = Util::MESSAGE_TYPE_MAPPING[$type]; + } + $capabilities = array(); + if ($type === 'LtiResourceLinkRequest') { + $toolUrl = "{$this->baseUrl}{$message->path}"; + $redirectUris[] = $toolUrl; + $capabilities = $message->capabilities; + $variables = array_merge($variables, $message->variables); + $constants = array_merge($constants, $message->constants); + } else if (in_array($type, $messagesSupported)) { + $redirectUris[] = "{$this->baseUrl}{$message->path}"; + $capabilities = $message->capabilities; + $variables = array_merge($message->variables, $variables); + $constants = array_merge($message->constants, $constants); + $messages[] = array( + 'type' => $type, + 'target_link_uri' => "{$this->baseUrl}{$message->path}", + 'label' => $toolName + ); + } + foreach ($capabilities as $capability) { + if (array_key_exists($capability, $claimsMapping) && in_array($claimsMapping[$capability], $claimsSupported)) { + $claims[] = $claimsMapping[$capability]; + } + } + } + } + if (empty($redirectUris)) { + $redirectUris = array($toolUrl); + } else { + $redirectUris = array_unique($redirectUris); + } + if (!empty($claims)) { + $claims = array_unique($claims); + } + $custom = array(); + foreach ($constants as $name => $value) { + $custom[$name] = $value; + } + foreach ($variables as $name => $value) { + $custom[$name] = '$' . $value; + } + $toolConfig = array(); + $toolConfig['application_type'] = 'web'; + $toolConfig['client_name'] = $toolName; + $toolConfig['response_types'] = array('id_token'); + $toolConfig['grant_types'] = array('implicit', 'client_credentials'); + $toolConfig['initiate_login_uri'] = $toolUrl; + $toolConfig['redirect_uris'] = $redirectUris; + $toolConfig['jwks_uri'] = $this->jku; + $toolConfig['token_endpoint_auth_method'] = 'private_key_jwt'; + $toolConfig['https://purl.imsglobal.org/spec/lti-tool-configuration'] = array( + 'domain' => $domain, + 'target_link_uri' => $toolUrl, + 'custom_parameters' => $custom, + 'claims' => $claims, + 'messages' => $messages, + 'description' => $toolDescription + ); + $toolConfig['scope'] = implode(' ', array_intersect($this->requiredScopes, $scopesSupported)); + if (!empty($iconUrl)) { + $toolConfig['logo_uri'] = "{$this->baseUrl}{$iconUrl}"; + } + + return $toolConfig; + } + + /** + * Send the tool registration to the platform + * + * @param array $platformConfig Platform configuration data + * @param array $toolConfig Tool configuration data + * + * @return array Registration data + */ + protected function sendRegistration($platformConfig, $toolConfig) + { + if ($this->ok) { + $parameters = Util::getRequestParameters(); + $this->ok = !empty($parameters['registration_token']); + if ($this->ok) { + $body = json_encode($toolConfig); + $headers = "Content-type: application/json\n" . + "Authorization: Bearer {$parameters['registration_token']}"; + $http = new HttpMessage($platformConfig['registration_endpoint'], 'POST', $body, $headers); + $this->ok = $http->send(); + if ($this->ok) { + $registrationConfig = json_decode($http->response, true); + $this->ok = !empty($registrationConfig); + } + if (!$this->ok) { + $this->reason = 'Unable to register with platform.'; + } + } else { + $this->reason = 'Invalid registration request: missing registration_token parameter.'; + } + } + if (!$this->ok) { + $registrationConfig = null; + } + + return $registrationConfig; + } + + /** + * Initialise the platform to be registered + * + * @param array $platformConfig Platform configuration data + * @param array $registrationConfig Registration data + * @param bool $doSave True if the platform should be saved (optional, default is true) + * + * @return Platform Platform object + */ + protected function getPlatformToRegister($platformConfig, $registrationConfig, $doSave = true) + { + $domain = $platformConfig['issuer']; + $pos = strpos($domain, '//'); + if ($pos !== false) { + $domain = substr($domain, $pos + 2); + $pos = strpos($domain, '/'); + if ($pos !== false) { + $domain = substr($domain, 0, $pos); + } + } + $platform = new Platform($this->dataConnector); + $platform->name = $domain; + $platform->ltiVersion = Util::LTI_VERSION1P3; + $platform->signatureMethod = reset($platformConfig['id_token_signing_alg_values_supported']); + $platform->platformId = $platformConfig['issuer']; + $platform->clientId = $registrationConfig['client_id']; + $platform->deploymentId = $registrationConfig['https://purl.imsglobal.org/spec/lti-tool-configuration']['deployment_id']; + $platform->authenticationUrl = $platformConfig['authorization_endpoint']; + $platform->accessTokenUrl = $platformConfig['token_endpoint']; + $platform->jku = $platformConfig['jwks_uri']; + if ($doSave) { + $this->ok = $platform->save(); + if (!$this->ok) { + $checkPlatform = Platform::fromPlatformId($platform->platformId, $platform->clientId, $platform->deploymentId, + $this->dataConnector); + if (!empty($checkPlatform->created)) { + $this->reason = 'The platform is already registered.'; + } else { + $this->reason = 'Sorry, an error occurred when saving the platform details.'; + } + } + } + + return $platform; + } + + /** + * Prepare the page to complete a registration request + * + * @param array $toolConfig Tool configuration data + */ + protected function getRegistrationResponsePage($toolConfig) + { + $html = <<< EOD + + + + + LTI Tool registration + + + + +

{$toolConfig['client_name']} registration

+ +EOD; + if ($this->ok) { + $html .= <<< EOD +

+ The tool registration was successful, but it will need to be enabled by the tool provider before it can be used. +

+

+ +

+ +EOD; + } else { + $html .= <<< EOD +

+ Sorry, the registration was not successful: {$this->reason} +

+ +EOD; + } + $html .= <<< EOD + + +EOD; + $this->output = $html; + } + ### ### PRIVATE METHODS ### @@ -1361,16 +1714,17 @@ private function checkForShare() */ private function sendAuthenticationRequest($hint) { + $parameters = Util::getRequestParameters(); $clientId = null; - if (isset($_REQUEST['client_id'])) { - $clientId = $_REQUEST['client_id']; + if (isset($parameters['client_id'])) { + $clientId = $parameters['client_id']; } $deploymentId = null; - if (isset($_REQUEST['lti_deployment_id'])) { - $deploymentId = $_REQUEST['lti_deployment_id']; + if (isset($parameters['lti_deployment_id'])) { + $deploymentId = $parameters['lti_deployment_id']; } $currentLogLevel = Util::$logLevel; - $this->platform = Platform::fromPlatformId($_REQUEST['iss'], $clientId, $deploymentId, $this->dataConnector); + $this->platform = Platform::fromPlatformId($parameters['iss'], $clientId, $deploymentId, $this->dataConnector); if ($this->platform->debugMode && ($currentLogLevel < Util::LOGLEVEL_INFO)) { $this->debugMode = true; Util::logRequest(); @@ -1412,7 +1766,7 @@ private function sendAuthenticationRequest($hint) } $params = array( 'client_id' => $this->platform->clientId, - 'login_hint' => $_REQUEST['login_hint'], + 'login_hint' => $parameters['login_hint'], 'nonce' => Util::getRandomString(32), 'prompt' => 'none', 'redirect_uri' => $redirectUri, diff --git a/src/Util.php b/src/Util.php index 65a9a6f..6a64746 100644 --- a/src/Util.php +++ b/src/Util.php @@ -141,6 +141,11 @@ final class Util 'ToolProxyRegistrationRequest' => 'onRegister' ); + /** + * GET and POST request parameters + */ + public static $requestParameters = null; + /** * Current logging level. * @@ -148,6 +153,20 @@ final class Util */ public static $logLevel = self::LOGLEVEL_NONE; + /** + * Return GET and POST request parameters (POST parameters take precedence) + * + * @return array + */ + public static function getRequestParameters() + { + if (is_null(self::$requestParameters)) { + self::$requestParameters = array_merge($_GET, $_POST); + } + + return self::$requestParameters; + } + /** * Log an error message. * @@ -189,6 +208,8 @@ public static function logDebug($message, $showSource = false) /** * Log a request. + * + * @param bool $debugLevel True if the request details should be logged at the debug level (optional, default is false for information level) */ public static function logRequest($debugLevel = false) {