diff --git a/lib/classes/component.php b/lib/classes/component.php index 38ebc4155e8..333282e7193 100644 --- a/lib/classes/component.php +++ b/lib/classes/component.php @@ -99,6 +99,7 @@ class core_component { 'MatthiasMullie\\Minify' => 'lib/minify/matthiasmullie-minify/src/', 'MatthiasMullie\\PathConverter' => 'lib/minify/matthiasmullie-pathconverter/src/', 'IMSGlobal\LTI' => 'lib/ltiprovider/src', + 'Packback\\Lti1p3' => 'lib/lti1p3/src', 'Phpml' => 'lib/mlbackend/php/phpml/src/Phpml', 'PHPMailer\\PHPMailer' => 'lib/phpmailer/src', 'RedeyeVentures\\GeoPattern' => 'lib/geopattern-php/GeoPattern', diff --git a/lib/lti1p3/LICENSE.md b/lib/lti1p3/LICENSE.md new file mode 100644 index 00000000000..814f7de993f --- /dev/null +++ b/lib/lti1p3/LICENSE.md @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2018 Turnitin, LLC + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/lib/lti1p3/README.md b/lib/lti1p3/README.md new file mode 100644 index 00000000000..751149555b2 --- /dev/null +++ b/lib/lti1p3/README.md @@ -0,0 +1,18 @@ +# LTI 1.3 Tool Library + +A library used for building IMS-certified LTI 1.3 tool providers in PHP. + +This library is a fork of the [packbackbooks/lti-1-3-php-library](https://github.com/packbackbooks/lti-1-3-php-library), patched specifically for use in [Moodle](https://github.com/moodle/moodle). + +It is currently based on version [5.1.0 of the packbackbooks/lti-1-3-php-library](https://github.com/packbackbooks/lti-1-3-php-library/releases/tag/v5.1.0) library. + +The following changes are included so that the library may be used with Moodle: + + * Replace the phpseclib dependency with openssl equivalent call in public key generation code. + * Replace the Guzzle dependency with generic HTTP client interfaces for client, response, exception. + * Small fix to http_build_query() calls, which now explicitly include the '&' arg separator param, for compatibility with applications that override PHP's arg_separator.output value via an ini_set() call, like Moodle does. + * Upgrade the firebase/php-jwt requirement from ^5.2 to ^6.0, to match Moodle's shipped version of the lib. This change can be dropped when [this upstream issue](https://github.com/packbackbooks/lti-1-3-php-library/pull/46) is merged. + +Please see the original [README](https://github.com/packbackbooks/lti-1-3-php-library/blob/master/README.md) for more information about the upstream library. + + diff --git a/lib/lti1p3/readme_moodle.txt b/lib/lti1p3/readme_moodle.txt new file mode 100644 index 00000000000..e383fcd0b54 --- /dev/null +++ b/lib/lti1p3/readme_moodle.txt @@ -0,0 +1,24 @@ +LTI 1.3 Tool Library import instructions + +This library is a patched for use in Moodle - it requires the following changes be applied on top of its upstream base: +1. Removal of phpseclib dependency (replaces a single call with openssl equivalent) +2. Removal of Guzzle dependency (replaced with generic http client interfaces which are more compatible with Moodle's curl.) +3. Small fix to http_build_query() usages, to make sure the arg separator is explicitly set to '&', so as not to trip up +on Moodle's definition of PHP's arg_separator.output which is set to '&' in lib/setup.php. +4. Update the firebase/php-jwt requirement from ^5.2 to ^6.0, to match the version shipped with Moodle. This can be removed +when the upstream PR is merged: https://github.com/packbackbooks/lti-1-3-php-library/pull/46 + +To upgrade to a new version of this library: +1. Clone the latest version of the upstream library from github: +https://github.com/packbackbooks/lti-1-3-php-library/tags +2. Apply the changes mentioned above from the moodle-fixes branch of this repository: +https://github.com/snake/lti-1-3-php-library/tree/moodle-fixes +Apply these commits on top of the upstream clone. +3. Copy the entire src/ directory to lib/lti1p3/ +4. Copy LICENSE.md to lib/lti1p3/ +5. Copy README.me to lib/lti1p3/ +6. Update the dependency note in lib/php-jwt/readme_moodle.txt, listing the correct version of this library there. +7. Check the upstream library's release notes and UPGRADES.md for any backwards incompatible changes to names, etc. +Moodle's calling code may require updates if changes are breaking - so check this and make any changes if needed. +8. Run all unit tests in enrol/lti and auth/lti. +9. Regression test Moodle-to-Moodle LTI using LTI Advantage (not legacy) using the relevant MDLQA tests as a guide. diff --git a/lib/lti1p3/src/Helpers/Helpers.php b/lib/lti1p3/src/Helpers/Helpers.php new file mode 100644 index 00000000000..ab4195cc13e --- /dev/null +++ b/lib/lti1p3/src/Helpers/Helpers.php @@ -0,0 +1,14 @@ +loadCache(); + + return $this->cache[$key] ?? null; + } + + public function cacheLaunchData(string $key, array $jwtBody): void + { + $this->loadCache(); + + $this->cache[$key] = $jwtBody; + $this->saveCache(); + } + + public function cacheNonce(string $nonce, string $state): void + { + $this->loadCache(); + + $this->cache['nonce'][$nonce] = $state; + $this->saveCache(); + } + + public function checkNonceIsValid(string $nonce, string $state): bool + { + $this->loadCache(); + + return isset($this->cache['nonce'][$nonce]) && + $this->cache['nonce'][$nonce] === $state; + } + + public function cacheAccessToken(string $key, string $accessToken): void + { + $this->loadCache(); + + $this->cache[$key] = $accessToken; + $this->saveCache(); + } + + public function getAccessToken(string $key): ?string + { + $this->loadCache(); + + return $this->cache[$key] ?? null; + } + + public function clearAccessToken(string $key): void + { + $this->loadCache(); + + unset($this->cache[$key]); + $this->saveCache(); + } + + private function loadCache() + { + $cache = file_get_contents(sys_get_temp_dir().'/lti_cache.txt'); + if (empty($cache)) { + file_put_contents(sys_get_temp_dir().'/lti_cache.txt', '{}'); + $this->cache = []; + } + $this->cache = json_decode($cache, true); + } + + private function saveCache() + { + file_put_contents(sys_get_temp_dir().'/lti_cache.txt', json_encode($this->cache)); + } +} diff --git a/lib/lti1p3/src/ImsStorage/ImsCookie.php b/lib/lti1p3/src/ImsStorage/ImsCookie.php new file mode 100644 index 00000000000..a98175e2d79 --- /dev/null +++ b/lib/lti1p3/src/ImsStorage/ImsCookie.php @@ -0,0 +1,39 @@ + time() + $exp, + ]; + + // SameSite none and secure will be required for tools to work inside iframes + $same_site_options = [ + 'samesite' => 'None', + 'secure' => true, + ]; + + setcookie($name, $value, array_merge($cookie_options, $same_site_options, $options)); + + // Set a second fallback cookie in the event that "SameSite" is not supported + setcookie('LEGACY_'.$name, $value, array_merge($cookie_options, $options)); + } +} diff --git a/lib/lti1p3/src/Interfaces/ICache.php b/lib/lti1p3/src/Interfaces/ICache.php new file mode 100644 index 00000000000..41178b0eeb4 --- /dev/null +++ b/lib/lti1p3/src/Interfaces/ICache.php @@ -0,0 +1,20 @@ +keys = $keys; + } + + public static function new(array $keys) + { + return new JwksEndpoint($keys); + } + + public static function fromIssuer(IDatabase $database, $issuer) + { + $registration = $database->findRegistrationByIssuer($issuer); + + return new JwksEndpoint([$registration->getKid() => $registration->getToolPrivateKey()]); + } + + public static function fromRegistration(ILtiRegistration $registration) + { + return new JwksEndpoint([$registration->getKid() => $registration->getToolPrivateKey()]); + } + + public function getPublicJwks() + { + $jwks = []; + foreach ($this->keys as $kid => $private_key) { + $key_res = openssl_pkey_get_private($private_key); + $key_details = openssl_pkey_get_details($key_res); + $components = [ + 'kty' => 'RSA', + 'alg' => 'RS256', + 'use' => 'sig', + 'e' => JWT::urlsafeB64Encode($key_details['rsa']['e']), + 'n' => JWT::urlsafeB64Encode($key_details['rsa']['n']), + 'kid' => $kid, + ]; + $jwks[] = $components; + } + + return ['keys' => $jwks]; + } + + public function outputJwks() + { + echo json_encode($this->getPublicJwks()); + } +} diff --git a/lib/lti1p3/src/LtiAbstractService.php b/lib/lti1p3/src/LtiAbstractService.php new file mode 100644 index 00000000000..952544c88ff --- /dev/null +++ b/lib/lti1p3/src/LtiAbstractService.php @@ -0,0 +1,57 @@ +serviceConnector = $serviceConnector; + $this->registration = $registration; + $this->serviceData = $serviceData; + } + + public function getServiceData(): array + { + return $this->serviceData; + } + + public function setServiceData(array $serviceData): self + { + $this->serviceData = $serviceData; + + return $this; + } + + abstract public function getScope(): array; + + protected function makeServiceRequest(IServiceRequest $request): array + { + return $this->serviceConnector->makeServiceRequest( + $this->registration, + $this->getScope(), + $request + ); + } + + protected function getAll(IServiceRequest $request, string $key = null): array + { + return $this->serviceConnector->getAll( + $this->registration, + $this->getScope(), + $request, + $key + ); + } +} diff --git a/lib/lti1p3/src/LtiAssignmentsGradesService.php b/lib/lti1p3/src/LtiAssignmentsGradesService.php new file mode 100644 index 00000000000..7d45c4c3818 --- /dev/null +++ b/lib/lti1p3/src/LtiAssignmentsGradesService.php @@ -0,0 +1,163 @@ +getServiceData()['scope']; + } + + // https://www.imsglobal.org/spec/lti-ags/v2p0#assignment-and-grade-service-claim + // When an LTI message is launching a resource associated to one and only one lineitem, + // the claim must include the endpoint URL for accessing the associated line item; + // in all other cases, this property must be either blank or not included in the claim. + public function getResourceLaunchLineItem(): ?LtiLineitem + { + $serviceData = $this->getServiceData(); + if (empty($serviceData['lineitem'])) { + return null; + } + + return LtiLineitem::new()->setId($serviceData['lineitem']); + } + + public function putGrade(LtiGrade $grade, LtiLineitem $lineitem = null) + { + if (!in_array(LtiConstants::AGS_SCOPE_SCORE, $this->getScope())) { + throw new LtiException('Missing required scope', 1); + } + + $lineitem = $this->ensureLineItemExists($lineitem); + + $scoreUrl = $lineitem->getId(); + + // Place '/scores' before url params + $pos = strpos($scoreUrl, '?'); + $scoreUrl = $pos === false ? $scoreUrl.'/scores' : substr_replace($scoreUrl, '/scores', $pos, 0); + + $request = new ServiceRequest(LtiServiceConnector::METHOD_POST, $scoreUrl); + $request->setBody($grade); + $request->setContentType(static::CONTENTTYPE_SCORE); + + return $this->makeServiceRequest($request); + } + + public function findLineItem(LtiLineitem $newLineItem): ?LtiLineitem + { + $lineitems = $this->getLineItems(); + + foreach ($lineitems as $lineitem) { + if ($this->isMatchingLineitem($lineitem, $newLineItem)) { + return new LtiLineitem($lineitem); + } + } + + return null; + } + + public function createLineitem(LtiLineitem $newLineItem): LtiLineitem + { + $request = new ServiceRequest(LtiServiceConnector::METHOD_POST, $this->getServiceData()['lineitems']); + $request->setBody($newLineItem) + ->setContentType(static::CONTENTTYPE_LINEITEM) + ->setAccept(static::CONTENTTYPE_LINEITEM); + $createdLineItems = $this->makeServiceRequest($request); + + return new LtiLineitem($createdLineItems['body']); + } + + public function findOrCreateLineitem(LtiLineitem $newLineItem): LtiLineitem + { + return $this->findLineItem($newLineItem) ?? $this->createLineitem($newLineItem); + } + + public function getGrades(LtiLineitem $lineitem = null) + { + $lineitem = $this->ensureLineItemExists($lineitem); + $resultsUrl = $lineitem->getId(); + + // Place '/results' before url params + $pos = strpos($resultsUrl, '?'); + $resultsUrl = $pos === false ? $resultsUrl.'/results' : substr_replace($resultsUrl, '/results', $pos, 0); + + $request = new ServiceRequest(LtiServiceConnector::METHOD_GET, $resultsUrl); + $request->setAccept(static::CONTENTTYPE_RESULTCONTAINER); + $scores = $this->makeServiceRequest($request); + + return $scores['body']; + } + + public function getLineItems(): array + { + if (!in_array(LtiConstants::AGS_SCOPE_LINEITEM, $this->getScope())) { + throw new LtiException('Missing required scope', 1); + } + + $request = new ServiceRequest( + LtiServiceConnector::METHOD_GET, + $this->getServiceData()['lineitems'] + ); + $request->setAccept(static::CONTENTTYPE_LINEITEMCONTAINER); + + $lineitems = $this->getAll($request); + + // If there is only one item, then wrap it in an array so the foreach works + if (isset($lineitems['body']['id'])) { + $lineitems['body'] = [$lineitems['body']]; + } + + return $lineitems; + } + + public function getLineItem(string $url): LtiLineitem + { + if (!in_array(LtiConstants::AGS_SCOPE_LINEITEM, $this->getScope())) { + throw new LtiException('Missing required scope', 1); + } + + $request = new ServiceRequest(LtiServiceConnector::METHOD_GET, $url); + $request->setAccept(static::CONTENTTYPE_LINEITEM); + + $response = $this->makeServiceRequest($request)['body']; + + return new LtiLineitem($response); + } + + private function ensureLineItemExists(LtiLineitem $lineitem = null): LtiLineitem + { + // If no line item is passed in, attempt to use the one associated with + // this launch. + if (!isset($lineitem)) { + $lineitem = $this->getResourceLaunchLineItem(); + } + + // If none exists still, create a default line item. + if (!isset($lineitem)) { + $defaultLineitem = LtiLineitem::new() + ->setLabel('default') + ->setScoreMaximum(100); + $lineitem = $this->createLineitem($defaultLineitem); + } + + // If the line item does not contain an ID, find or create it. + if (empty($lineitem->getId())) { + $lineitem = $this->findOrCreateLineitem($lineitem); + } + + return $lineitem; + } + + private function isMatchingLineitem(array $lineitem, LtiLineitem $newLineItem): bool + { + return $newLineItem->getTag() == ($lineitem['tag'] ?? null) && + $newLineItem->getResourceId() == ($lineitem['resourceId'] ?? null) && + $newLineItem->getResourceLinkId() == ($lineitem['resourceLinkId'] ?? null); + } +} diff --git a/lib/lti1p3/src/LtiConstants.php b/lib/lti1p3/src/LtiConstants.php new file mode 100644 index 00000000000..e157ab6b052 --- /dev/null +++ b/lib/lti1p3/src/LtiConstants.php @@ -0,0 +1,94 @@ +getServiceData()['scope']; + } + + public function getGroups(): array + { + $request = new ServiceRequest( + LtiServiceConnector::METHOD_GET, + $this->getServiceData()['context_groups_url'] + ); + $request->setAccept(static::CONTENTTYPE_CONTEXTGROUPCONTAINER); + + return $this->getAll($request, 'groups'); + } + + public function getSets(): array + { + // Sets are optional. + if (!isset($this->getServiceData()['context_group_sets_url'])) { + return []; + } + + $request = new ServiceRequest( + LtiServiceConnector::METHOD_GET, + $this->getServiceData()['context_group_sets_url'] + ); + $request->setAccept(static::CONTENTTYPE_CONTEXTGROUPCONTAINER); + + return $this->getAll($request, 'sets'); + } + + public function getGroupsBySet() + { + $groups = $this->getGroups(); + $sets = $this->getSets(); + + $groupsBySet = []; + $unsetted = []; + + foreach ($sets as $key => $set) { + $groupsBySet[$set['id']] = $set; + $groupsBySet[$set['id']]['groups'] = []; + } + + foreach ($groups as $key => $group) { + if (isset($group['set_id']) && isset($groupsBySet[$group['set_id']])) { + $groupsBySet[$group['set_id']]['groups'][$group['id']] = $group; + } else { + $unsetted[$group['id']] = $group; + } + } + + if (!empty($unsetted)) { + $groupsBySet['none'] = [ + 'name' => 'None', + 'id' => 'none', + 'groups' => $unsetted, + ]; + } + + return $groupsBySet; + } +} diff --git a/lib/lti1p3/src/LtiDeepLink.php b/lib/lti1p3/src/LtiDeepLink.php new file mode 100644 index 00000000000..7734c57b0dc --- /dev/null +++ b/lib/lti1p3/src/LtiDeepLink.php @@ -0,0 +1,59 @@ +registration = $registration; + $this->deployment_id = $deployment_id; + $this->deep_link_settings = $deep_link_settings; + } + + public function getResponseJwt($resources) + { + $message_jwt = [ + 'iss' => $this->registration->getClientId(), + 'aud' => [$this->registration->getIssuer()], + 'exp' => time() + 600, + 'iat' => time(), + 'nonce' => LtiOidcLogin::secureRandomString('nonce-'), + LtiConstants::DEPLOYMENT_ID => $this->deployment_id, + LtiConstants::MESSAGE_TYPE => 'LtiDeepLinkingResponse', + LtiConstants::VERSION => LtiConstants::V1_3, + LtiConstants::DL_CONTENT_ITEMS => array_map(function ($resource) { return $resource->toArray(); }, $resources), + ]; + + // https://www.imsglobal.org/spec/lti-dl/v2p0/#deep-linking-request-message + // 'data' is an optional property which, if it exists, must be returned by the tool + if (isset($this->deep_link_settings['data'])) { + $message_jwt[LtiConstants::DL_DATA] = $this->deep_link_settings['data']; + } + + return JWT::encode($message_jwt, $this->registration->getToolPrivateKey(), 'RS256', $this->registration->getKid()); + } + + public function outputResponseForm($resources) + { + $jwt = $this->getResponseJwt($resources); + /* + * @todo Fix this + */ ?> +
+ + +
+ + type; + } + + public function setType($value) + { + $this->type = $value; + + return $this; + } + + public function getTitle() + { + return $this->title; + } + + public function setTitle($value) + { + $this->title = $value; + + return $this; + } + + public function getText() + { + return $this->text; + } + + public function setText($value) + { + $this->text = $value; + + return $this; + } + + public function getUrl() + { + return $this->url; + } + + public function setUrl($value) + { + $this->url = $value; + + return $this; + } + + public function getLineitem() + { + return $this->lineitem; + } + + public function setLineitem(LtiLineitem $value) + { + $this->lineitem = $value; + + return $this; + } + + public function getCustomParams() + { + return $this->custom_params; + } + + public function setCustomParams($value) + { + $this->custom_params = $value; + + return $this; + } + + public function getTarget() + { + return $this->target; + } + + public function setTarget($value) + { + $this->target = $value; + + return $this; + } + + public function toArray() + { + $resource = [ + 'type' => $this->type, + 'title' => $this->title, + 'text' => $this->text, + 'url' => $this->url, + 'presentation' => [ + 'documentTarget' => $this->target, + ], + 'custom' => $this->custom_params, + ]; + if ($this->lineitem !== null) { + $resource['lineItem'] = [ + 'scoreMaximum' => $this->lineitem->getScoreMaximum(), + 'label' => $this->lineitem->getLabel(), + ]; + } + + return $resource; + } +} diff --git a/lib/lti1p3/src/LtiDeployment.php b/lib/lti1p3/src/LtiDeployment.php new file mode 100644 index 00000000000..cbe083aa196 --- /dev/null +++ b/lib/lti1p3/src/LtiDeployment.php @@ -0,0 +1,25 @@ +deployment_id; + } + + public function setDeploymentId($deployment_id) + { + $this->deployment_id = $deployment_id; + + return $this; + } +} diff --git a/lib/lti1p3/src/LtiException.php b/lib/lti1p3/src/LtiException.php new file mode 100644 index 00000000000..16527eafe81 --- /dev/null +++ b/lib/lti1p3/src/LtiException.php @@ -0,0 +1,9 @@ +score_given = $grade['scoreGiven'] ?? null; + $this->score_maximum = $grade['scoreMaximum'] ?? null; + $this->comment = $grade['comment'] ?? null; + $this->activity_progress = $grade['activityProgress'] ?? null; + $this->grading_progress = $grade['gradingProgress'] ?? null; + $this->timestamp = $grade['timestamp'] ?? null; + $this->user_id = $grade['userId'] ?? null; + $this->submission_review = $grade['submissionReview'] ?? null; + $this->canvas_extension = $grade['https://canvas.instructure.com/lti/submission'] ?? null; + } + + public function __toString() + { + // Additionally, includes the call back to filter out only NULL values + $request = array_filter([ + 'scoreGiven' => $this->score_given, + 'scoreMaximum' => $this->score_maximum, + 'comment' => $this->comment, + 'activityProgress' => $this->activity_progress, + 'gradingProgress' => $this->grading_progress, + 'timestamp' => $this->timestamp, + 'userId' => $this->user_id, + 'submissionReview' => $this->submission_review, + 'https://canvas.instructure.com/lti/submission' => $this->canvas_extension, + ], '\Packback\Lti1p3\Helpers\Helpers::checkIfNullValue'); + + return json_encode($request); + } + + /** + * Static function to allow for method chaining without having to assign to a variable first. + */ + public static function new() + { + return new LtiGrade(); + } + + public function getScoreGiven() + { + return $this->score_given; + } + + public function setScoreGiven($value) + { + $this->score_given = $value; + + return $this; + } + + public function getScoreMaximum() + { + return $this->score_maximum; + } + + public function setScoreMaximum($value) + { + $this->score_maximum = $value; + + return $this; + } + + public function getComment() + { + return $this->comment; + } + + public function setComment($comment) + { + $this->comment = $comment; + + return $this; + } + + public function getActivityProgress() + { + return $this->activity_progress; + } + + public function setActivityProgress($value) + { + $this->activity_progress = $value; + + return $this; + } + + public function getGradingProgress() + { + return $this->grading_progress; + } + + public function setGradingProgress($value) + { + $this->grading_progress = $value; + + return $this; + } + + public function getTimestamp() + { + return $this->timestamp; + } + + public function setTimestamp($value) + { + $this->timestamp = $value; + + return $this; + } + + public function getUserId() + { + return $this->user_id; + } + + public function setUserId($value) + { + $this->user_id = $value; + + return $this; + } + + public function getSubmissionReview() + { + return $this->submission_review; + } + + public function setSubmissionReview($value) + { + $this->submission_review = $value; + + return $this; + } + + public function getCanvasExtension() + { + return $this->canvas_extension; + } + + // Custom Extension for Canvas. + // https://documentation.instructure.com/doc/api/score.html + public function setCanvasExtension($value) + { + $this->canvas_extension = $value; + + return $this; + } +} diff --git a/lib/lti1p3/src/LtiGradeSubmissionReview.php b/lib/lti1p3/src/LtiGradeSubmissionReview.php new file mode 100644 index 00000000000..a185930f2fb --- /dev/null +++ b/lib/lti1p3/src/LtiGradeSubmissionReview.php @@ -0,0 +1,86 @@ +reviewable_status = $gradeSubmission['reviewableStatus'] ?? null; + $this->label = $gradeSubmission['label'] ?? null; + $this->url = $gradeSubmission['url'] ?? null; + $this->custom = $gradeSubmission['custom'] ?? null; + } + + public function __toString() + { + // Additionally, includes the call back to filter out only NULL values + return json_encode(array_filter([ + 'reviewableStatus' => $this->reviewable_status, + 'label' => $this->label, + 'url' => $this->url, + 'custom' => $this->custom, + ], '\Packback\Lti1p3\Helpers\Helpers::checkIfNullValue')); + } + + /** + * Static function to allow for method chaining without having to assign to a variable first. + */ + public static function new() + { + return new LtiGradeSubmissionReview(); + } + + public function getReviewableStatus() + { + return $this->reviewable_status; + } + + public function setReviewableStatus($value) + { + $this->reviewable_status = $value; + + return $this; + } + + public function getLabel() + { + return $this->label; + } + + public function setLabel($value) + { + $this->label = $value; + + return $this; + } + + public function getUrl() + { + return $this->url; + } + + public function setUrl($url) + { + $this->url = $url; + + return $this; + } + + public function getCustom() + { + return $this->custom; + } + + public function setCustom($value) + { + $this->custom = $value; + + return $this; + } +} diff --git a/lib/lti1p3/src/LtiLineitem.php b/lib/lti1p3/src/LtiLineitem.php new file mode 100644 index 00000000000..8964ce19f3c --- /dev/null +++ b/lib/lti1p3/src/LtiLineitem.php @@ -0,0 +1,146 @@ +id = $lineitem['id'] ?? null; + $this->score_maximum = $lineitem['scoreMaximum'] ?? null; + $this->label = $lineitem['label'] ?? null; + $this->resource_id = $lineitem['resourceId'] ?? null; + $this->resource_link_id = $lineitem['resourceLinkId'] ?? null; + $this->tag = $lineitem['tag'] ?? null; + $this->start_date_time = $lineitem['startDateTime'] ?? null; + $this->end_date_time = $lineitem['endDateTime'] ?? null; + } + + public function __toString() + { + // Additionally, includes the call back to filter out only NULL values + return json_encode(array_filter([ + 'id' => $this->id, + 'scoreMaximum' => $this->score_maximum, + 'label' => $this->label, + 'resourceId' => $this->resource_id, + 'resourceLinkId' => $this->resource_link_id, + 'tag' => $this->tag, + 'startDateTime' => $this->start_date_time, + 'endDateTime' => $this->end_date_time, + ], '\Packback\Lti1p3\Helpers\Helpers::checkIfNullValue')); + } + + /** + * Static function to allow for method chaining without having to assign to a variable first. + */ + public static function new() + { + return new LtiLineitem(); + } + + public function getId() + { + return $this->id; + } + + public function setId($value) + { + $this->id = $value; + + return $this; + } + + public function getLabel() + { + return $this->label; + } + + public function setLabel($value) + { + $this->label = $value; + + return $this; + } + + public function getScoreMaximum() + { + return $this->score_maximum; + } + + public function setScoreMaximum($value) + { + $this->score_maximum = $value; + + return $this; + } + + public function getResourceId() + { + return $this->resource_id; + } + + public function setResourceId($value) + { + $this->resource_id = $value; + + return $this; + } + + public function getResourceLinkId() + { + return $this->resource_link_id; + } + + public function setResourceLinkId($value) + { + $this->resource_link_id = $value; + + return $this; + } + + public function getTag() + { + return $this->tag; + } + + public function setTag($value) + { + $this->tag = $value; + + return $this; + } + + public function getStartDateTime() + { + return $this->start_date_time; + } + + public function setStartDateTime($value) + { + $this->start_date_time = $value; + + return $this; + } + + public function getEndDateTime() + { + return $this->end_date_time; + } + + public function setEndDateTime($value) + { + $this->end_date_time = $value; + + return $this; + } +} diff --git a/lib/lti1p3/src/LtiMessageLaunch.php b/lib/lti1p3/src/LtiMessageLaunch.php new file mode 100644 index 00000000000..060e4cb4adb --- /dev/null +++ b/lib/lti1p3/src/LtiMessageLaunch.php @@ -0,0 +1,455 @@ +db = $database; + + $this->launch_id = uniqid('lti1p3_launch_', true); + + $this->cache = $cache; + $this->cookie = $cookie; + $this->serviceConnector = $serviceConnector; + } + + /** + * Static function to allow for method chaining without having to assign to a variable first. + */ + public static function new( + IDatabase $database, + ICache $cache = null, + ICookie $cookie = null, + ILtiServiceConnector $serviceConnector = null + ) { + return new LtiMessageLaunch($database, $cache, $cookie, $serviceConnector); + } + + /** + * Load an LtiMessageLaunch from a Cache using a launch id. + * + * @param string $launch_id the launch id of the LtiMessageLaunch object that is being pulled from the cache + * @param IDatabase $database instance of the database interface used for looking up registrations and deployments + * @param ICache $cache Instance of the Cache interface used to loading and storing launches. If non is provided launch data will be store in $_SESSION. + * + * @throws LtiException will throw an LtiException if validation fails or launch cannot be found + * + * @return LtiMessageLaunch a populated and validated LtiMessageLaunch + */ + public static function fromCache($launch_id, + IDatabase $database, + ICache $cache = null, + ILtiServiceConnector $serviceConnector = null) + { + $new = new LtiMessageLaunch($database, $cache, null, $serviceConnector); + $new->launch_id = $launch_id; + $new->jwt = ['body' => $new->cache->getLaunchData($launch_id)]; + + return $new->validateRegistration(); + } + + /** + * Validates all aspects of an incoming LTI message launch and caches the launch if successful. + * + * @param array|string $request An array of post request parameters. If not set will default to $_POST. + * + * @throws LtiException will throw an LtiException if validation fails + * + * @return LtiMessageLaunch will return $this if validation is successful + */ + public function validate(array $request = null) + { + if ($request === null) { + $request = $_POST; + } + $this->request = $request; + + return $this->validateState() + ->validateJwtFormat() + ->validateNonce() + ->validateRegistration() + ->validateJwtSignature() + ->validateDeployment() + ->validateMessage() + ->cacheLaunchData(); + } + + /** + * Returns whether or not the current launch can use the names and roles service. + * + * @return bool returns a boolean indicating the availability of names and roles + */ + public function hasNrps() + { + return !empty($this->jwt['body'][LtiConstants::NRPS_CLAIM_SERVICE]['context_memberships_url']); + } + + /** + * Fetches an instance of the names and roles service for the current launch. + * + * @return LtiNamesRolesProvisioningService an instance of the names and roles service that can be used to make calls within the scope of the current launch + */ + public function getNrps() + { + return new LtiNamesRolesProvisioningService( + $this->serviceConnector, + $this->registration, + $this->jwt['body'][LtiConstants::NRPS_CLAIM_SERVICE]); + } + + /** + * Returns whether or not the current launch can use the groups service. + * + * @return bool returns a boolean indicating the availability of groups + */ + public function hasGs() + { + return !empty($this->jwt['body'][LtiConstants::GS_CLAIM_SERVICE]['context_groups_url']); + } + + /** + * Fetches an instance of the groups service for the current launch. + * + * @return LtiCourseGroupsService an instance of the groups service that can be used to make calls within the scope of the current launch + */ + public function getGs() + { + return new LtiCourseGroupsService( + $this->serviceConnector, + $this->registration, + $this->jwt['body'][LtiConstants::GS_CLAIM_SERVICE]); + } + + /** + * Returns whether or not the current launch can use the assignments and grades service. + * + * @return bool returns a boolean indicating the availability of assignments and grades + */ + public function hasAgs() + { + return !empty($this->jwt['body'][LtiConstants::AGS_CLAIM_ENDPOINT]); + } + + /** + * Fetches an instance of the assignments and grades service for the current launch. + * + * @return LtiAssignmentsGradesService an instance of the assignments an grades service that can be used to make calls within the scope of the current launch + */ + public function getAgs() + { + return new LtiAssignmentsGradesService( + $this->serviceConnector, + $this->registration, + $this->jwt['body'][LtiConstants::AGS_CLAIM_ENDPOINT]); + } + + /** + * Returns whether or not the current launch is a deep linking launch. + * + * @return bool returns true if the current launch is a deep linking launch + */ + public function isDeepLinkLaunch() + { + return $this->jwt['body'][LtiConstants::MESSAGE_TYPE] === static::TYPE_DEEPLINK; + } + + /** + * Fetches a deep link that can be used to construct a deep linking response. + * + * @return LtiDeepLink an instance of a deep link to construct a deep linking response for the current launch + */ + public function getDeepLink() + { + return new LtiDeepLink( + $this->registration, + $this->jwt['body'][LtiConstants::DEPLOYMENT_ID], + $this->jwt['body'][LtiConstants::DL_DEEP_LINK_SETTINGS]); + } + + /** + * Returns whether or not the current launch is a submission review launch. + * + * @return bool returns true if the current launch is a submission review launch + */ + public function isSubmissionReviewLaunch() + { + return $this->jwt['body'][LtiConstants::MESSAGE_TYPE] === static::TYPE_SUBMISSIONREVIEW; + } + + /** + * Returns whether or not the current launch is a resource launch. + * + * @return bool returns true if the current launch is a resource launch + */ + public function isResourceLaunch() + { + return $this->jwt['body'][LtiConstants::MESSAGE_TYPE] === static::TYPE_RESOURCELINK; + } + + /** + * Fetches the decoded body of the JWT used in the current launch. + * + * @return array|object returns the decoded json body of the launch as an array + */ + public function getLaunchData() + { + return $this->jwt['body']; + } + + /** + * Get the unique launch id for the current launch. + * + * @return string a unique identifier used to re-reference the current launch in subsequent requests + */ + public function getLaunchId() + { + return $this->launch_id; + } + + private function getPublicKey() + { + $keySetUrl = $this->registration->getKeySetUrl(); + $request = new ServiceRequest(LtiServiceConnector::METHOD_GET, $keySetUrl); + + // Download key set + try { + $response = $this->serviceConnector->makeRequest($request); + } catch (IHttpException $e) { + throw new LtiException(static::ERR_NO_PUBLIC_KEY); + } + $publicKeySet = $this->serviceConnector->getResponseBody($response); + + if (empty($publicKeySet)) { + // Failed to fetch public keyset from URL. + throw new LtiException(static::ERR_FETCH_PUBLIC_KEY); + } + + // Find key used to sign the JWT (matches the KID in the header) + foreach ($publicKeySet['keys'] as $key) { + if ($key['kid'] == $this->jwt['header']['kid']) { + try { + $keySet = JWK::parseKeySet([ + 'keys' => [$key], + ]); + } catch (\Exception $e) { + // Do nothing + } + + if (isset($keySet[$key['kid']])) { + return $keySet[$key['kid']]; + } + } + } + + // Could not find public key with a matching kid and alg. + throw new LtiException(static::ERR_NO_PUBLIC_KEY); + } + + private function cacheLaunchData() + { + $this->cache->cacheLaunchData($this->launch_id, $this->jwt['body']); + + return $this; + } + + private function validateState() + { + // Check State for OIDC. + if ($this->cookie->getCookie(LtiOidcLogin::COOKIE_PREFIX.$this->request['state']) !== $this->request['state']) { + // Error if state doesn't match + throw new LtiException(static::ERR_STATE_NOT_FOUND); + } + + return $this; + } + + private function validateJwtFormat() + { + $jwt = $this->request['id_token'] ?? null; + + if (empty($jwt)) { + throw new LtiException(static::ERR_MISSING_ID_TOKEN); + } + + // Get parts of JWT. + $jwt_parts = explode('.', $jwt); + + if (count($jwt_parts) !== 3) { + // Invalid number of parts in JWT. + throw new LtiException(static::ERR_INVALID_ID_TOKEN); + } + + // Decode JWT headers. + $this->jwt['header'] = json_decode(JWT::urlsafeB64Decode($jwt_parts[0]), true); + // Decode JWT Body. + $this->jwt['body'] = json_decode(JWT::urlsafeB64Decode($jwt_parts[1]), true); + + return $this; + } + + private function validateNonce() + { + if (!isset($this->jwt['body']['nonce'])) { + throw new LtiException(static::ERR_MISSING_NONCE); + } + if (!$this->cache->checkNonceIsValid($this->jwt['body']['nonce'], $this->request['state'])) { + throw new LtiException(static::ERR_INVALID_NONCE); + } + + return $this; + } + + private function validateRegistration() + { + // Find registration. + $client_id = is_array($this->jwt['body']['aud']) ? $this->jwt['body']['aud'][0] : $this->jwt['body']['aud']; + $this->registration = $this->db->findRegistrationByIssuer($this->jwt['body']['iss'], $client_id); + + if (empty($this->registration)) { + throw new LtiException(static::ERR_MISSING_REGISTRATION); + } + + // Check client id. + if ($client_id !== $this->registration->getClientId()) { + // Client not registered. + throw new LtiException(static::ERR_CLIENT_NOT_REGISTERED); + } + + return $this; + } + + private function validateJwtSignature() + { + if (!isset($this->jwt['header']['kid'])) { + throw new LtiException(static::ERR_NO_KID); + } + + // Fetch public key. + $public_key = $this->getPublicKey(); + + // Validate JWT signature + try { + JWT::decode($this->request['id_token'], $public_key); + } catch (ExpiredException $e) { + // Error validating signature. + throw new LtiException(static::ERR_INVALID_SIGNATURE); + } + + return $this; + } + + private function validateDeployment() + { + if (!isset($this->jwt['body'][LtiConstants::DEPLOYMENT_ID])) { + throw new LtiException(static::ERR_MISSING_DEPLOYEMENT_ID); + } + + // Find deployment. + $client_id = is_array($this->jwt['body']['aud']) ? $this->jwt['body']['aud'][0] : $this->jwt['body']['aud']; + $deployment = $this->db->findDeployment($this->jwt['body']['iss'], $this->jwt['body'][LtiConstants::DEPLOYMENT_ID], $client_id); + + if (empty($deployment)) { + // deployment not recognized. + throw new LtiException(static::ERR_NO_DEPLOYMENT); + } + + return $this; + } + + private function validateMessage() + { + if (empty($this->jwt['body'][LtiConstants::MESSAGE_TYPE])) { + // Unable to identify message type. + throw new LtiException(static::ERR_INVALID_MESSAGE_TYPE); + } + + /** + * @todo Fix this nonsense + */ + + // Create instances of all validators + $validators = [ + new DeepLinkMessageValidator(), + new ResourceMessageValidator(), + new SubmissionReviewMessageValidator(), + ]; + + $message_validator = false; + foreach ($validators as $validator) { + if ($validator->canValidate($this->jwt['body'])) { + if ($message_validator !== false) { + // Can't have more than one validator apply at a time. + throw new LtiException(static::ERR_VALIDATOR_CONFLICT); + } + $message_validator = $validator; + } + } + + if ($message_validator === false) { + throw new LtiException(static::ERR_UNRECOGNIZED_MESSAGE_TYPE); + } + + if (!$message_validator->validate($this->jwt['body'])) { + throw new LtiException(static::ERR_INVALID_MESSAGE); + } + + return $this; + } +} diff --git a/lib/lti1p3/src/LtiNamesRolesProvisioningService.php b/lib/lti1p3/src/LtiNamesRolesProvisioningService.php new file mode 100644 index 00000000000..1df1cdd6fe7 --- /dev/null +++ b/lib/lti1p3/src/LtiNamesRolesProvisioningService.php @@ -0,0 +1,24 @@ +getServiceData()['context_memberships_url'] + ); + $request->setAccept(static::CONTENTTYPE_MEMBERSHIPCONTAINER); + + return $this->getAll($request, 'members'); + } +} diff --git a/lib/lti1p3/src/LtiOidcLogin.php b/lib/lti1p3/src/LtiOidcLogin.php new file mode 100644 index 00000000000..9d8185bc3aa --- /dev/null +++ b/lib/lti1p3/src/LtiOidcLogin.php @@ -0,0 +1,131 @@ +db = $database; + $this->cache = $cache; + $this->cookie = $cookie; + } + + /** + * Static function to allow for method chaining without having to assign to a variable first. + */ + public static function new(IDatabase $database, ICache $cache = null, ICookie $cookie = null) + { + return new LtiOidcLogin($database, $cache, $cookie); + } + + /** + * Calculate the redirect location to return to based on an OIDC third party initiated login request. + * + * @param string $launch_url URL to redirect back to after the OIDC login. This URL must match exactly a URL white listed in the platform. + * @param array|string $request An array of request parameters. If not set will default to $_REQUEST. + * + * @return Redirect returns a redirect object containing the fully formed OIDC login URL + */ + public function doOidcLoginRedirect($launch_url, array $request = null) + { + if ($request === null) { + $request = $_REQUEST; + } + + if (empty($launch_url)) { + throw new OidcException(static::ERROR_MSG_LAUNCH_URL, 1); + } + + // Validate Request Data. + $registration = $this->validateOidcLogin($request); + + /* + * Build OIDC Auth Response. + */ + + // Generate State. + // Set cookie (short lived) + $state = static::secureRandomString('state-'); + $this->cookie->setCookie(static::COOKIE_PREFIX.$state, $state, 60); + + // Generate Nonce. + $nonce = static::secureRandomString('nonce-'); + $this->cache->cacheNonce($nonce, $state); + + // Build Response. + $auth_params = [ + 'scope' => 'openid', // OIDC Scope. + 'response_type' => 'id_token', // OIDC response is always an id token. + 'response_mode' => 'form_post', // OIDC response is always a form post. + 'prompt' => 'none', // Don't prompt user on redirect. + 'client_id' => $registration->getClientId(), // Registered client id. + 'redirect_uri' => $launch_url, // URL to return to after login. + 'state' => $state, // State to identify browser session. + 'nonce' => $nonce, // Prevent replay attacks. + 'login_hint' => $request['login_hint'], // Login hint to identify platform session. + ]; + + // Pass back LTI message hint if we have it. + if (isset($request['lti_message_hint'])) { + // LTI message hint to identify LTI context within the platform. + $auth_params['lti_message_hint'] = $request['lti_message_hint']; + } + + $auth_login_return_url = $registration->getAuthLoginUrl().'?'.http_build_query($auth_params, '', '&'); + + // Return auth redirect. + return new Redirect($auth_login_return_url, http_build_query($request, '', '&')); + } + + public function validateOidcLogin($request) + { + // Validate Issuer. + if (empty($request['iss'])) { + throw new OidcException(static::ERROR_MSG_ISSUER, 1); + } + + // Validate Login Hint. + if (empty($request['login_hint'])) { + throw new OidcException(static::ERROR_MSG_LOGIN_HINT, 1); + } + + // Fetch Registration Details. + $registration = $this->db->findRegistrationByIssuer($request['iss'], $request['client_id'] ?? null); + + // Check we got something. + if (empty($registration)) { + throw new OidcException(static::ERROR_MSG_REGISTRATION, 1); + } + + // Return Registration. + return $registration; + } + + public static function secureRandomString(string $prefix = ''): string + { + return $prefix.hash('sha256', random_bytes(64)); + } +} diff --git a/lib/lti1p3/src/LtiRegistration.php b/lib/lti1p3/src/LtiRegistration.php new file mode 100644 index 00000000000..21eebb7e300 --- /dev/null +++ b/lib/lti1p3/src/LtiRegistration.php @@ -0,0 +1,130 @@ +issuer = $registration['issuer'] ?? null; + $this->clientId = $registration['clientId'] ?? null; + $this->keySetUrl = $registration['keySetUrl'] ?? null; + $this->authTokenUrl = $registration['authTokenUrl'] ?? null; + $this->authLoginUrl = $registration['authLoginUrl'] ?? null; + $this->authServer = $registration['authServer'] ?? null; + $this->toolPrivateKey = $registration['toolPrivateKey'] ?? null; + $this->kid = $registration['kid'] ?? null; + } + + public static function new(array $registration = []) + { + return new LtiRegistration($registration); + } + + public function getIssuer() + { + return $this->issuer; + } + + public function setIssuer($issuer) + { + $this->issuer = $issuer; + + return $this; + } + + public function getClientId() + { + return $this->clientId; + } + + public function setClientId($clientId) + { + $this->clientId = $clientId; + + return $this; + } + + public function getKeySetUrl() + { + return $this->keySetUrl; + } + + public function setKeySetUrl($keySetUrl) + { + $this->keySetUrl = $keySetUrl; + + return $this; + } + + public function getAuthTokenUrl() + { + return $this->authTokenUrl; + } + + public function setAuthTokenUrl($authTokenUrl) + { + $this->authTokenUrl = $authTokenUrl; + + return $this; + } + + public function getAuthLoginUrl() + { + return $this->authLoginUrl; + } + + public function setAuthLoginUrl($authLoginUrl) + { + $this->authLoginUrl = $authLoginUrl; + + return $this; + } + + public function getAuthServer() + { + return empty($this->authServer) ? $this->authTokenUrl : $this->authServer; + } + + public function setAuthServer($authServer) + { + $this->authServer = $authServer; + + return $this; + } + + public function getToolPrivateKey() + { + return $this->toolPrivateKey; + } + + public function setToolPrivateKey($toolPrivateKey) + { + $this->toolPrivateKey = $toolPrivateKey; + + return $this; + } + + public function getKid() + { + return $this->kid ?? hash('sha256', trim($this->issuer.$this->clientId)); + } + + public function setKid($kid) + { + $this->kid = $kid; + + return $this; + } +} diff --git a/lib/lti1p3/src/LtiServiceConnector.php b/lib/lti1p3/src/LtiServiceConnector.php new file mode 100644 index 00000000000..8b3009a046d --- /dev/null +++ b/lib/lti1p3/src/LtiServiceConnector.php @@ -0,0 +1,194 @@ +]*)>; ?rel="next"/i'; + + public const METHOD_GET = 'GET'; + public const METHOD_POST = 'POST'; + + private $cache; + private $client; + private $debuggingMode = false; + + public function __construct(ICache $cache, IHttpClient $client) + { + $this->cache = $cache; + $this->client = $client; + } + + public function setDebuggingMode(bool $enable): void + { + $this->debuggingMode = $enable; + } + + public function getAccessToken(ILtiRegistration $registration, array $scopes) + { + // Get a unique cache key for the access token + $accessTokenKey = $this->getAccessTokenCacheKey($registration, $scopes); + // Get access token from cache if it exists + $accessToken = $this->cache->getAccessToken($accessTokenKey); + if ($accessToken) { + return $accessToken; + } + + // Build up JWT to exchange for an auth token + $clientId = $registration->getClientId(); + $jwtClaim = [ + 'iss' => $clientId, + 'sub' => $clientId, + 'aud' => $registration->getAuthServer(), + 'iat' => time() - 5, + 'exp' => time() + 60, + 'jti' => 'lti-service-token'.hash('sha256', random_bytes(64)), + ]; + + // Sign the JWT with our private key (given by the platform on registration) + $jwt = JWT::encode($jwtClaim, $registration->getToolPrivateKey(), 'RS256', $registration->getKid()); + + // Build auth token request headers + $authRequest = [ + 'grant_type' => 'client_credentials', + 'client_assertion_type' => 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer', + 'client_assertion' => $jwt, + 'scope' => implode(' ', $scopes), + ]; + + $url = $registration->getAuthTokenUrl(); + + // Get Access + $tokenRequest = new ServiceRequest('POST', $url); + $tokenRequest->setBody(http_build_query($authRequest, '', '&')); + $tokenRequest->setContentType('application/x-www-form-urlencoded'); + $tokenRequest->setAccept('application/json'); + $response = $this->client->request( + $tokenRequest->getMethod(), + $tokenRequest->getUrl(), + [ + 'headers' => $tokenRequest->getPayload()['headers'], + 'body' => $tokenRequest->getPayload()['body'] + ] + ); + $tokenData = $this->getResponseBody($response); + + // Cache access token + $this->cache->cacheAccessToken($accessTokenKey, $tokenData['access_token']); + + return $tokenData['access_token']; + } + + + public function makeRequest(IServiceRequest $request) + { + return $this->client->request( + $request->getMethod(), + $request->getUrl(), + $request->getPayload() + ); + } + + public function getResponseBody(IHttpResponse $response): ?array + { + $responseBody = (string) $response->getBody(); + + return json_decode($responseBody, true); + } + + public function makeServiceRequest( + ILtiRegistration $registration, + array $scopes, + IServiceRequest $request, + bool $shouldRetry = true + ): array { + $request->setAccessToken($this->getAccessToken($registration, $scopes)); + try { + $response = $this->makeRequest($request); + } catch (IHttpException $e) { + $status = $e->getResponse()->getStatusCode(); + // If the error was due to invalid authentication and the request + // should be retried, clear the access token and retry it. + if ($status === 401 && $shouldRetry) { + $key = $this->getAccessTokenCacheKey($registration, $scopes); + $this->cache->clearAccessToken($key); + + return $this->makeServiceRequest($registration, $scopes, $request, false); + } + throw $e; + } + + $responseHeaders = $response->getHeaders(); + $responseBody = $this->getResponseBody($response); + + if ($this->debuggingMode) { + error_log('Syncing grade for this lti_user_id: '. + json_decode($request->getPayload()['body'])->userId.' '.print_r([ + 'request_method' => $request->getMethod(), + 'request_url' => $request->getUrl(), + 'request_body' => $request->getPayload()['body'], + 'response_headers' => $responseHeaders, + 'response_body' => json_encode($responseBody), + ], true)); + } + + return [ + 'headers' => $responseHeaders, + 'body' => $responseBody, + 'status' => $response->getStatusCode(), + ]; + } + + public function getAll( + ILtiRegistration $registration, + array $scopes, + IServiceRequest $request, + string $key = null + ): array { + if ($request->getMethod() !== static::METHOD_GET) { + throw new \Exception('An invalid method was specified by an LTI service requesting all items.'); + } + + $results = []; + $nextUrl = $request->getUrl(); + + while ($nextUrl) { + $response = $this->makeServiceRequest($registration, $scopes, $request); + + $page_results = $key === null ? ($response['body'] ?? []) : ($response['body'][$key] ?? []); + $results = array_merge($results, $page_results); + + $nextUrl = $this->getNextUrl($response['headers']); + if ($nextUrl) { + $request->setUrl($nextUrl); + } + } + + return $results; + } + + private function getAccessTokenCacheKey(ILtiRegistration $registration, array $scopes) + { + sort($scopes); + $scopeKey = md5(implode('|', $scopes)); + + return $registration->getIssuer().$registration->getClientId().$scopeKey; + } + + private function getNextUrl(array $headers) + { + $subject = $headers['Link'] ?? ''; + preg_match(LtiServiceConnector::NEXT_PAGE_REGEX, $subject, $matches); + + return $matches[1] ?? null; + } +} diff --git a/lib/lti1p3/src/MessageValidators/DeepLinkMessageValidator.php b/lib/lti1p3/src/MessageValidators/DeepLinkMessageValidator.php new file mode 100644 index 00000000000..c3cb3e55b62 --- /dev/null +++ b/lib/lti1p3/src/MessageValidators/DeepLinkMessageValidator.php @@ -0,0 +1,43 @@ +location = $location; + $this->referer_query = $referer_query; + } + + public function doRedirect() + { + header('Location: '.$this->location, true, 302); + exit; + } + + public function doHybridRedirect(ICookie $cookie) + { + if (!empty($cookie->getCookie(self::$CAN_302_COOKIE))) { + return $this->doRedirect(); + } + $cookie->setCookie(self::$CAN_302_COOKIE, 'true'); + $this->doJsRedirect(); + } + + public function getRedirectUrl() + { + return $this->location; + } + + public function doJsRedirect() + { + ?> + If you are not automatically redirected, click here to continue + + method = $method; + $this->url = $url; + } + + public function getMethod(): string + { + return strtoupper($this->method); + } + + public function getUrl(): string + { + return $this->url; + } + + public function getPayload(): array + { + $payload = [ + 'headers' => $this->getHeaders(), + ]; + + $body = $this->getBody(); + if ($body) { + $payload['body'] = $body; + } + + return $payload; + } + + public function setUrl(string $url): IServiceRequest + { + $this->url = $url; + + return $this; + } + + public function setAccessToken(string $accessToken): IServiceRequest + { + $this->accessToken = 'Bearer '.$accessToken; + + return $this; + } + + public function setBody(string $body): IServiceRequest + { + $this->body = $body; + + return $this; + } + + public function setAccept(string $accept): IServiceRequest + { + $this->accept = $accept; + + return $this; + } + + public function setContentType(string $contentType): IServiceRequest + { + $this->contentType = $contentType; + + return $this; + } + + private function getHeaders(): array + { + $headers = [ + 'Accept' => $this->accept, + ]; + + if (isset($this->accessToken)) { + $headers['Authorization'] = $this->accessToken; + } + + if ($this->getMethod() === LtiServiceConnector::METHOD_POST) { + $headers['Content-Type'] = $this->contentType; + } + + return $headers; + } + + private function getBody(): ?string + { + return $this->body; + } +} diff --git a/lib/php-jwt/readme_moodle.txt b/lib/php-jwt/readme_moodle.txt index 6366cf86f2b..78820d50f35 100644 --- a/lib/php-jwt/readme_moodle.txt +++ b/lib/php-jwt/readme_moodle.txt @@ -1,10 +1,15 @@ Description of php-jwt library import into Moodle +Dependencies +------------ +- The lib/lti1p3 library currently depends on version 6.0 of php-jwt. +- There are usages of this library in mod/lti too. Please check these. + Instructions ------------ -1. Visit [https://github.com/firebase/php-jwt]. -2. Click on 'X releases'. -3. Download the latest release. -4. Unzip it in lib as php-jwt. -5. Update entry for this library in lib/thirdpartylibs.xml. - +1. Check dependencies to confirm suitability of the new version of the library (see above). +2. Visit [https://github.com/firebase/php-jwt]. +3. Click on 'X releases'. +4. Download the latest release. +5. Unzip it in lib as php-jwt. +6. Update entry for this library in lib/thirdpartylibs.xml. diff --git a/lib/thirdpartylibs.xml b/lib/thirdpartylibs.xml index 846cca21ce3..352b0fc2317 100644 --- a/lib/thirdpartylibs.xml +++ b/lib/thirdpartylibs.xml @@ -256,6 +256,13 @@ 3.0.2 2.0 + + lti1p3 + LTI 1.3 Tool Library + Apache + 5.1.0 + 2.0 + amd/src/truncate.js Truncate.js